diff --git a/crates/attestation/README.md b/crates/attestation/README.md index 2af5194..54a138f 100644 --- a/crates/attestation/README.md +++ b/crates/attestation/README.md @@ -21,6 +21,14 @@ returns `ExpectedMeasurements::Image`, while an allow-any DCAP policy returns `ExpectedMeasurements::Dcap` with an empty register map. Successful verification without attestation returns `None`. +`VerifiedAttestation` also carries an `EndorsementSnapshot`: the DCAP +collateral bundle the verification consumed and the instant its freshness +checks were evaluated at. Archived beside the evidence, it lets +`AttestationVerifier::verify_attestation_archived` reproduce the verdict later, +against that bundle and at that instant, without fetching anything whose answer +could have changed since. This is for re-verifying archived evidence, not for +verifying live evidence with collateral obtained out of band. + Matched expected measurements can be transported in an HTTP header using `ExpectedMeasurements::to_header_format` and reconstructed with `ExpectedMeasurements::from_header_format`. See diff --git a/crates/attestation/src/azure/attester/mod.rs b/crates/attestation/src/azure/attester/mod.rs index bf4de2a..0943852 100644 --- a/crates/attestation/src/azure/attester/mod.rs +++ b/crates/attestation/src/azure/attester/mod.rs @@ -20,7 +20,6 @@ use super::{ ak_certificate::verify_ak_cert_with_azure_roots, ensure_azure_attestation_payload_size, tpm_quote::TpmQuote, - unix_time_now_secs, }; /// Used in attestation type detection to check if we are on Azure @@ -48,6 +47,10 @@ const AIA_CA_ISSUERS_ACCESS_METHOD_OID: &str = "1.3.6.1.5.5.7.48.2"; /// need network access or AIA-fetching logic. This keeps verification /// deterministic and easier to reuse in constrained verifier environments /// such as TEEs, onchain verification, or zero-knowledge proof generation. +fn unix_time_now_secs() -> Result { + Ok(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs()) +} + pub fn create_azure_attestation(input_data: [u8; 64]) -> Result, MaaError> { let hcl_report_bytes = vtpm::get_report_with_report_data(&input_data)?; diff --git a/crates/attestation/src/azure/mod.rs b/crates/attestation/src/azure/mod.rs index fd0e778..0a53029 100644 --- a/crates/attestation/src/azure/mod.rs +++ b/crates/attestation/src/azure/mod.rs @@ -15,7 +15,12 @@ use thiserror::Error; use tpm_quote::TpmQuote; pub use tpm_quote::TpmQuoteError; pub use tpms_attest::AttestError; -pub use verify::{get_measurements, verify_azure_attestation, verify_azure_attestation_sync}; +pub use verify::{ + get_measurements, + verify_azure_attestation, + verify_azure_attestation_archived, + verify_azure_attestation_sync, +}; /// The attestation evidence payload that gets sent over the channel #[derive(Debug, Serialize, Deserialize)] @@ -103,10 +108,6 @@ where Ok(certificates) } -fn unix_time_now_secs() -> Result { - Ok(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs()) -} - /// An error when generating or verifying a Microsoft Azure vTPM attestation /// (MAA is short for Microsoft Azure Attestation) #[derive(Error, Debug)] diff --git a/crates/attestation/src/azure/verify.rs b/crates/attestation/src/azure/verify.rs index 234196a..039cc78 100644 --- a/crates/attestation/src/azure/verify.rs +++ b/crates/attestation/src/azure/verify.rs @@ -2,9 +2,30 @@ //! computation over the evidence bytes: DCAP verification of the TDX quote, //! HCL report binding checks, vTPM quote verification, and AK certificate //! chain verification against pinned Azure roots. +//! +//! Three entry points, one per situation a relying party is in: +//! +//! - [verify_azure_attestation] is for a live handshake with an async +//! runtime to hand: it fetches whatever collateral the quote needs and +//! judges freshness at the wall clock. +//! - [verify_azure_attestation_sync] is for a live handshake inside a +//! callback that cannot await, such as a rustls certificate verifier. It +//! can only read the PCCS cache, so the collateral has to be there +//! already; a miss fails and starts a background fetch for next time. +//! - [verify_azure_attestation_archived] is for re-checking evidence long +//! after the fact, against the [EndorsementSnapshot] its original +//! verification reported. It fetches nothing and judges freshness at the +//! snapshot's instant, so the verdict is the same however much later it +//! runs. +//! +//! They share one body and differ only in the DCAP leg, which is where +//! collateral and the instant come from. Parsing before it and the vTPM leg +//! after it are identical, and the vTPM leg takes its instant from whatever +//! the DCAP leg reported, so the archived path pins the AK chain check +//! without any code of its own. use az_cvm_vtpm::{hcl, tdx}; use base64::{Engine as _, engine::general_purpose::URL_SAFE as BASE64_URL_SAFE}; -use dcap_qvl::QuoteCollateralV3; +use dcap_qvl::verify::QuoteVerifier; use num_bigint::BigUint; use openssl::pkey::PKey; use pccs::Pccs; @@ -17,45 +38,55 @@ use super::{ TpmAttest, ak_certificate::verify_ak_cert_with_azure_roots, ensure_azure_attestation_payload_size, - unix_time_now_secs, }; use crate::{ + EndorsementSnapshot, VerifiedAttestation, - dcap::{ - verify_dcap_attestation_with_given_timestamp, - verify_dcap_attestation_with_timestamp_sync, - }, + dcap::{verify_quote, verify_quote_archived, verify_quote_sync}, measurements::MultiMeasurements, }; -/// Used during verification to support both sync and async verification -/// paths without duplicating code -struct PreparedAzureAttestation { +/// The TD quote inside an Azure attestation, and the input data it has to +/// commit to +/// +/// Split from [VtpmLeg] so the three verification entry points share +/// everything but the DCAP call +struct PreparedTdQuote { tdx_quote_bytes: Vec, + expected_tdx_input_data: [u8; 64], +} + +/// Everything the vTPM leg of an Azure verification needs once the TD quote +/// has been verified +struct VtpmLeg { hcl_report: hcl::HclReport, var_data_hash: [u8; 32], - expected_tdx_input_data: [u8; 64], tpm_attestation: TpmAttest, } /// Verify a TDX attestation from Azure +/// +/// Collateral for the TD quote comes from `pccs`, and both legs are held +/// to the wall clock. To re-verify archived evidence, see +/// [verify_azure_attestation_archived]. pub async fn verify_azure_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, override_azure_outdated_tcb: bool, ) -> Result { - let now = unix_time_now_secs()?; + let (prepared, vtpm) = prepare_azure_attestation(input)?; - verify_azure_attestation_with_given_timestamp( - input, - expected_input_data, + let (dcap, _) = verify_quote( + prepared.tdx_quote_bytes, + prepared.expected_tdx_input_data, pccs, - None, - now, override_azure_outdated_tcb, + &QuoteVerifier::new_prod(), ) - .await + .await?; + + finish_azure_attestation_verification(vtpm, expected_input_data, dcap) } /// Verify a TDX attestation from Azure - synchronous version @@ -72,107 +103,48 @@ pub fn verify_azure_attestation_sync( pccs: Pccs, override_azure_outdated_tcb: bool, ) -> Result { - let now = unix_time_now_secs()?; - - verify_azure_attestation_with_given_timestamp_sync( - input, - expected_input_data, - pccs, - None, - now, - override_azure_outdated_tcb, - ) -} + let (prepared, vtpm) = prepare_azure_attestation(input)?; -/// Do the verification, passing in the current time -/// This allows us to test this function without time checks going out of -/// date -async fn verify_azure_attestation_with_given_timestamp( - input: Vec, - expected_input_data: [u8; 64], - pccs: Pccs, - collateral: Option, - now: u64, - override_azure_outdated_tcb: bool, -) -> Result { - let PreparedAzureAttestation { - tdx_quote_bytes, - hcl_report, - var_data_hash, - expected_tdx_input_data, - tpm_attestation, - } = prepare_azure_attestation(input)?; - - // Only the endorsements travel upward: this platform is judged on the - // vTPM PCRs, not the TD quote - let (dcap, _) = verify_dcap_attestation_with_given_timestamp( - tdx_quote_bytes, - expected_tdx_input_data, + let (dcap, _) = verify_quote_sync( + prepared.tdx_quote_bytes, + prepared.expected_tdx_input_data, pccs, - collateral, - now, override_azure_outdated_tcb, - ) - .await?; - - // The vTPM leg fetches nothing — AK chain in the evidence, roots - // compiled in — so it adds no endorsements of its own - let measurements = finish_azure_attestation_verification( - hcl_report, - var_data_hash, - tpm_attestation, - expected_input_data, - now, + &QuoteVerifier::new_prod(), )?; - Ok(VerifiedAttestation { - measurements, - expected_measurements: None, - endorsements: dcap.endorsements, - }) + + finish_azure_attestation_verification(vtpm, expected_input_data, dcap) } -/// Synchronous version of the verifier -fn verify_azure_attestation_with_given_timestamp_sync( +/// Re-verify a TDX attestation from Azure against the endorsements a +/// previous verification reported +/// +/// The TD quote is checked against the snapshot's collateral bundle and the +/// AK certificate chain is checked at the snapshot's instant, so both legs +/// are held to the same instant as the original verification and nothing +/// is fetched. A snapshot with no DCAP bundle is refused; see +/// [crate::dcap::verify_dcap_attestation_archived]. +pub fn verify_azure_attestation_archived( input: Vec, expected_input_data: [u8; 64], - pccs: Pccs, - collateral: Option, - now: u64, + endorsements: &EndorsementSnapshot, override_azure_outdated_tcb: bool, ) -> Result { - let PreparedAzureAttestation { - tdx_quote_bytes, - hcl_report, - var_data_hash, - expected_tdx_input_data, - tpm_attestation, - } = prepare_azure_attestation(input)?; - - let (dcap, _) = verify_dcap_attestation_with_timestamp_sync( - tdx_quote_bytes, - expected_tdx_input_data, - pccs, - collateral, - now, + let (prepared, vtpm) = prepare_azure_attestation(input)?; + + let (dcap, _) = verify_quote_archived( + prepared.tdx_quote_bytes, + prepared.expected_tdx_input_data, + endorsements, override_azure_outdated_tcb, + &QuoteVerifier::new_prod(), )?; - let measurements = finish_azure_attestation_verification( - hcl_report, - var_data_hash, - tpm_attestation, - expected_input_data, - now, - )?; - Ok(VerifiedAttestation { - measurements, - expected_measurements: None, - endorsements: dcap.endorsements, - }) + finish_azure_attestation_verification(vtpm, expected_input_data, dcap) } /// Parses the attestation during verification -fn prepare_azure_attestation(input: Vec) -> Result { +fn prepare_azure_attestation(input: Vec) -> Result<(PreparedTdQuote, VtpmLeg), MaaError> { ensure_azure_attestation_payload_size(&input)?; let attestation_document: AttestationDocument = serde_json::from_slice(&input)?; @@ -190,23 +162,27 @@ fn prepare_azure_attestation(input: Vec) -> Result Result { + dcap: VerifiedAttestation, +) -> Result { + let VtpmLeg { hcl_report, var_data_hash, tpm_attestation } = vtpm; + let now = dcap.endorsements.at; let hcl_ak_pub = hcl_report.ak_pub()?; // Get attestation key from runtime claims @@ -273,7 +249,11 @@ fn finish_azure_attestation_verification( now, )?; - Ok(MultiMeasurements::from_indexed_pcrs(pcrs)) + Ok(VerifiedAttestation { + measurements: MultiMeasurements::from_indexed_pcrs(pcrs), + expected_measurements: None, + endorsements: dcap.endorsements, + }) } /// Extract the measurements from the attestation, but do not verify @@ -429,30 +409,10 @@ mod tests { .unwrap_err(); assert_payload_too_large(err, actual); - let err = verify_azure_attestation_with_given_timestamp( - input.clone(), - [0; 64], - Pccs::new( - pccs::CollateralSource::IntelPcs { subscription_key: None }, - pccs::CachePolicy::Passthrough, - ), - None, - 0, - false, - ) - .await - .unwrap_err(); - assert_payload_too_large(err, actual); - - let err = verify_azure_attestation_with_given_timestamp_sync( + let err = verify_azure_attestation_archived( input, [0; 64], - Pccs::new( - pccs::CollateralSource::IntelPcs { subscription_key: None }, - pccs::CachePolicy::OnDemand, - ), - None, - 0, + &EndorsementSnapshot { at: 0, dcap: None }, false, ) .unwrap_err(); @@ -475,8 +435,8 @@ mod tests { /// Verify a complete observed Azure attestation payload that includes /// AK intermediates fetched from the leaf certificate's AIA URLs. - #[tokio::test] - async fn test_verify() { + #[test] + fn test_verify() { // generated using the attester module's [capture_azure_fixture]. let attestation_bytes: &'static [u8] = include_bytes!("../../test-assets/azure-tdx-with-ak-intermediates-1780922561.yaml"); @@ -497,52 +457,19 @@ mod tests { let fixture_collateral: QuoteCollateralV3 = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let VerifiedAttestation { - measurements: async_measurements, - endorsements: async_endorsements, - .. - } = verify_azure_attestation_with_given_timestamp( - attestation_json.clone(), - [0; 64], - Pccs::new( - pccs::CollateralSource::IntelPcs { subscription_key: None }, - pccs::CachePolicy::Passthrough, - ), - Some(fixture_collateral.clone()), - now, - false, - ) - .await - .unwrap(); - - let VerifiedAttestation { - measurements: sync_measurements, - endorsements: sync_endorsements, - .. - } = verify_azure_attestation_with_given_timestamp_sync( - attestation_json, - [0; 64], - Pccs::new( - pccs::CollateralSource::IntelPcs { subscription_key: None }, - pccs::CachePolicy::OnDemand, - ), - Some(fixture_collateral.clone()), - now, - false, - ) - .unwrap(); + let endorsements = EndorsementSnapshot::dcap(fixture_collateral, now); + let VerifiedAttestation { measurements, endorsements: reported, .. } = + verify_azure_attestation_archived(attestation_json, [0; 64], &endorsements, false) + .unwrap(); - assert_eq!(async_measurements, sync_measurements); - // The bundle handed back is the one the DCAP leg consumed, which is - // what makes archiving it provenance rather than a second copy, and - // it arrives paired with the instant both legs were held to - let expected = EndorsementSnapshot::dcap(fixture_collateral, now); - assert_eq!(async_endorsements, expected); - assert_eq!(sync_endorsements, expected); + // The snapshot handed back is the one both legs were held to, so a + // replay of a replay reports the same thing + assert_eq!(reported, endorsements); + assert!(matches!(measurements, MultiMeasurements::Azure(_))); } - #[tokio::test] - async fn test_verify_fails_on_input_mismatch() { + #[test] + fn test_verify_fails_on_input_mismatch() { let attestation_bytes: &'static [u8] = include_bytes!("../../test-assets/azure-tdx-1764662251380464271.yaml"); let now = 1771423480; @@ -558,18 +485,12 @@ mod tests { ) .unwrap(); - let err = verify_azure_attestation_with_given_timestamp( + let err = verify_azure_attestation_archived( attestation_json, expected_input_data, - Pccs::new( - pccs::CollateralSource::IntelPcs { subscription_key: None }, - pccs::CachePolicy::Passthrough, - ), - Some(collateral), - now, + &EndorsementSnapshot::dcap(collateral, now), false, ) - .await .unwrap_err(); assert!(matches!(err, MaaError::ClaimsUserDataInputMismatch)); diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index 98f96e4..39883e2 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -1,6 +1,24 @@ //! Data Center Attestation Primitives (DCAP) evidence generation and //! verification //! +//! Three entry points, one per situation a relying party is in: +//! +//! - [verify_dcap_attestation] is for a live handshake with an async +//! runtime to hand: it fetches whatever collateral the quote needs and +//! judges freshness at the wall clock. +//! - [verify_dcap_attestation_sync] is for a live handshake inside a +//! callback that cannot await, such as a rustls certificate verifier. It +//! can only read the PCCS cache, so the collateral has to be there +//! already; a miss fails and starts a background fetch for next time. +//! - [verify_dcap_attestation_archived] is for re-checking evidence long +//! after the fact, against the [EndorsementSnapshot] its original +//! verification reported. It fetches nothing and judges freshness at the +//! snapshot's instant, so the verdict is the same however much later it +//! runs. +//! +//! They differ only in where collateral and the instant come from; the +//! verification itself is one function they all reach. +//! //! Every verify function returns the parsed [Quote] beside the //! [VerifiedAttestation]: verification parses it anyway, and the GCP //! provenance check needs the PPID from its PCK leaf. Other callers drop @@ -10,6 +28,7 @@ use dcap_qvl::{ intel::{quote_ca, quote_fmspc}, quote::{Quote, Report}, tcb_info::TcbInfo, + verify::QuoteVerifier, }; #[cfg(any(test, feature = "mock"))] use mock_tdx::generate_mock_tdx_quote; @@ -37,23 +56,17 @@ pub fn create_dcap_attestation(input_data: [u8; 64]) -> Result, Attestat } /// Verify a DCAP TDX quote +/// +/// Collateral comes from `pccs`, and every freshness check is evaluated +/// at the wall clock. To re-verify archived evidence, see +/// [verify_dcap_attestation_archived]. #[cfg(not(any(test, feature = "mock")))] pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, ) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); - let override_azure_outdated_tcb = false; - verify_dcap_attestation_with_given_timestamp( - input, - expected_input_data, - pccs, - None, - now, - override_azure_outdated_tcb, - ) - .await + verify_quote(input, expected_input_data, pccs, false, &QuoteVerifier::new_prod()).await } /// Synchronous version - verify a DCAP TDX quote @@ -61,8 +74,7 @@ pub async fn verify_dcap_attestation( /// This relies on having DCAP collateral already present in the cache /// /// [`CachePolicy::Passthrough`](pccs::CachePolicy::Passthrough) is not -/// supported because -/// fetching collateral requires asynchronous I/O. +/// supported because fetching collateral requires asynchronous I/O. /// /// If possible, prefer the async version #[cfg(not(any(test, feature = "mock")))] @@ -71,98 +83,198 @@ pub fn verify_dcap_attestation_sync( expected_input_data: [u8; 64], pccs: Pccs, ) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); - let override_azure_outdated_tcb = false; - verify_dcap_attestation_with_timestamp_sync( + verify_quote_sync(input, expected_input_data, pccs, false, &QuoteVerifier::new_prod()) +} + +/// Re-verify a DCAP TDX quote against the endorsements a previous +/// verification reported +/// +/// The quote is checked against the snapshot's collateral bundle, with +/// every freshness check evaluated at the snapshot's instant rather than +/// the wall clock, and nothing is fetched. Same evidence, same snapshot, +/// same verdict, however much later it runs. +/// +/// A snapshot with no DCAP bundle is refused rather than completed by a +/// fetch: that would evaluate live collateral at a pinned instant, which +/// reproduces nothing. +#[cfg(not(any(test, feature = "mock")))] +pub fn verify_dcap_attestation_archived( + input: Vec, + expected_input_data: [u8; 64], + endorsements: &EndorsementSnapshot, +) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { + verify_quote_archived( input, expected_input_data, - pccs, - None, - now, - override_azure_outdated_tcb, + endorsements, + false, + &QuoteVerifier::new_prod(), ) } -/// Verify a DCAP TDX quote, providing a timestamp and an optional -/// pre-fetched collateral +/// Verify a quote minted by [mock_tdx], which chains to the mock root CA /// -/// This relies on having DCAP collateral already present in the cache -/// -/// [`CachePolicy::Passthrough`](pccs::CachePolicy::Passthrough) is not -/// supported unless `collateral` is provided. -/// -/// If possible, prefer the async version -pub fn verify_dcap_attestation_with_timestamp_sync( +/// With a passthrough PCCS this verifies against the embedded mock +/// collateral, which is what lets a mock build run with no network at all. +#[cfg(any(test, feature = "mock"))] +pub async fn verify_dcap_attestation( + input: Vec, + expected_input_data: [u8; 64], + pccs: Pccs, +) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { + if pccs.is_passthrough() { + return verify_quote_archived( + input, + expected_input_data, + &mock_endorsements_now()?, + false, + &mock_tdx::mock_dcap_verifier(), + ); + } + verify_quote(input, expected_input_data, pccs, false, &mock_tdx::mock_dcap_verifier()).await +} + +/// Synchronous version - verify a quote minted by [mock_tdx] +#[cfg(any(test, feature = "mock"))] +pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, - collateral: Option, - now: u64, - override_azure_outdated_tcb: bool, ) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { - let quote = Quote::parse(&input)?; + if pccs.is_passthrough() { + return verify_quote_archived( + input, + expected_input_data, + &mock_endorsements_now()?, + false, + &mock_tdx::mock_dcap_verifier(), + ); + } + verify_quote_sync(input, expected_input_data, pccs, false, &mock_tdx::mock_dcap_verifier()) +} +/// Re-verify a quote minted by [mock_tdx] against a reported snapshot +#[cfg(any(test, feature = "mock"))] +pub fn verify_dcap_attestation_archived( + input: Vec, + expected_input_data: [u8; 64], + endorsements: &EndorsementSnapshot, +) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { + verify_quote_archived( + input, + expected_input_data, + endorsements, + false, + &mock_tdx::mock_dcap_verifier(), + ) +} + +/// The embedded mock collateral, held to the wall clock +#[cfg(any(test, feature = "mock"))] +fn mock_endorsements_now() -> Result { + Ok(EndorsementSnapshot::dcap(mock_tdx::mock_collateral(), unix_time_now_secs()?)) +} + +fn unix_time_now_secs() -> Result { + Ok(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs()) +} + +/// Fetch collateral through the PCCS and verify at the wall clock +/// +/// Every live verification goes through here or [verify_quote_sync]: the +/// public entry points pick the root per build, while the Azure verifier +/// passes Intel's whatever the build. `override_azure_outdated_tcb` is the +/// TCB relaxation the Azure verifier applies to the quote inside an HCL +/// report. +pub(crate) async fn verify_quote( + raw_quote: Vec, + expected_input_data: [u8; 64], + pccs: Pccs, + override_azure_outdated_tcb: bool, + verifier: &QuoteVerifier, +) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { + let now = unix_time_now_secs()?; + let quote = Quote::parse(&raw_quote)?; let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); - let collateral = if let Some(given_collateral) = collateral { - given_collateral - } else { - pccs.get_collateral_sync(fmspc.clone(), ca, now)? - }; + let (collateral, _is_fresh) = pccs.get_collateral(fmspc, ca, now).await?; - verify_dcap_attestation_with_collateral_and_timestamp( - input, + verify_quote_with_collateral( + raw_quote, quote, expected_input_data, collateral, now, override_azure_outdated_tcb, + verifier, ) } -/// Allows the timestamp to be given, making it possible to test with -/// existing attestations +/// [verify_quote], for a caller with no async runtime /// -/// If collateral is given, it is used instead of contacting PCCS (used in -/// tests) -pub async fn verify_dcap_attestation_with_given_timestamp( - input: Vec, +/// The collateral has to be in the PCCS cache already. +pub(crate) fn verify_quote_sync( + raw_quote: Vec, expected_input_data: [u8; 64], pccs: Pccs, - collateral: Option, - now: u64, override_azure_outdated_tcb: bool, + verifier: &QuoteVerifier, ) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { - let quote = Quote::parse(&input)?; - + let now = unix_time_now_secs()?; + let quote = Quote::parse(&raw_quote)?; let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); - let collateral = if let Some(given_collateral) = collateral { - given_collateral - } else { - let (collateral, _is_fresh) = pccs.get_collateral(fmspc.clone(), ca, now).await?; - collateral - }; + let collateral = pccs.get_collateral_sync(fmspc, ca, now)?; - verify_dcap_attestation_with_collateral_and_timestamp( - input, + verify_quote_with_collateral( + raw_quote, quote, expected_input_data, collateral, now, override_azure_outdated_tcb, + verifier, ) } -fn verify_dcap_attestation_with_collateral_and_timestamp( +/// Verify against a reported snapshot: its bundle, at its instant, with +/// nothing fetched +/// +/// The snapshot is checked before the quote is parsed, so a snapshot with +/// no DCAP bundle is refused up front. +pub(crate) fn verify_quote_archived( + raw_quote: Vec, + expected_input_data: [u8; 64], + endorsements: &EndorsementSnapshot, + override_azure_outdated_tcb: bool, + verifier: &QuoteVerifier, +) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { + let collateral = + endorsements.dcap.clone().ok_or(DcapVerificationError::ArchivedWithoutDcapCollateral)?; + let quote = Quote::parse(&raw_quote)?; + + verify_quote_with_collateral( + raw_quote, + quote, + expected_input_data, + collateral, + endorsements.at, + override_azure_outdated_tcb, + verifier, + ) +} + +/// Verify a quote against collateral already in hand, at a given instant +fn verify_quote_with_collateral( raw_quote: Vec, quote: Quote, expected_input_data: [u8; 64], collateral: QuoteCollateralV3, now: u64, override_azure_outdated_tcb: bool, + verifier: &QuoteVerifier, ) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { tracing::info!("Verifying DCAP attestation: {quote:?}"); @@ -186,7 +298,7 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( |tcb_info: TcbInfo| tcb_info }; - let verified_report = dcap_qvl::verify::dangerous_verify_with_tcb_override( + let verified_report = verifier.dangerous_verify_with_tcb_override( &raw_quote, &collateral, now, @@ -218,75 +330,6 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( )) } -#[cfg(any(test, feature = "mock"))] -pub async fn verify_dcap_attestation( - input: Vec, - expected_input_data: [u8; 64], - pccs: Pccs, -) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { - let quote = Quote::parse(&input)?; - let ca = quote_ca("e)?.as_id_str(); - let fmspc = hex::encode_upper(quote_fmspc("e)?); - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); - - let collateral = if pccs.is_passthrough() { - mock_tdx::mock_collateral() - } else { - let (collateral, _is_fresh) = pccs.get_collateral(fmspc, ca, now).await?; - collateral - }; - let verifier = mock_tdx::mock_dcap_verifier(); - verifier.verify(&input, &collateral, now)?; - - let measurements = MultiMeasurements::from_dcap_qvl_quote("e)?; - if get_quote_input_data("e.report) != expected_input_data { - return Err(DcapVerificationError::InputMismatch); - } - - Ok(( - VerifiedAttestation { - measurements, - expected_measurements: None, - endorsements: EndorsementSnapshot::dcap(collateral, now), - }, - quote, - )) -} - -#[cfg(any(test, feature = "mock"))] -pub fn verify_dcap_attestation_sync( - input: Vec, - expected_input_data: [u8; 64], - pccs: Pccs, -) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { - let quote = Quote::parse(&input)?; - let ca = quote_ca("e)?.as_id_str(); - let fmspc = hex::encode_upper(quote_fmspc("e)?); - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); - - let collateral = if pccs.is_passthrough() { - mock_tdx::mock_collateral() - } else { - pccs.get_collateral_sync(fmspc, ca, now)? - }; - - let verifier = mock_tdx::mock_dcap_verifier(); - verifier.verify(&input, &collateral, now)?; - - let measurements = MultiMeasurements::from_dcap_qvl_quote("e)?; - if get_quote_input_data("e.report) != expected_input_data { - return Err(DcapVerificationError::InputMismatch); - } - Ok(( - VerifiedAttestation { - measurements, - expected_measurements: None, - endorsements: EndorsementSnapshot::dcap(collateral, now), - }, - quote, - )) -} - /// Create a mock quote for testing on non-confidential hardware #[cfg(any(test, feature = "mock"))] fn generate_quote(input: [u8; 64]) -> Result, AttestationError> { @@ -323,6 +366,8 @@ pub enum DcapVerificationError { Pccs(#[from] PccsError), #[error("Timestamp exceeds i64 range")] TimeStampExceedsI64, + #[error("Archived snapshot carries no DCAP collateral to replay the quote against")] + ArchivedWithoutDcapCollateral, } #[cfg(test)] @@ -332,8 +377,8 @@ mod tests { use super::*; use crate::measurements::MeasurementPolicy; - #[tokio::test] - async fn test_dcap_verify() { + #[test] + fn test_dcap_verify() { let attestation_bytes: &'static [u8] = include_bytes!("../test-assets/dcap-tdx-1766059550570652607"); @@ -364,63 +409,47 @@ mod tests { let fixture_collateral: QuoteCollateralV3 = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let (VerifiedAttestation { measurements: async_measurements, endorsements, .. }, _) = - verify_dcap_attestation_with_given_timestamp( - attestation_bytes.to_vec(), - [ - 116, 39, 106, 100, 143, 31, 212, 145, 244, 116, 162, 213, 44, 114, 216, 80, - 227, 118, 129, 87, 180, 62, 194, 151, 169, 145, 116, 130, 189, 119, 39, 139, - 161, 136, 37, 136, 57, 29, 25, 86, 182, 246, 70, 106, 216, 184, 220, 205, 85, - 245, 114, 33, 173, 129, 180, 32, 247, 70, 250, 141, 176, 248, 99, 125, - ], - Pccs::new( - CollateralSource::IntelPcs { subscription_key: None }, - CachePolicy::Passthrough, - ), - Some(fixture_collateral.clone()), - now, - false, - ) - .await - .unwrap(); - - let (VerifiedAttestation { measurements: sync_measurements, .. }, _) = - verify_dcap_attestation_with_timestamp_sync( - attestation_bytes.to_vec(), - [ - 116, 39, 106, 100, 143, 31, 212, 145, 244, 116, 162, 213, 44, 114, 216, 80, - 227, 118, 129, 87, 180, 62, 194, 151, 169, 145, 116, 130, 189, 119, 39, 139, - 161, 136, 37, 136, 57, 29, 25, 86, 182, 246, 70, 106, 216, 184, 220, 205, 85, - 245, 114, 33, 173, 129, 180, 32, 247, 70, 250, 141, 176, 248, 99, 125, - ], - Pccs::new( - CollateralSource::IntelPcs { subscription_key: None }, - CachePolicy::OnDemand, - ), - Some(fixture_collateral.clone()), - now, - false, - ) - .unwrap(); - - assert_eq!(async_measurements, sync_measurements); - // A caller archiving provenance gets back the bundle the - // verification consumed, not a second copy of it - assert_eq!(endorsements.dcap, Some(fixture_collateral)); - // ... and the instant it was held to, which is the other half of - // what makes the verification reproducible - assert_eq!(endorsements.at, now); + // A real Intel quote, so it is checked against Intel's root + // whatever the build: the public archived entry point would + // use the mock root under `test` + let (VerifiedAttestation { measurements, endorsements, .. }, _) = verify_quote_archived( + attestation_bytes.to_vec(), + [ + 116, 39, 106, 100, 143, 31, 212, 145, 244, 116, 162, 213, 44, 114, 216, 80, 227, + 118, 129, 87, 180, 62, 194, 151, 169, 145, 116, 130, 189, 119, 39, 139, 161, 136, + 37, 136, 57, 29, 25, 86, 182, 246, 70, 106, 216, 184, 220, 205, 85, 245, 114, 33, + 173, 129, 180, 32, 247, 70, 250, 141, 176, 248, 99, 125, + ], + &EndorsementSnapshot::dcap(fixture_collateral.clone(), now), + false, + &QuoteVerifier::new_prod(), + ) + .unwrap(); + + // The snapshot handed back is the one the verification ran against, + // which is what lets a caller archive and replay it + assert_eq!(endorsements, EndorsementSnapshot::dcap(fixture_collateral, now)); let platform_metadata = crate::mock_platform_metadata(crate::AttestationType::DcapTdx).unwrap(); - measurement_policy - .check_measurement(&async_measurements, Some(&platform_metadata)) - .unwrap(); + measurement_policy.check_measurement(&measurements, Some(&platform_metadata)).unwrap(); + } + + /// An archived snapshot without a bundle is refused up front, before + /// the quote is even parsed: completing it with a fetch would evaluate + /// live collateral at a pinned instant, which reproduces nothing + #[test] + fn archived_without_collateral_is_refused() { + let endorsements = EndorsementSnapshot { at: 0, dcap: None }; + + let err = verify_dcap_attestation_archived(Vec::new(), [0; 64], &endorsements).unwrap_err(); + + assert!(matches!(err, DcapVerificationError::ArchivedWithoutDcapCollateral), "{err:?}"); } // This specifically tests a quote which has outdated TCB level from // Azure - #[tokio::test] - async fn test_dcap_verify_azure_override() { + #[test] + fn test_dcap_verify_azure_override() { let attestation_bytes: &'static [u8] = include_bytes!("../test-assets/azure_failed_dcap_quote_10.bin"); @@ -433,22 +462,17 @@ mod tests { let collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); - verify_dcap_attestation_with_given_timestamp( + verify_quote_archived( attestation_bytes.to_vec(), [ 210, 20, 43, 100, 53, 152, 235, 95, 174, 43, 200, 82, 157, 215, 154, 85, 139, 41, 248, 104, 204, 187, 101, 49, 203, 40, 218, 185, 220, 228, 119, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ], - Pccs::new( - CollateralSource::IntelPcs { subscription_key: None }, - CachePolicy::Passthrough, - ), - Some(collateral), - now, + &EndorsementSnapshot::dcap(collateral, now), true, + &QuoteVerifier::new_prod(), ) - .await .unwrap(); } diff --git a/crates/attestation/src/gcp/firmware.rs b/crates/attestation/src/gcp/firmware.rs index e57b2e1..ae06e61 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -73,13 +73,14 @@ pub(crate) enum GcpFirmwareCacheError { mod tests { use attest_measure::dcap::DcapFirmware; use attest_types::{AcpiHashes, DcapImageHashes}; - use dcap_qvl::quote::Quote; + use dcap_qvl::{quote::Quote, verify::QuoteVerifier}; use super::GcpFirmwareCache; use crate::{ + EndorsementSnapshot, PlatformMetadata, VerifiedAttestation, - dcap::{get_quote_input_data, verify_dcap_attestation_with_given_timestamp}, + dcap::{get_quote_input_data, verify_quote_archived}, measurements::{ExpectedMeasurements, MeasurementPolicy, MeasurementRecord}, }; @@ -140,8 +141,8 @@ mod tests { } } - #[tokio::test] - async fn test_gcp_tdx_portable_policy_with_stored_collateral() { + #[test] + fn test_gcp_tdx_portable_policy_with_stored_collateral() { let attestation_bytes: &'static [u8] = include_bytes!("../../test-assets/gcp-tdx-1782809233226668671"); let collateral_bytes: &'static [u8] = @@ -156,20 +157,16 @@ mod tests { let collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); let firmware = serde_saphyr::from_slice(firmware_bytes).unwrap(); - let (VerifiedAttestation { measurements, .. }, _) = - verify_dcap_attestation_with_given_timestamp( - attestation_bytes.to_vec(), - expected_input_data, - pccs::Pccs::new( - pccs::CollateralSource::IntelPcs { subscription_key: None }, - pccs::CachePolicy::Passthrough, - ), - Some(collateral), - GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, - false, - ) - .await - .unwrap(); + // A real GCP quote, so it is checked against Intel's root whatever + // the build + let (VerifiedAttestation { measurements, .. }, _) = verify_quote_archived( + attestation_bytes.to_vec(), + expected_input_data, + &EndorsementSnapshot::dcap(collateral, GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP), + false, + &QuoteVerifier::new_prod(), + ) + .unwrap(); let measurement_policy = MeasurementPolicy { accepted_measurements: vec![MeasurementRecord { diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 44ce5a2..8be629e 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -361,6 +361,10 @@ impl AttestationGenerator { /// instead. Hence a struct that grows fields rather than an enum keyed by /// platform, and `#[non_exhaustive]` to keep that growth additive. /// +/// Handed back by every verification and accepted back by +/// [`AttestationVerifier::verify_attestation_archived`], which re-verifies +/// the evidence against it with nothing fetched. +/// /// Two caveats. Trust anchors are compiled in rather than captured here, so /// a replay needs a build carrying the same ones — under `mock`, the mock /// root. And "endorsements" is loose: in [RFC 9334] terms a DCAP bundle @@ -631,6 +635,11 @@ impl AttestationVerifier { /// Verify an attestation, and return the expected measurements from the /// matching policy record. + /// + /// Endorsements are fetched and every freshness check is evaluated at + /// the wall clock. To re-verify archived evidence against the + /// endorsements it was originally verified with, see + /// [`Self::verify_attestation_archived`]. pub async fn verify_attestation( &self, attestation_exchange_message: AttestationExchangeMessage, @@ -742,6 +751,59 @@ impl AttestationVerifier { &self, attestation_exchange_message: AttestationExchangeMessage, expected_input_data: [u8; 64], + ) -> Result, AttestationError> { + self.verify_attestation_blocking(attestation_exchange_message, expected_input_data, None) + } + + /// Re-verifies archived evidence against the endorsements a previous + /// verification reported, then checks the configured policy. + /// + /// `endorsements` is the [`EndorsementSnapshot`] that verification + /// handed back in its [`VerifiedAttestation`]. The DCAP quote is + /// checked against that snapshot's collateral bundle, and on Azure + /// the AK certificate chain is checked as well, both at the + /// snapshot's instant rather than the wall clock. Same evidence, + /// same snapshot, same verdict, however long after the fact. + /// + /// Nothing whose answer can change over time is fetched. On GCP that + /// means the provenance lookup against Google's PPID registry is + /// skipped: the registry is unsigned and mutable, so a replay could + /// only learn what it says today, and the original verification + /// already consulted it. Firmware for a portable-image policy is + /// still fetched on a cache miss, since it is signed and + /// content-addressed by the quote's MRTD, so the fetch cannot + /// change the verdict. Recording the provenance outcome and the + /// firmware in the snapshot would remove these caveats and is left + /// for a later change. + /// + /// This is not a way to verify live evidence with collateral obtained + /// out of band: the snapshot pins the instant along with the bundle, + /// and a live verification belongs on [`Self::verify_attestation`]. + /// A snapshot with no DCAP bundle fails with + /// [`DcapVerificationError::ArchivedWithoutDcapCollateral`] rather than + /// being completed by a fetch. + pub fn verify_attestation_archived( + &self, + attestation_exchange_message: AttestationExchangeMessage, + expected_input_data: [u8; 64], + endorsements: &EndorsementSnapshot, + ) -> Result, AttestationError> { + self.verify_attestation_blocking( + attestation_exchange_message, + expected_input_data, + Some(endorsements), + ) + } + + /// The body shared by [`Self::verify_attestation_sync`] and + /// [`Self::verify_attestation_archived`], which differ only in where + /// the DCAP leg gets its endorsements and whether the GCP provenance + /// lookup runs + fn verify_attestation_blocking( + &self, + attestation_exchange_message: AttestationExchangeMessage, + expected_input_data: [u8; 64], + archived: Option<&EndorsementSnapshot>, ) -> Result, AttestationError> { let attestation_type = attestation_exchange_message.attestation_type(); tracing::debug!("Verifying {attestation_type} attestation"); @@ -768,12 +830,20 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - azure::verify_azure_attestation_sync( - attestation_evidence.quote.clone(), - expected_input_data, - self.internal_pccs.clone(), - self.override_azure_outdated_tcb, - )? + match archived { + None => azure::verify_azure_attestation_sync( + attestation_evidence.quote.clone(), + expected_input_data, + self.internal_pccs.clone(), + self.override_azure_outdated_tcb, + )?, + Some(endorsements) => azure::verify_azure_attestation_archived( + attestation_evidence.quote.clone(), + expected_input_data, + endorsements, + self.override_azure_outdated_tcb, + )?, + } } #[cfg(not(feature = "azure-verifier"))] { @@ -785,14 +855,21 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - let pccs = self.internal_pccs.clone(); - - let (verified, quote) = dcap::verify_dcap_attestation_sync( - attestation_evidence.quote.clone(), - expected_input_data, - pccs, - )?; - if attestation_type == AttestationType::GcpTdx { + let (verified, quote) = match archived { + None => dcap::verify_dcap_attestation_sync( + attestation_evidence.quote.clone(), + expected_input_data, + self.internal_pccs.clone(), + )?, + Some(endorsements) => dcap::verify_dcap_attestation_archived( + attestation_evidence.quote.clone(), + expected_input_data, + endorsements, + )?, + }; + // The registry is unsigned and mutable: a replay could only + // learn what it says now, so only a live verification asks + if attestation_type == AttestationType::GcpTdx && archived.is_none() { self.gcp_provenance_checker.verify_provenance_sync("e)?; } verified @@ -1236,6 +1313,67 @@ mod tests { ); } + /// What a live verification reports is enough to reproduce its verdict + /// later, policy check included, with nothing fetched + #[tokio::test] + async fn archived_replay_reproduces_the_live_verdict() { + let input_data = [7u8; 64]; + let quote = dcap::create_dcap_attestation(input_data).unwrap(); + let attestation_evidence = AttestationEvidence { + quote, + platform: mock_platform_metadata(AttestationType::DcapTdx).unwrap(), + }; + let verifier = AttestationVerifier::mock(); + let message: AttestationExchangeMessage = attestation_evidence.into(); + + let live = verifier + .verify_attestation(message.clone(), input_data) + .await + .unwrap() + .expect("mock evidence carries an attestation"); + let replayed = verifier + .verify_attestation_archived(message, input_data, &live.endorsements) + .unwrap() + .expect("mock evidence carries an attestation"); + + assert_eq!(replayed.measurements, live.measurements); + assert_eq!(replayed.expected_measurements, live.expected_measurements); + assert_eq!(replayed.endorsements, live.endorsements); + } + + /// A replay of GCP evidence does not consult the provenance registry. + /// A mock PPID is not in Google's registry, so were the lookup to run + /// it would fail closed + #[test] + fn archived_gcp_replay_skips_the_provenance_lookup() { + let input_data = [7u8; 64]; + let quote = dcap::create_dcap_attestation(input_data).unwrap(); + let attestation_evidence = AttestationEvidence { + quote, + platform: mock_platform_metadata(AttestationType::GcpTdx).unwrap(), + }; + let verifier = AttestationVerifier::mock(); + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let endorsements = EndorsementSnapshot::dcap(mock_tdx::mock_collateral(), now); + + let replayed = verifier.verify_attestation_archived( + attestation_evidence.into(), + input_data, + &endorsements, + ); + + assert!( + matches!( + replayed, + Ok(Some(VerifiedAttestation { + expected_measurements: Some(ExpectedMeasurements::Dcap(_)), + .. + })) + ), + "expected archived GCP replay to pass the policy check: {replayed:?}" + ); + } + /// On the fetching path, the reported bundle is the one the fetch /// produced — the property that makes archiving it provenance rather /// than a second, possibly different, copy.