From a5835e5bf43dd2eb72f1ab238a61db92ce66d6de Mon Sep 17 00:00:00 2001 From: peg Date: Tue, 4 Aug 2026 11:07:50 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 95546842b8330542c1b852469aa81948eb8637d2 Mon Sep 17 00:00:00 2001 From: peg Date: Fri, 21 Aug 2026 09:59:30 +0200 Subject: [PATCH 6/7] Add task to regularly refetch dynamic policies --- crates/attestation/Cargo.toml | 2 +- crates/attestation/src/lib.rs | 205 ++++++++++++++++++++++++++++++++-- 2 files changed, 199 insertions(+), 8 deletions(-) diff --git a/crates/attestation/Cargo.toml b/crates/attestation/Cargo.toml index 2a51288..bf27ccd 100644 --- a/crates/attestation/Cargo.toml +++ b/crates/attestation/Cargo.toml @@ -11,7 +11,7 @@ keywords = ["attestation", "CVM", "TDX"] dcap-qvl = { workspace = true, features = ["danger-allow-tcb-override"] } pccs = { workspace = true } mock-tdx = { workspace = true, optional = true } -tokio = { workspace = true, features = ["fs", "rt", "rt-multi-thread"] } +tokio = { workspace = true, features = ["fs", "rt", "rt-multi-thread", "time"] } tokio-rustls = { workspace = true, default-features = false } attest-types = { git = "https://github.com/easy-tee/attest.git", rev = "8206cd19d9dcb1978d85a3d8dece06a3ee7a1206" } attest-measure = {git = "https://github.com/easy-tee/attest.git", rev = "8206cd19d9dcb1978d85a3d8dece06a3ee7a1206" } diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index ad884f6..f694352 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -25,6 +25,7 @@ use parity_scale_codec::{Decode, Encode}; use pccs::{Pccs, PccsError}; use serde::{Deserialize, Serialize}; use thiserror::Error; +use tokio::time::sleep; use crate::{ dcap::DcapVerificationError, @@ -45,6 +46,9 @@ pub(crate) fn install_test_crypto_provider() { /// Used in attestation type detection to check if we are on GCP const GCP_METADATA_API: &str = "http://metadata.google.internal"; +/// How often a dynamic measurement policy is refreshed in the background. +const DYNAMIC_MEASUREMENT_POLICY_REFRESH_INTERVAL: Duration = Duration::from_secs(12 * 60 * 60); + /// An attestation payload together with its type #[derive(Clone, Debug, Serialize, Deserialize, Encode, Decode)] pub struct AttestationExchangeMessage { @@ -424,8 +428,8 @@ impl AttestationVerifierBuilder { self } - /// Re-fetch the measurement policy from this file or URL after a - /// measurement mismatch. + /// Re-fetch the measurement policy from this file or URL periodically + /// and after a measurement mismatch. /// /// Both asynchronous and synchronous verification perform one retry /// with the refreshed policy. Synchronous URL refreshes block for @@ -446,7 +450,7 @@ impl AttestationVerifier { } }); - Self { + let verifier = Self { measurement_policy: Arc::new(RwLock::new(MeasurementPolicyState::new( builder.measurement_policy, ))), @@ -456,7 +460,10 @@ impl AttestationVerifier { internal_pccs, known_gcp_firmware: GcpFirmwareCache::new(), dynamic_measurement_policy: builder.dynamic_measurement_policy, - } + }; + + verifier.spawn_dynamic_measurement_policy_refresh(); + verifier } pub fn builder(measurement_policy: MeasurementPolicy) -> AttestationVerifierBuilder { @@ -737,11 +744,126 @@ impl AttestationVerifier { } /// Whether this verifier automatically refreshes its measurement policy - /// after a mismatch. + /// periodically and after a mismatch. pub fn has_dynamic_measurement_policy(&self) -> bool { self.dynamic_measurement_policy.is_some() } + /// Periodically refreshes a dynamic policy so removals are observed + /// even while incoming attestations continue to match the cached + /// policy. + fn spawn_dynamic_measurement_policy_refresh(&self) { + let Some(file_or_url) = self.dynamic_measurement_policy.clone() else { + return; + }; + + Self::spawn_dynamic_measurement_policy_refresh_with_interval( + Arc::downgrade(&self.measurement_policy), + file_or_url, + DYNAMIC_MEASUREMENT_POLICY_REFRESH_INTERVAL, + ); + } + + fn spawn_dynamic_measurement_policy_refresh_with_interval( + measurement_policy: std::sync::Weak>, + file_or_url: String, + refresh_interval: Duration, + ) { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(Self::refresh_dynamic_measurement_policy( + measurement_policy, + file_or_url, + refresh_interval, + )); + } else { + std::thread::spawn(move || { + Self::refresh_dynamic_measurement_policy_sync( + measurement_policy, + file_or_url, + refresh_interval, + ); + }); + } + } + + async fn refresh_dynamic_measurement_policy( + measurement_policy: std::sync::Weak>, + file_or_url: String, + refresh_interval: Duration, + ) { + loop { + sleep(refresh_interval).await; + + let Some(generation) = Self::measurement_policy_generation(&measurement_policy) else { + return; + }; + + let new_policy = match MeasurementPolicy::from_file_or_url(file_or_url.clone()).await { + Ok(policy) => policy, + Err(err) => { + tracing::warn!(error = %err, "Failed to periodically refresh measurement policy"); + continue; + } + }; + + if !Self::install_measurement_policy(&measurement_policy, new_policy, generation) { + return; + } + } + } + + fn refresh_dynamic_measurement_policy_sync( + measurement_policy: std::sync::Weak>, + file_or_url: String, + refresh_interval: Duration, + ) { + loop { + std::thread::sleep(refresh_interval); + + let Some(generation) = Self::measurement_policy_generation(&measurement_policy) else { + return; + }; + + let new_policy = match MeasurementPolicy::from_file_or_url_sync(file_or_url.clone()) { + Ok(policy) => policy, + Err(err) => { + tracing::warn!(error = %err, "Failed to periodically refresh measurement policy"); + continue; + } + }; + + if !Self::install_measurement_policy(&measurement_policy, new_policy, generation) { + return; + } + } + } + + fn measurement_policy_generation( + measurement_policy: &std::sync::Weak>, + ) -> Option { + let policy_state = measurement_policy.upgrade()?; + Some(policy_state.read().unwrap_or_else(|poisoned| poisoned.into_inner()).generation) + } + + /// Installs a refreshed policy if no newer refresh won the race. + /// Returns false when the verifier has been dropped and the refresh + /// loop should exit. + fn install_measurement_policy( + measurement_policy: &std::sync::Weak>, + new_policy: MeasurementPolicy, + expected_generation: u64, + ) -> bool { + let Some(policy_state) = measurement_policy.upgrade() else { + return false; + }; + let mut state = policy_state.write().unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.generation == expected_generation { + state.policy = new_policy; + state.generation = state.generation.wrapping_add(1); + } + true + } + /// Replaces the measurement policy used by this verifier and all of its /// clones if it has not changed since `expected_generation` was /// observed. @@ -799,8 +921,8 @@ fn running_on_gcp() -> Result { let resp = agent.get(GCP_METADATA_API).call(); if let Ok(r) = resp { - return Ok(r.status() == 200 && - r.header("Metadata-Flavor").map(|v| v == "Google").unwrap_or(false)); + return Ok(r.status() == 200 + && r.header("Metadata-Flavor").map(|v| v == "Google").unwrap_or(false)); } Ok(false) @@ -1017,6 +1139,75 @@ mod tests { assert!(verifier.measurement_policy().check_measurement(&measurements, None).is_ok()); } + #[tokio::test] + async fn dynamic_measurement_policy_refreshes_periodically() { + let temp_dir = tempfile::tempdir().unwrap(); + let policy_path = temp_dir.path().join("measurements.json"); + tokio::fs::write(&policy_path, br#"[{"attestation_type":"dcap-tdx"}]"#).await.unwrap(); + + let policy_source = policy_path.to_string_lossy().into_owned(); + 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_source.clone()) + .build(); + let measurements = measurements::mock_dcap_measurements(); + + assert!(verifier.measurement_policy().check_measurement(&measurements, None).is_ok()); + + AttestationVerifier::spawn_dynamic_measurement_policy_refresh_with_interval( + Arc::downgrade(&verifier.measurement_policy), + policy_source, + Duration::from_millis(10), + ); + tokio::fs::write(&policy_path, br#"[{"attestation_type":"none"}]"#).await.unwrap(); + + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if verifier.measurement_policy().check_measurement(&measurements, None).is_err() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("periodic policy refresh did not remove the revoked measurement"); + } + + #[test] + fn dynamic_measurement_policy_refreshes_periodically_without_tokio() { + let temp_dir = tempfile::tempdir().unwrap(); + let policy_path = temp_dir.path().join("measurements.json"); + std::fs::write(&policy_path, br#"[{"attestation_type":"dcap-tdx"}]"#).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 verifier = AttestationVerifier::builder(initial_policy) + .with_no_internal_pccs() + .with_dynamic_measurements_file_or_url(policy_source.clone()) + .build(); + let measurements = measurements::mock_dcap_measurements(); + + assert!(verifier.measurement_policy().check_measurement(&measurements, None).is_ok()); + + AttestationVerifier::spawn_dynamic_measurement_policy_refresh_with_interval( + Arc::downgrade(&verifier.measurement_policy), + policy_source, + Duration::from_millis(10), + ); + std::fs::write(&policy_path, br#"[{"attestation_type":"none"}]"#).unwrap(); + + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while verifier.measurement_policy().check_measurement(&measurements, None).is_ok() { + assert!( + std::time::Instant::now() < deadline, + "standard-thread policy refresh did not remove the revoked measurement" + ); + std::thread::sleep(Duration::from_millis(5)); + } + } + #[tokio::test] async fn sync_verification_refetches_dynamic_measurement_policy_on_mismatch() { let temp_dir = tempfile::tempdir().unwrap(); From 57569dac65df0bbad99dfc42c1d6f7677671a9b6 Mon Sep 17 00:00:00 2001 From: peg Date: Fri, 21 Aug 2026 10:46:28 +0200 Subject: [PATCH 7/7] Fmt --- crates/attestation/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index f694352..b7cc812 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -921,8 +921,8 @@ fn running_on_gcp() -> Result { let resp = agent.get(GCP_METADATA_API).call(); if let Ok(r) = resp { - return Ok(r.status() == 200 - && r.header("Metadata-Flavor").map(|v| v == "Google").unwrap_or(false)); + return Ok(r.status() == 200 && + r.header("Metadata-Flavor").map(|v| v == "Google").unwrap_or(false)); } Ok(false)