From e919d078c737d97ac482164cd93be679b8e47644 Mon Sep 17 00:00:00 2001 From: Samuel Laferriere <9342524+samlaf@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:05:05 -0400 Subject: [PATCH 1/3] attestation!: report the DCAP collateral a verification consumed The crate is usable as a relying party for a one-time event rather than a live handshake: evidence is verified once against a measurement policy and archived as permanent provenance. Nothing reported which collateral bundle a verification used, so there was nothing to archive alongside the evidence it verified. Fetching a second copy next to the verification is the obvious workaround, and it is subtly wrong. A PCCS cache refresh between the two fetches makes the archived bundle *a* bundle rather than *the* bundle the verification consumed, and for provenance that distinction is the whole point. Verification now returns VerifiedAttestation: the measurements, the collateral it consumed, and the instant every freshness check was evaluated at. The public entry points return Option - None when the evidence carried no attestation and none was expected, the one case with no bundle and no instant to report. Nesting the absence in one Option keeps the three fields from ever disagreeing. One type serves every platform. A GCP TDX quote is a DCAP quote, and Azure wraps one in an HCL report and a vTPM attestation, so a verification always consumes exactly one collateral bundle, whichever platform produced the evidence. Azure holds its vTPM leg to the instant its DCAP leg reported, so verified_at is the single instant behind every freshness check rather than one per leg. That instant is the other half of what archiving buys: with the bundle, the same evidence and the same instant give the same answer forever. QuoteCollateralV3 is re-exported so callers can keep the bundle without taking a direct dependency on dcap-qvl. The return type of verify_attestation and verify_attestation_sync changes from Option to Option. Both in-tree callers discard the value, so neither needed a change. Addresses the reporting half of #84. Verifying archived evidence against a pinned bundle at an explicit instant, the other half, follows separately. --- crates/attestation/src/azure/verify.rs | 83 +++++++++++-------- crates/attestation/src/dcap.rs | 85 +++++++++++--------- crates/attestation/src/gcp/firmware.rs | 22 ++--- crates/attestation/src/lib.rs | 107 +++++++++++++++++++++---- 4 files changed, 204 insertions(+), 93 deletions(-) diff --git a/crates/attestation/src/azure/verify.rs b/crates/attestation/src/azure/verify.rs index b2ec97f..780b0df 100644 --- a/crates/attestation/src/azure/verify.rs +++ b/crates/attestation/src/azure/verify.rs @@ -20,6 +20,7 @@ use super::{ unix_time_now_secs, }; use crate::{ + VerifiedAttestation, dcap::{ verify_dcap_attestation_with_given_timestamp, verify_dcap_attestation_with_timestamp_sync, @@ -43,7 +44,7 @@ pub async fn verify_azure_attestation( expected_input_data: [u8; 64], pccs: Option, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let now = unix_time_now_secs()?; verify_azure_attestation_with_given_timestamp( @@ -67,7 +68,7 @@ pub fn verify_azure_attestation_sync( expected_input_data: [u8; 64], pccs: Pccs, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let now = unix_time_now_secs()?; verify_azure_attestation_with_given_timestamp_sync( @@ -90,7 +91,7 @@ async fn verify_azure_attestation_with_given_timestamp( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let PreparedAzureAttestation { tdx_quote_bytes, hcl_report, @@ -99,23 +100,25 @@ async fn verify_azure_attestation_with_given_timestamp( tpm_attestation, } = prepare_azure_attestation(input)?; - let _dcap_measurements = verify_dcap_attestation_with_given_timestamp( - tdx_quote_bytes, - expected_tdx_input_data, - pccs, - collateral, - now, - override_azure_outdated_tcb, - ) - .await?; + let VerifiedAttestation { quote, dcap_collateral, verified_at, .. } = + verify_dcap_attestation_with_given_timestamp( + tdx_quote_bytes, + expected_tdx_input_data, + pccs, + collateral, + now, + override_azure_outdated_tcb, + ) + .await?; - finish_azure_attestation_verification( + let measurements = finish_azure_attestation_verification( hcl_report, var_data_hash, tpm_attestation, expected_input_data, now, - ) + )?; + Ok(VerifiedAttestation { measurements, quote, dcap_collateral, verified_at }) } /// Synchronous version of the verifier @@ -126,7 +129,7 @@ fn verify_azure_attestation_with_given_timestamp_sync( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let PreparedAzureAttestation { tdx_quote_bytes, hcl_report, @@ -135,22 +138,24 @@ fn verify_azure_attestation_with_given_timestamp_sync( tpm_attestation, } = prepare_azure_attestation(input)?; - let _dcap_measurements = verify_dcap_attestation_with_timestamp_sync( - tdx_quote_bytes, - expected_tdx_input_data, - pccs, - collateral, - now, - override_azure_outdated_tcb, - )?; + let VerifiedAttestation { quote, dcap_collateral, verified_at, .. } = + verify_dcap_attestation_with_timestamp_sync( + tdx_quote_bytes, + expected_tdx_input_data, + pccs, + collateral, + now, + override_azure_outdated_tcb, + )?; - finish_azure_attestation_verification( + let measurements = finish_azure_attestation_verification( hcl_report, var_data_hash, tpm_attestation, expected_input_data, now, - ) + )?; + Ok(VerifiedAttestation { measurements, quote, dcap_collateral, verified_at }) } /// Parses the attestation during verification @@ -339,6 +344,8 @@ impl RsaPubKey { #[cfg(test)] mod tests { + use dcap_qvl::QuoteCollateralV3; + use super::{super::MAX_AZURE_ATTESTATION_PAYLOAD_SIZE, *}; fn input_data_from_attestation(attestation_bytes: &[u8]) -> [u8; 64] { @@ -454,31 +461,43 @@ mod tests { assert_eq!(attestation_document.tpm_attestation.ak_intermediate_certificates_pem.len(), 2); let attestation_json = serde_json::to_vec(&attestation_document).unwrap(); - let async_collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let sync_collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); - - let async_measurements = verify_azure_attestation_with_given_timestamp( + let fixture_collateral: QuoteCollateralV3 = + serde_saphyr::from_slice(collateral_bytes).unwrap(); + + let VerifiedAttestation { + measurements: async_measurements, + dcap_collateral: async_collateral, + .. + } = verify_azure_attestation_with_given_timestamp( attestation_json.clone(), [0; 64], None, - Some(async_collateral), + Some(fixture_collateral.clone()), now, false, ) .await .unwrap(); - let sync_measurements = verify_azure_attestation_with_given_timestamp_sync( + let VerifiedAttestation { + measurements: sync_measurements, + dcap_collateral: sync_collateral, + .. + } = verify_azure_attestation_with_given_timestamp_sync( attestation_json, [0; 64], Pccs::new_without_prewarm(None), - Some(sync_collateral), + Some(fixture_collateral.clone()), now, false, ) .unwrap(); assert_eq!(async_measurements, sync_measurements); + // The bundle handed back is the one the verification consumed, which + // is what makes archiving it provenance rather than a second copy + assert_eq!(async_collateral, fixture_collateral); + assert_eq!(sync_collateral, fixture_collateral); } #[tokio::test] diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index ee91fe2..e778ab7 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -12,7 +12,7 @@ use mock_tdx::generate_mock_tdx_quote; use pccs::{Pccs, PccsError}; use thiserror::Error; -use crate::{AttestationError, measurements::MultiMeasurements}; +use crate::{AttestationError, VerifiedAttestation, measurements::MultiMeasurements}; /// FMSPC with which to override TCB level checks on Azure (not used for GCP /// or other platforms) @@ -28,13 +28,13 @@ pub fn create_dcap_attestation(input_data: [u8; 64]) -> Result, Attestat Ok(quote) } -/// Verify a DCAP TDX quote, and return the measurement values +/// Verify a DCAP TDX quote #[cfg(not(any(test, feature = "mock")))] pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Option, -) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { +) -> Result { 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( @@ -48,8 +48,7 @@ pub async fn verify_dcap_attestation( .await } -/// Synchronous version - Verify a DCAP TDX quote, and return the -/// measurement values +/// Synchronous version - verify a DCAP TDX quote /// /// This relies on having DCAP collateral already present in the cache /// @@ -59,7 +58,7 @@ pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { +) -> Result { 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( @@ -72,8 +71,8 @@ pub fn verify_dcap_attestation_sync( ) } -/// Verify a DCAP TDX quote, and return the measurement values, providing a -/// timestamp an optional pre-fetched collateral +/// Verify a DCAP TDX quote, providing a timestamp and an optional +/// pre-fetched collateral /// /// This relies on having DCAP collateral already present in the cache /// @@ -85,7 +84,7 @@ pub fn verify_dcap_attestation_with_timestamp_sync( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { +) -> Result { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); @@ -119,7 +118,7 @@ pub async fn verify_dcap_attestation_with_given_timestamp( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { +) -> Result { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); @@ -153,7 +152,7 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( collateral: QuoteCollateralV3, now: u64, override_azure_outdated_tcb: bool, -) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { +) -> Result { tracing::info!("Verifying DCAP attestation: {quote:?}"); let fmspc = hex::encode_upper(quote_fmspc("e)?); @@ -198,7 +197,7 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( return Err(DcapVerificationError::InputMismatch); } - Ok((measurements, quote)) + Ok(VerifiedAttestation { measurements, quote, dcap_collateral: collateral, verified_at: now }) } #[cfg(any(test, feature = "mock"))] @@ -206,7 +205,7 @@ pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Option, -) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { +) -> Result { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); @@ -225,7 +224,7 @@ pub async fn verify_dcap_attestation( return Err(DcapVerificationError::InputMismatch); } - Ok((measurements, quote)) + Ok(VerifiedAttestation { measurements, quote, dcap_collateral: collateral, verified_at: now }) } #[cfg(any(test, feature = "mock"))] @@ -233,7 +232,7 @@ pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { +) -> Result { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); @@ -246,7 +245,7 @@ pub fn verify_dcap_attestation_sync( if get_quote_input_data("e.report) != expected_input_data { return Err(DcapVerificationError::InputMismatch); } - Ok((measurements, quote)) + Ok(VerifiedAttestation { measurements, quote, dcap_collateral: collateral, verified_at: now }) } /// Create a mock quote for testing on non-confidential hardware @@ -323,10 +322,15 @@ mod tests { let collateral_bytes: &'static [u8] = include_bytes!("../test-assets/dcap-quote-collateral-00.yaml"); - let async_collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let sync_collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); + let fixture_collateral: QuoteCollateralV3 = + serde_saphyr::from_slice(collateral_bytes).unwrap(); - let (async_measurements, _) = verify_dcap_attestation_with_given_timestamp( + let VerifiedAttestation { + measurements: async_measurements, + dcap_collateral, + verified_at, + .. + } = 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, @@ -335,29 +339,36 @@ mod tests { 173, 129, 180, 32, 247, 70, 250, 141, 176, 248, 99, 125, ], None, - Some(async_collateral), + Some(fixture_collateral.clone()), now, false, ) .await .unwrap(); - let (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(sync_collateral), - now, - false, - ) - .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(); 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!(dcap_collateral, fixture_collateral); + // ... and the instant it was held to, which is the other half of what + // makes the verification reproducible + assert_eq!(verified_at, now); let platform_metadata = crate::mock_platform_metadata(crate::AttestationType::DcapTdx).unwrap(); measurement_policy @@ -381,7 +392,7 @@ mod tests { let collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let _measurements = verify_dcap_attestation_with_given_timestamp( + verify_dcap_attestation_with_given_timestamp( attestation_bytes.to_vec(), [ 210, 20, 43, 100, 53, 152, 235, 95, 174, 43, 200, 82, 157, 215, 154, 85, 139, 41, @@ -409,10 +420,10 @@ mod tests { let expected_input_data = [0xA5; 64]; let quote = create_dcap_attestation(expected_input_data).unwrap(); - let (measurements, _) = + let verified = verify_dcap_attestation(quote, expected_input_data, Some(pccs)).await.unwrap(); - assert_eq!(measurements, crate::measurements::mock_dcap_measurements()); + assert_eq!(verified.measurements, crate::measurements::mock_dcap_measurements()); assert_eq!(mock_pcs.tcb_call_count(), 1); assert_eq!(mock_pcs.qe_call_count(), 1); } diff --git a/crates/attestation/src/gcp/firmware.rs b/crates/attestation/src/gcp/firmware.rs index b6d37ec..330ea1e 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -78,6 +78,7 @@ mod tests { use super::GcpFirmwareCache; use crate::{ PlatformMetadata, + VerifiedAttestation, dcap::{get_quote_input_data, verify_dcap_attestation_with_given_timestamp}, measurements::{ExpectedMeasurements, MeasurementPolicy, MeasurementRecord}, }; @@ -155,16 +156,17 @@ mod tests { let collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); let firmware = serde_saphyr::from_slice(firmware_bytes).unwrap(); - let (measurements, _) = verify_dcap_attestation_with_given_timestamp( - attestation_bytes.to_vec(), - expected_input_data, - None, - Some(collateral), - GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, - false, - ) - .await - .unwrap(); + let VerifiedAttestation { measurements, .. } = + verify_dcap_attestation_with_given_timestamp( + attestation_bytes.to_vec(), + expected_input_data, + None, + Some(collateral), + GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, + false, + ) + .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 d3a0d0d..a06dd17 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -20,6 +20,11 @@ use std::{ use attest_measure::platform::PlatformError; pub use attest_types::{AttestationEvidence, PlatformMetadata}; +/// The DCAP collateral a verification consumed, reported as +/// [VerifiedAttestation::dcap_collateral]. Re-exported so callers can +/// archive it without taking a direct dependency on `dcap-qvl` +pub use dcap_qvl::QuoteCollateralV3; +use dcap_qvl::quote::Quote; use measurements::MultiMeasurements; use parity_scale_codec::{Decode, Encode}; use pccs::{Pccs, PccsError}; @@ -352,6 +357,32 @@ pub enum PccsMode { Lazy, } +/// The outcome of verifying one piece of attestation evidence +/// +/// Every attested platform here rests on a DCAP quote: a GCP TDX quote is +/// one, and Azure wraps one in an HCL report and a vTPM attestation. So one +/// verification always consumes exactly one collateral bundle, whichever +/// platform produced the evidence. +#[derive(Clone, Debug)] +pub struct VerifiedAttestation { + /// The measurements the evidence carries + pub measurements: MultiMeasurements, + /// The parsed DCAP quote the measurements were read from + pub quote: Quote, + /// The DCAP collateral the verification consumed — the bundle to + /// archive next to the evidence it verified. A second copy fetched + /// alongside may differ, since a collateral cache can refresh between + /// the two fetches + pub dcap_collateral: QuoteCollateralV3, + /// The instant every freshness check was evaluated at, as seconds since + /// the Unix epoch + /// + /// With the collateral, this is what makes a verification reproducible: + /// the same evidence, the same bundle and this instant give the same + /// answer forever. + pub verified_at: u64, +} + /// Allows remote attestations to be verified #[derive(Clone, Debug)] pub struct AttestationVerifier { @@ -502,11 +533,16 @@ impl AttestationVerifier { /// Verify an attestation, and ensure the measurements match one of our /// accepted measurements + /// + /// The result reports the DCAP collateral the verification consumed, + /// which is the bundle to archive next to the evidence: a second copy + /// fetched alongside may differ, since a collateral cache can refresh + /// between the two fetches. pub async fn verify_attestation( &self, attestation_exchange_message: AttestationExchangeMessage, expected_input_data: [u8; 64], - ) -> Result, AttestationError> { + ) -> Result, AttestationError> { let attestation_type = attestation_exchange_message.attestation_type(); tracing::debug!("Verifying {attestation_type} attestation"); @@ -514,7 +550,7 @@ impl AttestationVerifier { log_attestation(&attestation_exchange_message); } - let measurements = match attestation_type { + let verified = match attestation_type { AttestationType::None => { if self.has_remote_attestation() { return Err(AttestationError::AttestationTypeNotAccepted); @@ -550,16 +586,16 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - let (measurements, quote) = dcap::verify_dcap_attestation( + let verified = dcap::verify_dcap_attestation( attestation_evidence.quote.clone(), expected_input_data, self.internal_pccs.clone(), ) .await?; if attestation_type == AttestationType::GcpTdx { - self.gcp_provenance_checker.verify_provenance(quote).await?; + self.gcp_provenance_checker.verify_provenance(verified.quote.clone()).await?; } - measurements + verified } }; @@ -569,20 +605,20 @@ impl AttestationVerifier { .as_ref() .map(|evidence| evidence.platform.clone()); self.measurement_policy.check_measurement_with_gcp_cache( - &measurements, + &verified.measurements, platform_metadata.as_ref(), Some(&self.known_gcp_firmware), )?; tracing::debug!("Verification successful"); - Ok(Some(measurements)) + Ok(Some(verified)) } pub fn verify_attestation_sync( &self, attestation_exchange_message: AttestationExchangeMessage, expected_input_data: [u8; 64], - ) -> Result, AttestationError> { + ) -> Result, AttestationError> { let attestation_type = attestation_exchange_message.attestation_type(); tracing::debug!("Verifying {attestation_type} attestation"); @@ -590,7 +626,7 @@ impl AttestationVerifier { log_attestation(&attestation_exchange_message); } - let measurements = match attestation_type { + let verified = match attestation_type { AttestationType::None => { if self.has_remote_attestation() { return Err(AttestationError::AttestationTypeNotAccepted); @@ -632,15 +668,15 @@ impl AttestationVerifier { #[cfg(not(any(test, feature = "mock")))] let pccs = self.internal_pccs.clone().ok_or(AttestationError::NoPccs)?; - let (measurements, quote) = dcap::verify_dcap_attestation_sync( + let verified = dcap::verify_dcap_attestation_sync( attestation_evidence.quote.clone(), expected_input_data, pccs, )?; if attestation_type == AttestationType::GcpTdx { - self.gcp_provenance_checker.verify_provenance_sync("e)?; + self.gcp_provenance_checker.verify_provenance_sync(&verified.quote)?; } - measurements + verified } }; @@ -650,13 +686,13 @@ impl AttestationVerifier { .as_ref() .map(|evidence| evidence.platform.clone()); self.measurement_policy.check_measurement_with_gcp_cache( - &measurements, + &verified.measurements, platform_metadata.as_ref(), Some(&self.known_gcp_firmware), )?; tracing::debug!("Verification successful"); - Ok(Some(measurements)) + Ok(Some(verified)) } /// Whether we allow no remote attestation @@ -860,4 +896,47 @@ mod tests { assert!(result.is_ok(), "expected sync mock verification to succeed: {result:?}"); } + + /// 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. + #[tokio::test] + async fn verify_reports_the_collateral_the_fetch_produced() { + let input_data = [7u8; 64]; + let quote_bytes = dcap::create_dcap_attestation(input_data).unwrap(); + let quote = dcap_qvl::quote::Quote::parse("e_bytes).unwrap(); + let fmspc = hex::encode_upper(dcap_qvl::intel::quote_fmspc("e).unwrap()); + let ca = dcap_qvl::intel::quote_ca("e).unwrap().as_id_str(); + let attestation_evidence = AttestationEvidence { + quote: quote_bytes, + platform: mock_platform_metadata(AttestationType::DcapTdx).unwrap(), + }; + + let mock_pcs_server = spawn_mock_pcs_server(MockPcsConfig::default()).await.unwrap(); + let verifier = AttestationVerifier::mock_with_pccs(mock_pcs_server.base_url.clone()); + + let verified = verifier + .verify_attestation(attestation_evidence.into(), input_data) + .await + .unwrap() + .expect("mock evidence carries an attestation"); + + // The second read is served from the PCCS cache, not a second fetch, + // so it yields the same bundle the verification consumed. That is what + // makes it a valid comparison here — and the reason a caller must not + // rely on the pattern in general, where a refresh in between would + // hand back a different bundle + let (served, _is_fresh) = verifier + .internal_pccs + .as_ref() + .unwrap() + .get_collateral( + fmspc, + ca, + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(), + ) + .await + .unwrap(); + assert_eq!(verified.dcap_collateral, served); + } } From 9b7c1ca8d25301cd19e03b264187ce6060f9c2fe Mon Sep 17 00:00:00 2001 From: Samuel Laferriere <9342524+samlaf@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:43:05 -0400 Subject: [PATCH 2/3] attestation!: bind the collateral to the instant it was held to The bundle and the instant were two independent public fields, so the pair a later replay needs arrived pre-split. Taking one without the other is not a mistake a caller has to work at: it is the shape of least resistance, and nothing objects. The input side already refuses that - VerifyMode::Archived carries both or neither - so the output was the one place the pair could come apart. CollateralSnapshot binds them. It is one value coming out and, in the change that follows, the same value going back in, so archiving is "keep the snapshot" and re-verifying is "hand it back". VerifiedAttestation becomes AttestationResult. RFC 9334 calls what an attester produces Evidence and what a verifier produces from it an Attestation Result. The crate already takes AttestationEvidence in, so this names the far end of one appraisal; "attestation" on its own named no field of the struct. The measurements docs now say where the values come from, which the Azure path made worth stating. They are read out of the quote on DCAP and GCP. On Azure they are the vTPM PCRs, measuring the guest boot rather than the launched TD - chained to the quote, whose report data commits to the HCL var data carrying the AK public key that signs the vTPM quote, but no field of it. The fixture test now asserts the whole snapshot, so the pairing itself is covered. --- crates/attestation/src/azure/verify.rs | 69 +++++++++++----------- crates/attestation/src/dcap.rs | 81 +++++++++++++++----------- crates/attestation/src/gcp/firmware.rs | 23 ++++---- crates/attestation/src/lib.rs | 59 ++++++++++++------- 4 files changed, 131 insertions(+), 101 deletions(-) diff --git a/crates/attestation/src/azure/verify.rs b/crates/attestation/src/azure/verify.rs index 780b0df..6355308 100644 --- a/crates/attestation/src/azure/verify.rs +++ b/crates/attestation/src/azure/verify.rs @@ -20,7 +20,7 @@ use super::{ unix_time_now_secs, }; use crate::{ - VerifiedAttestation, + AttestationResult, dcap::{ verify_dcap_attestation_with_given_timestamp, verify_dcap_attestation_with_timestamp_sync, @@ -44,7 +44,7 @@ pub async fn verify_azure_attestation( expected_input_data: [u8; 64], pccs: Option, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let now = unix_time_now_secs()?; verify_azure_attestation_with_given_timestamp( @@ -68,7 +68,7 @@ pub fn verify_azure_attestation_sync( expected_input_data: [u8; 64], pccs: Pccs, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let now = unix_time_now_secs()?; verify_azure_attestation_with_given_timestamp_sync( @@ -91,7 +91,7 @@ async fn verify_azure_attestation_with_given_timestamp( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let PreparedAzureAttestation { tdx_quote_bytes, hcl_report, @@ -100,16 +100,15 @@ async fn verify_azure_attestation_with_given_timestamp( tpm_attestation, } = prepare_azure_attestation(input)?; - let VerifiedAttestation { quote, dcap_collateral, verified_at, .. } = - verify_dcap_attestation_with_given_timestamp( - tdx_quote_bytes, - expected_tdx_input_data, - pccs, - collateral, - now, - override_azure_outdated_tcb, - ) - .await?; + let AttestationResult { quote, collateral, .. } = verify_dcap_attestation_with_given_timestamp( + tdx_quote_bytes, + expected_tdx_input_data, + pccs, + collateral, + now, + override_azure_outdated_tcb, + ) + .await?; let measurements = finish_azure_attestation_verification( hcl_report, @@ -118,7 +117,7 @@ async fn verify_azure_attestation_with_given_timestamp( expected_input_data, now, )?; - Ok(VerifiedAttestation { measurements, quote, dcap_collateral, verified_at }) + Ok(AttestationResult { measurements, quote, collateral }) } /// Synchronous version of the verifier @@ -129,7 +128,7 @@ fn verify_azure_attestation_with_given_timestamp_sync( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let PreparedAzureAttestation { tdx_quote_bytes, hcl_report, @@ -138,15 +137,14 @@ fn verify_azure_attestation_with_given_timestamp_sync( tpm_attestation, } = prepare_azure_attestation(input)?; - let VerifiedAttestation { quote, dcap_collateral, verified_at, .. } = - verify_dcap_attestation_with_timestamp_sync( - tdx_quote_bytes, - expected_tdx_input_data, - pccs, - collateral, - now, - override_azure_outdated_tcb, - )?; + let AttestationResult { quote, collateral, .. } = verify_dcap_attestation_with_timestamp_sync( + tdx_quote_bytes, + expected_tdx_input_data, + pccs, + collateral, + now, + override_azure_outdated_tcb, + )?; let measurements = finish_azure_attestation_verification( hcl_report, @@ -155,7 +153,7 @@ fn verify_azure_attestation_with_given_timestamp_sync( expected_input_data, now, )?; - Ok(VerifiedAttestation { measurements, quote, dcap_collateral, verified_at }) + Ok(AttestationResult { measurements, quote, collateral }) } /// Parses the attestation during verification @@ -347,6 +345,7 @@ mod tests { use dcap_qvl::QuoteCollateralV3; use super::{super::MAX_AZURE_ATTESTATION_PAYLOAD_SIZE, *}; + use crate::CollateralSnapshot; fn input_data_from_attestation(attestation_bytes: &[u8]) -> [u8; 64] { let attestation_document: AttestationDocument = @@ -464,9 +463,9 @@ mod tests { let fixture_collateral: QuoteCollateralV3 = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let VerifiedAttestation { + let AttestationResult { measurements: async_measurements, - dcap_collateral: async_collateral, + collateral: async_collateral, .. } = verify_azure_attestation_with_given_timestamp( attestation_json.clone(), @@ -479,10 +478,8 @@ mod tests { .await .unwrap(); - let VerifiedAttestation { - measurements: sync_measurements, - dcap_collateral: sync_collateral, - .. + let AttestationResult { + measurements: sync_measurements, collateral: sync_collateral, .. } = verify_azure_attestation_with_given_timestamp_sync( attestation_json, [0; 64], @@ -495,9 +492,11 @@ mod tests { assert_eq!(async_measurements, sync_measurements); // The bundle handed back is the one the verification consumed, which - // is what makes archiving it provenance rather than a second copy - assert_eq!(async_collateral, fixture_collateral); - assert_eq!(sync_collateral, fixture_collateral); + // is what makes archiving it provenance rather than a second copy, + // and it arrives paired with the instant it was held to + let expected = CollateralSnapshot { collateral: fixture_collateral, at: now }; + assert_eq!(async_collateral, expected); + assert_eq!(sync_collateral, expected); } #[tokio::test] diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index e778ab7..9f127d0 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -12,7 +12,12 @@ use mock_tdx::generate_mock_tdx_quote; use pccs::{Pccs, PccsError}; use thiserror::Error; -use crate::{AttestationError, VerifiedAttestation, measurements::MultiMeasurements}; +use crate::{ + AttestationError, + AttestationResult, + CollateralSnapshot, + measurements::MultiMeasurements, +}; /// FMSPC with which to override TCB level checks on Azure (not used for GCP /// or other platforms) @@ -34,7 +39,7 @@ pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Option, -) -> Result { +) -> Result { 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( @@ -58,7 +63,7 @@ pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result { +) -> Result { 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( @@ -84,7 +89,7 @@ pub fn verify_dcap_attestation_with_timestamp_sync( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); @@ -118,7 +123,7 @@ pub async fn verify_dcap_attestation_with_given_timestamp( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); @@ -152,7 +157,7 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( collateral: QuoteCollateralV3, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { tracing::info!("Verifying DCAP attestation: {quote:?}"); let fmspc = hex::encode_upper(quote_fmspc("e)?); @@ -197,7 +202,11 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( return Err(DcapVerificationError::InputMismatch); } - Ok(VerifiedAttestation { measurements, quote, dcap_collateral: collateral, verified_at: now }) + Ok(AttestationResult { + measurements, + quote, + collateral: CollateralSnapshot { collateral, at: now }, + }) } #[cfg(any(test, feature = "mock"))] @@ -205,7 +214,7 @@ pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Option, -) -> Result { +) -> Result { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); @@ -224,7 +233,11 @@ pub async fn verify_dcap_attestation( return Err(DcapVerificationError::InputMismatch); } - Ok(VerifiedAttestation { measurements, quote, dcap_collateral: collateral, verified_at: now }) + Ok(AttestationResult { + measurements, + quote, + collateral: CollateralSnapshot { collateral, at: now }, + }) } #[cfg(any(test, feature = "mock"))] @@ -232,7 +245,7 @@ pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result { +) -> Result { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); @@ -245,7 +258,11 @@ pub fn verify_dcap_attestation_sync( if get_quote_input_data("e.report) != expected_input_data { return Err(DcapVerificationError::InputMismatch); } - Ok(VerifiedAttestation { measurements, quote, dcap_collateral: collateral, verified_at: now }) + Ok(AttestationResult { + measurements, + quote, + collateral: CollateralSnapshot { collateral, at: now }, + }) } /// Create a mock quote for testing on non-confidential hardware @@ -325,28 +342,24 @@ mod tests { let fixture_collateral: QuoteCollateralV3 = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let VerifiedAttestation { - measurements: async_measurements, - dcap_collateral, - verified_at, - .. - } = 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, - ], - None, - Some(fixture_collateral.clone()), - now, - false, - ) - .await - .unwrap(); + let AttestationResult { measurements: async_measurements, collateral, .. } = + 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, + ], + None, + Some(fixture_collateral.clone()), + now, + false, + ) + .await + .unwrap(); - let VerifiedAttestation { measurements: sync_measurements, .. } = + let AttestationResult { measurements: sync_measurements, .. } = verify_dcap_attestation_with_timestamp_sync( attestation_bytes.to_vec(), [ @@ -365,10 +378,10 @@ mod tests { 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!(dcap_collateral, fixture_collateral); + assert_eq!(collateral.collateral, fixture_collateral); // ... and the instant it was held to, which is the other half of what // makes the verification reproducible - assert_eq!(verified_at, now); + assert_eq!(collateral.at, now); let platform_metadata = crate::mock_platform_metadata(crate::AttestationType::DcapTdx).unwrap(); measurement_policy diff --git a/crates/attestation/src/gcp/firmware.rs b/crates/attestation/src/gcp/firmware.rs index 330ea1e..a22c4b6 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -77,8 +77,8 @@ mod tests { use super::GcpFirmwareCache; use crate::{ + AttestationResult, PlatformMetadata, - VerifiedAttestation, dcap::{get_quote_input_data, verify_dcap_attestation_with_given_timestamp}, measurements::{ExpectedMeasurements, MeasurementPolicy, MeasurementRecord}, }; @@ -156,17 +156,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, - None, - Some(collateral), - GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, - false, - ) - .await - .unwrap(); + let AttestationResult { measurements, .. } = verify_dcap_attestation_with_given_timestamp( + attestation_bytes.to_vec(), + expected_input_data, + None, + Some(collateral), + GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, + false, + ) + .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 a06dd17..f999e87 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -20,9 +20,9 @@ use std::{ use attest_measure::platform::PlatformError; pub use attest_types::{AttestationEvidence, PlatformMetadata}; -/// The DCAP collateral a verification consumed, reported as -/// [VerifiedAttestation::dcap_collateral]. Re-exported so callers can -/// archive it without taking a direct dependency on `dcap-qvl` +/// The DCAP collateral a verification consumed, reported inside +/// [AttestationResult::collateral]. Re-exported so callers can archive +/// it without taking a direct dependency on `dcap-qvl` pub use dcap_qvl::QuoteCollateralV3; use dcap_qvl::quote::Quote; use measurements::MultiMeasurements; @@ -357,6 +357,26 @@ pub enum PccsMode { Lazy, } +/// A DCAP collateral bundle together with the instant it is evaluated at +/// +/// Every part of the bundle expires: TCB Info, QE Identity and both CRLs +/// carry `nextUpdate`, and the issuer chains carry `notAfter`. A bundle +/// therefore answers a freshness question only with respect to some +/// instant, and the two are one value rather than two. Holding them +/// together is what makes a verification reproducible: the same evidence, +/// the same bundle and the same instant give the same answer forever. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CollateralSnapshot { + /// The collateral the verification consumed. A second copy fetched + /// alongside may differ, since a collateral cache can refresh between + /// the two fetches + pub collateral: QuoteCollateralV3, + /// Seconds since the Unix epoch. Gates every freshness check: + /// certificate validity windows, the CRLs, and the TCB Info and QE + /// Identity windows + pub at: u64, +} + /// The outcome of verifying one piece of attestation evidence /// /// Every attested platform here rests on a DCAP quote: a GCP TDX quote is @@ -364,23 +384,22 @@ pub enum PccsMode { /// verification always consumes exactly one collateral bundle, whichever /// platform produced the evidence. #[derive(Clone, Debug)] -pub struct VerifiedAttestation { +pub struct AttestationResult { /// The measurements the evidence carries + /// + /// Read out of `quote` on the DCAP and GCP paths. On Azure they are + /// the vTPM PCRs, which measure the guest boot rather than the launched + /// TD. They chain to `quote` - its report data commits to the HCL var + /// data, which carries the AK public key that signs the vTPM quote - + /// but are no field of it, so `quote` alone does not yield them pub measurements: MultiMeasurements, - /// The parsed DCAP quote the measurements were read from + /// The parsed DCAP quote the verification rests on. On Azure this is + /// the TD quote the evidence wraps, not the vTPM quote pub quote: Quote, - /// The DCAP collateral the verification consumed — the bundle to - /// archive next to the evidence it verified. A second copy fetched - /// alongside may differ, since a collateral cache can refresh between - /// the two fetches - pub dcap_collateral: QuoteCollateralV3, - /// The instant every freshness check was evaluated at, as seconds since - /// the Unix epoch - /// - /// With the collateral, this is what makes a verification reproducible: - /// the same evidence, the same bundle and this instant give the same - /// answer forever. - pub verified_at: u64, + /// What the verification consumed, and when it was held to — the pair + /// to archive next to the evidence it verified, and to hand back to + /// re-verify that evidence later + pub collateral: CollateralSnapshot, } /// Allows remote attestations to be verified @@ -542,7 +561,7 @@ impl AttestationVerifier { &self, attestation_exchange_message: AttestationExchangeMessage, expected_input_data: [u8; 64], - ) -> Result, AttestationError> { + ) -> Result, AttestationError> { let attestation_type = attestation_exchange_message.attestation_type(); tracing::debug!("Verifying {attestation_type} attestation"); @@ -618,7 +637,7 @@ impl AttestationVerifier { &self, attestation_exchange_message: AttestationExchangeMessage, expected_input_data: [u8; 64], - ) -> Result, AttestationError> { + ) -> Result, AttestationError> { let attestation_type = attestation_exchange_message.attestation_type(); tracing::debug!("Verifying {attestation_type} attestation"); @@ -937,6 +956,6 @@ mod tests { ) .await .unwrap(); - assert_eq!(verified.dcap_collateral, served); + assert_eq!(verified.collateral.collateral, served); } } From f828787c00d4453c550d5eec516506864bc8a199 Mon Sep 17 00:00:00 2001 From: Samuel Laferriere <9342524+samlaf@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:03:05 -0400 Subject: [PATCH 3/3] refactor(attestation)!: keep DCAP types off the platform-agnostic result AttestationResult sat at the boundary that is meant to hide which platform produced the evidence, and two of its three fields were Intel types: a dcap-qvl Quote and a QuoteCollateralV3. The AWS Nitro work in #45 has neither, so the struct would break a second time the moment a non-TDX platform lands. #65 breaks it with no new platform at all - it moves the collateral into the evidence, so the verifier fetches none. The parsed quote leaves the shared type. It had exactly one consumer: AttestationVerifier binds it only to hand to the GCP provenance check, which reads the PPID out of the PCK leaf. gcp/firmware.rs discards it, and so does our own downstream. dcap.rs still hands it back, now as the second element of a tuple, so nothing re-parses the same bytes (34346c6) and the module gains no public type of its own - #40 may replace that module with attest-verify, which already owns a ValidatedDcapQuote one letter away. CollateralSnapshot becomes EndorsementSnapshot, and the bundle becomes an optional field on it. What a verifier fetches varies; the instant it holds that material to does not. So the instant stays a plain field and the fetched material hangs off it, with #[non_exhaustive] and a ::dcap() constructor keeping the pairing structural and later fields additive. A struct rather than an enum keyed by platform, because a verification consumes a set of fetched material rather than one of several alternatives, and because the axis it varies along is not the platform: whether endorsements ride in the evidence or get fetched is a transport choice of the protocol. #49 already made that choice one way for the Azure AK chain, and #65 proposes the opposite for DCAP collateral. "Collateral" is Intel's DCAP word, and would read as a category error the moment the snapshot holds Azure vTPM roots or whatever Nitro needs. "Endorsements" is the loose umbrella rather than RFC 9334's strict term - a DCAP bundle spans Endorsements and Reference Values both - and the doc comment says so, so the imprecision is deliberate rather than sloppy. The type is called VerifiedAttestation again, which reverses the rename in the commit before this one. That rename argued the value is the far end of one appraisal. It is not: the appraisal policy runs after the value is built, and a caller can configure it to check nothing, which is exactly what our enclave does before appraising in its own admission predicate. So no Reference Value comparison is implied, and what comes back is verified evidence rather than an Attestation Result. Leaving the RFC's name unspent keeps it for the type that would earn it if appraisal is ever separated from verification. Result is also taken in Rust, with AttestationError in this same crate. The docs on both types are shorter for it. The cache-refresh caveat is stated once instead of three times, and where the value sits in the RATS pipeline is stated once, linked to RFC 9334. Addresses review feedback on #85. BREAKING CHANGE: AttestationResult is renamed VerifiedAttestation and loses its quote field. CollateralSnapshot is renamed EndorsementSnapshot, is #[non_exhaustive], and its collateral field becomes dcap: Option, built through EndorsementSnapshot::dcap. The dcap::verify_* functions return (VerifiedAttestation, Quote), the azure::verify_* functions return VerifiedAttestation, and AttestationVerifier::verify_attestation{,_sync} return Option. --- crates/attestation/src/azure/verify.rs | 46 +++++----- crates/attestation/src/dcap.rs | 71 ++++++++------- crates/attestation/src/gcp/firmware.rs | 23 ++--- crates/attestation/src/lib.rs | 119 +++++++++++++------------ 4 files changed, 141 insertions(+), 118 deletions(-) diff --git a/crates/attestation/src/azure/verify.rs b/crates/attestation/src/azure/verify.rs index 6355308..8c6285d 100644 --- a/crates/attestation/src/azure/verify.rs +++ b/crates/attestation/src/azure/verify.rs @@ -20,7 +20,7 @@ use super::{ unix_time_now_secs, }; use crate::{ - AttestationResult, + VerifiedAttestation, dcap::{ verify_dcap_attestation_with_given_timestamp, verify_dcap_attestation_with_timestamp_sync, @@ -44,7 +44,7 @@ pub async fn verify_azure_attestation( expected_input_data: [u8; 64], pccs: Option, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let now = unix_time_now_secs()?; verify_azure_attestation_with_given_timestamp( @@ -68,7 +68,7 @@ pub fn verify_azure_attestation_sync( expected_input_data: [u8; 64], pccs: Pccs, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let now = unix_time_now_secs()?; verify_azure_attestation_with_given_timestamp_sync( @@ -91,7 +91,7 @@ async fn verify_azure_attestation_with_given_timestamp( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let PreparedAzureAttestation { tdx_quote_bytes, hcl_report, @@ -100,7 +100,9 @@ async fn verify_azure_attestation_with_given_timestamp( tpm_attestation, } = prepare_azure_attestation(input)?; - let AttestationResult { quote, collateral, .. } = verify_dcap_attestation_with_given_timestamp( + // 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, pccs, @@ -110,6 +112,8 @@ async fn verify_azure_attestation_with_given_timestamp( ) .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, @@ -117,7 +121,7 @@ async fn verify_azure_attestation_with_given_timestamp( expected_input_data, now, )?; - Ok(AttestationResult { measurements, quote, collateral }) + Ok(VerifiedAttestation { measurements, endorsements: dcap.endorsements }) } /// Synchronous version of the verifier @@ -128,7 +132,7 @@ fn verify_azure_attestation_with_given_timestamp_sync( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result { let PreparedAzureAttestation { tdx_quote_bytes, hcl_report, @@ -137,7 +141,7 @@ fn verify_azure_attestation_with_given_timestamp_sync( tpm_attestation, } = prepare_azure_attestation(input)?; - let AttestationResult { quote, collateral, .. } = verify_dcap_attestation_with_timestamp_sync( + let (dcap, _) = verify_dcap_attestation_with_timestamp_sync( tdx_quote_bytes, expected_tdx_input_data, pccs, @@ -153,7 +157,7 @@ fn verify_azure_attestation_with_given_timestamp_sync( expected_input_data, now, )?; - Ok(AttestationResult { measurements, quote, collateral }) + Ok(VerifiedAttestation { measurements, endorsements: dcap.endorsements }) } /// Parses the attestation during verification @@ -345,7 +349,7 @@ mod tests { use dcap_qvl::QuoteCollateralV3; use super::{super::MAX_AZURE_ATTESTATION_PAYLOAD_SIZE, *}; - use crate::CollateralSnapshot; + use crate::EndorsementSnapshot; fn input_data_from_attestation(attestation_bytes: &[u8]) -> [u8; 64] { let attestation_document: AttestationDocument = @@ -463,10 +467,9 @@ mod tests { let fixture_collateral: QuoteCollateralV3 = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let AttestationResult { + let VerifiedAttestation { measurements: async_measurements, - collateral: async_collateral, - .. + endorsements: async_endorsements, } = verify_azure_attestation_with_given_timestamp( attestation_json.clone(), [0; 64], @@ -478,8 +481,9 @@ mod tests { .await .unwrap(); - let AttestationResult { - measurements: sync_measurements, collateral: sync_collateral, .. + let VerifiedAttestation { + measurements: sync_measurements, + endorsements: sync_endorsements, } = verify_azure_attestation_with_given_timestamp_sync( attestation_json, [0; 64], @@ -491,12 +495,12 @@ mod tests { .unwrap(); assert_eq!(async_measurements, sync_measurements); - // 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 = CollateralSnapshot { collateral: fixture_collateral, at: now }; - assert_eq!(async_collateral, expected); - assert_eq!(sync_collateral, expected); + // 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); } #[tokio::test] diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index 9f127d0..f863ae9 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -1,5 +1,10 @@ //! Data Center Attestation Primitives (DCAP) evidence generation and //! verification +//! +//! 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 +//! it. use dcap_qvl::{ QuoteCollateralV3, collateral::CollateralClient, @@ -14,8 +19,8 @@ use thiserror::Error; use crate::{ AttestationError, - AttestationResult, - CollateralSnapshot, + EndorsementSnapshot, + VerifiedAttestation, measurements::MultiMeasurements, }; @@ -39,7 +44,7 @@ pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Option, -) -> Result { +) -> 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( @@ -63,7 +68,7 @@ pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result { +) -> 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( @@ -89,7 +94,7 @@ pub fn verify_dcap_attestation_with_timestamp_sync( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); @@ -123,7 +128,7 @@ pub async fn verify_dcap_attestation_with_given_timestamp( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); @@ -157,7 +162,7 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( collateral: QuoteCollateralV3, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { tracing::info!("Verifying DCAP attestation: {quote:?}"); let fmspc = hex::encode_upper(quote_fmspc("e)?); @@ -202,11 +207,13 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( return Err(DcapVerificationError::InputMismatch); } - Ok(AttestationResult { - measurements, + Ok(( + VerifiedAttestation { + measurements, + endorsements: EndorsementSnapshot::dcap(collateral, now), + }, quote, - collateral: CollateralSnapshot { collateral, at: now }, - }) + )) } #[cfg(any(test, feature = "mock"))] @@ -214,7 +221,7 @@ pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Option, -) -> Result { +) -> 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)?); @@ -233,11 +240,13 @@ pub async fn verify_dcap_attestation( return Err(DcapVerificationError::InputMismatch); } - Ok(AttestationResult { - measurements, + Ok(( + VerifiedAttestation { + measurements, + endorsements: EndorsementSnapshot::dcap(collateral, now), + }, quote, - collateral: CollateralSnapshot { collateral, at: now }, - }) + )) } #[cfg(any(test, feature = "mock"))] @@ -245,7 +254,7 @@ pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result { +) -> 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)?); @@ -258,11 +267,13 @@ pub fn verify_dcap_attestation_sync( if get_quote_input_data("e.report) != expected_input_data { return Err(DcapVerificationError::InputMismatch); } - Ok(AttestationResult { - measurements, + Ok(( + VerifiedAttestation { + measurements, + endorsements: EndorsementSnapshot::dcap(collateral, now), + }, quote, - collateral: CollateralSnapshot { collateral, at: now }, - }) + )) } /// Create a mock quote for testing on non-confidential hardware @@ -342,7 +353,7 @@ mod tests { let fixture_collateral: QuoteCollateralV3 = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let AttestationResult { measurements: async_measurements, collateral, .. } = + let (VerifiedAttestation { measurements: async_measurements, endorsements }, _) = verify_dcap_attestation_with_given_timestamp( attestation_bytes.to_vec(), [ @@ -359,7 +370,7 @@ mod tests { .await .unwrap(); - let AttestationResult { measurements: sync_measurements, .. } = + let (VerifiedAttestation { measurements: sync_measurements, .. }, _) = verify_dcap_attestation_with_timestamp_sync( attestation_bytes.to_vec(), [ @@ -376,12 +387,12 @@ mod tests { .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!(collateral.collateral, fixture_collateral); - // ... and the instant it was held to, which is the other half of what - // makes the verification reproducible - assert_eq!(collateral.at, now); + // 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); let platform_metadata = crate::mock_platform_metadata(crate::AttestationType::DcapTdx).unwrap(); measurement_policy @@ -433,7 +444,7 @@ mod tests { let expected_input_data = [0xA5; 64]; let quote = create_dcap_attestation(expected_input_data).unwrap(); - let verified = + let (verified, _) = verify_dcap_attestation(quote, expected_input_data, Some(pccs)).await.unwrap(); assert_eq!(verified.measurements, crate::measurements::mock_dcap_measurements()); diff --git a/crates/attestation/src/gcp/firmware.rs b/crates/attestation/src/gcp/firmware.rs index a22c4b6..40affc1 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -77,8 +77,8 @@ mod tests { use super::GcpFirmwareCache; use crate::{ - AttestationResult, PlatformMetadata, + VerifiedAttestation, dcap::{get_quote_input_data, verify_dcap_attestation_with_given_timestamp}, measurements::{ExpectedMeasurements, MeasurementPolicy, MeasurementRecord}, }; @@ -156,16 +156,17 @@ mod tests { let collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); let firmware = serde_saphyr::from_slice(firmware_bytes).unwrap(); - let AttestationResult { measurements, .. } = verify_dcap_attestation_with_given_timestamp( - attestation_bytes.to_vec(), - expected_input_data, - None, - Some(collateral), - GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, - false, - ) - .await - .unwrap(); + let (VerifiedAttestation { measurements, .. }, _) = + verify_dcap_attestation_with_given_timestamp( + attestation_bytes.to_vec(), + expected_input_data, + None, + Some(collateral), + GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, + false, + ) + .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 f999e87..e45e132 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -20,11 +20,9 @@ use std::{ use attest_measure::platform::PlatformError; pub use attest_types::{AttestationEvidence, PlatformMetadata}; -/// The DCAP collateral a verification consumed, reported inside -/// [AttestationResult::collateral]. Re-exported so callers can archive -/// it without taking a direct dependency on `dcap-qvl` +/// Re-exported so callers can archive [EndorsementSnapshot::dcap] without +/// depending on `dcap-qvl` directly pub use dcap_qvl::QuoteCollateralV3; -use dcap_qvl::quote::Quote; use measurements::MultiMeasurements; use parity_scale_codec::{Decode, Encode}; use pccs::{Pccs, PccsError}; @@ -357,49 +355,62 @@ pub enum PccsMode { Lazy, } -/// A DCAP collateral bundle together with the instant it is evaluated at +/// Fetched endorsement material, bound to the instant it was evaluated at /// -/// Every part of the bundle expires: TCB Info, QE Identity and both CRLs -/// carry `nextUpdate`, and the issuer chains carry `notAfter`. A bundle -/// therefore answers a freshness question only with respect to some -/// instant, and the two are one value rather than two. Holding them -/// together is what makes a verification reproducible: the same evidence, -/// the same bundle and the same instant give the same answer forever. +/// Everything fetched expires — `nextUpdate` on TCB Info, QE Identity and +/// both CRLs, `notAfter` on the issuer chains — so a bundle answers +/// freshness only with respect to an instant. Pairing the two is what makes +/// a verdict reproducible: same evidence, same snapshot, same verdict. +/// +/// What a verifier fetches is a transport choice of the protocol, not a +/// property of the platform: evidence can carry its own endorsements +/// instead. Hence a struct that grows fields rather than an enum keyed by +/// platform, and `#[non_exhaustive]` to keep that growth additive. +/// +/// 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 +/// spans both Endorsements (issuer chains, CRLs) and Reference Values (TCB +/// Info, QE Identity). +/// +/// [RFC 9334]: https://www.rfc-editor.org/rfc/rfc9334.html #[derive(Clone, Debug, PartialEq, Eq)] -pub struct CollateralSnapshot { - /// The collateral the verification consumed. A second copy fetched - /// alongside may differ, since a collateral cache can refresh between - /// the two fetches - pub collateral: QuoteCollateralV3, - /// Seconds since the Unix epoch. Gates every freshness check: - /// certificate validity windows, the CRLs, and the TCB Info and QE - /// Identity windows +#[non_exhaustive] +pub struct EndorsementSnapshot { + /// Seconds since the Unix epoch — the unit `dcap-qvl` and webpki take pub at: u64, + /// `Some` when the verification fetched a DCAP bundle, `None` when the + /// evidence carried its own or the platform has no DCAP leg. The bundle + /// consumed, not a second copy: a cache can refresh between two fetches + pub dcap: Option, } -/// The outcome of verifying one piece of attestation evidence +impl EndorsementSnapshot { + /// A verification that fetched one DCAP collateral bundle + pub fn dcap(collateral: QuoteCollateralV3, at: u64) -> Self { + Self { at, dcap: Some(collateral) } + } +} + +/// Evidence whose authenticity a Verifier established, with what it was +/// established against +/// +/// Not an Attestation Result in [RFC 9334] terms: the appraisal policy runs +/// after this value is built, and a caller may configure it to check +/// nothing, so no Reference Value comparison is implied. Archived beside +/// the evidence, it reproduces the verdict. /// -/// Every attested platform here rests on a DCAP quote: a GCP TDX quote is -/// one, and Azure wraps one in an HCL report and a vTPM attestation. So one -/// verification always consumes exactly one collateral bundle, whichever -/// platform produced the evidence. +/// [RFC 9334]: https://www.rfc-editor.org/rfc/rfc9334.html #[derive(Clone, Debug)] -pub struct AttestationResult { - /// The measurements the evidence carries - /// - /// Read out of `quote` on the DCAP and GCP paths. On Azure they are - /// the vTPM PCRs, which measure the guest boot rather than the launched - /// TD. They chain to `quote` - its report data commits to the HCL var - /// data, which carries the AK public key that signs the vTPM quote - - /// but are no field of it, so `quote` alone does not yield them +pub struct VerifiedAttestation { + /// MRTD and RTMR0–3 from the quote on DCAP and GCP. On Azure the vTPM + /// PCRs, which measure the guest boot rather than the launched TD and + /// chain to the TD quote: its report data commits to the HCL var data + /// carrying the AK public key that signs the vTPM quote pub measurements: MultiMeasurements, - /// The parsed DCAP quote the verification rests on. On Azure this is - /// the TD quote the evidence wraps, not the vTPM quote - pub quote: Quote, - /// What the verification consumed, and when it was held to — the pair - /// to archive next to the evidence it verified, and to hand back to - /// re-verify that evidence later - pub collateral: CollateralSnapshot, + /// The half of a reproducible verdict that does not ride in the + /// evidence + pub endorsements: EndorsementSnapshot, } /// Allows remote attestations to be verified @@ -552,16 +563,11 @@ impl AttestationVerifier { /// Verify an attestation, and ensure the measurements match one of our /// accepted measurements - /// - /// The result reports the DCAP collateral the verification consumed, - /// which is the bundle to archive next to the evidence: a second copy - /// fetched alongside may differ, since a collateral cache can refresh - /// between the two fetches. pub async fn verify_attestation( &self, attestation_exchange_message: AttestationExchangeMessage, expected_input_data: [u8; 64], - ) -> Result, AttestationError> { + ) -> Result, AttestationError> { let attestation_type = attestation_exchange_message.attestation_type(); tracing::debug!("Verifying {attestation_type} attestation"); @@ -605,14 +611,14 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - let verified = dcap::verify_dcap_attestation( + let (verified, quote) = dcap::verify_dcap_attestation( attestation_evidence.quote.clone(), expected_input_data, self.internal_pccs.clone(), ) .await?; if attestation_type == AttestationType::GcpTdx { - self.gcp_provenance_checker.verify_provenance(verified.quote.clone()).await?; + self.gcp_provenance_checker.verify_provenance(quote).await?; } verified } @@ -637,7 +643,7 @@ impl AttestationVerifier { &self, attestation_exchange_message: AttestationExchangeMessage, expected_input_data: [u8; 64], - ) -> Result, AttestationError> { + ) -> Result, AttestationError> { let attestation_type = attestation_exchange_message.attestation_type(); tracing::debug!("Verifying {attestation_type} attestation"); @@ -687,13 +693,13 @@ impl AttestationVerifier { #[cfg(not(any(test, feature = "mock")))] let pccs = self.internal_pccs.clone().ok_or(AttestationError::NoPccs)?; - let verified = dcap::verify_dcap_attestation_sync( + let (verified, quote) = dcap::verify_dcap_attestation_sync( attestation_evidence.quote.clone(), expected_input_data, pccs, )?; if attestation_type == AttestationType::GcpTdx { - self.gcp_provenance_checker.verify_provenance_sync(&verified.quote)?; + self.gcp_provenance_checker.verify_provenance_sync("e)?; } verified } @@ -940,11 +946,12 @@ mod tests { .unwrap() .expect("mock evidence carries an attestation"); - // The second read is served from the PCCS cache, not a second fetch, - // so it yields the same bundle the verification consumed. That is what - // makes it a valid comparison here — and the reason a caller must not - // rely on the pattern in general, where a refresh in between would - // hand back a different bundle + // The second read is served from the PCCS cache, not a second + // fetch, so it yields the same bundle the verification + // consumed. That is what makes it a valid comparison here — + // and the reason a caller must not rely on the pattern in + // general, where a refresh in between would hand back a + // different bundle let (served, _is_fresh) = verifier .internal_pccs .as_ref() @@ -956,6 +963,6 @@ mod tests { ) .await .unwrap(); - assert_eq!(verified.collateral.collateral, served); + assert_eq!(verified.endorsements.dcap, Some(served)); } }