From 0f31c65e7c9f2adced8643843f4f426949dd98cd Mon Sep 17 00:00:00 2001 From: peg Date: Thu, 27 Aug 2026 09:57:42 +0200 Subject: [PATCH 1/6] Make Pccs mandatory but add a remote mode with no internal cache --- crates/attestation/src/azure/attester/mod.rs | 2 +- crates/attestation/src/azure/verify.rs | 25 ++- crates/attestation/src/dcap.rs | 43 ++-- crates/attestation/src/gcp/firmware.rs | 2 +- crates/attestation/src/lib.rs | 78 +++---- crates/pccs/examples/intel_pcs.rs | 4 +- crates/pccs/src/lib.rs | 220 ++++++++++++++----- 7 files changed, 227 insertions(+), 147 deletions(-) diff --git a/crates/attestation/src/azure/attester/mod.rs b/crates/attestation/src/azure/attester/mod.rs index 4e16a96..a1e48a5 100644 --- a/crates/attestation/src/azure/attester/mod.rs +++ b/crates/attestation/src/azure/attester/mod.rs @@ -286,9 +286,9 @@ fn fetch_certificate_der(url: &str) -> Result, MaaError> { #[cfg(test)] mod test_utils { use base64::{Engine as _, engine::general_purpose::URL_SAFE as BASE64_URL_SAFE}; + use pccs::PCS_URL; use super::{super::AttestationDocument, create_azure_attestation}; - use crate::dcap::PCS_URL; /// Capture a complete Azure TDX attestation fixture from inside an /// Azure TDX CVM. diff --git a/crates/attestation/src/azure/verify.rs b/crates/attestation/src/azure/verify.rs index 49daf37..4a02d20 100644 --- a/crates/attestation/src/azure/verify.rs +++ b/crates/attestation/src/azure/verify.rs @@ -41,7 +41,7 @@ struct PreparedAzureAttestation { pub async fn verify_azure_attestation( input: Vec, expected_input_data: [u8; 64], - pccs: Option, + pccs: Pccs, override_azure_outdated_tcb: bool, ) -> Result { let now = unix_time_now_secs()?; @@ -86,7 +86,7 @@ pub fn verify_azure_attestation_sync( async fn verify_azure_attestation_with_given_timestamp( input: Vec, expected_input_data: [u8; 64], - pccs: Option, + pccs: Pccs, collateral: Option, now: u64, override_azure_outdated_tcb: bool, @@ -385,13 +385,20 @@ mod tests { let actual = MAX_AZURE_ATTESTATION_PAYLOAD_SIZE + 1; let input = vec![b'{'; actual]; - let err = verify_azure_attestation(input.clone(), [0; 64], None, false).await.unwrap_err(); + let err = verify_azure_attestation( + input.clone(), + [0; 64], + Pccs::new(None, pccs::PccsMode::Remote), + false, + ) + .await + .unwrap_err(); assert_payload_too_large(err, actual); let err = verify_azure_attestation_sync( input.clone(), [0; 64], - Pccs::new_without_prewarm(None), + Pccs::new(None, pccs::PccsMode::Lazy), false, ) .unwrap_err(); @@ -400,7 +407,7 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp( input.clone(), [0; 64], - None, + Pccs::new(None, pccs::PccsMode::Remote), None, 0, false, @@ -412,7 +419,7 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp_sync( input, [0; 64], - Pccs::new_without_prewarm(None), + Pccs::new(None, pccs::PccsMode::Lazy), None, 0, false, @@ -462,7 +469,7 @@ mod tests { let async_measurements = verify_azure_attestation_with_given_timestamp( attestation_json.clone(), [0; 64], - None, + Pccs::new(None, pccs::PccsMode::Remote), Some(async_collateral), now, false, @@ -473,7 +480,7 @@ mod tests { let sync_measurements = verify_azure_attestation_with_given_timestamp_sync( attestation_json, [0; 64], - Pccs::new_without_prewarm(None), + Pccs::new(None, pccs::PccsMode::Lazy), Some(sync_collateral), now, false, @@ -503,7 +510,7 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp( attestation_json, expected_input_data, - None, + Pccs::new(None, pccs::PccsMode::Remote), Some(collateral), now, false, diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index 832822f..ea71762 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -2,13 +2,14 @@ //! verification use dcap_qvl::{ QuoteCollateralV3, - collateral::CollateralClient, intel::{quote_ca, quote_fmspc}, quote::{Quote, Report}, tcb_info::TcbInfo, }; #[cfg(any(test, feature = "mock"))] use mock_tdx::generate_mock_tdx_quote; +#[cfg(test)] +use pccs::PccsMode; use pccs::{Pccs, PccsError}; use thiserror::Error; @@ -18,9 +19,6 @@ use crate::{AttestationError, measurements::MultiMeasurements}; /// or other platforms) const AZURE_BAD_FMSPC: &str = "90C06F000000"; -/// For fetching collateral directly from Intel, if no PCCS is specified -pub const PCS_URL: &str = "https://api.trustedservices.intel.com"; - /// Generate a TDX quote pub fn create_dcap_attestation(input_data: [u8; 64]) -> Result, AttestationError> { let quote = generate_quote(input_data)?; @@ -33,7 +31,7 @@ pub fn create_dcap_attestation(input_data: [u8; 64]) -> Result, Attestat pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], - pccs: Option, + pccs: Pccs, ) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); let override_azure_outdated_tcb = false; @@ -115,7 +113,7 @@ pub fn verify_dcap_attestation_with_timestamp_sync( pub async fn verify_dcap_attestation_with_given_timestamp( input: Vec, expected_input_data: [u8; 64], - pccs_option: Option, + pccs: Pccs, collateral: Option, now: u64, override_azure_outdated_tcb: bool, @@ -127,13 +125,9 @@ pub async fn verify_dcap_attestation_with_given_timestamp( let collateral = if let Some(given_collateral) = collateral { given_collateral - } else if let Some(ref pccs) = pccs_option { + } else { let (collateral, _is_fresh) = pccs.get_collateral(fmspc.clone(), ca, now).await?; collateral - } else { - CollateralClient::with_default_http(PCS_URL)? - .fetch_for_fmspc_without_pck_chain(&fmspc, ca, false) - .await? }; verify_dcap_attestation_with_collateral_and_timestamp( @@ -205,17 +199,18 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], - pccs: Option, + pccs: Pccs, ) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); - let collateral = if let Some(ref pccs) = pccs { + + let collateral = if pccs.is_remote() { + mock_tdx::mock_collateral() + } else { let (collateral, _is_fresh) = pccs.get_collateral(fmspc, ca, now).await?; collateral - } else { - mock_tdx::mock_collateral() }; let verifier = mock_tdx::mock_dcap_verifier(); verifier.verify(&input, &collateral, now)?; @@ -238,7 +233,13 @@ pub fn verify_dcap_attestation_sync( let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); - let collateral = pccs.get_collateral_sync(fmspc, ca, now)?; + + let collateral = if pccs.is_remote() { + mock_tdx::mock_collateral() + } else { + pccs.get_collateral_sync(fmspc, ca, now)? + }; + let verifier = mock_tdx::mock_dcap_verifier(); verifier.verify(&input, &collateral, now)?; @@ -334,7 +335,7 @@ mod tests { 37, 136, 57, 29, 25, 86, 182, 246, 70, 106, 216, 184, 220, 205, 85, 245, 114, 33, 173, 129, 180, 32, 247, 70, 250, 141, 176, 248, 99, 125, ], - None, + Pccs::new(None, PccsMode::Remote), Some(async_collateral), now, false, @@ -350,7 +351,7 @@ mod tests { 37, 136, 57, 29, 25, 86, 182, 246, 70, 106, 216, 184, 220, 205, 85, 245, 114, 33, 173, 129, 180, 32, 247, 70, 250, 141, 176, 248, 99, 125, ], - Pccs::new_without_prewarm(None), + Pccs::new(None, PccsMode::Lazy), Some(sync_collateral), now, false, @@ -383,7 +384,7 @@ mod tests { 248, 104, 204, 187, 101, 49, 203, 40, 218, 185, 220, 228, 119, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ], - None, + Pccs::new(None, PccsMode::Remote), Some(collateral), now, true, @@ -400,12 +401,12 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock_pcs.base_url.clone())); + let pccs = Pccs::new(Some(mock_pcs.base_url.clone()), PccsMode::Lazy); let expected_input_data = [0xA5; 64]; let quote = create_dcap_attestation(expected_input_data).unwrap(); let (measurements, _) = - verify_dcap_attestation(quote, expected_input_data, Some(pccs)).await.unwrap(); + verify_dcap_attestation(quote, expected_input_data, pccs).await.unwrap(); assert_eq!(measurements, crate::measurements::mock_dcap_measurements()); assert_eq!(mock_pcs.tcb_call_count(), 1); diff --git a/crates/attestation/src/gcp/firmware.rs b/crates/attestation/src/gcp/firmware.rs index 4aa56fe..047200b 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -156,7 +156,7 @@ mod tests { let (measurements, _) = verify_dcap_attestation_with_given_timestamp( attestation_bytes.to_vec(), expected_input_data, - None, + pccs::Pccs::new(None, pccs::PccsMode::Remote), Some(collateral), GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, false, diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index e8115e0..f1bfaea 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -21,6 +21,7 @@ use attest_measure::platform::PlatformError; pub use attest_types::{AttestationEvidence, PlatformMetadata}; use measurements::MultiMeasurements; use parity_scale_codec::{Decode, Encode}; +pub use pccs::PccsMode; use pccs::{Pccs, PccsError}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -338,19 +339,6 @@ impl AttestationGenerator { } } -/// How the verifier obtains DCAP collateral -#[derive(Clone, Debug)] -pub enum PccsMode { - /// No internal collateral cache. Collateral is always fetched from - /// remote source. - None, - /// Internal cache pre-filled with all available collateral at build - /// time. - Prewarmed, - /// Internal cache that starts empty and fetches on demand. - Lazy, -} - /// Allows remote attestations to be verified #[derive(Clone, Debug)] pub struct AttestationVerifier { @@ -362,8 +350,8 @@ pub struct AttestationVerifier { /// /// This provides a workaround for a known outdated FMSPC used by Azure override_azure_outdated_tcb: bool, - /// Internal cache for collateral - internal_pccs: Option, + /// PCCS collateral source, optionally backed by an internal cache + internal_pccs: Pccs, /// Cached GCP firmware blobs indexed by MRTD known_gcp_firmware: GcpFirmwareCache, /// Cached PPIDs that have a valid GCP host-registry document @@ -385,17 +373,11 @@ pub struct AttestationVerifierBuilder { impl AttestationVerifierBuilder { pub fn build(self) -> AttestationVerifier { - let internal_pccs = match self.pccs_mode { - PccsMode::None => None, - PccsMode::Prewarmed => Some(Pccs::new(self.pccs_url)), - PccsMode::Lazy => Some(Pccs::new_without_prewarm(self.pccs_url)), - }; - AttestationVerifier { measurement_policy: self.measurement_policy, dump_dcap_quotes: self.dump_dcap_quotes, override_azure_outdated_tcb: self.override_azure_outdated_tcb, - internal_pccs, + internal_pccs: Pccs::new(self.pccs_url, self.pccs_mode), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), } @@ -433,7 +415,7 @@ impl AttestationVerifier { pub fn builder(measurement_policy: MeasurementPolicy) -> AttestationVerifierBuilder { AttestationVerifierBuilder { measurement_policy, - pccs_mode: PccsMode::None, + pccs_mode: PccsMode::Remote, pccs_url: None, dump_dcap_quotes: false, override_azure_outdated_tcb: false, @@ -447,7 +429,7 @@ impl AttestationVerifier { measurement_policy: MeasurementPolicy::expect_none(), dump_dcap_quotes: false, override_azure_outdated_tcb: false, - internal_pccs: None, + internal_pccs: Pccs::new(None, PccsMode::Remote), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), } @@ -460,7 +442,7 @@ impl AttestationVerifier { measurement_policy: MeasurementPolicy::mock(), dump_dcap_quotes: false, override_azure_outdated_tcb: false, - internal_pccs: None, + internal_pccs: Pccs::new(None, PccsMode::Remote), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), } @@ -469,11 +451,14 @@ impl AttestationVerifier { /// Expect mock measurements used in tests, and use a PCCS #[cfg(any(test, feature = "mock"))] pub fn mock_with_pccs(pccs_url: String) -> Self { + #[cfg(test)] + install_test_crypto_provider(); + Self { measurement_policy: MeasurementPolicy::mock(), dump_dcap_quotes: false, override_azure_outdated_tcb: false, - internal_pccs: Some(Pccs::new(Some(pccs_url))), + internal_pccs: Pccs::new(Some(pccs_url), PccsMode::Prewarmed), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), } @@ -486,13 +471,8 @@ impl AttestationVerifier { /// guarantee that collateral will not be fetched during /// verification pub async fn ready(&self) -> Result<(), AttestationError> { - // If we have no PCCS then we are ready - let Some(pccs) = &self.internal_pccs else { - return Ok(()); - }; - - // If we have pccs, and pre-warm is disabled we are also ready - match pccs.ready().await { + // If pre-warm is disabled we are ready + match self.internal_pccs.ready().await { Ok(_) | Err(PccsError::PrewarmDisabled) => Ok(()), Err(err) => Err(err.into()), } @@ -606,11 +586,10 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - let pccs = self.internal_pccs.clone().ok_or(AttestationError::NoPccs)?; azure::verify_azure_attestation_sync( attestation_evidence.quote.clone(), expected_input_data, - pccs, + self.internal_pccs.clone(), self.override_azure_outdated_tcb, )? } @@ -624,11 +603,7 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - #[cfg(any(test, feature = "mock"))] - let pccs = - self.internal_pccs.clone().unwrap_or_else(|| Pccs::new_without_prewarm(None)); - #[cfg(not(any(test, feature = "mock")))] - let pccs = self.internal_pccs.clone().ok_or(AttestationError::NoPccs)?; + let pccs = self.internal_pccs.clone(); let (measurements, quote) = dcap::verify_dcap_attestation_sync( attestation_evidence.quote.clone(), @@ -809,8 +784,6 @@ pub enum AttestationError { Reqwest(#[from] reqwest::Error), #[error("PCCS: {0}")] Pccs(#[from] PccsError), - #[error("Sync verification requested but no PCCS configured")] - NoPccs, #[cfg(any(test, feature = "mock"))] #[error("Cannot create mock attestation: {0}")] Mock(String), @@ -820,8 +793,6 @@ pub enum AttestationError { #[cfg(test)] mod tests { - use mock_tdx::mock_pcs::{MockPcsConfig, spawn_mock_pcs_server}; - use super::*; #[test] @@ -837,7 +808,7 @@ mod tests { } #[tokio::test] - async fn mock_verifier_supports_sync_verification() { + async fn mock_verifier_uses_mock_collateral_for_async_and_sync_verification() { let input_data = [7u8; 64]; let quote = dcap::create_dcap_attestation(input_data).unwrap(); let attestation_evidence = AttestationEvidence { @@ -845,15 +816,16 @@ mod tests { platform: mock_platform_metadata(AttestationType::DcapTdx).unwrap(), }; - let mock_pcs_server = spawn_mock_pcs_server(MockPcsConfig::default()).await.unwrap(); - - let verifier = AttestationVerifier::mock_with_pccs(mock_pcs_server.base_url.clone()); - if let Some(ref pccs) = verifier.internal_pccs { - pccs.ready().await.unwrap(); - } + let verifier = AttestationVerifier::mock(); + let message: AttestationExchangeMessage = attestation_evidence.into(); - let result = verifier.verify_attestation_sync(attestation_evidence.into(), input_data); + let async_result = verifier.verify_attestation(message.clone(), input_data).await; + let sync_result = verifier.verify_attestation_sync(message, input_data); - assert!(result.is_ok(), "expected sync mock verification to succeed: {result:?}"); + assert!( + async_result.is_ok(), + "expected async mock verification to succeed: {async_result:?}" + ); + assert!(sync_result.is_ok(), "expected sync mock verification to succeed: {sync_result:?}"); } } diff --git a/crates/pccs/examples/intel_pcs.rs b/crates/pccs/examples/intel_pcs.rs index 4e6b3d0..0a07b39 100644 --- a/crates/pccs/examples/intel_pcs.rs +++ b/crates/pccs/examples/intel_pcs.rs @@ -1,7 +1,7 @@ //! Demonstrates setting up a PCCS cache using Intel PCS use std::time::Instant; -use pccs::{PCS_URL, Pccs}; +use pccs::{PCS_URL, Pccs, PccsMode}; use tracing::info; use tracing_subscriber::{EnvFilter, fmt}; @@ -18,7 +18,7 @@ async fn main() -> Result<(), pccs::PccsError> { info!(pcs_url = PCS_URL, "Starting PCCS with Intel PCS"); - let pccs = Pccs::new(None); + let pccs = Pccs::new(None, PccsMode::Prewarmed); let started_at = Instant::now(); let summary = pccs.ready().await?; let elapsed = started_at.elapsed().as_secs_f64(); diff --git a/crates/pccs/src/lib.rs b/crates/pccs/src/lib.rs index 9ebd6f4..af6d250 100644 --- a/crates/pccs/src/lib.rs +++ b/crates/pccs/src/lib.rs @@ -42,6 +42,19 @@ const REFRESH_RETRY_SECS: u64 = 60; /// pre-warm const STARTUP_PREWARM_CONCURRENCY: usize = 8; +/// How the verifier obtains DCAP collateral +#[derive(Clone, Debug)] +pub enum PccsMode { + /// No internal collateral cache. Collateral is always fetched from + /// remote source. + Remote, + /// Internal cache pre-filled with all available collateral at build + /// time. + Prewarmed, + /// Internal cache that starts empty and fetches on demand. + Lazy, +} + /// PCCS collateral cache with proactive background refresh /// /// Fetching runs over rustls-backed HTTP, so the application must install a @@ -54,6 +67,12 @@ const STARTUP_PREWARM_CONCURRENCY: usize = 8; pub struct Pccs { /// The URL of the service used to fetch collateral (PCS / PCCS) url: String, + /// An internal cache if configured + inner: Option, +} + +#[derive(Clone)] +struct PccsInner { /// The internal cache cache: Arc>>, /// Dedupes one-shot background refreshes for cache misses @@ -74,25 +93,7 @@ impl std::fmt::Debug for Pccs { impl Pccs { /// Creates a new PCCS cache using the provided URL or Intel PCS default - pub fn new(url: Option) -> Self { - let mut pccs = Self::new_without_prewarm(url); - - let (prewarm_outcome_tx, _) = watch::channel(None); - pccs.prewarm_outcome_tx = Some(prewarm_outcome_tx); - - // Start filling the cache right away - let pccs_for_prewarm = pccs.clone(); - tokio::spawn(async move { - let outcome = pccs_for_prewarm.startup_prewarm_all_tdx().await; - pccs_for_prewarm.finish_prewarm(outcome); - }); - - pccs - } - - /// Creates a new PCCS cache using the provided URL or Intel PCS default - /// and does not pre-warm by proactively fetching collateral - pub fn new_without_prewarm(url: Option) -> Self { + pub fn new(url: Option, mode: PccsMode) -> Self { let url = url .unwrap_or(PCS_URL.to_string()) .trim_end_matches('/') @@ -100,18 +101,53 @@ impl Pccs { .trim_end_matches("/tdx/certification/v4") .to_string(); - Self { - url, - cache: RwLock::new(HashMap::new()).into(), - pending_refreshes: RwLock::new(HashSet::new()).into(), - prewarm_stats: Arc::new(PrewarmStats::default()), - prewarm_outcome_tx: None, + match mode { + PccsMode::Remote => Self { url, inner: None }, + PccsMode::Lazy => Self { + url, + inner: Some(PccsInner { + cache: RwLock::new(HashMap::new()).into(), + pending_refreshes: RwLock::new(HashSet::new()).into(), + prewarm_stats: Arc::new(PrewarmStats::default()), + prewarm_outcome_tx: None, + }), + }, + PccsMode::Prewarmed => { + let (prewarm_outcome_tx, _) = watch::channel(None); + + let pccs = Self { + url, + inner: Some(PccsInner { + cache: RwLock::new(HashMap::new()).into(), + pending_refreshes: RwLock::new(HashSet::new()).into(), + prewarm_stats: Arc::new(PrewarmStats::default()), + prewarm_outcome_tx: Some(prewarm_outcome_tx), + }), + }; + + // Start filling the cache right away + let pccs_for_prewarm = pccs.clone(); + tokio::spawn(async move { + let outcome = pccs_for_prewarm.startup_prewarm_all_tdx().await; + pccs_for_prewarm.finish_prewarm(outcome); + }); + + pccs + } } } + /// Returns whether this PCCS fetches collateral directly without an + /// internal cache. + pub fn is_remote(&self) -> bool { + self.inner.is_none() + } + /// Resolves when cache is pre-warmed with all available collateral pub async fn ready(&self) -> Result { - if let Some(prewarm_outcome_tx) = &self.prewarm_outcome_tx { + if let Some(ref inner) = self.inner && + let Some(prewarm_outcome_tx) = &inner.prewarm_outcome_tx + { let mut outcome_rx = prewarm_outcome_tx.subscribe(); loop { if let Some(outcome) = outcome_rx.borrow_and_update().clone() { @@ -124,13 +160,13 @@ impl Pccs { return Err(PccsError::PrewarmSignalClosed); } } - } else { - Err(PccsError::PrewarmDisabled) } + Err(PccsError::PrewarmDisabled) } - /// Returns collateral from cache when valid, otherwise fetches and - /// caches fresh collateral + /// Fetches collateral, using the internal cache when configured. + /// Remote mode always fetches from the configured endpoint. + /// /// Returns collateral together with a flag indicating whether it is /// fresh (true) or from the cache (false) pub async fn get_collateral( @@ -139,11 +175,16 @@ impl Pccs { ca: &'static str, now: u64, ) -> Result<(QuoteCollateralV3, bool), PccsError> { + let Some(inner) = &self.inner else { + let collateral = fetch_collateral(&self.url, fmspc, ca).await?; + return Ok((collateral, true)); + }; + let now = i64::try_from(now).map_err(|_| PccsError::TimeStampExceedsI64)?; let cache_key = PccsInput::new(fmspc.clone(), ca); { - let cache = self.cache.read().map_err(|_| PccsError::CachePoisoned)?; + let cache = inner.cache.read().map_err(|_| PccsError::CachePoisoned)?; if let Some(entry) = cache.get(&cache_key) { if now < entry.next_update { return Ok((entry.collateral.clone(), false)); @@ -161,7 +202,7 @@ impl Pccs { let next_update = extract_next_update(&collateral, now)?; { - let mut cache = self.cache.write().map_err(|_| PccsError::CachePoisoned)?; + let mut cache = inner.cache.write().map_err(|_| PccsError::CachePoisoned)?; if let Some(existing) = cache.get(&cache_key) && now < existing.next_update { @@ -188,9 +229,13 @@ impl Pccs { ca: &'static str, now: u64, ) -> Result { + let Some(inner) = &self.inner else { + return Err(PccsError::CacheDisabled); + }; + let now = i64::try_from(now).map_err(|_| PccsError::TimeStampExceedsI64)?; let cache_key = PccsInput::new(fmspc.clone(), ca); - let cache = self.cache.read().map_err(|_| PccsError::CachePoisoned)?; + let cache = inner.cache.read().map_err(|_| PccsError::CachePoisoned)?; if let Some(entry) = cache.get(&cache_key) { if now >= entry.next_update { let collateral = entry.collateral.clone(); @@ -225,13 +270,17 @@ impl Pccs { fmspc: String, ca: &'static str, ) -> Result { + let Some(inner) = &self.inner else { + return fetch_collateral(&self.url, fmspc, ca).await; + }; + let now = unix_now()?; let collateral = fetch_collateral(&self.url, fmspc.clone(), ca).await?; let next_update = extract_next_update(&collateral, now)?; let cache_key = PccsInput::new(fmspc, ca); { - let mut cache = self.cache.write().map_err(|_| PccsError::CachePoisoned)?; + let mut cache = inner.cache.write().map_err(|_| PccsError::CachePoisoned)?; upsert_cache_entry(&mut cache, cache_key.clone(), collateral.clone(), next_update); } self.ensure_refresh_task(&cache_key).await; @@ -241,7 +290,10 @@ impl Pccs { /// Starts a background refresh loop for a cache key when no task is /// active async fn ensure_refresh_task(&self, cache_key: &PccsInput) { - let Ok(mut cache) = self.cache.write() else { + let Some(inner) = &self.inner else { + return; + }; + let Ok(mut cache) = inner.cache.write() else { tracing::warn!("PCCS cache lock poisoned, cannot ensure refresh task"); return; }; @@ -252,7 +304,7 @@ impl Pccs { return; } - let weak_cache = Arc::downgrade(&self.cache); + let weak_cache = Arc::downgrade(&inner.cache); let key = cache_key.clone(); let url = self.url.clone(); entry.refresh_task = Some(tokio::spawn(async move { @@ -262,8 +314,11 @@ impl Pccs { /// Starts a one-shot background fetch to populate a missing cache entry fn spawn_background_refresh_for_cache_miss(&self, cache_key: PccsInput) { + let Some(inner) = &self.inner else { + return; + }; { - let Ok(mut pending_refreshes) = self.pending_refreshes.write() else { + let Ok(mut pending_refreshes) = inner.pending_refreshes.write() else { tracing::warn!("PCCS pending-refresh lock poisoned, cannot start sync refresh"); return; }; @@ -292,7 +347,10 @@ impl Pccs { // Always clear the dedupe marker so a later sync miss can // retry if this repair attempt failed. - if let Ok(mut pending_refreshes) = pccs.pending_refreshes.write() { + let Some(inner) = &pccs.inner else { + return; + }; + if let Ok(mut pending_refreshes) = inner.pending_refreshes.write() { pending_refreshes.remove(&cache_key); } else { tracing::warn!("PCCS pending-refresh lock poisoned during cleanup"); @@ -303,6 +361,10 @@ impl Pccs { /// Pre-provisions TDX collateral for discovered FMSPC values to reduce /// hot-path fetches async fn startup_prewarm_all_tdx(&self) -> PrewarmOutcome { + let Some(inner) = &self.inner else { + return PrewarmOutcome::Failed("PCCS cache is disabled".to_string()); + }; + // First get all FMSPCs let fmspcs = match self.fetch_fmspcs().await { Ok(fmspcs) => fmspcs, @@ -317,11 +379,11 @@ impl Pccs { )); } }; - self.prewarm_stats.discovered_fmspcs.store(fmspcs.len(), Ordering::SeqCst); + inner.prewarm_stats.discovered_fmspcs.store(fmspcs.len(), Ordering::SeqCst); if fmspcs.is_empty() { tracing::warn!("No FMSPC entries returned during startup pre-provision"); - return PrewarmOutcome::Ready(self.prewarm_stats.snapshot()); + return PrewarmOutcome::Ready(inner.prewarm_stats.snapshot()); } // For each FMSPC, get the 'processor' and 'platform' collateral @@ -334,7 +396,7 @@ impl Pccs { let Ok(permit) = permit else { continue; }; - self.prewarm_stats.attempted.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.attempted.fetch_add(1, Ordering::SeqCst); let pccs = self.clone(); let fmspc = entry.fmspc.clone(); join_set.spawn(async move { @@ -357,11 +419,11 @@ impl Pccs { Ok(Ok((fmspc, ca, Ok(())))) => { successes += 1; debug!("Successfully cached: {fmspc} {ca}"); - self.prewarm_stats.successes.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.successes.fetch_add(1, Ordering::SeqCst); } Ok(Ok((fmspc, ca, Err(e)))) => { failures += 1; - self.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); tracing::debug!( fmspc, ca, @@ -371,29 +433,32 @@ impl Pccs { } Ok(Err(e)) => { failures += 1; - self.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); tracing::debug!(error = %e, "Startup pre-provision task failed"); } Err(e) => { failures += 1; - self.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); tracing::debug!(error = %e, "Startup pre-provision join error"); } } } tracing::info!( - discovered_fmspcs = self.prewarm_stats.discovered_fmspcs.load(Ordering::SeqCst), - attempted = self.prewarm_stats.attempted.load(Ordering::SeqCst), + discovered_fmspcs = inner.prewarm_stats.discovered_fmspcs.load(Ordering::SeqCst), + attempted = inner.prewarm_stats.attempted.load(Ordering::SeqCst), successes, failures, "Completed PCCS startup pre-provisioning for TDX collateral" ); - PrewarmOutcome::Ready(self.prewarm_stats.snapshot()) + PrewarmOutcome::Ready(inner.prewarm_stats.snapshot()) } fn finish_prewarm(&self, outcome: PrewarmOutcome) { - if let Some(prewarm_outcome_tx) = &self.prewarm_outcome_tx { - self.prewarm_stats.completed.store(true, Ordering::SeqCst); + let Some(inner) = &self.inner else { + return; + }; + if let Some(prewarm_outcome_tx) = &inner.prewarm_outcome_tx { + inner.prewarm_stats.completed.store(true, Ordering::SeqCst); let _ = prewarm_outcome_tx.send(Some(outcome)); } } @@ -457,6 +522,9 @@ async fn fetch_collateral( fmspc: String, ca: &'static str, ) -> Result { + #[cfg(test)] + install_test_crypto_provider(); + CollateralClient::with_default_http(url)? .fetch_for_fmspc_without_pck_chain(&fmspc, ca, false) .await @@ -756,6 +824,8 @@ pub enum PccsError { TimeStampExceedsI64, #[error("PCCS cache lock poisoned")] CachePoisoned, + #[error("PCCS cache is disabled; synchronous collateral lookup is unavailable")] + CacheDisabled, #[error("No collateral in cache for FMSPC {0}")] NoCollateralForFmspc(String), } @@ -786,12 +856,42 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Lazy); let now = 1_700_000_000_u64; let (_, is_fresh) = pccs.get_collateral(fmspc, "processor", now).await.unwrap(); assert!(is_fresh); } + #[tokio::test] + async fn test_remote_mode_fetches_collateral_every_time() { + let fmspc = mock_tdx_fmspc(); + let mock = spawn_mock_pcs_server(MockPcsConfig { + include_fmspcs_listing: false, + tcb_next_update: "2999-01-01T00:00:00Z".to_string(), + qe_next_update: "2999-01-01T00:00:00Z".to_string(), + refreshed_tcb_next_update: None, + refreshed_qe_next_update: None, + }) + .await + .unwrap(); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Remote); + + let (_, first_is_fresh) = + pccs.get_collateral(fmspc.clone(), "processor", 1_700_000_000).await.unwrap(); + let (_, second_is_fresh) = + pccs.get_collateral(fmspc.clone(), "processor", 1_700_000_000).await.unwrap(); + + assert!(pccs.inner.is_none()); + assert!(first_is_fresh); + assert!(second_is_fresh); + assert_eq!(mock.tcb_call_count(), 2); + assert_eq!(mock.qe_call_count(), 2); + assert!(matches!( + pccs.get_collateral_sync(fmspc, "processor", 1_700_000_000), + Err(PccsError::CacheDisabled) + )); + } + #[test] fn test_extract_next_update_includes_crl_expiry() { let mut collateral: QuoteCollateralV3 = mock_collateral(); @@ -833,7 +933,7 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Lazy); let (_, is_fresh) = pccs.get_collateral(fmspc.clone(), "processor", initial_now as u64).await.unwrap(); assert!(is_fresh); @@ -875,7 +975,7 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Prewarmed); let summary = tokio::time::timeout(Duration::from_secs(5), pccs.ready()).await.unwrap().unwrap(); assert_eq!(summary.discovered_fmspcs, 1); @@ -884,7 +984,7 @@ mod tests { assert_eq!(summary.failures, 0); let (total_entries, fmspc, ca) = { - let cache_guard = pccs.cache.read().unwrap(); + let cache_guard = pccs.inner.as_ref().unwrap().cache.read().unwrap(); let total_entries = cache_guard.len(); let (fmspc, ca) = cache_guard .keys() @@ -911,7 +1011,7 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Prewarmed); let pccs_clone = pccs.clone(); let (first, second) = tokio::join!(pccs.ready(), pccs_clone.ready()); @@ -923,7 +1023,7 @@ mod tests { #[tokio::test] async fn test_ready_returns_error_when_prewarm_bootstrap_fails() { - let pccs = Pccs::new(Some("http://127.0.0.1:1".to_string())); + let pccs = Pccs::new(Some("http://127.0.0.1:1".to_string()), PccsMode::Prewarmed); let ready_result = tokio::time::timeout(Duration::from_secs(2), pccs.ready()).await.unwrap(); assert!(matches!(ready_result, Err(PccsError::PrewarmFailed(_)))); @@ -931,7 +1031,7 @@ mod tests { #[tokio::test] async fn test_ready_returns_error_when_prewarm_disabled() { - let pccs = Pccs::new_without_prewarm(None); + let pccs = Pccs::new(None, PccsMode::Lazy); let ready_result = pccs.ready().await; assert!(matches!(ready_result, Err(PccsError::PrewarmDisabled))); } @@ -949,7 +1049,7 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new_without_prewarm(Some(mock.base_url.clone())); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Lazy); let now = unix_now().unwrap() as u64; let err = pccs.get_collateral_sync(fmspc.clone(), "processor", now); @@ -989,13 +1089,13 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new_without_prewarm(Some(mock.base_url.clone())); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Lazy); let (_, is_fresh) = pccs.get_collateral(fmspc.clone(), "processor", initial_now as u64).await.unwrap(); assert!(is_fresh); { - let mut cache = pccs.cache.write().unwrap(); + let mut cache = pccs.inner.as_ref().unwrap().cache.write().unwrap(); let entry = cache .get_mut(&PccsInput::new(fmspc.clone(), "processor")) .expect("expected cached collateral entry"); From ce16f57efeb656ae7ed690d7991eeda067f85771 Mon Sep 17 00:00:00 2001 From: peg Date: Thu, 27 Aug 2026 10:11:46 +0200 Subject: [PATCH 2/6] Documentation for new API --- crates/attestation/README.md | 56 ++++++++++++++++++------ crates/attestation/src/azure/verify.rs | 3 ++ crates/attestation/src/dcap.rs | 6 +++ crates/attestation/src/lib.rs | 30 +++++++++---- crates/pccs/README.md | 60 +++++++++++++++++++++----- crates/pccs/src/lib.rs | 38 +++++++++++----- readme.md | 3 +- 7 files changed, 153 insertions(+), 43 deletions(-) diff --git a/crates/attestation/README.md b/crates/attestation/README.md index 2fe0194..e3747fb 100644 --- a/crates/attestation/README.md +++ b/crates/attestation/README.md @@ -12,14 +12,39 @@ This crate provides: ## Runtime Requirements -Verification uses the [`pccs`](../pccs) crate for collateral caching and -background refresh. As a result, constructing an `AttestationVerifier` with -PCCS enabled and calling verification APIs is expected to happen from within a -Tokio runtime and might panic if called outside of one. - -Note that although some of the verification API methods are synchronous (for -example `verify_attestation_sync`), still their functionality depends on -Tokio-backed background tasks such as PCCS pre-warm and cache refresh. +Verification uses the [`pccs`](../pccs) crate to fetch DCAP collateral and, +depending on the selected mode, cache and refresh it. Asynchronous +verification requires a Tokio runtime. Constructing an `AttestationVerifier` +in `Prewarmed` mode also requires an active runtime because pre-warming starts +immediately; constructing it in `Remote` or `Lazy` mode does not itself spawn +a task. + +Synchronous verification requires a cached mode (`Lazy` or `Prewarmed`) with +the required collateral already cached. Cache misses and expired entries may +start Tokio-backed background refresh tasks. `Remote` mode cannot be used for +synchronous verification because fetching collateral requires asynchronous +I/O. + +## DCAP collateral modes + +Every `AttestationVerifier` has a PCCS collateral source configured through +`AttestationVerifierBuilder::with_pccs_mode`. The default is +`PccsMode::Remote`. + +- `Remote` keeps no internal cache and fetches collateral from the configured + endpoint for every asynchronous verification. +- `Lazy` starts with an empty internal cache and fetches collateral on demand. +- `Prewarmed` immediately starts discovering and caching available TDX + collateral, then refreshes cached entries before expiry. + +Use `with_pccs_url` to select an Intel PCS or PCCS-compatible endpoint. Without +an explicit URL, the endpoint defaults to Intel PCS. + +`AttestationVerifier::ready()` waits for initial work only in `Prewarmed` +mode. It returns immediately for `Remote` and `Lazy`. A successful return in +`Remote` or `Lazy` does not mean later verification will avoid fetching +collateral. In `Prewarmed` mode it means pre-warm bootstrap completed, but +individual collateral fetches can still have failed. ## Feature flags @@ -64,6 +89,10 @@ must be explicitly enabled via the `override_azure_outdated_tcb` flag on Enables mock quote support via the local `mock-tdx` crate for tests and development on non-TDX hardware. +In mock builds, `Remote` mode uses embedded mock collateral rather than making +an external request. Cached modes can be pointed at a local mock PCCS when +testing cache behavior. + Do not use in production. Disabled by default. ## Attestation Types @@ -90,11 +119,12 @@ attempted. Alternatively, an external 'attestation provider service' URL can be provided which outsources the attestation generation to another process. -When verifying DCAP attestations, the Intel PCS is used to retrieve collateral -unless a PCCS URL is provided via a command line argument. If outdated TCB is -used, the quote will fail to verify. For special cases where outdated TCB -should be allowed, a custom override function can be passed when verifying which -may modify collateral before it is validated against the TCB. +When verifying DCAP attestations, collateral is retrieved according to the +configured PCCS mode. The endpoint defaults to Intel PCS unless a PCCS URL is +provided through the verifier builder. If outdated TCB is used, the quote will +fail to verify. For special cases where outdated TCB should be allowed, a +custom override function can be passed when verifying which may modify +collateral before it is validated against the TCB. ## Measurements File diff --git a/crates/attestation/src/azure/verify.rs b/crates/attestation/src/azure/verify.rs index 4a02d20..c3210f3 100644 --- a/crates/attestation/src/azure/verify.rs +++ b/crates/attestation/src/azure/verify.rs @@ -61,6 +61,9 @@ pub async fn verify_azure_attestation( /// /// This relies on having DCAP collateral already present in the cache /// +/// [`PccsMode::Remote`](pccs::PccsMode::Remote) is not supported because +/// fetching collateral requires asynchronous I/O. +/// /// If possible, prefer the async version pub fn verify_azure_attestation_sync( input: Vec, diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index ea71762..3bfe8b5 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -51,6 +51,9 @@ pub async fn verify_dcap_attestation( /// /// This relies on having DCAP collateral already present in the cache /// +/// [`PccsMode::Remote`](pccs::PccsMode::Remote) is not supported because +/// fetching collateral requires asynchronous I/O. +/// /// If possible, prefer the async version #[cfg(not(any(test, feature = "mock")))] pub fn verify_dcap_attestation_sync( @@ -75,6 +78,9 @@ pub fn verify_dcap_attestation_sync( /// /// This relies on having DCAP collateral already present in the cache /// +/// [`PccsMode::Remote`](pccs::PccsMode::Remote) is not supported unless +/// `collateral` is provided. +/// /// If possible, prefer the async version pub fn verify_dcap_attestation_with_timestamp_sync( input: Vec, diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index f1bfaea..5a4d9ea 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -362,9 +362,9 @@ pub struct AttestationVerifier { pub struct AttestationVerifierBuilder { /// The measurement policy with accepted values and attestation types measurement_policy: MeasurementPolicy, - /// Internal PCCS setting + /// How DCAP collateral is fetched and cached pccs_mode: PccsMode, - /// A PCCS service to use - defaults to Intel PCS + /// Collateral endpoint; defaults to Intel PCS pccs_url: Option, dump_dcap_quotes: bool, /// Whether to override outdated TCB when on Azure @@ -399,12 +399,15 @@ impl AttestationVerifierBuilder { self } + /// Configures how DCAP collateral is fetched and cached. + /// + /// The default is [`PccsMode::Remote`]. pub fn with_pccs_mode(mut self, pccs_mode: PccsMode) -> Self { self.pccs_mode = pccs_mode; self } - /// Set the URL used by internal PCCS + /// Sets the Intel PCS or PCCS endpoint used to fetch collateral. pub fn with_pccs_url(mut self, pccs_url: String) -> Self { self.pccs_url = Some(pccs_url); self @@ -464,12 +467,17 @@ impl AttestationVerifier { } } - /// Resolves once the internal PCCS cache is ready to verify - /// attestations + /// Waits for initial PCCS pre-warming when configured. + /// + /// In [`PccsMode::Prewarmed`], this waits for the initial pre-warm and + /// returns an error if pre-warm bootstrap failed. In + /// [`PccsMode::Remote`] and [`PccsMode::Lazy`], there is no initial + /// pre-warm to await, so this returns immediately. /// - /// Calling this is optional - it is only really needed when you want to - /// guarantee that collateral will not be fetched during - /// verification + /// Only `Prewarmed` mode is intended to avoid on-demand collateral + /// fetches during verification. Initial pre-warming can complete even + /// if individual collateral fetches failed, so it is not an + /// absolute guarantee that verification will avoid a fetch. pub async fn ready(&self) -> Result<(), AttestationError> { // If pre-warm is disabled we are ready match self.internal_pccs.ready().await { @@ -556,6 +564,12 @@ impl AttestationVerifier { Ok(Some(measurements)) } + /// Synchronously verifies an attestation against the configured policy. + /// + /// DCAP and Azure verification require `Lazy` or `Prewarmed` mode with + /// the requested collateral already cached. `Remote` mode cannot fetch + /// collateral synchronously. A cache miss returns an error and starts a + /// background fetch for a later attempt. pub fn verify_attestation_sync( &self, attestation_exchange_message: AttestationExchangeMessage, diff --git a/crates/pccs/README.md b/crates/pccs/README.md index e1a3cfd..b902d79 100644 --- a/crates/pccs/README.md +++ b/crates/pccs/README.md @@ -1,7 +1,7 @@ # pccs -An internal Provisioning Certificate Caching Service implementation for DCAP -collateral fetching and caching. +A DCAP collateral client with optional in-process caching and proactive +refresh. This crate is used by attestation verification code that needs Intel TDX/SGX collateral such as TCB info, QE identity, and certificate revocation lists. @@ -9,20 +9,58 @@ collateral such as TCB info, QE identity, and certificate revocation lists. It can: - Fetch collateral from Intel PCS or a configured PCCS endpoint -- Cache collateral in-process -- Pre-warm the cache at startup -- Refresh cached collateral in the background before expiry +- Operate as a remote pass-through without caching +- Cache collateral lazily on demand +- Pre-warm and proactively refresh an in-process cache -This is an alternative to Intel's reference PCCS server implementation which -can be embedded in Rust services that verify quotes. +The caching modes provide an embeddable alternative to deploying Intel's +reference PCCS server alongside services that verify quotes. For Intel's terminology and architecture, see the Intel documentation for the [Provisioning Certificate Caching Service (PCCS)](https://cc-enabling.trustedservices.intel.com/intel-sgx-tdx-pccs/01/introduction/). +## Modes + +Every `Pccs` has a [`PccsMode`](src/lib.rs): + +- `Remote` keeps no internal cache. Every `get_collateral()` call fetches from + the configured endpoint. `get_collateral_sync()` returns `CacheDisabled` + because it cannot perform asynchronous network I/O. +- `Lazy` starts with an empty cache. Asynchronous cache misses are fetched + immediately; synchronous misses return an error and start a background + fetch for a later attempt. +- `Prewarmed` starts the same cache and immediately begins pre-warming it with + discovered TDX collateral. Call `ready()` to wait for that initial work. + +The endpoint passed to `Pccs::new` may be Intel PCS or another PCCS-compatible +service. Passing `None` uses [`PCS_URL`](src/lib.rs), the Intel PCS default. + +```rust,no_run +use pccs::{Pccs, PccsMode}; + +#[tokio::main] +async fn main() -> Result<(), pccs::PccsError> { + let _remote = Pccs::new(None, PccsMode::Remote); + let _lazy = Pccs::new(Some("https://pccs.example".into()), PccsMode::Lazy); + let prewarmed = Pccs::new(None, PccsMode::Prewarmed); + let _summary = prewarmed.ready().await?; + + Ok(()) +} +``` + +`ready()` only waits for `Prewarmed` mode. It returns `PrewarmDisabled` for +`Remote` and `Lazy`. A successful pre-warm result includes failure counters; +it does not guarantee that every possible collateral item was cached. + ## Runtime Requirements -This crate expects to be used from within a Tokio runtime. +Asynchronous collateral fetching requires a Tokio runtime. Constructing a +`Prewarmed` instance also requires an active runtime because it immediately +spawns the initial pre-warm task. Constructing `Remote` or `Lazy` does not +itself spawn a task. -The above applies even when calling synchronous-looking APIs such as -`get_collateral_sync()` because cache miss repair, proactive refresh, and -startup pre-warm are all driven by Tokio background tasks. +`get_collateral_sync()` is available only with a cache (`Lazy` or +`Prewarmed`). A cache miss or expired entry may spawn a Tokio background task, +so applications that can encounter either condition must have an active +runtime. diff --git a/crates/pccs/src/lib.rs b/crates/pccs/src/lib.rs index af6d250..d1c5303 100644 --- a/crates/pccs/src/lib.rs +++ b/crates/pccs/src/lib.rs @@ -42,20 +42,24 @@ const REFRESH_RETRY_SECS: u64 = 60; /// pre-warm const STARTUP_PREWARM_CONCURRENCY: usize = 8; -/// How the verifier obtains DCAP collateral +/// How PCCS obtains and stores DCAP collateral. #[derive(Clone, Debug)] pub enum PccsMode { - /// No internal collateral cache. Collateral is always fetched from - /// remote source. + /// Fetch collateral from the configured endpoint for every asynchronous + /// lookup, without keeping an internal cache. + /// + /// Synchronous lookups are unavailable in this mode because fetching + /// collateral requires asynchronous I/O. Remote, - /// Internal cache pre-filled with all available collateral at build - /// time. + /// Start pre-warming an internal cache when [`Pccs`] is constructed. + /// + /// Call [`Pccs::ready`] to wait for the initial pre-warm to complete. Prewarmed, - /// Internal cache that starts empty and fetches on demand. + /// Start with an empty internal cache and fetch collateral on demand. Lazy, } -/// PCCS collateral cache with proactive background refresh +/// DCAP collateral source with optional caching and background refresh. /// /// Fetching runs over rustls-backed HTTP, so the application must install a /// process-level rustls [crypto provider] before collateral can be fetched, @@ -92,7 +96,11 @@ impl std::fmt::Debug for Pccs { } impl Pccs { - /// Creates a new PCCS cache using the provided URL or Intel PCS default + /// Creates a collateral source in the requested mode. + /// + /// The endpoint defaults to Intel PCS when `url` is `None`. + /// Constructing [`PccsMode::Prewarmed`] immediately spawns its initial + /// fetch task and therefore requires an active Tokio runtime. pub fn new(url: Option, mode: PccsMode) -> Self { let url = url .unwrap_or(PCS_URL.to_string()) @@ -143,7 +151,12 @@ impl Pccs { self.inner.is_none() } - /// Resolves when cache is pre-warmed with all available collateral + /// Waits for the initial pre-warm to complete. + /// + /// Returns [`PccsError::PrewarmDisabled`] for [`PccsMode::Remote`] and + /// [`PccsMode::Lazy`]. A successful result means the initial pre-warm + /// completed; individual collateral fetches may still have failed, as + /// reported in [`PrewarmSummary`]. pub async fn ready(&self) -> Result { if let Some(ref inner) = self.inner && let Some(prewarm_outcome_tx) = &inner.prewarm_outcome_tx @@ -168,7 +181,8 @@ impl Pccs { /// Remote mode always fetches from the configured endpoint. /// /// Returns collateral together with a flag indicating whether it is - /// fresh (true) or from the cache (false) + /// freshly fetched (`true`) or from the cache (`false`). Remote mode + /// always returns `true`. pub async fn get_collateral( &self, fmspc: String, @@ -217,6 +231,10 @@ impl Pccs { /// A synchronous method to get collateral from the cache. /// + /// In [`PccsMode::Remote`], this returns [`PccsError::CacheDisabled`] + /// because a synchronous call cannot perform the required asynchronous + /// fetch. + /// /// If the requested collateral is not present in the cache, this will /// return an error rather than waiting to fetch it. But it does /// begin fetching it in a background task. diff --git a/readme.md b/readme.md index 2416f3d..ef975d0 100644 --- a/readme.md +++ b/readme.md @@ -41,7 +41,8 @@ More details in the individual READMEs of the provided crates: session for attestation. - [`attestation`](./crates/attestation) - provides attestation generation, verification and measurement handling. -- [`pccs`](./crates/pccs) provides collateral fetching and caching for DCAP +- [`pccs`](./crates/pccs) provides collateral fetching and optional caching + for DCAP verification. - [`mock-tdx`](./crates/mock-tdx) - generates deterministic mock TDX DCAP quotes, collateral, and trust roots for tests and development on non-TDX From bcb850a5dd6776be63819fd7a469eeed4bfc43e1 Mon Sep 17 00:00:00 2001 From: peg Date: Thu, 3 Sep 2026 11:59:08 +0200 Subject: [PATCH 3/6] Fix attested-tls crate following api change --- crates/attested-tls/src/lib.rs | 60 ++++++++++------------------------ 1 file changed, 17 insertions(+), 43 deletions(-) diff --git a/crates/attested-tls/src/lib.rs b/crates/attested-tls/src/lib.rs index 7931430..724b1e5 100644 --- a/crates/attested-tls/src/lib.rs +++ b/crates/attested-tls/src/lib.rs @@ -7,12 +7,8 @@ use std::{ }; pub use attestation::{ - AttestationEvidence, - AttestationExchangeMessage, - AttestationGenerator, - AttestationType, - AttestationVerifier, - PlatformMetadata, + AttestationEvidence, AttestationExchangeMessage, AttestationGenerator, AttestationType, + AttestationVerifier, PlatformMetadata, }; use ra_tls::{ attestation::{Attestation, AttestationQuote, VersionedAttestation}, @@ -21,32 +17,20 @@ use ra_tls::{ }; pub use ra_tls::{cert::CaCert, rcgen}; use rustls::{ - CertificateError, - DigitallySignedStruct, - DistinguishedName, + CertificateError, DigitallySignedStruct, DistinguishedName, Error::InvalidCertificate, - RootCertStore, - SignatureScheme, + RootCertStore, SignatureScheme, client::{ - ResolvesClientCert, - VerifierBuilderError, - WebPkiServerVerifier, + ResolvesClientCert, VerifierBuilderError, WebPkiServerVerifier, danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, verify_server_name, }, crypto::CryptoProvider, pki_types::{ - CertificateDer, - PrivateKeyDer, - PrivatePkcs8KeyDer, - ServerName, - UnixTime, - pem::PemObject, + CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime, pem::PemObject, }, server::{ - ParsedCertificate, - ResolvesServerCert, - WebPkiClientVerifier, + ParsedCertificate, ResolvesServerCert, WebPkiClientVerifier, danger::{ClientCertVerified, ClientCertVerifier}, }, sign::{CertifiedKey, SigningKey}, @@ -537,8 +521,8 @@ impl AttestedCertificateVerifier { cert: &X509Certificate<'_>, ) -> Result { if let Ok(Some(VersionedAttestation::V0 { attestation })) = - ra_tls::attestation::from_cert(cert) && - let AttestationQuote::DstackTdx(tdx_quote) = attestation.quote + ra_tls::attestation::from_cert(cert) + && let AttestationQuote::DstackTdx(tdx_quote) = attestation.quote { return serde_json::from_slice::(&tdx_quote.quote).map_err( |err| { @@ -753,8 +737,8 @@ impl ServerCertVerifier for AttestedCertificateVerifier { Ok(_) => {} }; - if let Some(ref allowed_leaf_cert_pubkeys) = self.allowed_leaf_cert_pubkeys && - !allowed_leaf_cert_pubkeys.contains(&Sha512::digest(cert.public_key().raw).into()) + if let Some(ref allowed_leaf_cert_pubkeys) = self.allowed_leaf_cert_pubkeys + && !allowed_leaf_cert_pubkeys.contains(&Sha512::digest(cert.public_key().raw).into()) { tracing::warn!("Rejecting leaf certificate with un-allowed public key"); return Err(InvalidCertificate(CertificateError::UnknownIssuer)); @@ -841,8 +825,8 @@ impl ClientCertVerifier for AttestedCertificateVerifier { Ok(_) => {} } - if let Some(ref allowed_leaf_cert_pubkeys) = self.allowed_leaf_cert_pubkeys && - !allowed_leaf_cert_pubkeys.contains(&Sha512::digest(cert.public_key().raw).into()) + if let Some(ref allowed_leaf_cert_pubkeys) = self.allowed_leaf_cert_pubkeys + && !allowed_leaf_cert_pubkeys.contains(&Sha512::digest(cert.public_key().raw).into()) { tracing::warn!("Rejecting leaf certificate with un-allowed public key"); return Err(InvalidCertificate(CertificateError::UnknownIssuer)); @@ -1048,21 +1032,11 @@ mod tests { use mock_tdx::mock_pcs::{MockPcsConfig, spawn_mock_pcs_server}; use ra_tls::rcgen::{ - BasicConstraints, - CertificateParams, - IsCa, - KeyPair, - PKCS_ECDSA_P256_SHA256, + BasicConstraints, CertificateParams, IsCa, KeyPair, PKCS_ECDSA_P256_SHA256, }; use rustls::{ - CertificateError, - ClientConfig, - ClientConnection, - Error, - RootCertStore, - ServerConfig, - ServerConnection, - crypto::aws_lc_rs, + CertificateError, ClientConfig, ClientConnection, Error, RootCertStore, ServerConfig, + ServerConnection, crypto::aws_lc_rs, }; use super::*; @@ -1742,7 +1716,7 @@ mod tests { let dynamic_verifier = AttestationVerifier::builder( attestation::measurements::MeasurementPolicy::expect_none(), ) - .with_pccs_mode(attestation::PccsMode::None) + .with_pccs_mode(attestation::PccsMode::Remote) .with_dynamic_measurements_file_or_url("measurements.json".into()) .build(); From 038a7e04f7563a3ec74be76d2ca9c9838f57fc3d Mon Sep 17 00:00:00 2001 From: peg Date: Thu, 3 Sep 2026 12:01:10 +0200 Subject: [PATCH 4/6] Fmt --- crates/attested-tls/src/lib.rs | 58 ++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/crates/attested-tls/src/lib.rs b/crates/attested-tls/src/lib.rs index 724b1e5..77fa1a2 100644 --- a/crates/attested-tls/src/lib.rs +++ b/crates/attested-tls/src/lib.rs @@ -7,8 +7,12 @@ use std::{ }; pub use attestation::{ - AttestationEvidence, AttestationExchangeMessage, AttestationGenerator, AttestationType, - AttestationVerifier, PlatformMetadata, + AttestationEvidence, + AttestationExchangeMessage, + AttestationGenerator, + AttestationType, + AttestationVerifier, + PlatformMetadata, }; use ra_tls::{ attestation::{Attestation, AttestationQuote, VersionedAttestation}, @@ -17,20 +21,32 @@ use ra_tls::{ }; pub use ra_tls::{cert::CaCert, rcgen}; use rustls::{ - CertificateError, DigitallySignedStruct, DistinguishedName, + CertificateError, + DigitallySignedStruct, + DistinguishedName, Error::InvalidCertificate, - RootCertStore, SignatureScheme, + RootCertStore, + SignatureScheme, client::{ - ResolvesClientCert, VerifierBuilderError, WebPkiServerVerifier, + ResolvesClientCert, + VerifierBuilderError, + WebPkiServerVerifier, danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, verify_server_name, }, crypto::CryptoProvider, pki_types::{ - CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime, pem::PemObject, + CertificateDer, + PrivateKeyDer, + PrivatePkcs8KeyDer, + ServerName, + UnixTime, + pem::PemObject, }, server::{ - ParsedCertificate, ResolvesServerCert, WebPkiClientVerifier, + ParsedCertificate, + ResolvesServerCert, + WebPkiClientVerifier, danger::{ClientCertVerified, ClientCertVerifier}, }, sign::{CertifiedKey, SigningKey}, @@ -521,8 +537,8 @@ impl AttestedCertificateVerifier { cert: &X509Certificate<'_>, ) -> Result { if let Ok(Some(VersionedAttestation::V0 { attestation })) = - ra_tls::attestation::from_cert(cert) - && let AttestationQuote::DstackTdx(tdx_quote) = attestation.quote + ra_tls::attestation::from_cert(cert) && + let AttestationQuote::DstackTdx(tdx_quote) = attestation.quote { return serde_json::from_slice::(&tdx_quote.quote).map_err( |err| { @@ -737,8 +753,8 @@ impl ServerCertVerifier for AttestedCertificateVerifier { Ok(_) => {} }; - if let Some(ref allowed_leaf_cert_pubkeys) = self.allowed_leaf_cert_pubkeys - && !allowed_leaf_cert_pubkeys.contains(&Sha512::digest(cert.public_key().raw).into()) + if let Some(ref allowed_leaf_cert_pubkeys) = self.allowed_leaf_cert_pubkeys && + !allowed_leaf_cert_pubkeys.contains(&Sha512::digest(cert.public_key().raw).into()) { tracing::warn!("Rejecting leaf certificate with un-allowed public key"); return Err(InvalidCertificate(CertificateError::UnknownIssuer)); @@ -825,8 +841,8 @@ impl ClientCertVerifier for AttestedCertificateVerifier { Ok(_) => {} } - if let Some(ref allowed_leaf_cert_pubkeys) = self.allowed_leaf_cert_pubkeys - && !allowed_leaf_cert_pubkeys.contains(&Sha512::digest(cert.public_key().raw).into()) + if let Some(ref allowed_leaf_cert_pubkeys) = self.allowed_leaf_cert_pubkeys && + !allowed_leaf_cert_pubkeys.contains(&Sha512::digest(cert.public_key().raw).into()) { tracing::warn!("Rejecting leaf certificate with un-allowed public key"); return Err(InvalidCertificate(CertificateError::UnknownIssuer)); @@ -1032,11 +1048,21 @@ mod tests { use mock_tdx::mock_pcs::{MockPcsConfig, spawn_mock_pcs_server}; use ra_tls::rcgen::{ - BasicConstraints, CertificateParams, IsCa, KeyPair, PKCS_ECDSA_P256_SHA256, + BasicConstraints, + CertificateParams, + IsCa, + KeyPair, + PKCS_ECDSA_P256_SHA256, }; use rustls::{ - CertificateError, ClientConfig, ClientConnection, Error, RootCertStore, ServerConfig, - ServerConnection, crypto::aws_lc_rs, + CertificateError, + ClientConfig, + ClientConnection, + Error, + RootCertStore, + ServerConfig, + ServerConnection, + crypto::aws_lc_rs, }; use super::*; From a64d2b59cadea233abf7ca1a71fe36d669cf0dad Mon Sep 17 00:00:00 2001 From: peg Date: Fri, 4 Sep 2026 09:03:42 +0200 Subject: [PATCH 5/6] Support Intel PCS subscription key --- crates/attestation/README.md | 54 ++-- crates/attestation/src/azure/verify.rs | 39 ++- crates/attestation/src/dcap.rs | 33 ++- crates/attestation/src/gcp/firmware.rs | 5 +- crates/attestation/src/lib.rs | 77 +++-- crates/attested-tls/src/lib.rs | 2 +- crates/mock-tdx/src/mock_pcs.rs | 44 ++- crates/pccs/README.md | 51 ++-- crates/pccs/examples/intel_pcs.rs | 5 +- crates/pccs/src/lib.rs | 390 +++++++++++++++++++++---- 10 files changed, 538 insertions(+), 162 deletions(-) diff --git a/crates/attestation/README.md b/crates/attestation/README.md index 3a2b469..77f1aad 100644 --- a/crates/attestation/README.md +++ b/crates/attestation/README.md @@ -29,37 +29,39 @@ Matched expected measurements can be transported in an HTTP header using ## Runtime Requirements Verification uses the [`pccs`](../pccs) crate to fetch DCAP collateral and, -depending on the selected mode, cache and refresh it. Asynchronous +depending on the selected cache policy, cache and refresh it. Asynchronous verification requires a Tokio runtime. Constructing an `AttestationVerifier` -in `Prewarmed` mode also requires an active runtime because pre-warming starts -immediately; constructing it in `Remote` or `Lazy` mode does not itself spawn -a task. - -Synchronous verification requires a cached mode (`Lazy` or `Prewarmed`) with -the required collateral already cached. Cache misses and expired entries may -start Tokio-backed background refresh tasks. `Remote` mode cannot be used for -synchronous verification because fetching collateral requires asynchronous -I/O. - -## DCAP collateral modes - -Every `AttestationVerifier` has a PCCS collateral source configured through -`AttestationVerifierBuilder::with_pccs_mode`. The default is -`PccsMode::Remote`. - -- `Remote` keeps no internal cache and fetches collateral from the configured - endpoint for every asynchronous verification. -- `Lazy` starts with an empty internal cache and fetches collateral on demand. +with the `Prewarmed` policy also requires an active runtime because pre-warming +starts immediately; constructing it with the `Passthrough` or `OnDemand` +policy does not itself spawn a task. + +Synchronous verification requires a cached policy (`OnDemand` or `Prewarmed`) +with the required collateral already cached. Cache misses and expired entries +may start Tokio-backed background refresh tasks. `Passthrough` cannot be used +for synchronous verification because fetching collateral requires +asynchronous I/O. + +## DCAP collateral configuration + +Every `AttestationVerifier` has an independent collateral source and cache +policy configured through `AttestationVerifierBuilder::with_collateral_source` +and `AttestationVerifierBuilder::with_cache_policy`. The default is anonymous +Intel PCS with `CachePolicy::Passthrough`. + +- `Passthrough` keeps no internal cache and fetches collateral from the + configured endpoint for every asynchronous verification. +- `OnDemand` starts with an empty internal cache and fetches collateral on + demand. - `Prewarmed` immediately starts discovering and caching available TDX collateral, then refreshes cached entries before expiry. -Use `with_pccs_url` to select an Intel PCS or PCCS-compatible endpoint. Without -an explicit URL, the endpoint defaults to Intel PCS. +Use `CollateralSource::IntelPcs` with an optional subscription key, or +`CollateralSource::Pccs` with the URL of a compatible service. -`AttestationVerifier::ready()` waits for initial work only in `Prewarmed` -mode. It returns immediately for `Remote` and `Lazy`. A successful return in -`Remote` or `Lazy` does not mean later verification will avoid fetching -collateral. In `Prewarmed` mode it means pre-warm bootstrap completed, but +`AttestationVerifier::ready()` waits for initial work only with `Prewarmed`. +It returns immediately for `Passthrough` and `OnDemand`. A successful +return with either policy does not mean later verification will avoid fetching +collateral. With `Prewarmed` it means pre-warm bootstrap completed, but individual collateral fetches can still have failed. ## Feature flags diff --git a/crates/attestation/src/azure/verify.rs b/crates/attestation/src/azure/verify.rs index 479ca87..234196a 100644 --- a/crates/attestation/src/azure/verify.rs +++ b/crates/attestation/src/azure/verify.rs @@ -62,8 +62,8 @@ pub async fn verify_azure_attestation( /// /// This relies on having DCAP collateral already present in the cache /// -/// [`PccsMode::Remote`](pccs::PccsMode::Remote) is not supported because -/// fetching collateral requires asynchronous I/O. +/// [`CachePolicy::Passthrough`](pccs::CachePolicy::Passthrough) is not +/// supported because fetching collateral requires asynchronous I/O. /// /// If possible, prefer the async version pub fn verify_azure_attestation_sync( @@ -407,7 +407,10 @@ mod tests { let err = verify_azure_attestation( input.clone(), [0; 64], - Pccs::new(None, pccs::PccsMode::Remote), + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::Passthrough, + ), false, ) .await @@ -417,7 +420,10 @@ mod tests { let err = verify_azure_attestation_sync( input.clone(), [0; 64], - Pccs::new(None, pccs::PccsMode::Lazy), + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::OnDemand, + ), false, ) .unwrap_err(); @@ -426,7 +432,10 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp( input.clone(), [0; 64], - Pccs::new(None, pccs::PccsMode::Remote), + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::Passthrough, + ), None, 0, false, @@ -438,7 +447,10 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp_sync( input, [0; 64], - Pccs::new(None, pccs::PccsMode::Lazy), + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::OnDemand, + ), None, 0, false, @@ -492,7 +504,10 @@ mod tests { } = verify_azure_attestation_with_given_timestamp( attestation_json.clone(), [0; 64], - Pccs::new(None, pccs::PccsMode::Remote), + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::Passthrough, + ), Some(fixture_collateral.clone()), now, false, @@ -507,7 +522,10 @@ mod tests { } = verify_azure_attestation_with_given_timestamp_sync( attestation_json, [0; 64], - Pccs::new(None, pccs::PccsMode::Lazy), + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::OnDemand, + ), Some(fixture_collateral.clone()), now, false, @@ -543,7 +561,10 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp( attestation_json, expected_input_data, - Pccs::new(None, pccs::PccsMode::Remote), + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::Passthrough, + ), Some(collateral), now, false, diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index 2946d08..98f96e4 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -14,7 +14,7 @@ use dcap_qvl::{ #[cfg(any(test, feature = "mock"))] use mock_tdx::generate_mock_tdx_quote; #[cfg(test)] -use pccs::PccsMode; +use pccs::{CachePolicy, CollateralSource}; use pccs::{Pccs, PccsError}; use thiserror::Error; @@ -60,7 +60,8 @@ pub async fn verify_dcap_attestation( /// /// This relies on having DCAP collateral already present in the cache /// -/// [`PccsMode::Remote`](pccs::PccsMode::Remote) is not supported because +/// [`CachePolicy::Passthrough`](pccs::CachePolicy::Passthrough) is not +/// supported because /// fetching collateral requires asynchronous I/O. /// /// If possible, prefer the async version @@ -87,8 +88,8 @@ pub fn verify_dcap_attestation_sync( /// /// This relies on having DCAP collateral already present in the cache /// -/// [`PccsMode::Remote`](pccs::PccsMode::Remote) is not supported unless -/// `collateral` is provided. +/// [`CachePolicy::Passthrough`](pccs::CachePolicy::Passthrough) is not +/// supported unless `collateral` is provided. /// /// If possible, prefer the async version pub fn verify_dcap_attestation_with_timestamp_sync( @@ -228,7 +229,7 @@ pub async fn verify_dcap_attestation( let fmspc = hex::encode_upper(quote_fmspc("e)?); let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); - let collateral = if pccs.is_remote() { + let collateral = if pccs.is_passthrough() { mock_tdx::mock_collateral() } else { let (collateral, _is_fresh) = pccs.get_collateral(fmspc, ca, now).await?; @@ -263,7 +264,7 @@ pub fn verify_dcap_attestation_sync( let fmspc = hex::encode_upper(quote_fmspc("e)?); let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); - let collateral = if pccs.is_remote() { + let collateral = if pccs.is_passthrough() { mock_tdx::mock_collateral() } else { pccs.get_collateral_sync(fmspc, ca, now)? @@ -372,7 +373,10 @@ mod tests { 161, 136, 37, 136, 57, 29, 25, 86, 182, 246, 70, 106, 216, 184, 220, 205, 85, 245, 114, 33, 173, 129, 180, 32, 247, 70, 250, 141, 176, 248, 99, 125, ], - Pccs::new(None, PccsMode::Remote), + Pccs::new( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::Passthrough, + ), Some(fixture_collateral.clone()), now, false, @@ -389,7 +393,10 @@ mod tests { 161, 136, 37, 136, 57, 29, 25, 86, 182, 246, 70, 106, 216, 184, 220, 205, 85, 245, 114, 33, 173, 129, 180, 32, 247, 70, 250, 141, 176, 248, 99, 125, ], - Pccs::new(None, PccsMode::Lazy), + Pccs::new( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::OnDemand, + ), Some(fixture_collateral.clone()), now, false, @@ -433,7 +440,10 @@ mod tests { 248, 104, 204, 187, 101, 49, 203, 40, 218, 185, 220, 228, 119, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ], - Pccs::new(None, PccsMode::Remote), + Pccs::new( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::Passthrough, + ), Some(collateral), now, true, @@ -450,7 +460,10 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock_pcs.base_url.clone()), PccsMode::Lazy); + let pccs = Pccs::new( + CollateralSource::Pccs { url: mock_pcs.base_url.clone() }, + CachePolicy::OnDemand, + ); let expected_input_data = [0xA5; 64]; let quote = create_dcap_attestation(expected_input_data).unwrap(); diff --git a/crates/attestation/src/gcp/firmware.rs b/crates/attestation/src/gcp/firmware.rs index 1e34b3f..e57b2e1 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -160,7 +160,10 @@ mod tests { verify_dcap_attestation_with_given_timestamp( attestation_bytes.to_vec(), expected_input_data, - pccs::Pccs::new(None, pccs::PccsMode::Remote), + pccs::Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::Passthrough, + ), Some(collateral), GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, false, diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 0f0c563..6d0675a 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -26,7 +26,7 @@ pub use attest_types::{AttestationEvidence, PlatformMetadata}; pub use dcap_qvl::QuoteCollateralV3; use measurements::{ExpectedMeasurements, MultiMeasurements}; use parity_scale_codec::{Decode, Encode}; -pub use pccs::PccsMode; +pub use pccs::{CachePolicy, CollateralSource}; use pccs::{Pccs, PccsError}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -451,12 +451,12 @@ impl MeasurementPolicyState { pub struct AttestationVerifierBuilder { /// The measurement policy with accepted values and attestation types measurement_policy: MeasurementPolicy, - /// How DCAP collateral is fetched and cached - pccs_mode: PccsMode, + /// Service from which DCAP collateral is fetched + collateral_source: CollateralSource, + /// Whether and how DCAP collateral is cached in process + cache_policy: CachePolicy, /// A dynamic measurement policy file or URL dynamic_measurement_policy: Option, - /// Collateral endpoint; defaults to Intel PCS - pccs_url: Option, dump_dcap_quotes: bool, /// Whether to override outdated TCB when on Azure override_azure_outdated_tcb: bool, @@ -470,7 +470,7 @@ impl AttestationVerifierBuilder { ))), dump_dcap_quotes: self.dump_dcap_quotes, override_azure_outdated_tcb: self.override_azure_outdated_tcb, - internal_pccs: Pccs::new(self.pccs_url, self.pccs_mode), + internal_pccs: Pccs::new(self.collateral_source, self.cache_policy), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), dynamic_measurement_policy: self.dynamic_measurement_policy, @@ -496,17 +496,19 @@ impl AttestationVerifierBuilder { self } - /// Configures how DCAP collateral is fetched and cached. + /// Configures the service from which DCAP collateral is fetched. /// - /// The default is [`PccsMode::Remote`]. - pub fn with_pccs_mode(mut self, pccs_mode: PccsMode) -> Self { - self.pccs_mode = pccs_mode; + /// The default is anonymous Intel PCS. + pub fn with_collateral_source(mut self, collateral_source: CollateralSource) -> Self { + self.collateral_source = collateral_source; self } - /// Sets the Intel PCS or PCCS endpoint used to fetch collateral. - pub fn with_pccs_url(mut self, pccs_url: String) -> Self { - self.pccs_url = Some(pccs_url); + /// Configures whether and how DCAP collateral is cached in process. + /// + /// The default is [`CachePolicy::Passthrough`]. + pub fn with_cache_policy(mut self, cache_policy: CachePolicy) -> Self { + self.cache_policy = cache_policy; self } @@ -526,8 +528,8 @@ impl AttestationVerifier { pub fn builder(measurement_policy: MeasurementPolicy) -> AttestationVerifierBuilder { AttestationVerifierBuilder { measurement_policy, - pccs_mode: PccsMode::Remote, - pccs_url: None, + collateral_source: CollateralSource::IntelPcs { subscription_key: None }, + cache_policy: CachePolicy::Passthrough, dump_dcap_quotes: false, override_azure_outdated_tcb: false, dynamic_measurement_policy: None, @@ -543,7 +545,10 @@ impl AttestationVerifier { ))), dump_dcap_quotes: false, override_azure_outdated_tcb: false, - internal_pccs: Pccs::new(None, PccsMode::Remote), + internal_pccs: Pccs::new( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::Passthrough, + ), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), dynamic_measurement_policy: None, @@ -559,7 +564,10 @@ impl AttestationVerifier { ))), dump_dcap_quotes: false, override_azure_outdated_tcb: false, - internal_pccs: Pccs::new(None, PccsMode::Remote), + internal_pccs: Pccs::new( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::Passthrough, + ), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), dynamic_measurement_policy: None, @@ -578,7 +586,10 @@ impl AttestationVerifier { ))), dump_dcap_quotes: false, override_azure_outdated_tcb: false, - internal_pccs: Pccs::new(Some(pccs_url), PccsMode::Prewarmed), + internal_pccs: Pccs::new( + CollateralSource::Pccs { url: pccs_url }, + CachePolicy::Prewarmed, + ), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), dynamic_measurement_policy: None, @@ -587,15 +598,15 @@ impl AttestationVerifier { /// Waits for initial PCCS pre-warming when configured. /// - /// In [`PccsMode::Prewarmed`], this waits for the initial pre-warm and - /// returns an error if pre-warm bootstrap failed. In - /// [`PccsMode::Remote`] and [`PccsMode::Lazy`], there is no initial - /// pre-warm to await, so this returns immediately. + /// With [`CachePolicy::Prewarmed`], this waits for the initial pre-warm + /// and returns an error if pre-warm bootstrap failed. With + /// [`CachePolicy::Passthrough`] and [`CachePolicy::OnDemand`], there is + /// no initial pre-warm to await, so this returns immediately. /// - /// Only `Prewarmed` mode is intended to avoid on-demand collateral - /// fetches during verification. Initial pre-warming can complete even - /// if individual collateral fetches failed, so it is not an - /// absolute guarantee that verification will avoid a fetch. + /// Only the `Prewarmed` policy is intended to avoid on-demand + /// collateral fetches during verification. Initial pre-warming can + /// complete even if individual collateral fetches failed, so it is + /// not an absolute guarantee that verification will avoid a fetch. pub async fn ready(&self) -> Result<(), AttestationError> { // If pre-warm is disabled we are ready match self.internal_pccs.ready().await { @@ -1229,7 +1240,7 @@ mod tests { #[test] fn measurement_policy_can_be_updated_between_verification_attempts() { let verifier = AttestationVerifier::builder(MeasurementPolicy::tdx()) - .with_pccs_mode(PccsMode::Remote) + .with_cache_policy(CachePolicy::Passthrough) .build(); let verifier_clone = verifier.clone(); let message = AttestationExchangeMessage::without_attestation(); @@ -1267,7 +1278,7 @@ mod tests { let initial_policy = MeasurementPolicy::from_file(policy_path.clone()).await.unwrap(); let verifier = AttestationVerifier::builder(initial_policy) - .with_pccs_mode(PccsMode::Remote) + .with_cache_policy(CachePolicy::Passthrough) .with_dynamic_measurements_file_or_url(policy_path.to_string_lossy().into_owned()) .build(); @@ -1303,7 +1314,7 @@ mod tests { 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_pccs_mode(PccsMode::Remote) + .with_cache_policy(CachePolicy::Passthrough) .with_dynamic_measurements_file_or_url(policy_source.clone()) .build(); let measurements = measurements::mock_dcap_measurements(); @@ -1339,7 +1350,7 @@ mod tests { let initial_policy = MeasurementPolicy::from_file_or_url_sync(policy_source.clone()).unwrap(); let verifier = AttestationVerifier::builder(initial_policy) - .with_pccs_mode(PccsMode::Remote) + .with_cache_policy(CachePolicy::Passthrough) .with_dynamic_measurements_file_or_url(policy_source.clone()) .build(); let measurements = measurements::mock_dcap_measurements(); @@ -1374,8 +1385,10 @@ mod tests { 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) - .with_pccs_mode(PccsMode::Prewarmed) - .with_pccs_url(mock_pcs_server.base_url.clone()) + .with_collateral_source(CollateralSource::Pccs { + url: mock_pcs_server.base_url.clone(), + }) + .with_cache_policy(CachePolicy::Prewarmed) .with_dynamic_measurements_file_or_url(policy_source) .build(); verifier.ready().await.unwrap(); diff --git a/crates/attested-tls/src/lib.rs b/crates/attested-tls/src/lib.rs index 77fa1a2..46a9b19 100644 --- a/crates/attested-tls/src/lib.rs +++ b/crates/attested-tls/src/lib.rs @@ -1742,7 +1742,7 @@ mod tests { let dynamic_verifier = AttestationVerifier::builder( attestation::measurements::MeasurementPolicy::expect_none(), ) - .with_pccs_mode(attestation::PccsMode::Remote) + .with_cache_policy(attestation::CachePolicy::Passthrough) .with_dynamic_measurements_file_or_url("measurements.json".into()) .build(); diff --git a/crates/mock-tdx/src/mock_pcs.rs b/crates/mock-tdx/src/mock_pcs.rs index c9fba83..234b91d 100644 --- a/crates/mock-tdx/src/mock_pcs.rs +++ b/crates/mock-tdx/src/mock_pcs.rs @@ -3,6 +3,7 @@ use std::{ net::SocketAddr, sync::{ Arc, + Mutex, atomic::{AtomicUsize, Ordering}, }, }; @@ -11,6 +12,7 @@ use axum::{ Json, Router, extract::{Query, State}, + http::HeaderMap, response::IntoResponse, routing::get, }; @@ -59,6 +61,7 @@ pub struct MockPcsServer { _task: JoinHandle<()>, tcb_calls: Arc, qe_calls: Arc, + subscription_keys: Arc>>>, } impl Drop for MockPcsServer { @@ -77,6 +80,11 @@ impl MockPcsServer { pub fn qe_call_count(&self) -> usize { self.qe_calls.load(Ordering::SeqCst) } + + /// Returns the Intel PCS subscription key observed on each request. + pub fn subscription_keys(&self) -> Vec> { + self.subscription_keys.lock().unwrap().clone() + } } /// Shared state served by the mock PCS routes @@ -92,6 +100,7 @@ struct MockPcsState { qe_next_update: String, refreshed_tcb_next_update: Option, refreshed_qe_next_update: Option, + subscription_keys: Arc>>>, pck_crl: Vec, pck_crl_issuer_chain: String, tcb_issuer_chain: String, @@ -115,6 +124,7 @@ pub async fn spawn_mock_pcs_server( let tcb_calls = Arc::new(AtomicUsize::new(0)); let qe_calls = Arc::new(AtomicUsize::new(0)); + let subscription_keys = Arc::new(Mutex::new(Vec::new())); let state = Arc::new(MockPcsState { fmspc: tcb_info["fmspc"].as_str().ok_or("mock collateral missing fmspc")?.to_string(), include_fmspcs_listing: config.include_fmspcs_listing, @@ -126,6 +136,7 @@ pub async fn spawn_mock_pcs_server( qe_next_update: config.qe_next_update, refreshed_tcb_next_update: config.refreshed_tcb_next_update, refreshed_qe_next_update: config.refreshed_qe_next_update, + subscription_keys: subscription_keys.clone(), pck_crl: base_collateral.pck_crl, pck_crl_issuer_chain: urlencoding::encode(&base_collateral.pck_crl_issuer_chain).into(), tcb_issuer_chain: urlencoding::encode(&base_collateral.tcb_info_issuer_chain).into(), @@ -149,14 +160,22 @@ pub async fn spawn_mock_pcs_server( axum::serve(listener, app).await.unwrap(); }); - Ok(MockPcsServer { base_url: format!("http://{addr}"), _task: task, tcb_calls, qe_calls }) + Ok(MockPcsServer { + base_url: format!("http://{addr}"), + _task: task, + tcb_calls, + qe_calls, + subscription_keys, + }) } /// Serves the mock PCK CRL and issuer chain async fn mock_pck_crl_handler( State(state): State>, + headers: HeaderMap, Query(params): Query>, ) -> impl IntoResponse { + record_subscription_key(&headers, &state); assert!( matches!(params.get("ca").map(String::as_str), Some("processor") | Some("platform")), "unexpected ca query value for pckcrl" @@ -166,7 +185,11 @@ async fn mock_pck_crl_handler( } /// Serves the optional FMSPC listing used by PCCS prewarm tests -async fn mock_fmspcs_handler(State(state): State>) -> impl IntoResponse { +async fn mock_fmspcs_handler( + State(state): State>, + headers: HeaderMap, +) -> impl IntoResponse { + record_subscription_key(&headers, &state); if state.include_fmspcs_listing { Json(json!([{ "fmspc": state.fmspc, @@ -180,8 +203,10 @@ async fn mock_fmspcs_handler(State(state): State>) -> impl Int /// Serves signed TCB info with configurable refresh behavior async fn mock_tcb_handler( State(state): State>, + headers: HeaderMap, Query(params): Query>, ) -> impl IntoResponse { + record_subscription_key(&headers, &state); assert_eq!(params.get("fmspc"), Some(&state.fmspc)); let call_number = state.tcb_calls.fetch_add(1, Ordering::SeqCst) + 1; let mut tcb_info = state.base_tcb_info.clone(); @@ -203,8 +228,10 @@ async fn mock_tcb_handler( /// Serves signed QE identity collateral with configurable refresh behavior async fn mock_qe_identity_handler( State(state): State>, + headers: HeaderMap, Query(params): Query>, ) -> impl IntoResponse { + record_subscription_key(&headers, &state); assert_eq!(params.get("update"), Some(&"standard".to_string())); let call_number = state.qe_calls.fetch_add(1, Ordering::SeqCst) + 1; let mut qe_identity = state.base_qe_identity.clone(); @@ -224,6 +251,17 @@ async fn mock_qe_identity_handler( } /// Serves the root CA CRL expected by the PCS client -async fn mock_root_ca_crl_handler(State(state): State>) -> impl IntoResponse { +async fn mock_root_ca_crl_handler( + State(state): State>, + headers: HeaderMap, +) -> impl IntoResponse { + record_subscription_key(&headers, &state); state.root_ca_crl_hex.clone() } + +fn record_subscription_key(headers: &HeaderMap, state: &MockPcsState) { + let subscription_key = headers + .get("Ocp-Apim-Subscription-Key") + .map(|value| value.to_str().expect("subscription key header should be ASCII").to_string()); + state.subscription_keys.lock().unwrap().push(subscription_key); +} diff --git a/crates/pccs/README.md b/crates/pccs/README.md index b902d79..1d2c2be 100644 --- a/crates/pccs/README.md +++ b/crates/pccs/README.md @@ -19,48 +19,61 @@ reference PCCS server alongside services that verify quotes. For Intel's terminology and architecture, see the Intel documentation for the [Provisioning Certificate Caching Service (PCCS)](https://cc-enabling.trustedservices.intel.com/intel-sgx-tdx-pccs/01/introduction/). -## Modes +## Collateral source and cache policy -Every `Pccs` has a [`PccsMode`](src/lib.rs): +Every `Pccs` has a [`CollateralSource`](src/lib.rs) and an independent +[`CachePolicy`](src/lib.rs). `CollateralSource::IntelPcs` uses Intel's canonical +endpoint and accepts an optional subscription key to lift anonymous rate +limits. `CollateralSource::Pccs` accepts the URL of any compatible service. -- `Remote` keeps no internal cache. Every `get_collateral()` call fetches from - the configured endpoint. `get_collateral_sync()` returns `CacheDisabled` - because it cannot perform asynchronous network I/O. -- `Lazy` starts with an empty cache. Asynchronous cache misses are fetched +The cache policies are: + +- `Passthrough` keeps no internal cache. Every `get_collateral()` call fetches + from the configured endpoint. `get_collateral_sync()` returns + `CacheDisabled` because it cannot perform asynchronous network I/O. +- `OnDemand` starts with an empty cache. Asynchronous cache misses are fetched immediately; synchronous misses return an error and start a background fetch for a later attempt. - `Prewarmed` starts the same cache and immediately begins pre-warming it with discovered TDX collateral. Call `ready()` to wait for that initial work. -The endpoint passed to `Pccs::new` may be Intel PCS or another PCCS-compatible -service. Passing `None` uses [`PCS_URL`](src/lib.rs), the Intel PCS default. - ```rust,no_run -use pccs::{Pccs, PccsMode}; +use pccs::{CachePolicy, CollateralSource, Pccs}; #[tokio::main] async fn main() -> Result<(), pccs::PccsError> { - let _remote = Pccs::new(None, PccsMode::Remote); - let _lazy = Pccs::new(Some("https://pccs.example".into()), PccsMode::Lazy); - let prewarmed = Pccs::new(None, PccsMode::Prewarmed); + let subscription_key = std::env::var("INTEL_PCS_SUBSCRIPTION_KEY").ok(); + let _passthrough = Pccs::new( + CollateralSource::IntelPcs { subscription_key }, + CachePolicy::Passthrough, + ); + let _on_demand = Pccs::new( + CollateralSource::Pccs { url: "https://pccs.example".into() }, + CachePolicy::OnDemand, + ); + let prewarmed = Pccs::new( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::Prewarmed, + ); let _summary = prewarmed.ready().await?; Ok(()) } ``` -`ready()` only waits for `Prewarmed` mode. It returns `PrewarmDisabled` for -`Remote` and `Lazy`. A successful pre-warm result includes failure counters; -it does not guarantee that every possible collateral item was cached. +`ready()` only waits for the `Prewarmed` policy. It returns `PrewarmDisabled` +for `Passthrough` and `OnDemand`. A successful pre-warm result includes failure +counters; it does not guarantee that every possible collateral item was +cached. ## Runtime Requirements Asynchronous collateral fetching requires a Tokio runtime. Constructing a `Prewarmed` instance also requires an active runtime because it immediately -spawns the initial pre-warm task. Constructing `Remote` or `Lazy` does not -itself spawn a task. +spawns the initial pre-warm task. Constructing `Passthrough` or `OnDemand` +does not itself spawn a task. -`get_collateral_sync()` is available only with a cache (`Lazy` or +`get_collateral_sync()` is available only with a cache (`OnDemand` or `Prewarmed`). A cache miss or expired entry may spawn a Tokio background task, so applications that can encounter either condition must have an active runtime. diff --git a/crates/pccs/examples/intel_pcs.rs b/crates/pccs/examples/intel_pcs.rs index 0a07b39..47d64a0 100644 --- a/crates/pccs/examples/intel_pcs.rs +++ b/crates/pccs/examples/intel_pcs.rs @@ -1,7 +1,7 @@ //! Demonstrates setting up a PCCS cache using Intel PCS use std::time::Instant; -use pccs::{PCS_URL, Pccs, PccsMode}; +use pccs::{CachePolicy, CollateralSource, PCS_URL, Pccs}; use tracing::info; use tracing_subscriber::{EnvFilter, fmt}; @@ -18,7 +18,8 @@ async fn main() -> Result<(), pccs::PccsError> { info!(pcs_url = PCS_URL, "Starting PCCS with Intel PCS"); - let pccs = Pccs::new(None, PccsMode::Prewarmed); + let subscription_key = std::env::var("INTEL_PCS_SUBSCRIPTION_KEY").ok(); + let pccs = Pccs::new(CollateralSource::IntelPcs { subscription_key }, CachePolicy::Prewarmed); let started_at = Instant::now(); let summary = pccs.ready().await?; let elapsed = started_at.elapsed().as_secs_f64(); diff --git a/crates/pccs/src/lib.rs b/crates/pccs/src/lib.rs index 73196ef..d757061 100644 --- a/crates/pccs/src/lib.rs +++ b/crates/pccs/src/lib.rs @@ -18,6 +18,11 @@ use dcap_qvl::{ http::{HttpClient as DcapHttpClient, HttpResponse}, tcb_info::TcbInfo, }; +use reqwest::{ + Url, + header::{HeaderValue, InvalidHeaderValue}, + redirect::Policy, +}; use thiserror::Error; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::{ @@ -40,6 +45,8 @@ fn install_test_crypto_provider() { /// For fetching collateral directly from Intel pub const PCS_URL: &str = "https://api.trustedservices.intel.com"; +/// Header used to authenticate requests to Intel PCS. +const PCS_SUBSCRIPTION_KEY_HEADER: &str = "Ocp-Apim-Subscription-Key"; /// How long before expiry to refresh collateral const REFRESH_MARGIN_SECS: i64 = 300; /// How long to wait before retrying when failing to fetch collateral @@ -50,21 +57,38 @@ const PCCS_HTTP_TIMEOUT_SECS: u64 = 180; /// pre-warm const STARTUP_PREWARM_CONCURRENCY: usize = 8; -/// How PCCS obtains and stores DCAP collateral. -#[derive(Clone, Debug)] -pub enum PccsMode { - /// Fetch collateral from the configured endpoint for every asynchronous - /// lookup, without keeping an internal cache. - /// - /// Synchronous lookups are unavailable in this mode because fetching - /// collateral requires asynchronous I/O. - Remote, - /// Start pre-warming an internal cache when [`Pccs`] is constructed. - /// - /// Call [`Pccs::ready`] to wait for the initial pre-warm to complete. +/// Service from which DCAP collateral is fetched. +#[derive(Clone, PartialEq, Eq)] +pub enum CollateralSource { + /// Intel PCS. A subscription key lifts the anonymous rate limit. + IntelPcs { subscription_key: Option }, + /// Any PCCS-compatible service, such as a self-hosted instance or local + /// sidecar. + Pccs { url: String }, +} + +impl std::fmt::Debug for CollateralSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::IntelPcs { subscription_key } => f + .debug_struct("IntelPcs") + .field("subscription_key_configured", &subscription_key.is_some()) + .finish(), + Self::Pccs { url } => f.debug_struct("Pccs").field("url", url).finish(), + } + } +} + +/// Whether and how [`Pccs`] keeps an in-process collateral cache. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CachePolicy { + /// No in-process cache. Every asynchronous lookup goes directly to the + /// configured endpoint. + Passthrough, + /// Start with an empty cache and fill it on demand. + OnDemand, + /// Fill the cache at construction, then refresh it proactively. Prewarmed, - /// Start with an empty internal cache and fetch collateral on demand. - Lazy, } type SharedCollateralClient = CollateralClient; @@ -82,7 +106,7 @@ pub struct Pccs { /// The URL of the service used to fetch collateral (PCS / PCCS) url: String, /// HTTP client used for FMSPC fetches - http_client: reqwest::Client, + http_client: SharedReqwestHttp, /// HTTP client for collateral fetches collateral_client: SharedCollateralClient, /// An internal cache if configured @@ -110,25 +134,38 @@ impl std::fmt::Debug for Pccs { } impl Pccs { - /// Creates a collateral source in the requested mode. + /// Creates a collateral client with the requested source and cache + /// policy. /// - /// The endpoint defaults to Intel PCS when `url` is `None`. - /// Constructing [`PccsMode::Prewarmed`] immediately spawns its initial - /// fetch task and therefore requires an active Tokio runtime. - pub fn new(url: Option, mode: PccsMode) -> Self { + /// Constructing [`CachePolicy::Prewarmed`] immediately spawns its + /// initial fetch task and therefore requires an active Tokio + /// runtime. + pub fn new(source: CollateralSource, cache: CachePolicy) -> Self { + let (url, subscription_key) = match source { + CollateralSource::IntelPcs { subscription_key } => { + (PCS_URL.to_string(), subscription_key) + } + CollateralSource::Pccs { url } => (url, None), + }; + Self::new_with_endpoint(url, subscription_key, cache) + } + + fn new_with_endpoint( + url: String, + subscription_key: Option, + cache: CachePolicy, + ) -> Self { let url = url - .unwrap_or(PCS_URL.to_string()) .trim_end_matches('/') .trim_end_matches("/sgx/certification/v4") .trim_end_matches("/tdx/certification/v4") .to_string(); - let http_client = reqwest::Client::new(); - let collateral_client = - CollateralClient::new(SharedReqwestHttp { client: http_client.clone() }, url.clone()); + let http_client = SharedReqwestHttp::new(&url, subscription_key); + let collateral_client = CollateralClient::new(http_client.clone(), url.clone()); - match mode { - PccsMode::Remote => Self { url, http_client, collateral_client, inner: None }, - PccsMode::Lazy => Self { + match cache { + CachePolicy::Passthrough => Self { url, http_client, collateral_client, inner: None }, + CachePolicy::OnDemand => Self { url, http_client, collateral_client, @@ -139,7 +176,7 @@ impl Pccs { prewarm_outcome_tx: None, }), }, - PccsMode::Prewarmed => { + CachePolicy::Prewarmed => { let (prewarm_outcome_tx, _) = watch::channel(None); let pccs = Self { @@ -168,14 +205,15 @@ impl Pccs { /// Returns whether this PCCS fetches collateral directly without an /// internal cache. - pub fn is_remote(&self) -> bool { + pub fn is_passthrough(&self) -> bool { self.inner.is_none() } /// Waits for the initial pre-warm to complete. /// - /// Returns [`PccsError::PrewarmDisabled`] for [`PccsMode::Remote`] and - /// [`PccsMode::Lazy`]. A successful result means the initial pre-warm + /// Returns [`PccsError::PrewarmDisabled`] for + /// [`CachePolicy::Passthrough`] and [`CachePolicy::OnDemand`]. A + /// successful result means the initial pre-warm /// completed; individual collateral fetches may still have failed, as /// reported in [`PrewarmSummary`]. pub async fn ready(&self) -> Result { @@ -199,10 +237,10 @@ impl Pccs { } /// Fetches collateral, using the internal cache when configured. - /// Remote mode always fetches from the configured endpoint. + /// Passthrough always fetches from the configured endpoint. /// /// Returns collateral together with a flag indicating whether it is - /// freshly fetched (`true`) or from the cache (`false`). Remote mode + /// freshly fetched (`true`) or from the cache (`false`). Passthrough /// always returns `true`. pub async fn get_collateral( &self, @@ -252,7 +290,8 @@ impl Pccs { /// A synchronous method to get collateral from the cache. /// - /// In [`PccsMode::Remote`], this returns [`PccsError::CacheDisabled`] + /// With [`CachePolicy::Passthrough`], this returns + /// [`PccsError::CacheDisabled`] /// because a synchronous call cannot perform the required asynchronous /// fetch. /// @@ -515,12 +554,7 @@ impl Pccs { } let url = format!("{}/sgx/certification/v4/fmspcs", self.url); - let response = self - .http_client - .get(&url) - .timeout(Duration::from_secs(PCCS_HTTP_TIMEOUT_SECS)) - .send() - .await?; + let response = self.http_client.request(&url)?.send().await?; if !response.status().is_success() { return Err(PccsError::FmspcFetch(response.status())); } @@ -535,16 +569,76 @@ impl Pccs { #[derive(Clone)] struct SharedReqwestHttp { client: reqwest::Client, + authenticated_client: Option, + subscription_key_origin: Option, + subscription_key: Option, +} + +impl SharedReqwestHttp { + fn new(base_url: &str, subscription_key: Option) -> Self { + let subscription_key_origin = + subscription_key.as_ref().and_then(|_| Url::parse(base_url).ok()); + let authenticated_client = subscription_key.as_ref().map(|_| { + reqwest::Client::builder() + .redirect(Policy::custom(|attempt| { + let same_origin_redirect = attempt + .previous() + .last() + .is_some_and(|previous| same_origin(previous, attempt.url())); + if !same_origin_redirect { + attempt.stop() + } else if attempt.previous().len() > 10 { + attempt.error("too many redirects") + } else { + attempt.follow() + } + })) + .build() + .expect("failed to build authenticated HTTP client") + }); + + Self { + client: reqwest::Client::new(), + authenticated_client, + subscription_key_origin, + subscription_key, + } + } + + fn request(&self, url: &str) -> Result { + let Some(subscription_key) = &self.subscription_key else { + return Ok(self.client.get(url).timeout(Duration::from_secs(PCCS_HTTP_TIMEOUT_SECS))); + }; + let authenticated_request = Url::parse(url) + .ok() + .zip(self.subscription_key_origin.as_ref()) + .is_some_and(|(url, origin)| same_origin(&url, origin)); + if !authenticated_request { + return Ok(self.client.get(url).timeout(Duration::from_secs(PCCS_HTTP_TIMEOUT_SECS))); + } + + let request = self + .authenticated_client + .as_ref() + .expect("subscription key client should be configured") + .get(url) + .timeout(Duration::from_secs(PCCS_HTTP_TIMEOUT_SECS)); + + let mut header = HeaderValue::from_bytes(subscription_key.as_bytes())?; + header.set_sensitive(true); + Ok(request.header(PCS_SUBSCRIPTION_KEY_HEADER, header)) + } +} + +fn same_origin(left: &Url, right: &Url) -> bool { + left.scheme() == right.scheme() && + left.host_str() == right.host_str() && + left.port_or_known_default() == right.port_or_known_default() } impl DcapHttpClient for SharedReqwestHttp { async fn get(&self, url: &str) -> anyhow::Result { - let resp = self - .client - .get(url) - .timeout(Duration::from_secs(PCCS_HTTP_TIMEOUT_SECS)) - .send() - .await?; + let resp = self.request(url)?.send().await?; let status = resp.status().as_u16(); let headers = resp .headers() @@ -873,6 +967,8 @@ pub enum PccsError { SystemTime(#[from] std::time::SystemTimeError), #[error("HTTP client: {0}")] Reqwest(#[from] reqwest::Error), + #[error("Invalid Intel PCS subscription key: {0}")] + InvalidSubscriptionKey(#[from] InvalidHeaderValue), #[error( "no process-level rustls crypto provider is installed; install one at application \ startup, e.g. `rustls::crypto::aws_lc_rs::default_provider().install_default()` — \ @@ -901,7 +997,17 @@ pub enum PccsError { #[cfg(test)] mod tests { - use mock_tdx::{MockPcsConfig, mock_collateral, spawn_mock_pcs_server}; + use std::{ + io::{Read, Write}, + net::TcpListener, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + thread, + }; + + use mock_tdx::{MockPcsConfig, MockPcsServer, mock_collateral, spawn_mock_pcs_server}; use tokio::time::Duration; use super::*; @@ -912,6 +1018,148 @@ mod tests { tcb_info.fmspc } + fn assert_observed_subscription_keys(mock: &MockPcsServer, expected: Option<&str>) { + let observed = mock.subscription_keys(); + assert!(!observed.is_empty(), "expected the mock PCS to receive requests"); + assert!( + observed.iter().all(|key| key.as_deref() == expected), + "observed keys: {observed:?}" + ); + } + + #[test] + fn intel_pcs_source_uses_canonical_url_and_redacts_key() { + let source = + CollateralSource::IntelPcs { subscription_key: Some("super-secret-key".to_string()) }; + let debug = format!("{source:?}"); + assert!(debug.contains("subscription_key_configured: true")); + assert!(!debug.contains("super-secret-key")); + + let pccs = Pccs::new(source, CachePolicy::Passthrough); + assert_eq!(pccs.url, PCS_URL); + assert_eq!(pccs.http_client.subscription_key.as_deref(), Some("super-secret-key")); + } + + #[test] + fn subscription_key_is_scoped_to_the_configured_origin() { + let pccs = Pccs::new( + CollateralSource::IntelPcs { subscription_key: Some("origin-key".to_string()) }, + CachePolicy::Passthrough, + ); + + let pcs_request = pccs + .http_client + .request(&format!("{PCS_URL}/tdx/certification/v4/tcb")) + .unwrap() + .build() + .unwrap(); + assert_eq!(pcs_request.headers().get(PCS_SUBSCRIPTION_KEY_HEADER).unwrap(), "origin-key"); + + for url in [ + "https://certificates.trustedservices.intel.com/IntelSGXRootCA.der", + "https://api.trustedservices.intel.com.attacker.example/", + "http://api.trustedservices.intel.com/", + "https://api.trustedservices.intel.com:444/", + ] { + let request = pccs.http_client.request(url).unwrap().build().unwrap(); + assert!( + request.headers().get(PCS_SUBSCRIPTION_KEY_HEADER).is_none(), + "subscription key attached to off-origin URL: {url}" + ); + } + } + + #[tokio::test] + async fn authenticated_client_does_not_follow_cross_origin_redirects() { + let redirect_listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let redirect_addr = redirect_listener.local_addr().unwrap(); + let redirect_task = thread::spawn(move || { + let (mut stream, _) = redirect_listener.accept().unwrap(); + let mut request = [0; 4096]; + let bytes_read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..bytes_read]).to_ascii_lowercase(); + assert!(request.contains("ocp-apim-subscription-key: redirect-key")); + stream + .write_all( + b"HTTP/1.1 307 Temporary Redirect\r\n\ + Location: http://127.0.0.1:1/\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n", + ) + .unwrap(); + }); + + let base_url = format!("http://{redirect_addr}"); + let client = SharedReqwestHttp::new(&base_url, Some("redirect-key".to_string())); + let response = client.request(&base_url).unwrap().send().await.unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::TEMPORARY_REDIRECT); + redirect_task.join().unwrap(); + } + + #[tokio::test] + async fn authenticated_client_limits_same_origin_redirects() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(AtomicUsize::new(0)); + let stop_server = stop.clone(); + let requests_server = requests.clone(); + let server = thread::spawn(move || { + while !stop_server.load(Ordering::SeqCst) { + let Ok((mut stream, _)) = listener.accept() else { + thread::sleep(std::time::Duration::from_millis(1)); + continue; + }; + requests_server.fetch_add(1, Ordering::SeqCst); + let mut request = [0; 4096]; + stream.read(&mut request).unwrap(); + stream + .write_all( + b"HTTP/1.1 307 Temporary Redirect\r\n\ + Location: /\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n", + ) + .unwrap(); + } + }); + + let base_url = format!("http://{addr}"); + let client = SharedReqwestHttp::new(&base_url, Some("redirect-key".to_string())); + let result = + tokio::time::timeout(Duration::from_secs(2), client.request(&base_url).unwrap().send()) + .await; + stop.store(true, Ordering::SeqCst); + server.join().unwrap(); + + let error = result.expect("redirect loop should terminate promptly").unwrap_err(); + assert!(error.is_redirect()); + assert_eq!(requests.load(Ordering::SeqCst), 11); + } + + #[test] + fn pccs_source_never_configures_an_intel_subscription_key() { + let pccs = Pccs::new( + CollateralSource::Pccs { url: "https://pccs.example/".to_string() }, + CachePolicy::Passthrough, + ); + assert_eq!(pccs.url, "https://pccs.example"); + assert!(pccs.http_client.subscription_key.is_none()); + } + + #[tokio::test] + async fn malformed_subscription_key_is_rejected_without_panicking_or_leaking() { + let pccs = Pccs::new( + CollateralSource::IntelPcs { subscription_key: Some("invalid\nkey".to_string()) }, + CachePolicy::Passthrough, + ); + let error = + pccs.get_collateral("000000000000".to_string(), "processor", 0).await.unwrap_err(); + assert!(!error.to_string().contains("invalid\nkey")); + } + #[tokio::test] async fn test_mock_pcs_server_helper_with_get_collateral() { let fmspc = mock_tdx_fmspc(); @@ -925,14 +1173,16 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Lazy); + let pccs = + Pccs::new(CollateralSource::Pccs { url: mock.base_url.clone() }, CachePolicy::OnDemand); let now = 1_700_000_000_u64; let (_, is_fresh) = pccs.get_collateral(fmspc, "processor", now).await.unwrap(); assert!(is_fresh); + assert_observed_subscription_keys(&mock, None); } #[tokio::test] - async fn test_remote_mode_fetches_collateral_every_time() { + async fn test_passthrough_policy_fetches_collateral_every_time() { let fmspc = mock_tdx_fmspc(); let mock = spawn_mock_pcs_server(MockPcsConfig { include_fmspcs_listing: false, @@ -943,7 +1193,10 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Remote); + let pccs = Pccs::new( + CollateralSource::Pccs { url: mock.base_url.clone() }, + CachePolicy::Passthrough, + ); let (_, first_is_fresh) = pccs.get_collateral(fmspc.clone(), "processor", 1_700_000_000).await.unwrap(); @@ -982,7 +1235,7 @@ mod tests { } #[tokio::test] - async fn test_proactive_refresh_updates_cached_entry() { + async fn test_authenticated_proactive_refresh_updates_cached_entry() { let initial_now = unix_now().unwrap(); let initial_next_update = OffsetDateTime::from_unix_timestamp(initial_now + 2).unwrap().format(&Rfc3339).unwrap(); @@ -1002,7 +1255,11 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Lazy); + let pccs = Pccs::new_with_endpoint( + mock.base_url.clone(), + Some("refresh-key".to_string()), + CachePolicy::OnDemand, + ); let (_, is_fresh) = pccs.get_collateral(fmspc.clone(), "processor", initial_now as u64).await.unwrap(); assert!(is_fresh); @@ -1031,10 +1288,11 @@ mod tests { pccs.get_collateral(fmspc, "processor", now_after_background as u64).await.unwrap(); assert!(!is_fresh_again); assert_eq!(mock.tcb_call_count(), before_check_calls); + assert_observed_subscription_keys(&mock, Some("refresh-key")); } #[tokio::test] - async fn test_ready_waits_for_startup_prewarm() { + async fn test_authenticated_ready_waits_for_startup_prewarm() { let mock = spawn_mock_pcs_server(MockPcsConfig { include_fmspcs_listing: true, tcb_next_update: "2999-01-01T00:00:00Z".to_string(), @@ -1044,13 +1302,18 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Prewarmed); + let pccs = Pccs::new_with_endpoint( + mock.base_url.clone(), + Some("prewarm-key".to_string()), + CachePolicy::Prewarmed, + ); let summary = tokio::time::timeout(Duration::from_secs(5), pccs.ready()).await.unwrap().unwrap(); assert_eq!(summary.discovered_fmspcs, 1); assert_eq!(summary.attempted, 2); assert_eq!(summary.successes, 2); assert_eq!(summary.failures, 0); + assert_observed_subscription_keys(&mock, Some("prewarm-key")); let (total_entries, fmspc, ca) = { let cache_guard = pccs.inner.as_ref().unwrap().cache.read().unwrap(); @@ -1080,7 +1343,10 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Prewarmed); + let pccs = Pccs::new( + CollateralSource::Pccs { url: mock.base_url.clone() }, + CachePolicy::Prewarmed, + ); let pccs_clone = pccs.clone(); let (first, second) = tokio::join!(pccs.ready(), pccs_clone.ready()); @@ -1092,7 +1358,10 @@ mod tests { #[tokio::test] async fn test_ready_returns_error_when_prewarm_bootstrap_fails() { - let pccs = Pccs::new(Some("http://127.0.0.1:1".to_string()), PccsMode::Prewarmed); + let pccs = Pccs::new( + CollateralSource::Pccs { url: "http://127.0.0.1:1".to_string() }, + CachePolicy::Prewarmed, + ); let ready_result = tokio::time::timeout(Duration::from_secs(2), pccs.ready()).await.unwrap(); assert!(matches!(ready_result, Err(PccsError::PrewarmFailed(_)))); @@ -1100,7 +1369,8 @@ mod tests { #[tokio::test] async fn test_ready_returns_error_when_prewarm_disabled() { - let pccs = Pccs::new(None, PccsMode::Lazy); + let pccs = + Pccs::new(CollateralSource::IntelPcs { subscription_key: None }, CachePolicy::OnDemand); let ready_result = pccs.ready().await; assert!(matches!(ready_result, Err(PccsError::PrewarmDisabled))); } @@ -1118,7 +1388,8 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Lazy); + let pccs = + Pccs::new(CollateralSource::Pccs { url: mock.base_url.clone() }, CachePolicy::OnDemand); let now = unix_now().unwrap() as u64; let err = pccs.get_collateral_sync(fmspc.clone(), "processor", now); @@ -1158,7 +1429,8 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Lazy); + let pccs = + Pccs::new(CollateralSource::Pccs { url: mock.base_url.clone() }, CachePolicy::OnDemand); let (_, is_fresh) = pccs.get_collateral(fmspc.clone(), "processor", initial_now as u64).await.unwrap(); assert!(is_fresh); From e21cdb952ef3afdfa880adaa77c58f8a8b5b2a3e Mon Sep 17 00:00:00 2001 From: peg Date: Fri, 4 Sep 2026 09:21:44 +0200 Subject: [PATCH 6/6] Update documenatation, add some convenience methods to attestation verifier builder --- crates/attestation/README.md | 20 +++++++++------ crates/attestation/src/lib.rs | 48 ++++++++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/crates/attestation/README.md b/crates/attestation/README.md index 77f1aad..4ea6efb 100644 --- a/crates/attestation/README.md +++ b/crates/attestation/README.md @@ -57,6 +57,9 @@ Intel PCS with `CachePolicy::Passthrough`. Use `CollateralSource::IntelPcs` with an optional subscription key, or `CollateralSource::Pccs` with the URL of a compatible service. +`AttestationVerifierBuilder::with_intel_pcs_subscription_key` and +`AttestationVerifierBuilder::with_pccs_url` provide shortcuts for these common +configurations. `AttestationVerifier::ready()` waits for initial work only with `Prewarmed`. It returns immediately for `Passthrough` and `OnDemand`. A successful @@ -107,9 +110,10 @@ must be explicitly enabled via the `override_azure_outdated_tcb` flag on Enables mock quote support via the local `mock-tdx` crate for tests and development on non-TDX hardware. -In mock builds, `Remote` mode uses embedded mock collateral rather than making -an external request. Cached modes can be pointed at a local mock PCCS when -testing cache behavior. +In mock builds, `CachePolicy::Passthrough` uses embedded mock collateral rather +than making an external request. `CachePolicy::OnDemand` and +`CachePolicy::Prewarmed` can be pointed at a local mock PCCS when testing cache +behavior. Do not use in production. Disabled by default. @@ -138,11 +142,11 @@ Alternatively, an external 'attestation provider service' URL can be provided which outsources the attestation generation to another process. When verifying DCAP attestations, collateral is retrieved according to the -configured PCCS mode. The endpoint defaults to Intel PCS unless a PCCS URL is -provided through the verifier builder. If outdated TCB is used, the quote will -fail to verify. For special cases where outdated TCB should be allowed, a -custom override function can be passed when verifying which may modify -collateral before it is validated against the TCB. +configured collateral source and cache policy. The source defaults to Intel PCS +unless a PCCS URL is provided through the verifier builder. If outdated TCB is +used, the quote will fail to verify. For special cases where outdated TCB +should be allowed, a custom override function can be passed when verifying +which may modify collateral before it is validated against the TCB. ## Measurements File diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 6d0675a..3574463 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -504,6 +504,19 @@ impl AttestationVerifierBuilder { self } + /// Fetches DCAP collateral from Intel PCS using the provided + /// subscription key. + pub fn with_intel_pcs_subscription_key(mut self, key: impl Into) -> Self { + self.collateral_source = CollateralSource::IntelPcs { subscription_key: Some(key.into()) }; + self + } + + /// Fetches DCAP collateral from the PCCS-compatible service at `url`. + pub fn with_pccs_url(mut self, url: impl Into) -> Self { + self.collateral_source = CollateralSource::Pccs { url: url.into() }; + self + } + /// Configures whether and how DCAP collateral is cached in process. /// /// The default is [`CachePolicy::Passthrough`]. @@ -719,10 +732,11 @@ impl AttestationVerifier { /// Synchronously verifies an attestation against the configured policy. /// - /// DCAP and Azure verification require `Lazy` or `Prewarmed` mode with - /// the requested collateral already cached. `Remote` mode cannot fetch - /// collateral synchronously. A cache miss returns an error and starts a - /// background fetch for a later attempt. + /// DCAP and Azure verification require [`CachePolicy::OnDemand`] or + /// [`CachePolicy::Prewarmed`] with the requested collateral already + /// cached. [`CachePolicy::Passthrough`] cannot fetch collateral + /// synchronously. A cache miss returns an error and starts a background + /// fetch for a later attempt. pub fn verify_attestation_sync( &self, attestation_exchange_message: AttestationExchangeMessage, @@ -1146,6 +1160,32 @@ mod tests { let _ = running_on_gcp(); } + #[test] + fn verifier_builder_configures_intel_pcs_subscription_key() { + let builder = AttestationVerifier::builder(MeasurementPolicy::tdx()) + .with_cache_policy(CachePolicy::OnDemand) + .with_intel_pcs_subscription_key("subscription-key"); + + assert_eq!(builder.cache_policy, CachePolicy::OnDemand); + assert_eq!( + builder.collateral_source, + CollateralSource::IntelPcs { subscription_key: Some("subscription-key".to_string()) } + ); + } + + #[test] + fn verifier_builder_configures_pccs_url() { + let builder = AttestationVerifier::builder(MeasurementPolicy::tdx()) + .with_cache_policy(CachePolicy::Prewarmed) + .with_pccs_url("https://pccs.example"); + + assert_eq!(builder.cache_policy, CachePolicy::Prewarmed); + assert_eq!( + builder.collateral_source, + CollateralSource::Pccs { url: "https://pccs.example".to_string() } + ); + } + #[tokio::test] async fn verifier_returns_no_verified_attestation_when_none_is_expected() { let verifier = AttestationVerifier::expect_none();