refactor(verify-era-proof-attestation): modularize and restructure proof verification logic

- Split `verify-era-proof-attestation` into modular subcomponents for maintainability.
- Moved client, proof handling, and core types into dedicated modules.
This commit is contained in:
Harald Hoyer 2025-04-02 16:03:01 +02:00
parent 1e853f653a
commit 2605e2ae3a
Signed by: harald
GPG key ID: F519A1143B3FBE32
34 changed files with 2918 additions and 2304 deletions

View file

@ -0,0 +1,35 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2023-2025 Matter Labs
use teepot::quote::{
error::QuoteContext, tee_qv_get_collateral, verify_quote_with_collateral,
QuoteVerificationResult,
};
use crate::error;
/// Handles verification of attestation quotes
pub struct AttestationVerifier;
impl AttestationVerifier {
/// Verify an attestation quote
pub fn verify_quote(attestation_quote_bytes: &[u8]) -> error::Result<QuoteVerificationResult> {
// Get collateral for the quote
let collateral = QuoteContext::context(
tee_qv_get_collateral(attestation_quote_bytes),
"Failed to get collateral!",
)?;
// Get current time for verification
let unix_time: i64 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| error::Error::internal(format!("Failed to get system time: {}", e)))?
.as_secs() as _;
// Verify the quote with the collateral
let res =
verify_quote_with_collateral(attestation_quote_bytes, Some(&collateral), unix_time)?;
Ok(res)
}
}

View file

@ -0,0 +1,141 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2025 Matter Labs
use crate::{
client::JsonRpcClient,
core::AttestationPolicy,
error,
proof::Proof,
verification::{AttestationVerifier, PolicyEnforcer, SignatureVerifier, VerificationReporter},
};
use tokio::sync::watch;
use zksync_basic_types::L1BatchNumber;
/// Result of a batch verification
#[derive(Debug, Clone, Copy)]
pub struct BatchVerificationResult {
/// Total number of proofs processed
pub total_count: u32,
/// Number of proofs that were verified successfully
pub verified_count: u32,
/// Number of proofs that failed verification
pub unverified_count: u32,
}
/// Handles the batch verification process
pub struct BatchVerifier<C: JsonRpcClient> {
node_client: C,
attestation_policy: AttestationPolicy,
}
impl<C: JsonRpcClient> BatchVerifier<C> {
/// Create a new batch verifier
pub fn new(node_client: C, attestation_policy: AttestationPolicy) -> Self {
Self {
node_client,
attestation_policy,
}
}
/// Verify proofs for a batch
pub async fn verify_batch_proofs(
&self,
stop_receiver: &mut watch::Receiver<bool>,
batch_number: L1BatchNumber,
proofs: Vec<Proof>,
) -> error::Result<BatchVerificationResult> {
let batch_no = batch_number.0;
let mut total_proofs_count: u32 = 0;
let mut verified_proofs_count: u32 = 0;
for proof in proofs.into_iter() {
if *stop_receiver.borrow() {
tracing::warn!("Stop signal received during batch verification");
return Ok(BatchVerificationResult {
total_count: total_proofs_count,
verified_count: verified_proofs_count,
unverified_count: total_proofs_count - verified_proofs_count,
});
}
total_proofs_count += 1;
let tee_type = proof.tee_type.to_uppercase();
if proof.is_permanently_ignored() {
tracing::debug!(
batch_no,
tee_type,
"Proof is marked as permanently ignored. Skipping."
);
continue;
}
tracing::debug!(batch_no, tee_type, proof.proved_at, "Verifying proof.");
let attestation_bytes = proof.attestation_bytes();
let signature_bytes = proof.signature_bytes();
tracing::debug!(
batch_no,
"Verifying quote ({} bytes)...",
attestation_bytes.len()
);
// Verify attestation
let quote_verification_result = AttestationVerifier::verify_quote(&attestation_bytes)?;
// Log verification results
VerificationReporter::log_quote_verification_summary(&quote_verification_result);
// Check if attestation matches policy
let policy_matches = PolicyEnforcer::validate_policy(
&self.attestation_policy,
&quote_verification_result,
);
if let Err(e) = policy_matches {
tracing::error!(batch_no, tee_type, "Attestation policy check failed: {e}");
continue;
}
// Verify signature
let root_hash = self
.node_client
.get_root_hash(L1BatchNumber(proof.l1_batch_number))
.await?;
let signature_verified = SignatureVerifier::verify_batch_proof(
&quote_verification_result,
root_hash,
&signature_bytes,
)?;
if signature_verified {
tracing::info!(
batch_no,
proof.proved_at,
tee_type,
"Verification succeeded.",
);
verified_proofs_count += 1;
} else {
tracing::warn!(batch_no, proof.proved_at, tee_type, "Verification failed!",);
}
}
let unverified_proofs_count = total_proofs_count.saturating_sub(verified_proofs_count);
// Log batch verification results
VerificationReporter::log_batch_verification_results(
batch_no,
verified_proofs_count,
unverified_proofs_count,
);
Ok(BatchVerificationResult {
total_count: total_proofs_count,
verified_count: verified_proofs_count,
unverified_count: unverified_proofs_count,
})
}
}

