From 2f110b72ea85c3f92a1706b546202e723eaf2651 Mon Sep 17 00:00:00 2001 From: peg Date: Thu, 9 Jul 2026 08:08:03 +0200 Subject: [PATCH 1/9] Add builder pattern for constructing AttestationVerifier --- crates/attestation/src/lib.rs | 106 ++++++++++++++++++++++++++-------- 1 file changed, 83 insertions(+), 23 deletions(-) diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index da13d9d..c6509ef 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -25,7 +25,7 @@ use pccs::{Pccs, PccsError}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::{dcap::DcapVerificationError, measurements::MeasurementPolicy}; +use crate::{dcap::DcapVerificationError, gcp::GcpFirmwareCache, measurements::MeasurementPolicy}; #[cfg(test)] static TEST_CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); @@ -330,10 +330,6 @@ impl AttestationGenerator { pub struct AttestationVerifier { /// The measurement policy with accepted values and attestation types pub measurement_policy: MeasurementPolicy, - /// If this is empty, anything will be accepted - but measurements are - /// always injected into HTTP headers, so that they can be verified - /// upstream A PCCS service to use - defaults to Intel PCS - pub pccs_url: Option, /// Whether to write quotes to files on disk pub dump_dcap_quotes: bool, /// Whether to override outdated TCB when on Azure @@ -346,21 +342,88 @@ pub struct AttestationVerifier { known_gcp_firmware: gcp::GcpFirmwareCache, } +/// Options used to construct an [AttestationVerifier] +pub struct AttestationVerifierBuilder { + /// The measurement policy with accepted values and attestation types + measurement_policy: MeasurementPolicy, + /// A PCCS service to use - defaults to Intel PCS + pccs_url: Option, + dump_dcap_quotes: bool, + override_azure_outdated_tcb: bool, + internal_pccs_prewarm: Option, +} + +impl AttestationVerifierBuilder { + pub fn build(self) -> AttestationVerifier { + AttestationVerifier::build(self) + } + + /// Whether to write quotes to files on disk + pub fn dump_dcap_quotes(mut self) -> Self { + self.dump_dcap_quotes = true; + self + } + + /// Whether to override outdated TCB when on Azure + /// + /// This provides a workaround for a known outdated FMSPC used by Azure + pub fn override_azure_outdated_tcb(mut self) -> Self { + self.override_azure_outdated_tcb = true; + self + } + + /// Do not keep an internal DCAP collateral cache + pub fn with_no_internal_pccs(mut self) -> Self { + self.internal_pccs_prewarm = None; + self + } + + /// Keep a DCAP collateral cache, and pre-fill it with all available + /// collateral + pub fn with_pccs_prewarmed(mut self) -> Self { + self.internal_pccs_prewarm = Some(true); + self + } + + /// Keep a DCAP collateral cache, starting empty + pub fn with_pccs_not_prewarmed(mut self) -> Self { + self.internal_pccs_prewarm = Some(false); + self + } + + /// Set the URL used by internal PCCS + pub fn pccs_url(mut self, pccs_url: String) -> Self { + self.pccs_url = Some(pccs_url); + self + } +} + impl AttestationVerifier { - fn build( - measurement_policy: MeasurementPolicy, - pccs_url: Option, - dump_dcap_quotes: bool, - override_azure_outdated_tcb: bool, - known_gcp_firmware: gcp::GcpFirmwareCache, - ) -> Self { + fn build(builder: AttestationVerifierBuilder) -> Self { + let internal_pccs = builder.internal_pccs_prewarm.map(|with_prewarm| { + if with_prewarm { + Pccs::new(builder.pccs_url) + } else { + Pccs::new_without_prewarm(builder.pccs_url) + } + }); + Self { + measurement_policy: builder.measurement_policy, + dump_dcap_quotes: builder.dump_dcap_quotes, + override_azure_outdated_tcb: builder.override_azure_outdated_tcb, + internal_pccs, + known_gcp_firmware: GcpFirmwareCache::new(), + } + } + + pub fn builder(measurement_policy: MeasurementPolicy) -> AttestationVerifierBuilder { + AttestationVerifierBuilder { measurement_policy, - pccs_url: pccs_url.clone(), - dump_dcap_quotes, - override_azure_outdated_tcb, - internal_pccs: Some(Pccs::new(pccs_url)), - known_gcp_firmware, + pccs_url: None, + dump_dcap_quotes: false, + override_azure_outdated_tcb: false, + internal_pccs_prewarm: Some(true), } } @@ -370,13 +433,13 @@ impl AttestationVerifier { dump_dcap_quotes: bool, override_azure_outdated_tcb: bool, ) -> Self { - Self::build( + Self::build(AttestationVerifierBuilder { measurement_policy, pccs_url, dump_dcap_quotes, override_azure_outdated_tcb, - gcp::GcpFirmwareCache::new(), - ) + internal_pccs_prewarm: Some(true), + }) } /// Create an [AttestationVerifier] which will only allow no attestation @@ -384,7 +447,6 @@ impl AttestationVerifier { pub fn expect_none() -> Self { Self { measurement_policy: MeasurementPolicy::expect_none(), - pccs_url: None, dump_dcap_quotes: false, override_azure_outdated_tcb: false, internal_pccs: None, @@ -397,7 +459,6 @@ impl AttestationVerifier { pub fn mock() -> Self { Self { measurement_policy: MeasurementPolicy::mock(), - pccs_url: None, dump_dcap_quotes: false, override_azure_outdated_tcb: false, internal_pccs: None, @@ -410,7 +471,6 @@ impl AttestationVerifier { pub fn mock_with_pccs(pccs_url: String) -> Self { Self { measurement_policy: MeasurementPolicy::mock(), - pccs_url: None, dump_dcap_quotes: false, override_azure_outdated_tcb: false, internal_pccs: Some(Pccs::new(Some(pccs_url))), From ddfdb7b1837b33966a737f579668404baa92f1bc Mon Sep 17 00:00:00 2001 From: peg Date: Thu, 9 Jul 2026 08:47:55 +0200 Subject: [PATCH 2/9] Add builder pattern for constructing AttestationVerifier --- .../attestation-provider-server/src/main.rs | 9 +++-- crates/attestation/src/lib.rs | 36 +++++++++---------- crates/attested-tls/src/lib.rs | 9 ++--- crates/attested-tls/tests/nested_tls.rs | 5 ++- 4 files changed, 29 insertions(+), 30 deletions(-) diff --git a/crates/attestation-provider-server/src/main.rs b/crates/attestation-provider-server/src/main.rs index e4d2b31..83fef20 100644 --- a/crates/attestation-provider-server/src/main.rs +++ b/crates/attestation-provider-server/src/main.rs @@ -97,8 +97,13 @@ async fn main() -> anyhow::Result<()> { None => MeasurementPolicy::accept_anything(), }; - let attestation_verifier = - AttestationVerifier::new(measurement_policy, None, cli.log_dcap_quote, false); + let mut attestation_verifier_builder = AttestationVerifier::builder(measurement_policy); + + if cli.log_dcap_quote { + attestation_verifier_builder = attestation_verifier_builder.dump_dcap_quotes(); + } + + let attestation_verifier = attestation_verifier_builder.build(); let attestation_message = attestation_provider_client(server_addr, attestation_verifier).await?; diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index c6509ef..4022aff 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -329,15 +329,16 @@ impl AttestationGenerator { #[derive(Clone, Debug)] pub struct AttestationVerifier { /// The measurement policy with accepted values and attestation types - pub measurement_policy: MeasurementPolicy, + measurement_policy: MeasurementPolicy, /// Whether to write quotes to files on disk - pub dump_dcap_quotes: bool, + dump_dcap_quotes: bool, + #[cfg(feature = "azure-verifier")] /// Whether to override outdated TCB when on Azure /// /// This provides a workaround for a known outdated FMSPC used by Azure - pub override_azure_outdated_tcb: bool, + override_azure_outdated_tcb: bool, /// Internal cache for collateral - pub internal_pccs: Option, + internal_pccs: Option, /// Cached GCP firmware blobs indexed by MRTD known_gcp_firmware: gcp::GcpFirmwareCache, } @@ -349,6 +350,7 @@ pub struct AttestationVerifierBuilder { /// A PCCS service to use - defaults to Intel PCS pccs_url: Option, dump_dcap_quotes: bool, + #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: bool, internal_pccs_prewarm: Option, } @@ -367,6 +369,7 @@ impl AttestationVerifierBuilder { /// Whether to override outdated TCB when on Azure /// /// This provides a workaround for a known outdated FMSPC used by Azure + #[cfg(feature = "azure-verifier")] pub fn override_azure_outdated_tcb(mut self) -> Self { self.override_azure_outdated_tcb = true; self @@ -411,6 +414,7 @@ impl AttestationVerifier { Self { measurement_policy: builder.measurement_policy, dump_dcap_quotes: builder.dump_dcap_quotes, + #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: builder.override_azure_outdated_tcb, internal_pccs, known_gcp_firmware: GcpFirmwareCache::new(), @@ -422,32 +426,19 @@ impl AttestationVerifier { measurement_policy, pccs_url: None, dump_dcap_quotes: false, + #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: false, internal_pccs_prewarm: Some(true), } } - pub fn new( - measurement_policy: MeasurementPolicy, - pccs_url: Option, - dump_dcap_quotes: bool, - override_azure_outdated_tcb: bool, - ) -> Self { - Self::build(AttestationVerifierBuilder { - measurement_policy, - pccs_url, - dump_dcap_quotes, - override_azure_outdated_tcb, - internal_pccs_prewarm: Some(true), - }) - } - /// Create an [AttestationVerifier] which will only allow no attestation /// and will reject if one is given pub fn expect_none() -> Self { Self { measurement_policy: MeasurementPolicy::expect_none(), dump_dcap_quotes: false, + #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: false, internal_pccs: None, known_gcp_firmware: gcp::GcpFirmwareCache::new(), @@ -460,6 +451,7 @@ impl AttestationVerifier { Self { measurement_policy: MeasurementPolicy::mock(), dump_dcap_quotes: false, + #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: false, internal_pccs: None, known_gcp_firmware: gcp::GcpFirmwareCache::new(), @@ -472,6 +464,7 @@ impl AttestationVerifier { Self { measurement_policy: MeasurementPolicy::mock(), dump_dcap_quotes: false, + #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: false, internal_pccs: Some(Pccs::new(Some(pccs_url))), known_gcp_firmware: gcp::GcpFirmwareCache::new(), @@ -652,6 +645,11 @@ impl AttestationVerifier { pub fn has_remote_attestation(&self) -> bool { self.measurement_policy.has_remote_attestation() } + + /// Returns the measurement policy used + pub fn measurement_policy(&self) -> &MeasurementPolicy { + &self.measurement_policy + } } /// Write attestation data to a log file diff --git a/crates/attested-tls/src/lib.rs b/crates/attested-tls/src/lib.rs index 25a386d..c6da230 100644 --- a/crates/attested-tls/src/lib.rs +++ b/crates/attested-tls/src/lib.rs @@ -1104,9 +1104,7 @@ mod tests { let mock_pcs_server = spawn_mock_pcs_server(MockPcsConfig::default()).await.unwrap(); let verifier = AttestationVerifier::mock_with_pccs(mock_pcs_server.base_url.clone()); - if let Some(ref pccs) = verifier.internal_pccs { - pccs.ready().await.unwrap(); - } + verifier.ready().await.unwrap(); let mut builder = AttestedCertificateVerifier::build(verifier).with_crypto_provider(provider); @@ -1559,9 +1557,8 @@ mod tests { .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()); - if let Some(ref pccs) = verifier.internal_pccs { - pccs.ready().await.unwrap(); - } + verifier.ready().await.unwrap(); + let verifier = AttestedCertificateVerifier::build(verifier) .with_crypto_provider(provider) .with_allowed_leaf_cert_pubkey(&key_pair.public_key_der()) diff --git a/crates/attested-tls/tests/nested_tls.rs b/crates/attested-tls/tests/nested_tls.rs index a3dcdf2..0e47c6b 100644 --- a/crates/attested-tls/tests/nested_tls.rs +++ b/crates/attested-tls/tests/nested_tls.rs @@ -120,9 +120,8 @@ async fn attested_client_config(provider: Arc) -> ClientConfig { let mock_pcs_server = spawn_mock_pcs_server(MockPcsConfig::default()).await.unwrap(); let verifier = AttestationVerifier::mock_with_pccs(mock_pcs_server.base_url.clone()); - if let Some(ref pccs) = verifier.internal_pccs { - pccs.ready().await.unwrap(); - } + verifier.ready().await.unwrap(); + let verifier = AttestedCertificateVerifier::build(verifier) .with_crypto_provider(provider.clone()) .finish() From a5835e5bf43dd2eb72f1ab238a61db92ce66d6de Mon Sep 17 00:00:00 2001 From: peg Date: Tue, 4 Aug 2026 11:07:50 +0200 Subject: [PATCH 3/9] Add dynamic measurement policies --- crates/attestation/src/lib.rs | 55 +++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 4022aff..01b21cd 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -14,6 +14,7 @@ use std::{ fmt::{self, Display, Formatter}, io::Read, net::IpAddr, + sync::{Arc, RwLock, RwLockReadGuard}, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -328,8 +329,9 @@ impl AttestationGenerator { /// Allows remote attestations to be verified #[derive(Clone, Debug)] pub struct AttestationVerifier { - /// The measurement policy with accepted values and attestation types - measurement_policy: MeasurementPolicy, + /// The measurement policy with accepted values and attestation types, + /// shared between clones + measurement_policy: Arc>, /// Whether to write quotes to files on disk dump_dcap_quotes: bool, #[cfg(feature = "azure-verifier")] @@ -412,7 +414,7 @@ impl AttestationVerifier { }); Self { - measurement_policy: builder.measurement_policy, + measurement_policy: Arc::new(RwLock::new(builder.measurement_policy)), dump_dcap_quotes: builder.dump_dcap_quotes, #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: builder.override_azure_outdated_tcb, @@ -436,7 +438,7 @@ impl AttestationVerifier { /// and will reject if one is given pub fn expect_none() -> Self { Self { - measurement_policy: MeasurementPolicy::expect_none(), + measurement_policy: Arc::new(RwLock::new(MeasurementPolicy::expect_none())), dump_dcap_quotes: false, #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: false, @@ -449,7 +451,7 @@ impl AttestationVerifier { #[cfg(any(test, feature = "mock"))] pub fn mock() -> Self { Self { - measurement_policy: MeasurementPolicy::mock(), + measurement_policy: Arc::new(RwLock::new(MeasurementPolicy::mock())), dump_dcap_quotes: false, #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: false, @@ -462,7 +464,7 @@ impl AttestationVerifier { #[cfg(any(test, feature = "mock"))] pub fn mock_with_pccs(pccs_url: String) -> Self { Self { - measurement_policy: MeasurementPolicy::mock(), + measurement_policy: Arc::new(RwLock::new(MeasurementPolicy::mock())), dump_dcap_quotes: false, #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: false, @@ -554,7 +556,7 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .map(|evidence| evidence.platform.clone()); - self.measurement_policy.check_measurement_with_gcp_cache( + self.measurement_policy_read().check_measurement_with_gcp_cache( &measurements, platform_metadata.as_ref(), Some(&self.known_gcp_firmware), @@ -631,7 +633,7 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .map(|evidence| evidence.platform.clone()); - self.measurement_policy.check_measurement_with_gcp_cache( + self.measurement_policy_read().check_measurement_with_gcp_cache( &measurements, platform_metadata.as_ref(), Some(&self.known_gcp_firmware), @@ -643,12 +645,23 @@ impl AttestationVerifier { /// Whether we allow no remote attestation pub fn has_remote_attestation(&self) -> bool { - self.measurement_policy.has_remote_attestation() + self.measurement_policy_read().has_remote_attestation() + } + + /// Returns a snapshot of the measurement policy currently in use. + pub fn measurement_policy(&self) -> MeasurementPolicy { + self.measurement_policy_read().clone() + } + + /// Replaces the measurement policy used by this verifier and all of its + /// clones. + pub fn set_measurement_policy(&self, measurement_policy: MeasurementPolicy) { + *self.measurement_policy.write().unwrap_or_else(|poisoned| poisoned.into_inner()) = + measurement_policy; } - /// Returns the measurement policy used - pub fn measurement_policy(&self) -> &MeasurementPolicy { - &self.measurement_policy + fn measurement_policy_read(&self) -> RwLockReadGuard<'_, MeasurementPolicy> { + self.measurement_policy.read().unwrap_or_else(|poisoned| poisoned.into_inner()) } } @@ -838,4 +851,22 @@ mod tests { assert!(result.is_ok(), "expected sync mock verification to succeed: {result:?}"); } + + #[test] + fn measurement_policy_can_be_updated_between_verification_attempts() { + let verifier = + AttestationVerifier::builder(MeasurementPolicy::tdx()).with_no_internal_pccs().build(); + let verifier_clone = verifier.clone(); + let message = AttestationExchangeMessage::without_attestation(); + let input_data = [0; 64]; + + assert!(matches!( + verifier.verify_attestation_sync(message.clone(), input_data), + Err(AttestationError::AttestationTypeNotAccepted) + )); + + verifier_clone.set_measurement_policy(MeasurementPolicy::expect_none()); + + assert!(matches!(verifier.verify_attestation_sync(message, input_data), Ok(None))); + } } From e14641b860c07fed6fb3bb0fadac392b58880299 Mon Sep 17 00:00:00 2001 From: peg Date: Tue, 11 Aug 2026 12:16:56 +0200 Subject: [PATCH 4/9] Add an option to re-fetch dynamic measurement policies from file or URL --- crates/attestation/src/lib.rs | 73 +++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 01b21cd..e6a9cba 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -26,7 +26,11 @@ use pccs::{Pccs, PccsError}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::{dcap::DcapVerificationError, gcp::GcpFirmwareCache, measurements::MeasurementPolicy}; +use crate::{ + dcap::DcapVerificationError, + gcp::GcpFirmwareCache, + measurements::{MeasurementFormatError, MeasurementPolicy}, +}; #[cfg(test)] static TEST_CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); @@ -343,12 +347,16 @@ pub struct AttestationVerifier { internal_pccs: Option, /// Cached GCP firmware blobs indexed by MRTD known_gcp_firmware: gcp::GcpFirmwareCache, + /// Dynamic measurement policy to re-fetch from file or URL + dynamic_measurement_policy: Option, } /// Options used to construct an [AttestationVerifier] pub struct AttestationVerifierBuilder { /// The measurement policy with accepted values and attestation types measurement_policy: MeasurementPolicy, + /// A dynamic measurement policy file or URL + dynamic_measurement_policy: Option, /// A PCCS service to use - defaults to Intel PCS pccs_url: Option, dump_dcap_quotes: bool, @@ -401,6 +409,11 @@ impl AttestationVerifierBuilder { self.pccs_url = Some(pccs_url); self } + + pub fn with_dynamic_measurements_file_or_url(mut self, file_or_url: String) -> Self { + self.dynamic_measurement_policy = Some(file_or_url); + self + } } impl AttestationVerifier { @@ -420,6 +433,7 @@ impl AttestationVerifier { override_azure_outdated_tcb: builder.override_azure_outdated_tcb, internal_pccs, known_gcp_firmware: GcpFirmwareCache::new(), + dynamic_measurement_policy: builder.dynamic_measurement_policy, } } @@ -431,6 +445,7 @@ impl AttestationVerifier { #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: false, internal_pccs_prewarm: Some(true), + dynamic_measurement_policy: None, } } @@ -444,6 +459,7 @@ impl AttestationVerifier { override_azure_outdated_tcb: false, internal_pccs: None, known_gcp_firmware: gcp::GcpFirmwareCache::new(), + dynamic_measurement_policy: None, } } @@ -457,6 +473,7 @@ impl AttestationVerifier { override_azure_outdated_tcb: false, internal_pccs: None, known_gcp_firmware: gcp::GcpFirmwareCache::new(), + dynamic_measurement_policy: None, } } @@ -470,6 +487,7 @@ impl AttestationVerifier { override_azure_outdated_tcb: false, internal_pccs: Some(Pccs::new(Some(pccs_url))), known_gcp_firmware: gcp::GcpFirmwareCache::new(), + dynamic_measurement_policy: None, } } @@ -556,11 +574,29 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .map(|evidence| evidence.platform.clone()); - self.measurement_policy_read().check_measurement_with_gcp_cache( + + let policy_check = self.measurement_policy_read().check_measurement_with_gcp_cache( &measurements, platform_metadata.as_ref(), Some(&self.known_gcp_firmware), - )?; + ); + + if let Err(err) = policy_check { + // If this fails, and we have dynamic measurement policy, re-retrieve our + // measurement policy, then check the policy a second time + if let Some(file_or_url) = &self.dynamic_measurement_policy { + let new_measurement_policy = + MeasurementPolicy::from_file_or_url(file_or_url.to_string()).await?; + self.set_measurement_policy(new_measurement_policy); + self.measurement_policy_read().check_measurement_with_gcp_cache( + &measurements, + platform_metadata.as_ref(), + Some(&self.known_gcp_firmware), + )?; + } else { + return Err(err); + } + } tracing::debug!("Verification successful"); Ok(Some(measurements)) @@ -789,6 +825,8 @@ pub enum AttestationError { AttestationTypeNotAccepted, #[error("Measurements not accepted")] MeasurementsNotAccepted, + #[error("Failed to refresh measurement policy: {0}")] + MeasurementPolicyRefresh(#[from] MeasurementFormatError), #[cfg(feature = "azure-verifier")] #[error("Microsoft Azure Attestation (MAA): {0}")] Maa(#[from] azure::MaaError), @@ -869,4 +907,33 @@ mod tests { assert!(matches!(verifier.verify_attestation_sync(message, input_data), Ok(None))); } + + #[tokio::test] + async fn dynamic_measurement_policy_refetches_on_mismatch() { + let temp_dir = tempfile::tempdir().unwrap(); + let policy_path = temp_dir.path().join("measurements.json"); + tokio::fs::write(&policy_path, br#"[{"attestation_type":"none"}]"#).await.unwrap(); + + let initial_policy = MeasurementPolicy::from_file(policy_path.clone()).await.unwrap(); + let verifier = AttestationVerifier::builder(initial_policy) + .with_no_internal_pccs() + .with_dynamic_measurements_file_or_url(policy_path.to_string_lossy().into_owned()) + .build(); + + let input_data = [7u8; 64]; + let quote = dcap::create_dcap_attestation(input_data).unwrap(); + let attestation = AttestationEvidence { + quote, + platform: mock_platform_metadata(AttestationType::DcapTdx).unwrap(), + }; + let measurements = measurements::mock_dcap_measurements(); + + assert!(verifier.measurement_policy().check_measurement(&measurements, None).is_err()); + + tokio::fs::write(&policy_path, br#"[{"attestation_type":"dcap-tdx"}]"#).await.unwrap(); + + verifier.verify_attestation(attestation.into(), input_data).await.unwrap(); + + assert!(verifier.measurement_policy().check_measurement(&measurements, None).is_ok()); + } } From 6b25a0432bdcefe07bf819a5949b3178bea74008 Mon Sep 17 00:00:00 2001 From: peg Date: Wed, 12 Aug 2026 09:14:46 +0200 Subject: [PATCH 5/9] Fix policy mutex handling and disallow dynamic policies with attested-tls crate due to certificate caching --- crates/attestation/src/lib.rs | 59 +++++++++++++++++++++++++- crates/attestation/src/measurements.rs | 39 ++++++++++++++++- crates/attested-tls/src/lib.rs | 29 +++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index e6a9cba..9eae788 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -410,6 +410,12 @@ impl AttestationVerifierBuilder { self } + /// Re-fetch the measurement policy from this file or URL after a + /// measurement mismatch. + /// + /// Both asynchronous and synchronous verification perform one retry + /// with the refreshed policy. Synchronous URL refreshes block for + /// up to ten seconds. pub fn with_dynamic_measurements_file_or_url(mut self, file_or_url: String) -> Self { self.dynamic_measurement_policy = Some(file_or_url); self @@ -669,11 +675,26 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .map(|evidence| evidence.platform.clone()); - self.measurement_policy_read().check_measurement_with_gcp_cache( + let policy_check = self.measurement_policy_read().check_measurement_with_gcp_cache( &measurements, platform_metadata.as_ref(), Some(&self.known_gcp_firmware), - )?; + ); + + if let Err(err) = policy_check { + if let Some(file_or_url) = &self.dynamic_measurement_policy { + let new_measurement_policy = + MeasurementPolicy::from_file_or_url_sync(file_or_url.to_string())?; + self.set_measurement_policy(new_measurement_policy); + self.measurement_policy_read().check_measurement_with_gcp_cache( + &measurements, + platform_metadata.as_ref(), + Some(&self.known_gcp_firmware), + )?; + } else { + return Err(err); + } + } tracing::debug!("Verification successful"); Ok(Some(measurements)) @@ -689,6 +710,12 @@ impl AttestationVerifier { self.measurement_policy_read().clone() } + /// Whether this verifier automatically refreshes its measurement policy + /// after a mismatch. + pub fn has_dynamic_measurement_policy(&self) -> bool { + self.dynamic_measurement_policy.is_some() + } + /// Replaces the measurement policy used by this verifier and all of its /// clones. pub fn set_measurement_policy(&self, measurement_policy: MeasurementPolicy) { @@ -936,4 +963,32 @@ mod tests { assert!(verifier.measurement_policy().check_measurement(&measurements, None).is_ok()); } + + #[tokio::test] + async fn sync_verification_refetches_dynamic_measurement_policy_on_mismatch() { + let temp_dir = tempfile::tempdir().unwrap(); + let policy_path = temp_dir.path().join("measurements.json"); + std::fs::write(&policy_path, br#"[{"attestation_type":"none"}]"#).unwrap(); + + let policy_source = policy_path.to_string_lossy().into_owned(); + let initial_policy = + MeasurementPolicy::from_file_or_url_sync(policy_source.clone()).unwrap(); + let mock_pcs_server = spawn_mock_pcs_server(MockPcsConfig::default()).await.unwrap(); + let verifier = AttestationVerifier::builder(initial_policy) + .pccs_url(mock_pcs_server.base_url.clone()) + .with_dynamic_measurements_file_or_url(policy_source) + .build(); + verifier.ready().await.unwrap(); + + let input_data = [7u8; 64]; + let quote = dcap::create_dcap_attestation(input_data).unwrap(); + let attestation = AttestationEvidence { + quote, + platform: mock_platform_metadata(AttestationType::DcapTdx).unwrap(), + }; + + std::fs::write(&policy_path, br#"[{"attestation_type":"dcap-tdx"}]"#).unwrap(); + + verifier.verify_attestation_sync(attestation.into(), input_data).unwrap(); + } } diff --git a/crates/attestation/src/measurements.rs b/crates/attestation/src/measurements.rs index 878ce87..0f640e2 100644 --- a/crates/attestation/src/measurements.rs +++ b/crates/attestation/src/measurements.rs @@ -1,6 +1,14 @@ //! Measurements and policy for enforcing them when validating a remote //! attestation -use std::{collections::HashMap, fmt, fmt::Formatter, net::IpAddr, path::PathBuf}; +use std::{ + collections::HashMap, + fmt, + fmt::Formatter, + io::Read, + net::IpAddr, + path::PathBuf, + time::Duration, +}; use attest_measure::dcap::expected_dcap_registers; use attest_types::{ @@ -502,6 +510,33 @@ impl MeasurementPolicy { } } + /// Synchronously parse a measurement policy from either a URL or a file + /// path. + pub fn from_file_or_url_sync(file_or_url: String) -> Result { + #[cfg(test)] + crate::install_test_crypto_provider(); + + let normalized_source = file_or_url.to_lowercase(); + let normalized_source = normalized_source.trim_ascii(); + let is_https = normalized_source.starts_with("https://"); + let is_http = normalized_source.starts_with("http://"); + if is_https || is_http { + if is_http && !Self::is_loopback_http_url(&file_or_url)? { + return Err(MeasurementFormatError::InsecureHttpNotLoopback(file_or_url)); + } + + let response = ureq::get(&file_or_url) + .timeout(Duration::from_secs(10)) + .call() + .map_err(|error| MeasurementFormatError::Ureq(Box::new(error)))?; + let mut measurements_json = Vec::new(); + response.into_reader().read_to_end(&mut measurements_json)?; + Self::from_json_bytes(measurements_json) + } else { + Self::from_json_bytes(std::fs::read(file_or_url)?) + } + } + /// Given the path to a JSON file containing measurements, return a /// [MeasurementPolicy] pub async fn from_file(measurement_file: PathBuf) -> Result { @@ -820,6 +855,8 @@ pub enum MeasurementFormatError { ParseInt(#[from] std::num::ParseIntError), #[error("Failed to read measurements from URL: {0}")] Reqwest(#[from] reqwest::Error), + #[error("Failed to synchronously read measurements from URL: {0}")] + Ureq(#[source] Box), #[error("Invalid URL: {0}")] InvalidUri(#[from] InvalidUri), #[error("Refusing to load measurement policy over plain HTTP from non-loopback host: {0}")] diff --git a/crates/attested-tls/src/lib.rs b/crates/attested-tls/src/lib.rs index c6da230..f93cf8b 100644 --- a/crates/attested-tls/src/lib.rs +++ b/crates/attested-tls/src/lib.rs @@ -487,6 +487,10 @@ impl AttestedCertificateVerifier { pub fn try_default( attestation_verifier: AttestationVerifier, ) -> Result { + if attestation_verifier.has_dynamic_measurement_policy() { + return Err(AttestedTlsError::DynamicMeasurementPolicyUnsupported); + } + let crypto_provider = default_crypto_provider()?; let server_verifier = WebPkiServerVerifier::builder(Arc::new({ @@ -956,6 +960,10 @@ impl AttestedCertificateVerifierBuilder { /// Finish the build of AttestedCertificateVerifier pub fn finish(self) -> Result { + if self.attestation_verifier.has_dynamic_measurement_policy() { + return Err(AttestedTlsError::DynamicMeasurementPolicyUnsupported); + } + let crypto_provider = match self.crypto_provider { None => default_crypto_provider()?, Some(provider) => provider, @@ -1001,6 +1009,8 @@ impl AttestedCertificateVerifierBuilder { #[derive(Debug, Error)] pub enum AttestedTlsError { + #[error("Dynamic measurement policies are not supported by AttestedCertificateVerifier")] + DynamicMeasurementPolicyUnsupported, #[error("Certificate validity duration must be at least {minimum:?}")] InvalidCertificateValidityDuration { minimum: Duration }, #[error("Failed to generate certificate key pair: {0}")] @@ -1727,6 +1737,25 @@ mod tests { .unwrap(); } + #[test] + fn attested_certificate_verifier_rejects_dynamic_measurement_policies() { + let dynamic_verifier = AttestationVerifier::builder( + attestation::measurements::MeasurementPolicy::expect_none(), + ) + .with_no_internal_pccs() + .with_dynamic_measurements_file_or_url("measurements.json".into()) + .build(); + + assert!(matches!( + AttestedCertificateVerifier::build(dynamic_verifier.clone()).finish(), + Err(AttestedTlsError::DynamicMeasurementPolicyUnsupported) + )); + assert!(matches!( + AttestedCertificateVerifier::try_default(dynamic_verifier), + Err(AttestedTlsError::DynamicMeasurementPolicyUnsupported) + )); + } + #[tokio::test(flavor = "multi_thread")] async fn sync_verifier_cache_miss_fails_then_succeeds_after_background_fetch() { install_test_crypto_provider(); From 7ceceb5cad10d06f732b05a557f0534f12f5e104 Mon Sep 17 00:00:00 2001 From: peg Date: Wed, 12 Aug 2026 09:24:06 +0200 Subject: [PATCH 6/9] Make set_measurement_policy be pub(crate) --- crates/attestation/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 9eae788..f24d66f 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -718,7 +718,7 @@ impl AttestationVerifier { /// Replaces the measurement policy used by this verifier and all of its /// clones. - pub fn set_measurement_policy(&self, measurement_policy: MeasurementPolicy) { + pub(crate) fn set_measurement_policy(&self, measurement_policy: MeasurementPolicy) { *self.measurement_policy.write().unwrap_or_else(|poisoned| poisoned.into_inner()) = measurement_policy; } From 9953f622400794eb3d60ff02fdac624a7dfc8fdc Mon Sep 17 00:00:00 2001 From: peg Date: Wed, 12 Aug 2026 09:49:09 +0200 Subject: [PATCH 7/9] Use a generation number to avoid multiple concurrent changes to policy --- crates/attestation/src/lib.rs | 93 +++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 20 deletions(-) diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index f24d66f..ad884f6 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -14,7 +14,7 @@ use std::{ fmt::{self, Display, Formatter}, io::Read, net::IpAddr, - sync::{Arc, RwLock, RwLockReadGuard}, + sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -335,7 +335,7 @@ impl AttestationGenerator { pub struct AttestationVerifier { /// The measurement policy with accepted values and attestation types, /// shared between clones - measurement_policy: Arc>, + measurement_policy: Arc>, /// Whether to write quotes to files on disk dump_dcap_quotes: bool, #[cfg(feature = "azure-verifier")] @@ -351,6 +351,20 @@ pub struct AttestationVerifier { dynamic_measurement_policy: Option, } +/// Measurement policy together with a generation number used to track +/// changes +#[derive(Clone, Debug)] +struct MeasurementPolicyState { + policy: MeasurementPolicy, + generation: u64, +} + +impl MeasurementPolicyState { + fn new(policy: MeasurementPolicy) -> Self { + Self { policy, generation: 0 } + } +} + /// Options used to construct an [AttestationVerifier] pub struct AttestationVerifierBuilder { /// The measurement policy with accepted values and attestation types @@ -433,7 +447,9 @@ impl AttestationVerifier { }); Self { - measurement_policy: Arc::new(RwLock::new(builder.measurement_policy)), + measurement_policy: Arc::new(RwLock::new(MeasurementPolicyState::new( + builder.measurement_policy, + ))), dump_dcap_quotes: builder.dump_dcap_quotes, #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: builder.override_azure_outdated_tcb, @@ -459,7 +475,9 @@ impl AttestationVerifier { /// and will reject if one is given pub fn expect_none() -> Self { Self { - measurement_policy: Arc::new(RwLock::new(MeasurementPolicy::expect_none())), + measurement_policy: Arc::new(RwLock::new(MeasurementPolicyState::new( + MeasurementPolicy::expect_none(), + ))), dump_dcap_quotes: false, #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: false, @@ -473,7 +491,9 @@ impl AttestationVerifier { #[cfg(any(test, feature = "mock"))] pub fn mock() -> Self { Self { - measurement_policy: Arc::new(RwLock::new(MeasurementPolicy::mock())), + measurement_policy: Arc::new(RwLock::new(MeasurementPolicyState::new( + MeasurementPolicy::mock(), + ))), dump_dcap_quotes: false, #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: false, @@ -487,7 +507,9 @@ impl AttestationVerifier { #[cfg(any(test, feature = "mock"))] pub fn mock_with_pccs(pccs_url: String) -> Self { Self { - measurement_policy: Arc::new(RwLock::new(MeasurementPolicy::mock())), + measurement_policy: Arc::new(RwLock::new(MeasurementPolicyState::new( + MeasurementPolicy::mock(), + ))), dump_dcap_quotes: false, #[cfg(feature = "azure-verifier")] override_azure_outdated_tcb: false, @@ -581,7 +603,8 @@ impl AttestationVerifier { .as_ref() .map(|evidence| evidence.platform.clone()); - let policy_check = self.measurement_policy_read().check_measurement_with_gcp_cache( + let policy_state = self.measurement_policy_read().clone(); + let policy_check = policy_state.policy.check_measurement_with_gcp_cache( &measurements, platform_metadata.as_ref(), Some(&self.known_gcp_firmware), @@ -593,8 +616,9 @@ impl AttestationVerifier { if let Some(file_or_url) = &self.dynamic_measurement_policy { let new_measurement_policy = MeasurementPolicy::from_file_or_url(file_or_url.to_string()).await?; - self.set_measurement_policy(new_measurement_policy); - self.measurement_policy_read().check_measurement_with_gcp_cache( + let measurement_policy = + self.set_measurement_policy(new_measurement_policy, policy_state.generation); + measurement_policy.check_measurement_with_gcp_cache( &measurements, platform_metadata.as_ref(), Some(&self.known_gcp_firmware), @@ -675,7 +699,8 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .map(|evidence| evidence.platform.clone()); - let policy_check = self.measurement_policy_read().check_measurement_with_gcp_cache( + let policy_state = self.measurement_policy_read().clone(); + let policy_check = policy_state.policy.check_measurement_with_gcp_cache( &measurements, platform_metadata.as_ref(), Some(&self.known_gcp_firmware), @@ -685,8 +710,9 @@ impl AttestationVerifier { if let Some(file_or_url) = &self.dynamic_measurement_policy { let new_measurement_policy = MeasurementPolicy::from_file_or_url_sync(file_or_url.to_string())?; - self.set_measurement_policy(new_measurement_policy); - self.measurement_policy_read().check_measurement_with_gcp_cache( + let measurement_policy = + self.set_measurement_policy(new_measurement_policy, policy_state.generation); + measurement_policy.check_measurement_with_gcp_cache( &measurements, platform_metadata.as_ref(), Some(&self.known_gcp_firmware), @@ -702,12 +728,12 @@ impl AttestationVerifier { /// Whether we allow no remote attestation pub fn has_remote_attestation(&self) -> bool { - self.measurement_policy_read().has_remote_attestation() + self.measurement_policy_read().policy.has_remote_attestation() } /// Returns a snapshot of the measurement policy currently in use. pub fn measurement_policy(&self) -> MeasurementPolicy { - self.measurement_policy_read().clone() + self.measurement_policy_read().policy.clone() } /// Whether this verifier automatically refreshes its measurement policy @@ -717,15 +743,28 @@ impl AttestationVerifier { } /// Replaces the measurement policy used by this verifier and all of its - /// clones. - pub(crate) fn set_measurement_policy(&self, measurement_policy: MeasurementPolicy) { - *self.measurement_policy.write().unwrap_or_else(|poisoned| poisoned.into_inner()) = - measurement_policy; + /// clones if it has not changed since `expected_generation` was + /// observed. + pub(crate) fn set_measurement_policy( + &self, + measurement_policy: MeasurementPolicy, + expected_generation: u64, + ) -> MeasurementPolicy { + let mut state = self.measurement_policy_write(); + if state.generation == expected_generation { + state.policy = measurement_policy; + state.generation = state.generation.wrapping_add(1); + } + state.policy.clone() } - fn measurement_policy_read(&self) -> RwLockReadGuard<'_, MeasurementPolicy> { + fn measurement_policy_read(&self) -> RwLockReadGuard<'_, MeasurementPolicyState> { self.measurement_policy.read().unwrap_or_else(|poisoned| poisoned.into_inner()) } + + fn measurement_policy_write(&self) -> RwLockWriteGuard<'_, MeasurementPolicyState> { + self.measurement_policy.write().unwrap_or_else(|poisoned| poisoned.into_inner()) + } } /// Write attestation data to a log file @@ -930,11 +969,25 @@ mod tests { Err(AttestationError::AttestationTypeNotAccepted) )); - verifier_clone.set_measurement_policy(MeasurementPolicy::expect_none()); + let generation = verifier_clone.measurement_policy_read().generation; + verifier_clone.set_measurement_policy(MeasurementPolicy::expect_none(), generation); assert!(matches!(verifier.verify_attestation_sync(message, input_data), Ok(None))); } + #[test] + fn stale_measurement_policy_refresh_does_not_overwrite_newer_policy() { + let verifier = AttestationVerifier::expect_none(); + let stale_generation = verifier.measurement_policy_read().generation; + + verifier.set_measurement_policy(MeasurementPolicy::tdx(), stale_generation); + let installed_policy = + verifier.set_measurement_policy(MeasurementPolicy::expect_none(), stale_generation); + + assert!(installed_policy.has_remote_attestation()); + assert!(verifier.has_remote_attestation()); + } + #[tokio::test] async fn dynamic_measurement_policy_refetches_on_mismatch() { let temp_dir = tempfile::tempdir().unwrap(); From 8079fe13aa799fdab0c01dc4f5effe621a0e70f0 Mon Sep 17 00:00:00 2001 From: peg Date: Wed, 12 Aug 2026 12:58:28 +0200 Subject: [PATCH 8/9] Verifier returns matched ExpectedMeasurements --- crates/attestation/src/gcp.rs | 4 +- crates/attestation/src/lib.rs | 123 ++++++++++++++++--------- crates/attestation/src/measurements.rs | 86 +++++++++-------- 3 files changed, 130 insertions(+), 83 deletions(-) diff --git a/crates/attestation/src/gcp.rs b/crates/attestation/src/gcp.rs index 28f8c1f..65e79b1 100644 --- a/crates/attestation/src/gcp.rs +++ b/crates/attestation/src/gcp.rs @@ -173,12 +173,14 @@ mod tests { }; let gcp_firmware_cache = create_cache_with_firmware(firmware); - measurement_policy + let matched_measurements = measurement_policy .check_measurement_with_gcp_cache( &measurements, Some(&gcp_portable_platform_metadata()), Some(&gcp_firmware_cache), ) .unwrap(); + + assert_eq!(matched_measurements, ExpectedMeasurements::Image(gcp_portable_image_hashes())); } } diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index ad884f6..d6420f2 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -20,7 +20,7 @@ use std::{ use attest_measure::platform::PlatformError; pub use attest_types::{AttestationEvidence, PlatformMetadata}; -use measurements::MultiMeasurements; +use measurements::{ExpectedMeasurements, MultiMeasurements}; use parity_scale_codec::{Decode, Encode}; use pccs::{Pccs, PccsError}; use serde::{Deserialize, Serialize}; @@ -538,13 +538,13 @@ impl AttestationVerifier { } } - /// Verify an attestation, and ensure the measurements match one of our - /// accepted measurements + /// Verify an attestation, and return the expected measurements from the + /// matching policy record. pub async fn verify_attestation( &self, attestation_exchange_message: AttestationExchangeMessage, expected_input_data: [u8; 64], - ) -> Result, AttestationError> { + ) -> Result { let attestation_type = attestation_exchange_message.attestation_type(); tracing::debug!("Verifying {attestation_type} attestation"); @@ -558,7 +558,7 @@ impl AttestationVerifier { return Err(AttestationError::AttestationTypeNotAccepted); } if attestation_exchange_message.attestation_evidence.is_none() { - return Ok(None); + MultiMeasurements::NoAttestation } else { return Err(AttestationError::AttestationGivenWhenNoneExpected); } @@ -602,7 +602,6 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .map(|evidence| evidence.platform.clone()); - let policy_state = self.measurement_policy_read().clone(); let policy_check = policy_state.policy.check_measurement_with_gcp_cache( &measurements, @@ -610,33 +609,38 @@ impl AttestationVerifier { Some(&self.known_gcp_firmware), ); - if let Err(err) = policy_check { - // If this fails, and we have dynamic measurement policy, re-retrieve our - // measurement policy, then check the policy a second time - if let Some(file_or_url) = &self.dynamic_measurement_policy { - let new_measurement_policy = - MeasurementPolicy::from_file_or_url(file_or_url.to_string()).await?; - let measurement_policy = - self.set_measurement_policy(new_measurement_policy, policy_state.generation); - measurement_policy.check_measurement_with_gcp_cache( - &measurements, - platform_metadata.as_ref(), - Some(&self.known_gcp_firmware), - )?; - } else { - return Err(err); + let matched_measurements = match policy_check { + Ok(matched_measurements) => matched_measurements, + Err(err) => { + // If this fails, and we have dynamic measurement policy, re-retrieve our + // measurement policy, then check the policy a second time + if let Some(file_or_url) = &self.dynamic_measurement_policy { + let new_measurement_policy = + MeasurementPolicy::from_file_or_url(file_or_url.to_string()).await?; + let measurement_policy = self + .set_measurement_policy(new_measurement_policy, policy_state.generation); + measurement_policy.check_measurement_with_gcp_cache( + &measurements, + platform_metadata.as_ref(), + Some(&self.known_gcp_firmware), + )? + } else { + return Err(err); + } } - } + }; tracing::debug!("Verification successful"); - Ok(Some(measurements)) + Ok(matched_measurements) } + /// Verify an attestation synchronously, and return the expected + /// measurements from the matching policy record. pub fn verify_attestation_sync( &self, attestation_exchange_message: AttestationExchangeMessage, expected_input_data: [u8; 64], - ) -> Result, AttestationError> { + ) -> Result { let attestation_type = attestation_exchange_message.attestation_type(); tracing::debug!("Verifying {attestation_type} attestation"); @@ -650,7 +654,7 @@ impl AttestationVerifier { return Err(AttestationError::AttestationTypeNotAccepted); } if attestation_exchange_message.attestation_evidence.is_none() { - return Ok(None); + MultiMeasurements::NoAttestation } else { return Err(AttestationError::AttestationGivenWhenNoneExpected); } @@ -706,24 +710,27 @@ impl AttestationVerifier { Some(&self.known_gcp_firmware), ); - if let Err(err) = policy_check { - if let Some(file_or_url) = &self.dynamic_measurement_policy { - let new_measurement_policy = - MeasurementPolicy::from_file_or_url_sync(file_or_url.to_string())?; - let measurement_policy = - self.set_measurement_policy(new_measurement_policy, policy_state.generation); - measurement_policy.check_measurement_with_gcp_cache( - &measurements, - platform_metadata.as_ref(), - Some(&self.known_gcp_firmware), - )?; - } else { - return Err(err); + let matched_measurements = match policy_check { + Ok(matched_measurements) => matched_measurements, + Err(err) => { + if let Some(file_or_url) = &self.dynamic_measurement_policy { + let new_measurement_policy = + MeasurementPolicy::from_file_or_url_sync(file_or_url.to_string())?; + let measurement_policy = self + .set_measurement_policy(new_measurement_policy, policy_state.generation); + measurement_policy.check_measurement_with_gcp_cache( + &measurements, + platform_metadata.as_ref(), + Some(&self.known_gcp_firmware), + )? + } else { + return Err(err); + } } - } + }; tracing::debug!("Verification successful"); - Ok(Some(measurements)) + Ok(matched_measurements) } /// Whether we allow no remote attestation @@ -935,6 +942,22 @@ mod tests { let _ = running_on_gcp(); } + #[tokio::test] + async fn verifier_returns_matched_no_attestation_measurements() { + let verifier = AttestationVerifier::expect_none(); + let attestation = AttestationExchangeMessage::without_attestation(); + let input_data = [0u8; 64]; + + assert_eq!( + verifier.verify_attestation(attestation.clone(), input_data).await.unwrap(), + ExpectedMeasurements::NoAttestation + ); + assert_eq!( + verifier.verify_attestation_sync(attestation, input_data).unwrap(), + ExpectedMeasurements::NoAttestation + ); + } + #[tokio::test] async fn mock_verifier_supports_sync_verification() { let input_data = [7u8; 64]; @@ -953,7 +976,10 @@ mod tests { let result = verifier.verify_attestation_sync(attestation_evidence.into(), input_data); - assert!(result.is_ok(), "expected sync mock verification to succeed: {result:?}"); + assert!( + matches!(result, Ok(ExpectedMeasurements::Dcap(_))), + "expected sync mock verification to return matched DCAP measurements: {result:?}" + ); } #[test] @@ -972,7 +998,10 @@ mod tests { let generation = verifier_clone.measurement_policy_read().generation; verifier_clone.set_measurement_policy(MeasurementPolicy::expect_none(), generation); - assert!(matches!(verifier.verify_attestation_sync(message, input_data), Ok(None))); + assert!(matches!( + verifier.verify_attestation_sync(message, input_data), + Ok(ExpectedMeasurements::NoAttestation) + )); } #[test] @@ -1012,7 +1041,10 @@ mod tests { tokio::fs::write(&policy_path, br#"[{"attestation_type":"dcap-tdx"}]"#).await.unwrap(); - verifier.verify_attestation(attestation.into(), input_data).await.unwrap(); + let matched_measurements = + verifier.verify_attestation(attestation.into(), input_data).await.unwrap(); + + assert!(matches!(matched_measurements, ExpectedMeasurements::Dcap(_))); assert!(verifier.measurement_policy().check_measurement(&measurements, None).is_ok()); } @@ -1042,6 +1074,9 @@ mod tests { std::fs::write(&policy_path, br#"[{"attestation_type":"dcap-tdx"}]"#).unwrap(); - verifier.verify_attestation_sync(attestation.into(), input_data).unwrap(); + let matched_measurements = + verifier.verify_attestation_sync(attestation.into(), input_data).unwrap(); + + assert!(matches!(matched_measurements, ExpectedMeasurements::Dcap(_))); } } diff --git a/crates/attestation/src/measurements.rs b/crates/attestation/src/measurements.rs index 0f640e2..1555e5c 100644 --- a/crates/attestation/src/measurements.rs +++ b/crates/attestation/src/measurements.rs @@ -421,64 +421,74 @@ impl MeasurementPolicy { } /// Given an attestation type and set of measurements, check whether - /// they are acceptable + /// they are acceptable. + /// + /// On success, returns the matched measurements. pub fn check_measurement( &self, measurements: &MultiMeasurements, platform_metadata: Option<&PlatformMetadata>, - ) -> Result<(), AttestationError> { + ) -> Result { self.check_measurement_with_gcp_cache(measurements, platform_metadata, None) } /// Given an attestation type and set of measurements, check whether /// they are acceptable, passing an optional cache for known GCP - /// firmware + /// firmware. Returns the expected measurements from the matching policy + /// record. pub(crate) fn check_measurement_with_gcp_cache( &self, measurements: &MultiMeasurements, platform_metadata: Option<&PlatformMetadata>, known_gcp_firmware: Option<&GcpFirmwareCache>, - ) -> Result<(), AttestationError> { - if self.accepted_measurements.iter().any(|measurement_record| match measurements { - MultiMeasurements::Dcap(dcap_measurements) => match &measurement_record.measurements { - ExpectedMeasurements::Dcap(expected) => { - // All measurements in our policy must be given and must match - for (k, v) in expected.iter() { - let actual_value = dcap_measurements.get(k); - if !v.iter().any(|v| actual_value == v) { - return false; + ) -> Result { + self.accepted_measurements + .iter() + .find(|measurement_record| match measurements { + MultiMeasurements::Dcap(dcap_measurements) => { + match &measurement_record.measurements { + ExpectedMeasurements::Dcap(expected) => { + // All measurements in our policy must be given and must match + for (k, v) in expected.iter() { + let actual_value = dcap_measurements.get(k); + if !v.iter().any(|v| actual_value == v) { + return false; + } + } + true + } + ExpectedMeasurements::Image(image_hashes) => { + compare_portable_dcap_measurement( + image_hashes, + dcap_measurements, + platform_metadata, + known_gcp_firmware, + ) + } + ExpectedMeasurements::Azure(_) | ExpectedMeasurements::NoAttestation => { + false } } - true } - ExpectedMeasurements::Image(image_hashes) => compare_portable_dcap_measurement( - image_hashes, - dcap_measurements, - platform_metadata, - known_gcp_firmware, - ), - ExpectedMeasurements::Azure(_) | ExpectedMeasurements::NoAttestation => false, - }, - MultiMeasurements::Azure(azure_measurements) => { - if let ExpectedMeasurements::Azure(expected) = &measurement_record.measurements { - for (k, v) in expected.iter() { - match azure_measurements.get(k) { - Some(actual_value) if v.iter().any(|v| actual_value == v) => {} - _ => return false, + MultiMeasurements::Azure(azure_measurements) => { + if let ExpectedMeasurements::Azure(expected) = &measurement_record.measurements + { + for (k, v) in expected.iter() { + match azure_measurements.get(k) { + Some(actual_value) if v.iter().any(|v| actual_value == v) => {} + _ => return false, + } } + return true; } - return true; + false } - false - } - MultiMeasurements::NoAttestation => { - matches!(measurement_record.measurements, ExpectedMeasurements::NoAttestation) - } - }) { - Ok(()) - } else { - Err(AttestationError::MeasurementsNotAccepted) - } + MultiMeasurements::NoAttestation => { + matches!(measurement_record.measurements, ExpectedMeasurements::NoAttestation) + } + }) + .map(|measurement_record| measurement_record.measurements.clone()) + .ok_or(AttestationError::MeasurementsNotAccepted) } /// Whether or not we require attestation From 8d53507ac7eb60a0e542bcaadc14e57e3d009e1b Mon Sep 17 00:00:00 2001 From: peg Date: Wed, 12 Aug 2026 15:12:06 +0200 Subject: [PATCH 9/9] Header format conversion for ExpectedMeasurements, and update readme --- crates/attestation/README.md | 82 +++++++++++- crates/attestation/src/measurements.rs | 169 ++++++++++++++----------- 2 files changed, 177 insertions(+), 74 deletions(-) diff --git a/crates/attestation/README.md b/crates/attestation/README.md index 2fe0194..8da4c06 100644 --- a/crates/attestation/README.md +++ b/crates/attestation/README.md @@ -10,6 +10,22 @@ This crate provides: - Attestation generation and verification for DCAP and (optionally) Azure - Parsing and evaluation of measurement policies +## Verification results + +`AttestationVerifier::verify_attestation` and +`AttestationVerifier::verify_attestation_sync` return the +`ExpectedMeasurements` value from the policy record that accepted the +attestation. This is the matched policy value, not the raw register values +extracted from the quote. For example, verification against a portable policy +returns `ExpectedMeasurements::Image`, while an allow-any DCAP policy returns +`ExpectedMeasurements::Dcap` with an empty register map. Successful +verification without attestation returns `ExpectedMeasurements::NoAttestation`. + +Matched expected measurements can be transported in an HTTP header using +`ExpectedMeasurements::to_header_format` and reconstructed with +`ExpectedMeasurements::from_header_format`. See +[Expected measurement header format](#expected-measurement-header-format). + ## Runtime Requirements Verification uses the [`pccs`](../pccs) crate for collateral caching and @@ -111,8 +127,7 @@ These objects have the following fields: - `attestation_type` - a string containing one of the attestation types (confidential computing platforms) described below. - `measurements` - an object with fields referring to the five measurement - registers. Field names are the same as for the measurement headers (see - below). + registers. See [Measurement field names](#measurement-field-names). - `dcap_image_hashes` - an alternative to `measurements` that pins the hashes of the boot components (UKI, kernel, initrd, cmdline, GPT disk GUID) rather than raw register values. The verifier reconstructs the expected RTMRs at @@ -256,6 +271,69 @@ Legacy numeric field names are still supported for backwards compatibility: - "3" - RTMR2 - "4" - RTMR3 +### Expected measurement header format + +`ExpectedMeasurements::to_header_format` serializes the matched policy value +as self-describing JSON suitable for an HTTP `HeaderValue`. Hashes are +lowercase hexadecimal strings. Both SHA-384 DCAP values and SHA-256 Azure +values are represented as hex. + +DCAP and Azure register values are arrays because a policy can accept more +than one value for each register. Header JSON uses numeric register keys: + +- DCAP: `"0"` is MRTD, followed by `"1"` through `"4"` for RTMR0 through + RTMR3. +- Azure: the key is the PCR index. + +DCAP example: + +```JSON +{ + "type": "dcap", + "measurements": { + "0": ["<96 hex characters>"], + "3": ["<96 hex characters>", "<96 hex characters>"] + } +} +``` + +Azure example: + +```JSON +{ + "type": "azure", + "measurements": { + "4": ["<64 hex characters>"], + "11": ["<64 hex characters>"] + } +} +``` + +Portable image-hash example: + +```JSON +{ + "type": "image", + "measurements": { + "uki_authenticode": "<96 hex characters>", + "kernel_authenticode": "<96 hex characters>", + "cmdline_hash": "<96 hex characters>", + "initrd_hash": "<96 hex characters>", + "gpt_disk_guid_hash": "<96 hex characters>" + } +} +``` + +No-attestation example: + +```JSON +{"type":"no_attestation"} +``` + +The actual header value is compact JSON on one line. The decoder preserves +partial register policies and every alternative value in a register's +`expected_any` list. + ### Portable measurement policies The `measurements` format above specifies register values, so any change diff --git a/crates/attestation/src/measurements.rs b/crates/attestation/src/measurements.rs index 1555e5c..e34affa 100644 --- a/crates/attestation/src/measurements.rs +++ b/crates/attestation/src/measurements.rs @@ -19,7 +19,7 @@ use attest_types::{ }; use dcap_qvl::quote::Report; use http::{HeaderValue, header::InvalidHeaderValue, uri::InvalidUri}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::warn; @@ -116,28 +116,6 @@ impl DcapMeasurements { Self { mrtd, rtmr0, rtmr1, rtmr2, rtmr3 } } - fn from_map( - mut measurements: HashMap, - ) -> Result { - Ok(Self { - mrtd: measurements - .remove(&DcapMeasurementRegister::MRTD) - .ok_or_else(|| MeasurementFormatError::MissingValue("MRTD".to_string()))?, - rtmr0: measurements - .remove(&DcapMeasurementRegister::RTMR0) - .ok_or_else(|| MeasurementFormatError::MissingValue("RTMR0".to_string()))?, - rtmr1: measurements - .remove(&DcapMeasurementRegister::RTMR1) - .ok_or_else(|| MeasurementFormatError::MissingValue("RTMR1".to_string()))?, - rtmr2: measurements - .remove(&DcapMeasurementRegister::RTMR2) - .ok_or_else(|| MeasurementFormatError::MissingValue("RTMR2".to_string()))?, - rtmr3: measurements - .remove(&DcapMeasurementRegister::RTMR3) - .ok_or_else(|| MeasurementFormatError::MissingValue("RTMR3".to_string()))?, - }) - } - fn iter(&self) -> impl Iterator { [ (DcapMeasurementRegister::MRTD, &self.mrtd), @@ -227,63 +205,80 @@ pub enum ExpectedMeasurements { NoAttestation, } -impl MultiMeasurements { +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", content = "measurements", rename_all = "snake_case")] +enum ExpectedMeasurementsHeader { + Image(DcapImageHashes), + Dcap(HashMap>), + Azure(HashMap>), + NoAttestation, +} + +impl ExpectedMeasurements { /// Convert to the JSON format used in HTTP headers pub fn to_header_format(&self) -> Result { - let measurements_map = match self { - MultiMeasurements::Dcap(dcap_measurements) => dcap_measurements - .iter() - .map(|(register, value)| ((register as u8).to_string(), hex::encode(value))) - .collect(), - MultiMeasurements::Azure(azure_measurements) => azure_measurements - .iter() - .map(|(index, value)| (index.to_string(), hex::encode(value))) - .collect(), - MultiMeasurements::NoAttestation => HashMap::new(), + let header_measurements = match self { + Self::Image(image_hashes) => ExpectedMeasurementsHeader::Image(image_hashes.clone()), + Self::Dcap(dcap_measurements) => ExpectedMeasurementsHeader::Dcap( + dcap_measurements + .iter() + .map(|(register, values)| { + ( + (register.clone() as u8).to_string(), + values.iter().map(hex::encode).collect(), + ) + }) + .collect(), + ), + Self::Azure(azure_measurements) => ExpectedMeasurementsHeader::Azure( + azure_measurements + .iter() + .map(|(index, values)| { + (index.to_string(), values.iter().map(hex::encode).collect()) + }) + .collect(), + ), + Self::NoAttestation => ExpectedMeasurementsHeader::NoAttestation, }; - Ok(HeaderValue::from_str(&serde_json::to_string(&measurements_map)?)?) + Ok(HeaderValue::from_str(&serde_json::to_string(&header_measurements)?)?) } /// Parse the JSON used in HTTP headers - pub fn from_header_format( - input: &str, - attestation_type: AttestationType, - ) -> Result { - let measurements_map: HashMap = serde_json::from_str(input)?; - - Ok(match attestation_type { - AttestationType::None => Self::NoAttestation, - AttestationType::AzureTdx => Self::Azure( - measurements_map + pub fn from_header_format(input: &str) -> Result { + fn decode_values( + values: Vec, + ) -> Result, MeasurementFormatError> { + values + .into_iter() + .map(|value| { + hex::decode(value)?.try_into().map_err(|_| MeasurementFormatError::BadLength) + }) + .collect() + } + + Ok(match serde_json::from_str(input)? { + ExpectedMeasurementsHeader::Image(image_hashes) => Self::Image(image_hashes), + ExpectedMeasurementsHeader::Dcap(dcap_measurements) => Self::Dcap( + dcap_measurements .into_iter() - .map(|(k, v)| { - Ok(( - k as u32, - hex::decode(v)? - .try_into() - .map_err(|_| MeasurementFormatError::BadLength)?, - )) + .map(|(register, values)| { + Ok((register.parse::()?.try_into()?, decode_values::<48>(values)?)) }) .collect::>()?, ), - AttestationType::DcapTdx | AttestationType::GcpTdx => { - let measurements_map = measurements_map + ExpectedMeasurementsHeader::Azure(azure_measurements) => Self::Azure( + azure_measurements .into_iter() - .map(|(k, v)| { - Ok(( - k.try_into()?, - hex::decode(v)? - .try_into() - .map_err(|_| MeasurementFormatError::BadLength)?, - )) - }) - .collect::>()?; - Self::Dcap(DcapMeasurements::from_map(measurements_map)?) - } + .map(|(index, values)| Ok((index.parse()?, decode_values::<32>(values)?))) + .collect::>()?, + ), + ExpectedMeasurementsHeader::NoAttestation => Self::NoAttestation, }) } +} +impl MultiMeasurements { /// Given a quote from the dcap_qvl library, extract the measurements pub fn from_dcap_qvl_quote( quote: &dcap_qvl::quote::Quote, @@ -847,8 +842,6 @@ pub(crate) fn compare_portable_dcap_measurement( pub enum MeasurementFormatError { #[error("JSON: {0}")] Json(#[from] serde_json::Error), - #[error("Missing value: {0}")] - MissingValue(String), #[error("Invalid header value: {0}")] BadHeaderValue(#[from] InvalidHeaderValue), #[error("IO: {0}")] @@ -1126,14 +1119,46 @@ mod tests { } #[test] - fn test_dcap_header_format_rejects_incomplete_measurements() { - let input = serde_json::to_string(&HashMap::from([("0", hex::encode([0u8; 48]))])).unwrap(); + fn expected_measurements_header_format_round_trips_all_variants() { + let measurements = [ + ExpectedMeasurements::Image(DcapImageHashes { + uki_authenticode: [0x11; 48], + kernel_authenticode: [0x22; 48], + cmdline_hash: [0x33; 48], + initrd_hash: [0x44; 48], + gpt_disk_guid_hash: [0x55; 48], + }), + ExpectedMeasurements::Dcap(HashMap::from([ + (DcapMeasurementRegister::MRTD, vec![[0x66; 48], [0x77; 48]]), + (DcapMeasurementRegister::RTMR2, vec![[0x88; 48]]), + ])), + ExpectedMeasurements::Azure(HashMap::from([ + (4, vec![[0x99; 32], [0xaa; 32]]), + (11, vec![[0xbb; 32]]), + ])), + ExpectedMeasurements::NoAttestation, + ]; + + for expected in measurements { + let header = expected.to_header_format().unwrap(); + let decoded = + ExpectedMeasurements::from_header_format(header.to_str().unwrap()).unwrap(); + + assert_eq!(decoded, expected); + } + } - let result = MultiMeasurements::from_header_format(&input, AttestationType::DcapTdx); + #[test] + fn expected_measurements_header_format_rejects_bad_hash_length() { + let input = serde_json::json!({ + "type": "dcap", + "measurements": { "0": ["00"] }, + }) + .to_string(); assert!(matches!( - result, - Err(MeasurementFormatError::MissingValue(register)) if register == "RTMR0" + ExpectedMeasurements::from_header_format(&input), + Err(MeasurementFormatError::BadLength) )); }