From d97066104c9cc00f383e8f6ef22d9f57d24330b9 Mon Sep 17 00:00:00 2001 From: Samuel Laferriere <9342524+samlaf@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:12:16 -0400 Subject: [PATCH] feat(attestation)!: pin verification to archived collateral with VerifyMode Closes flashbots/attested-tls#84 Second of two changes for that issue, on top of flashbots/attested-tls#85. Reporting the endorsements a verification consumed is half of provenance. The other half is running the same verification again later, against that snapshot, and getting the same answer. Nothing exposed that: the public entry points always fetched and always read the wall clock, and the only way to supply a bundle was through variants that also took a bare timestamp. API before and after -------------------- AttestationVerifier verify_attestation(msg, input) -> verify_attestation(msg, input, mode) verify_attestation_sync(msg, input) -> verify_attestation_sync(msg, input, mode) dcap verify_dcap_attestation(q, input, pccs) -> verify_dcap_attestation(q, input, mode, pccs) verify_dcap_attestation_sync(q, input, pccs) -> verify_dcap_attestation_sync(q, input, mode, pccs) verify_dcap_attestation_with_given_timestamp( q, input, pccs, Option, now, override_azure_outdated_tcb) -> (removed) verify_dcap_attestation_with_timestamp_sync( q, input, pccs, Option, now, override_azure_outdated_tcb) -> (removed) azure verify_azure_attestation(a, input, pccs, override) -> verify_azure_attestation(a, input, mode, pccs, override) verify_azure_attestation_sync(a, input, pccs, override) -> verify_azure_attestation_sync(a, input, mode, pccs, override) new enum VerifyMode { Live, Archived(EndorsementSnapshot) } DcapVerificationError::ArchivedWithoutDcapCollateral Why this shape -------------- One input instead of two. The removed variants took the collateral and the instant as separate arguments, so a caller could pair a pinned bundle with the wrong instant, or a live fetch with a pinned instant, and get a verdict that reproduces nothing. VerifyMode::Archived takes the EndorsementSnapshot that #85 hands back, so the bundle and the instant it was held to travel together and the mistake has no spelling. The mixed case is refused too: an archived snapshot with no bundle for the DCAP leg fails with ArchivedWithoutDcapCollateral rather than being completed by a fetch. The mode reaches the verifier. The measurement-policy check lives on AttestationVerifier, and a relying party re-checking archived evidence needs both it and the pinned instant. The removed variants sat below the verifier, so that combination did not exist. The mode is a parameter of the call rather than the builder because it is a fact about one verification, not about the verifier: the same instance serves a live handshake and an archive replay. One instant for both Azure legs. The DCAP leg reports the instant it evaluated at, and the vTPM AK chain is checked at that same instant, in either mode. The wall clock is read in exactly one place. The Azure TCB override leaves the public surface. It rode along on the removed variants only because the Azure verifier and the fixture tests shared them. Both now call the crate-private body that the two public entry points wrap, so the override is an argument of the Azure leg and nothing else. Only Azure has a reason to relax TCB checks. Live is behaviour-preserving. Every existing caller passes VerifyMode::Live and gets what it got before: collateral from the PCCS or Intel, freshness at the wall clock. The two in-tree callers, attested-tls and attestation-provider-server, needed only that argument. GCP checks and Archived mode ---------------------------- The GCP host provenance check from flashbots/attested-tls#54 stays live in either mode, as does the firmware fetch for the quote's MRTD. Neither rests on signed material a replay could re-verify: the provenance document is an unsigned JSON object whose trust is the TLS connection to Google's bucket, so archiving it would not make a replay stronger. The docs on VerifyMode::Archived and verify_attestation state the carve-out. Whether Archived should skip the provenance lookup instead is left open. BREAKING CHANGE: verify_attestation and verify_attestation_sync take a VerifyMode; the DCAP and Azure entry points take mode before pccs; the *_with_given_timestamp variants are gone, replaced by VerifyMode::Archived, which fails with DcapVerificationError::ArchivedWithoutDcapCollateral when its snapshot carries no DCAP bundle. --- crates/attestation-provider-server/src/lib.rs | 6 +- crates/attestation/src/azure/attester/mod.rs | 5 +- crates/attestation/src/azure/mod.rs | 4 - crates/attestation/src/azure/verify.rs | 162 ++++------ crates/attestation/src/dcap.rs | 300 ++++++++++-------- crates/attestation/src/gcp/firmware.rs | 29 +- crates/attestation/src/lib.rs | 52 ++- crates/attested-tls/src/lib.rs | 3 +- 8 files changed, 293 insertions(+), 268 deletions(-) diff --git a/crates/attestation-provider-server/src/lib.rs b/crates/attestation-provider-server/src/lib.rs index a167b37..9bd7e11 100644 --- a/crates/attestation-provider-server/src/lib.rs +++ b/crates/attestation-provider-server/src/lib.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; pub use attestation::AttestationGenerator; -use attestation::{AttestationError, AttestationExchangeMessage, AttestationVerifier}; +use attestation::{AttestationError, AttestationExchangeMessage, AttestationVerifier, VerifyMode}; use axum::{ extract::{Path, State}, http::StatusCode, @@ -61,7 +61,9 @@ pub async fn attestation_provider_client( println!("Remote attestation type: {remote_attestation_type}"); - attestation_verifier.verify_attestation(remote_attestation_message.clone(), input_data).await?; + attestation_verifier + .verify_attestation(remote_attestation_message.clone(), input_data, VerifyMode::Live) + .await?; Ok(remote_attestation_message) } diff --git a/crates/attestation/src/azure/attester/mod.rs b/crates/attestation/src/azure/attester/mod.rs index 0a557b7..cab17c7 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 @@ -151,6 +150,10 @@ impl TryFrom<&vtpm::Quote> for TpmQuote { } } +fn unix_time_now_secs() -> Result { + Ok(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs()) +} + /// Fetch intermediate certificates from the Authority Information Access /// (AIA) CA Issuers URLs in the leaf and each fetched intermediate. /// diff --git a/crates/attestation/src/azure/mod.rs b/crates/attestation/src/azure/mod.rs index d0cfd10..21a20ac 100644 --- a/crates/attestation/src/azure/mod.rs +++ b/crates/attestation/src/azure/mod.rs @@ -103,10 +103,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 8c6285d..0a512f1 100644 --- a/crates/attestation/src/azure/verify.rs +++ b/crates/attestation/src/azure/verify.rs @@ -4,7 +4,7 @@ //! chain verification against pinned Azure roots. 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,14 +17,11 @@ use super::{ TpmAttest, ak_certificate::verify_ak_cert_with_azure_roots, ensure_azure_attestation_payload_size, - unix_time_now_secs, }; use crate::{ VerifiedAttestation, - dcap::{ - verify_dcap_attestation_with_given_timestamp, - verify_dcap_attestation_with_timestamp_sync, - }, + VerifyMode, + dcap::{verify_quote, verify_quote_sync}, measurements::MultiMeasurements, }; @@ -39,57 +36,17 @@ struct PreparedAzureAttestation { } /// Verify a TDX attestation from Azure -pub async fn verify_azure_attestation( - input: Vec, - expected_input_data: [u8; 64], - pccs: Option, - override_azure_outdated_tcb: bool, -) -> Result { - let now = unix_time_now_secs()?; - - verify_azure_attestation_with_given_timestamp( - input, - expected_input_data, - pccs, - None, - now, - override_azure_outdated_tcb, - ) - .await -} - -/// Verify a TDX attestation from Azure - synchronous version /// -/// This relies on having DCAP collateral already present in the cache -/// -/// If possible, prefer the async version -pub fn verify_azure_attestation_sync( - input: Vec, - expected_input_data: [u8; 64], - 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, - ) -} - -/// 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( +/// `mode` gates the DCAP leg and the vTPM leg alike: on +/// [VerifyMode::Archived] the AK certificate chain is checked as of the +/// same instant as the snapshot, and nothing reaches the network. +/// `pccs` only matters on [VerifyMode::Live]; see +/// [crate::dcap::verify_dcap_attestation]. +pub async fn verify_azure_attestation( input: Vec, expected_input_data: [u8; 64], + mode: VerifyMode, pccs: Option, - collateral: Option, - now: u64, override_azure_outdated_tcb: bool, ) -> Result { let PreparedAzureAttestation { @@ -100,37 +57,46 @@ async fn verify_azure_attestation_with_given_timestamp( 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( + // The DCAP leg reports the instant it evaluated at, so the vTPM leg + // below is held to the same one - on [VerifyMode::Live] the clock + // is read once, not once per leg. Only the endorsements travel + // upward: this platform is judged on the vTPM PCRs, not the TD + // quote + let (dcap, _) = verify_quote( tdx_quote_bytes, expected_tdx_input_data, + mode, pccs, - collateral, - now, override_azure_outdated_tcb, + &QuoteVerifier::new_prod(), + None, ) .await?; - // The vTPM leg fetches nothing — AK chain in the evidence, roots - // compiled in — so it adds no endorsements of its own + // 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, + dcap.endorsements.at, )?; Ok(VerifiedAttestation { measurements, endorsements: dcap.endorsements }) } -/// Synchronous version of the verifier -fn verify_azure_attestation_with_given_timestamp_sync( +/// Verify a TDX attestation from Azure - synchronous version +/// +/// `pccs` only matters on [VerifyMode::Live], and then the collateral has +/// to be in its cache already; see +/// [crate::dcap::verify_dcap_attestation_sync]. +/// +/// If possible, prefer the async version +pub fn verify_azure_attestation_sync( input: Vec, expected_input_data: [u8; 64], + mode: VerifyMode, pccs: Pccs, - collateral: Option, - now: u64, override_azure_outdated_tcb: bool, ) -> Result { let PreparedAzureAttestation { @@ -141,13 +107,13 @@ fn verify_azure_attestation_with_given_timestamp_sync( tpm_attestation, } = prepare_azure_attestation(input)?; - let (dcap, _) = verify_dcap_attestation_with_timestamp_sync( + let (dcap, _) = verify_quote_sync( tdx_quote_bytes, expected_tdx_input_data, + mode, pccs, - collateral, - now, override_azure_outdated_tcb, + &QuoteVerifier::new_prod(), )?; let measurements = finish_azure_attestation_verification( @@ -155,7 +121,7 @@ fn verify_azure_attestation_with_given_timestamp_sync( var_data_hash, tpm_attestation, expected_input_data, - now, + dcap.endorsements.at, )?; Ok(VerifiedAttestation { measurements, endorsements: dcap.endorsements }) } @@ -346,10 +312,9 @@ impl RsaPubKey { #[cfg(test)] mod tests { - use dcap_qvl::QuoteCollateralV3; use super::{super::MAX_AZURE_ATTESTATION_PAYLOAD_SIZE, *}; - use crate::EndorsementSnapshot; + use crate::{EndorsementSnapshot, QuoteCollateralV3}; fn input_data_from_attestation(attestation_bytes: &[u8]) -> [u8; 64] { let attestation_document: AttestationDocument = @@ -386,43 +351,24 @@ mod tests { } /// All verification entry points must reject an oversized payload, and - /// must do so before attempting DCAP verification (no collateral or - /// usable PCCS is provided here). + /// must do so before attempting DCAP verification. [VerifyMode::Live] + /// with no PCCS is the strict case: were the size gate to miss, the + /// verification would reach out to Intel. #[tokio::test] async fn verify_rejects_oversized_payload_before_deserialize() { let actual = MAX_AZURE_ATTESTATION_PAYLOAD_SIZE + 1; let input = vec![b'{'; actual]; - let err = verify_azure_attestation(input.clone(), [0; 64], None, false).await.unwrap_err(); + let err = verify_azure_attestation(input.clone(), [0; 64], VerifyMode::Live, None, false) + .await + .unwrap_err(); assert_payload_too_large(err, actual); let err = verify_azure_attestation_sync( - input.clone(), - [0; 64], - Pccs::new_without_prewarm(None), - false, - ) - .unwrap_err(); - assert_payload_too_large(err, actual); - - let err = verify_azure_attestation_with_given_timestamp( - input.clone(), - [0; 64], - None, - None, - 0, - false, - ) - .await - .unwrap_err(); - assert_payload_too_large(err, actual); - - let err = verify_azure_attestation_with_given_timestamp_sync( input, [0; 64], + VerifyMode::Live, Pccs::new_without_prewarm(None), - None, - 0, false, ) .unwrap_err(); @@ -470,12 +416,11 @@ mod tests { let VerifiedAttestation { measurements: async_measurements, endorsements: async_endorsements, - } = verify_azure_attestation_with_given_timestamp( + } = verify_azure_attestation( attestation_json.clone(), [0; 64], + VerifyMode::Archived(EndorsementSnapshot::dcap(fixture_collateral.clone(), now)), None, - Some(fixture_collateral.clone()), - now, false, ) .await @@ -484,20 +429,20 @@ mod tests { let VerifiedAttestation { measurements: sync_measurements, endorsements: sync_endorsements, - } = verify_azure_attestation_with_given_timestamp_sync( + } = verify_azure_attestation_sync( attestation_json, [0; 64], + VerifyMode::Archived(EndorsementSnapshot::dcap(fixture_collateral.clone(), now)), Pccs::new_without_prewarm(None), - Some(fixture_collateral.clone()), - now, 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 + // The bundle handed back is the one the verification consumed, + // which is what makes archiving it provenance rather than a + // second copy, and it arrives paired with the instant it + // was held to let expected = EndorsementSnapshot::dcap(fixture_collateral, now); assert_eq!(async_endorsements, expected); assert_eq!(sync_endorsements, expected); @@ -520,12 +465,11 @@ mod tests { ) .unwrap(); - let err = verify_azure_attestation_with_given_timestamp( + let err = verify_azure_attestation( attestation_json, expected_input_data, + VerifyMode::Archived(EndorsementSnapshot::dcap(collateral, now)), None, - Some(collateral), - now, false, ) .await diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index f863ae9..29390f3 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -11,6 +11,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; @@ -21,6 +22,7 @@ use crate::{ AttestationError, EndorsementSnapshot, VerifiedAttestation, + VerifyMode, measurements::MultiMeasurements, }; @@ -39,129 +41,191 @@ pub fn create_dcap_attestation(input_data: [u8; 64]) -> Result, Attestat } /// Verify a DCAP TDX quote +/// +/// `pccs` only matters on [VerifyMode::Live]: collateral comes from it, or +/// straight from Intel when there is none. [VerifyMode::Archived] carries +/// its own bundle and never consults it. #[cfg(not(any(test, feature = "mock")))] pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], + mode: VerifyMode, + pccs: Option, +) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { + verify_quote(input, expected_input_data, mode, pccs, false, &QuoteVerifier::new_prod(), None) + .await +} + +/// Verify a quote minted by [mock_tdx], which chains to the mock root CA +/// +/// With neither a pinned bundle nor a 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], + mode: VerifyMode, pccs: Option, ) -> 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( + verify_quote( input, expected_input_data, + mode, pccs, - None, - now, - override_azure_outdated_tcb, + false, + &mock_tdx::mock_dcap_verifier(), + Some(mock_tdx::mock_collateral()), ) .await } /// Synchronous version - verify a DCAP TDX quote /// -/// This relies on having DCAP collateral already present in the cache +/// `pccs` only matters on [VerifyMode::Live], and then the collateral has +/// to be in its cache already. [VerifyMode::Archived] carries its own +/// bundle and never consults it. /// /// If possible, prefer the async version #[cfg(not(any(test, feature = "mock")))] pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], + mode: VerifyMode, 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, mode, pccs, false, &QuoteVerifier::new_prod()) +} + +/// 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], + mode: VerifyMode, + pccs: Pccs, +) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { + verify_quote_sync( input, expected_input_data, + mode, pccs, - None, - now, - override_azure_outdated_tcb, + false, + &mock_tdx::mock_dcap_verifier(), ) } -/// Verify a DCAP TDX quote, providing a timestamp and an optional -/// pre-fetched collateral +/// The collateral a DCAP verification runs against, or `None` to fetch it, +/// and the instant to evaluate freshness at /// -/// This relies on having DCAP collateral already present in the cache +/// The one place a verification reads the wall clock. An archived snapshot +/// has to carry a DCAP bundle: completing one with a fetch would evaluate +/// live collateral at a pinned instant, which is neither mode. +fn resolve_mode( + mode: VerifyMode, +) -> Result<(Option, u64), DcapVerificationError> { + match mode { + VerifyMode::Live => { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?; + Ok((None, now.as_secs())) + } + VerifyMode::Archived(EndorsementSnapshot { at, dcap: Some(collateral) }) => { + Ok((Some(collateral), at)) + } + VerifyMode::Archived(EndorsementSnapshot { dcap: None, .. }) => { + Err(DcapVerificationError::ArchivedWithoutDcapCollateral) + } + } +} + +/// Resolve the collateral a verification runs against, then verify /// -/// If possible, prefer the async version -pub fn verify_dcap_attestation_with_timestamp_sync( - input: Vec, +/// Every root goes through here: the public entry points pick one per +/// build, while the Azure verifier and the fixture tests replaying real +/// captures pass Intel's, whatever the build. `override_azure_outdated_tcb` +/// is the TCB relaxation the Azure verifier applies to the quote inside an +/// HCL report. `fallback_collateral` is the bundle of last resort, used +/// when the mode pins none and there is no PCCS; `None` fetches from Intel. +pub(crate) async fn verify_quote( + raw_quote: Vec, expected_input_data: [u8; 64], - pccs: Pccs, - collateral: Option, - now: u64, + mode: VerifyMode, + pccs: Option, override_azure_outdated_tcb: bool, + verifier: &QuoteVerifier, + fallback_collateral: Option, ) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { - let quote = Quote::parse(&input)?; - + let (pinned_collateral, now) = resolve_mode(mode)?; + 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 + let collateral = if let Some(pinned_collateral) = pinned_collateral { + pinned_collateral + } else if let Some(ref pccs) = pccs { + let (collateral, _is_fresh) = pccs.get_collateral(fmspc.clone(), ca, now).await?; + collateral + } else if let Some(fallback_collateral) = fallback_collateral { + fallback_collateral } else { - pccs.get_collateral_sync(fmspc.clone(), ca, now)? + CollateralClient::with_default_http(PCS_URL)? + .fetch_for_fmspc_without_pck_chain(&fmspc, ca, false) + .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, +/// On [VerifyMode::Live] the collateral has to be in the PCCS cache +/// already: there is no fetch of last resort here. +pub(crate) fn verify_quote_sync( + raw_quote: Vec, expected_input_data: [u8; 64], - pccs_option: Option, - collateral: Option, - now: u64, + mode: VerifyMode, + pccs: Pccs, override_azure_outdated_tcb: bool, + verifier: &QuoteVerifier, ) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { - let quote = Quote::parse(&input)?; - + let (pinned_collateral, now) = resolve_mode(mode)?; + 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 if let Some(ref pccs) = pccs_option { - let (collateral, _is_fresh) = pccs.get_collateral(fmspc.clone(), ca, now).await?; - collateral - } else { - CollateralClient::with_default_http(PCS_URL)? - .fetch_for_fmspc_without_pck_chain(&fmspc, ca, false) - .await? + let collateral = match pinned_collateral { + Some(pinned_collateral) => pinned_collateral, + None => 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 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:?}"); @@ -185,7 +249,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, @@ -216,66 +280,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: Option, -) -> 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 let Some(ref pccs) = pccs { - let (collateral, _is_fresh) = pccs.get_collateral(fmspc, ca, now).await?; - collateral - } else { - mock_tdx::mock_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, - 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 = 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, - 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> { @@ -312,6 +316,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)] @@ -321,6 +327,27 @@ mod tests { use super::*; use crate::measurements::MeasurementPolicy; + /// 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 is neither mode + #[tokio::test] + async fn archived_without_collateral_is_refused() { + let mode = VerifyMode::Archived(EndorsementSnapshot { at: 0, dcap: None }); + + let err = + verify_dcap_attestation(Vec::new(), [0; 64], mode.clone(), None).await.unwrap_err(); + assert!(matches!(err, DcapVerificationError::ArchivedWithoutDcapCollateral), "{err:?}"); + + let err = verify_dcap_attestation_sync( + Vec::new(), + [0; 64], + mode, + Pccs::new_without_prewarm(None), + ) + .unwrap_err(); + assert!(matches!(err, DcapVerificationError::ArchivedWithoutDcapCollateral), "{err:?}"); + } + #[tokio::test] async fn test_dcap_verify() { let attestation_bytes: &'static [u8] = @@ -354,7 +381,7 @@ mod tests { serde_saphyr::from_slice(collateral_bytes).unwrap(); let (VerifiedAttestation { measurements: async_measurements, endorsements }, _) = - verify_dcap_attestation_with_given_timestamp( + verify_quote( attestation_bytes.to_vec(), [ 116, 39, 106, 100, 143, 31, 212, 145, 244, 116, 162, 213, 44, 114, 216, 80, @@ -362,29 +389,29 @@ mod tests { 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, ], + VerifyMode::Archived(EndorsementSnapshot::dcap(fixture_collateral.clone(), now)), None, - Some(fixture_collateral.clone()), - now, false, + &QuoteVerifier::new_prod(), + None, ) .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_without_prewarm(None), - Some(fixture_collateral.clone()), - now, - false, - ) - .unwrap(); + let (VerifiedAttestation { measurements: sync_measurements, .. }, _) = verify_quote_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, + ], + VerifyMode::Archived(EndorsementSnapshot::dcap(fixture_collateral.clone(), now)), + Pccs::new_without_prewarm(None), + false, + &QuoteVerifier::new_prod(), + ) + .unwrap(); assert_eq!(async_measurements, sync_measurements); // A caller archiving provenance gets back the bundle the @@ -416,17 +443,18 @@ mod tests { let collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); - verify_dcap_attestation_with_given_timestamp( + verify_quote( 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, ], + VerifyMode::Archived(EndorsementSnapshot::dcap(collateral, now)), None, - Some(collateral), - now, true, + &QuoteVerifier::new_prod(), + None, ) .await .unwrap(); @@ -445,7 +473,9 @@ mod tests { let quote = create_dcap_attestation(expected_input_data).unwrap(); let (verified, _) = - verify_dcap_attestation(quote, expected_input_data, Some(pccs)).await.unwrap(); + verify_dcap_attestation(quote, expected_input_data, VerifyMode::Live, Some(pccs)) + .await + .unwrap(); assert_eq!(verified.measurements, crate::measurements::mock_dcap_measurements()); assert_eq!(mock_pcs.tcb_call_count(), 1); diff --git a/crates/attestation/src/gcp/firmware.rs b/crates/attestation/src/gcp/firmware.rs index 40affc1..5b9567d 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -73,13 +73,15 @@ 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}, + VerifyMode, + dcap::{get_quote_input_data, verify_quote}, measurements::{ExpectedMeasurements, MeasurementPolicy, MeasurementRecord}, }; @@ -156,17 +158,20 @@ 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, - None, - Some(collateral), + let (VerifiedAttestation { measurements, .. }, _) = verify_quote( + attestation_bytes.to_vec(), + expected_input_data, + VerifyMode::Archived(EndorsementSnapshot::dcap( + collateral, GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, - false, - ) - .await - .unwrap(); + )), + None, + false, + &QuoteVerifier::new_prod(), + None, + ) + .await + .unwrap(); let measurement_policy = MeasurementPolicy { accepted_measurements: vec![MeasurementRecord { diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index e45e132..a4136c1 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -20,8 +20,9 @@ use std::{ use attest_measure::platform::PlatformError; pub use attest_types::{AttestationEvidence, PlatformMetadata}; -/// Re-exported so callers can archive [EndorsementSnapshot::dcap] without -/// depending on `dcap-qvl` directly +/// Re-exported so callers can archive [EndorsementSnapshot::dcap] and +/// replay it through [VerifyMode::Archived] without depending on `dcap-qvl` +/// directly pub use dcap_qvl::QuoteCollateralV3; use measurements::MultiMeasurements; use parity_scale_codec::{Decode, Encode}; @@ -392,6 +393,29 @@ impl EndorsementSnapshot { } } +/// Where one verification gets its endorsements, and the instant it +/// evaluates freshness at +/// +/// This is a per-verification fact rather than verifier configuration: a +/// relying party re-checking archived evidence pins both to when that +/// evidence was collected, while a live handshake through the same verifier +/// does not. +// The snapshot makes `Archived` far larger than an empty `Live`. A mode is +// built once, passed once and dropped; boxing it would cost a `Box::new` at +// every call site for a value that is never stored. +#[allow(clippy::large_enum_variant)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VerifyMode { + /// Fetch whatever endorsements the evidence needs, and evaluate every + /// freshness check at the wall clock + Live, + /// Verify against a pinned [EndorsementSnapshot]: the endorsements as + /// of the instant they were held to, with nothing fetched. A leg the + /// snapshot carries no endorsements for is refused rather than + /// completed by a fetch + Archived(EndorsementSnapshot), +} + /// Evidence whose authenticity a Verifier established, with what it was /// established against /// @@ -563,10 +587,21 @@ impl AttestationVerifier { /// Verify an attestation, and ensure the measurements match one of our /// accepted measurements + /// + /// [VerifyMode::Live] fetches endorsements and evaluates every + /// freshness check at the wall clock. [VerifyMode::Archived] + /// verifies the DCAP quote, and on Azure the AK certificate chain, + /// as of a given instant with nothing fetched. Two GCP checks stay + /// live in either mode, since neither rests on signed material a + /// replay could re-verify: + /// + /// - the host provenance lookup against Google's PPID registry + /// - the firmware fetch for the quote's MRTD, on a cache miss pub async fn verify_attestation( &self, attestation_exchange_message: AttestationExchangeMessage, expected_input_data: [u8; 64], + mode: VerifyMode, ) -> Result, AttestationError> { let attestation_type = attestation_exchange_message.attestation_type(); tracing::debug!("Verifying {attestation_type} attestation"); @@ -596,6 +631,7 @@ impl AttestationVerifier { azure::verify_azure_attestation( attestation_evidence.quote.clone(), expected_input_data, + mode, self.internal_pccs.clone(), self.override_azure_outdated_tcb, ) @@ -614,6 +650,7 @@ impl AttestationVerifier { let (verified, quote) = dcap::verify_dcap_attestation( attestation_evidence.quote.clone(), expected_input_data, + mode, self.internal_pccs.clone(), ) .await?; @@ -643,6 +680,7 @@ impl AttestationVerifier { &self, attestation_exchange_message: AttestationExchangeMessage, expected_input_data: [u8; 64], + mode: VerifyMode, ) -> Result, AttestationError> { let attestation_type = attestation_exchange_message.attestation_type(); tracing::debug!("Verifying {attestation_type} attestation"); @@ -673,6 +711,7 @@ impl AttestationVerifier { azure::verify_azure_attestation_sync( attestation_evidence.quote.clone(), expected_input_data, + mode, pccs, self.override_azure_outdated_tcb, )? @@ -696,6 +735,7 @@ impl AttestationVerifier { let (verified, quote) = dcap::verify_dcap_attestation_sync( attestation_evidence.quote.clone(), expected_input_data, + mode, pccs, )?; if attestation_type == AttestationType::GcpTdx { @@ -917,7 +957,11 @@ mod tests { pccs.ready().await.unwrap(); } - let result = verifier.verify_attestation_sync(attestation_evidence.into(), input_data); + let result = verifier.verify_attestation_sync( + attestation_evidence.into(), + input_data, + VerifyMode::Live, + ); assert!(result.is_ok(), "expected sync mock verification to succeed: {result:?}"); } @@ -941,7 +985,7 @@ mod tests { let verifier = AttestationVerifier::mock_with_pccs(mock_pcs_server.base_url.clone()); let verified = verifier - .verify_attestation(attestation_evidence.into(), input_data) + .verify_attestation(attestation_evidence.into(), input_data, VerifyMode::Live) .await .unwrap() .expect("mock evidence carries an attestation"); diff --git a/crates/attested-tls/src/lib.rs b/crates/attested-tls/src/lib.rs index 3835b0e..52094d7 100644 --- a/crates/attested-tls/src/lib.rs +++ b/crates/attested-tls/src/lib.rs @@ -13,6 +13,7 @@ pub use attestation::{ AttestationType, AttestationVerifier, PlatformMetadata, + VerifyMode, }; use ra_tls::{ attestation::{Attestation, AttestationQuote, VersionedAttestation}, @@ -672,7 +673,7 @@ impl AttestedCertificateVerifier { let attestation = Self::extract_custom_attestation_from_cert(cert)?; self.attestation_verifier - .verify_attestation_sync(attestation, expected_input_data) + .verify_attestation_sync(attestation, expected_input_data, VerifyMode::Live) .map_err(|err| { tracing::warn!( "Rejecting certificate after attestation verification failure: {err}"