View file

@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2023-2025 Matter Labs
mod attestation;
mod batch;
mod policy;
mod reporting;
mod signature;
pub use attestation::AttestationVerifier;
pub use batch::BatchVerifier;
pub use policy::PolicyEnforcer;
pub use reporting::VerificationReporter;
pub use signature::SignatureVerifier;

View file

@ -0,0 +1,212 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2023-2025 Matter Labs
use crate::{
core::AttestationPolicy,
error::{Error, Result},
};
use bytes::Bytes;
use enumset::EnumSet;
use teepot::quote::{tcblevel::TcbLevel, QuoteVerificationResult, Report};
/// Enforces policy requirements on attestation quotes
pub struct PolicyEnforcer;
impl PolicyEnforcer {
/// Check if a quote matches the attestation policy
pub fn validate_policy(
attestation_policy: &AttestationPolicy,
quote_verification_result: &QuoteVerificationResult,
) -> Result<()> {
let quote = &quote_verification_result.quote;
let tcblevel = TcbLevel::from(quote_verification_result.result);
match &quote.report {
Report::SgxEnclave(report_body) => {
// Validate TCB level
Self::validate_tcb_level(&attestation_policy.sgx_allowed_tcb_levels, tcblevel)?;
// Validate SGX Advisories
for advisory in &quote_verification_result.advisories {
Self::check_policy(
attestation_policy.sgx_allowed_advisory_ids.as_deref(),
advisory,
"advisories",
)?;
}
// Validate SGX policies
Self::check_policy_hash(
attestation_policy.sgx_mrsigners.as_deref(),
&report_body.mr_signer,
"mrsigner",
)?;
Self::check_policy_hash(
attestation_policy.sgx_mrenclaves.as_deref(),
&report_body.mr_enclave,
"mrenclave",
)
}
Report::TD10(report_body) => {
// Validate TCB level
Self::validate_tcb_level(&attestation_policy.tdx_allowed_tcb_levels, tcblevel)?;
// Validate TDX Advisories
for advisory in &quote_verification_result.advisories {
Self::check_policy(
attestation_policy.tdx_allowed_advisory_ids.as_deref(),
advisory,
"mrsigner",
)?;
}
// Build combined TDX MR and validate
let tdx_mr = Self::build_tdx_mr([
&report_body.mr_td,
&report_body.rt_mr0,
&report_body.rt_mr1,
&report_body.rt_mr2,
&report_body.rt_mr3,
]);
Self::check_policy_hash(attestation_policy.tdx_mrs.as_deref(), &tdx_mr, "tdxmr")
}
Report::TD15(report_body) => {
// Validate TCB level
Self::validate_tcb_level(&attestation_policy.tdx_allowed_tcb_levels, tcblevel)?;
// Validate TDX Advisories
for advisory in &quote_verification_result.advisories {
Self::check_policy(
attestation_policy.tdx_allowed_advisory_ids.as_deref(),
advisory,
"advisories",
)?;
}
// Build combined TDX MR and validate
let tdx_mr = Self::build_tdx_mr([
&report_body.base.mr_td,
&report_body.base.rt_mr0,
&report_body.base.rt_mr1,
&report_body.base.rt_mr2,
&report_body.base.rt_mr3,
]);
Self::check_policy_hash(attestation_policy.tdx_mrs.as_deref(), &tdx_mr, "tdxmr")
}
_ => Err(Error::policy_violation("Unknown quote report format")),
}
}
/// Helper method to validate TCB levels
fn validate_tcb_level(
allowed_levels: &EnumSet<TcbLevel>,
actual_level: TcbLevel,
) -> Result<()> {
if !allowed_levels.contains(actual_level) {
let error_msg = format!(
"Quote verification failed: TCB level mismatch (expected one of: {:?}, actual: {})",
allowed_levels, actual_level
);
return Err(Error::policy_violation(error_msg));
}
Ok(())
}
/// Helper method to build combined TDX measurement register
fn build_tdx_mr<const N: usize>(parts: [&[u8]; N]) -> Vec<u8> {
parts.into_iter().flatten().cloned().collect()
}
/// Check if a policy value matches the actual value
fn check_policy(policy: Option<&[String]>, actual_value: &str, field_name: &str) -> Result<()> {
if let Some(valid_values) = policy {
if !valid_values.iter().any(|value| value == actual_value) {
let error_msg =
format!(
"Quote verification failed: {} mismatch (expected one of: [ {} ], actual: {})",
field_name, valid_values.join(", "), actual_value
);
return Err(Error::policy_violation(error_msg));
}
tracing::debug!(field_name, actual_value, "Attestation policy check passed");
}
Ok(())
}
fn check_policy_hash(
policy: Option<&[Bytes]>,
actual_value: &[u8],
field_name: &str,
) -> Result<()> {
if let Some(valid_values) = policy {
let actual_value = Bytes::copy_from_slice(actual_value);
if !valid_values.contains(&actual_value) {
let valid_values = valid_values
.iter()
.map(hex::encode)
.collect::<Vec<_>>()
.join(", ");
let error_msg = format!(
"Quote verification failed: {} mismatch (expected one of: [ {} ], actual: {:x})",
field_name, valid_values, actual_value
);
return Err(Error::policy_violation(error_msg));
}
tracing::debug!(
field_name,
actual_value = format!("{actual_value:x}"),
"Attestation policy check passed"
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_check_policy() {
// Test with no policy (should pass)
PolicyEnforcer::check_policy_hash(None, &[1, 2, 3], "test").unwrap();
// Test with matching policy
let actual_value: Bytes = hex::decode("01020304").unwrap().into();
PolicyEnforcer::check_policy_hash(
Some(vec![actual_value.clone()]).as_deref(),
&actual_value,
"test",
)
.unwrap();
//.clone() Test with matching policy (multiple values)
PolicyEnforcer::check_policy_hash(
Some(vec![
"aabbcc".into(),
"01020304".into(),
"ddeeff".into(),
actual_value.clone(),
])
.as_deref(),
&actual_value,
"test",
)
.unwrap();
// Test with non-matching policy
PolicyEnforcer::check_policy_hash(
Some(vec!["aabbcc".into(), "ddeeff".into()]).as_deref(),
&actual_value,
"test",
)
.unwrap_err();
}
}

View file

@ -0,0 +1,93 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2023-2025 Matter Labs
use teepot::quote::{tcblevel::TcbLevel, QuoteVerificationResult};
/// Handles reporting and logging of verification results
pub struct VerificationReporter;
impl VerificationReporter {
/// Log summary of a quote verification
pub fn log_quote_verification_summary(quote_verification_result: &QuoteVerificationResult) {
let QuoteVerificationResult {
collateral_expired,
result,
quote,
advisories,
..
} = quote_verification_result;
if *collateral_expired {
tracing::warn!("Freshly fetched collateral expired!");
}
let tcblevel = TcbLevel::from(*result);
let advisories = if advisories.is_empty() {
"None".to_string()
} else {
advisories
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
};
tracing::debug!(
"Quote verification result: {tcblevel}. {report}. Advisory IDs: {advisories}.",
report = &quote.report
);
}
/// Log the results of batch verification
pub fn log_batch_verification_results(
batch_no: u32,
verified_proofs_count: u32,
unverified_proofs_count: u32,
) {
if unverified_proofs_count > 0 {
if verified_proofs_count == 0 {
tracing::error!(
batch_no,
"All {} proofs failed verification!",
unverified_proofs_count
);
} else {
tracing::warn!(
batch_no,
"Some proofs failed verification. Unverified proofs: {}. Verified proofs: {}.",
unverified_proofs_count,
verified_proofs_count
);
}
} else if verified_proofs_count > 0 {
tracing::info!(
batch_no,
"All {} proofs verified successfully!",
verified_proofs_count
);
}
}
/// Log overall verification results for multiple batches
pub fn log_overall_verification_results(
verified_batches_count: u32,
unverified_batches_count: u32,
) {
if unverified_batches_count > 0 {
if verified_batches_count == 0 {
tracing::error!(
"All {} batches failed verification!",
unverified_batches_count
);
} else {
tracing::error!(
"Some batches failed verification! Unverified batches: {}. Verified batches: {}.",
unverified_batches_count,
verified_batches_count
);
}
} else {
tracing::info!("{} batches verified successfully!", verified_batches_count);
}
}
}

View file

@ -0,0 +1,157 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2023-2025 Matter Labs
use secp256k1::{
ecdsa::{RecoverableSignature, RecoveryId, Signature},
Message, SECP256K1,
};
use teepot::{
ethereum::{public_key_to_ethereum_address, recover_signer},
prover::reportdata::ReportData,
quote::QuoteVerificationResult,
};
use zksync_basic_types::H256;
use crate::error;
const SIGNATURE_LENGTH_WITH_RECOVERY_ID: usize = 65;
const SIGNATURE_LENGTH_WITHOUT_RECOVERY_ID: usize = 64;
/// Handles verification of signatures in proofs
pub struct SignatureVerifier;
impl SignatureVerifier {
/// Verify a batch proof signature
pub fn verify_batch_proof(
quote_verification_result: &QuoteVerificationResult,
root_hash: H256,
signature: &[u8],
) -> error::Result<bool> {
let report_data_bytes = quote_verification_result.quote.get_report_data();
tracing::trace!(?report_data_bytes);
let report_data = ReportData::try_from(report_data_bytes).map_err(|e| {
error::Error::internal(format!("Could not convert to ReportData: {}", e))
})?;
Self::verify(&report_data, &root_hash, signature)
}
/// Verify signature against report data and root hash
pub fn verify(
report_data: &ReportData,
root_hash: &H256,
signature: &[u8],
) -> error::Result<bool> {
match report_data {
ReportData::V0(report) => Self::verify_v0(report, root_hash, signature),
ReportData::V1(report) => Self::verify_v1(report, root_hash, signature),
ReportData::Unknown(_) => Ok(false),
}
}
/// Verify a V0 report
fn verify_v0(
report: &teepot::prover::reportdata::ReportDataV0,
root_hash: &H256,
signature: &[u8],
) -> error::Result<bool> {
tracing::debug!("ReportData::V0");
let signature = Signature::from_compact(signature)
.map_err(|e| error::Error::signature_verification(e.to_string()))?;
let root_hash_msg = Message::from_digest(root_hash.0);
Ok(signature.verify(&root_hash_msg, &report.pubkey).is_ok())
}
/// Verify a V1 report
fn verify_v1(
report: &teepot::prover::reportdata::ReportDataV1,
root_hash: &H256,
signature: &[u8],
) -> error::Result<bool> {
tracing::debug!("ReportData::V1");
let ethereum_address_from_report = report.ethereum_address;
let root_hash_msg = Message::from_digest(
root_hash
.as_bytes()
.try_into()
.map_err(|_| error::Error::signature_verification("root hash not 32 bytes"))?,
);
tracing::trace!("sig len = {}", signature.len());
// Try to recover Ethereum address from signature
let ethereum_address_from_signature = match signature.len() {
// Handle 64-byte signature case (missing recovery ID)
SIGNATURE_LENGTH_WITHOUT_RECOVERY_ID => {
SignatureVerifier::recover_address_with_missing_recovery_id(
signature,
&root_hash_msg,
)?
}
// Standard 65-byte signature case
SIGNATURE_LENGTH_WITH_RECOVERY_ID => {
let signature_bytes: [u8; SIGNATURE_LENGTH_WITH_RECOVERY_ID] =
signature.try_into().map_err(|_| {
error::Error::signature_verification(
"Expected 65-byte signature but got a different length",
)
})?;
recover_signer(&signature_bytes, &root_hash_msg).map_err(|e| {
error::Error::signature_verification(format!("Failed to recover signer: {}", e))
})?
}
// Any other length is invalid
len => {
return Err(error::Error::signature_verification(format!(
"Invalid signature length: {len} bytes"
)))
}
};
// Log verification details
tracing::debug!(
"Root hash: {}. Ethereum address from the attestation quote: {}. Ethereum address from the signature: {}.",
root_hash,
hex::encode(ethereum_address_from_report),
hex::encode(ethereum_address_from_signature),
);
Ok(ethereum_address_from_signature == ethereum_address_from_report)
}
/// Helper function to recover Ethereum address when recovery ID is missing
fn recover_address_with_missing_recovery_id(
signature: &[u8],
message: &Message,
) -> error::Result<[u8; 20]> {
tracing::info!("Signature is missing RecoveryId!");
// Try all possible recovery IDs
for rec_id in [
RecoveryId::Zero,
RecoveryId::One,
RecoveryId::Two,
RecoveryId::Three,
] {
let Ok(rec_sig) = RecoverableSignature::from_compact(signature, rec_id) else {
continue;
};
let Ok(public) = SECP256K1.recover_ecdsa(message, &rec_sig) else {
continue;
};
let ethereum_address = public_key_to_ethereum_address(&public);
tracing::info!("Had to use RecoveryId::{rec_id:?}");
return Ok(ethereum_address);
}
// No valid recovery ID found
Err(error::Error::signature_verification(
"Could not find valid recovery ID",
))
}
}