From cbf336ff4c5a3a9ecfcd27577d76c2325fcba283 Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 9 Mar 2026 10:24:24 +0100 Subject: [PATCH 1/6] Add PCCS crate --- Cargo.lock | 103 ++++- Cargo.toml | 2 + crates/attestation/Cargo.toml | 2 +- crates/pccs/Cargo.toml | 24 ++ crates/pccs/src/lib.rs | 783 ++++++++++++++++++++++++++++++++++ 5 files changed, 912 insertions(+), 2 deletions(-) create mode 100644 crates/pccs/Cargo.toml create mode 100644 crates/pccs/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 612867a..22015b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,58 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "az-cvm-vtpm" version = "0.7.4" @@ -545,7 +597,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "dcap-qvl" version = "0.3.12" -source = "git+https://github.com/flashbots/dcap-qvl.git?branch=peg%2Fazure-outdated-tcp-override#465c1c231a335db4c6acfb828e815ca1c9ffe2bf" +source = "git+https://github.com/Phala-Network/dcap-qvl.git#f1dcc65371e941a7b83e3234833d23a1fb232ab1" dependencies = [ "anyhow", "asn1_der", @@ -1191,6 +1243,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -1204,6 +1262,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1541,6 +1600,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "mbox" version = "0.7.1" @@ -1566,6 +1631,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1857,6 +1928,23 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pccs" +version = "0.0.1" +dependencies = [ + "anyhow", + "axum", + "dcap-qvl", + "hex", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", +] + [[package]] name = "pem" version = "3.0.6" @@ -2526,6 +2614,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2908,6 +3007,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2946,6 +3046,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index 9a9cc3a..0d3b4ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,11 +5,13 @@ members = [ "crates/attested-tls", "crates/nested-tls", "crates/attestation", + "crates/pccs", ] [workspace.dependencies] tokio = "1.48.0" tokio-rustls = { version = "0.26.4", default-features = false } +dcap-qvl = { git = "https://github.com/Phala-Network/dcap-qvl.git" } [workspace.lints.rust] unreachable_pub = "deny" diff --git a/crates/attestation/Cargo.toml b/crates/attestation/Cargo.toml index b04ae03..d47c104 100644 --- a/crates/attestation/Cargo.toml +++ b/crates/attestation/Cargo.toml @@ -16,7 +16,7 @@ anyhow = "1.0.100" pem-rfc7468 = { version = "0.7.0", features = ["std"] } configfs-tsm = "0.0.2" rand_core = { version = "0.6.4", features = ["getrandom"] } -dcap-qvl = { git = "https://github.com/flashbots/dcap-qvl.git", branch = "peg/azure-outdated-tcp-override", features = ["danger-allow-tcb-override"] } +dcap-qvl = { workspace = true, features = ["danger-allow-tcb-override"] } hex = "0.4.3" http = "1.3.1" serde_json = "1.0.145" diff --git a/crates/pccs/Cargo.toml b/crates/pccs/Cargo.toml new file mode 100644 index 0000000..d8669cb --- /dev/null +++ b/crates/pccs/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pccs" +version = "0.0.1" +edition = "2024" +license = "MIT" +description = "Provisioning Certificate Caching Service with pre-emptive fetching" +repository = "https://github.com/flashbots/attested-tls" + +[dependencies] +tokio = { workspace = true } +time = { version = "0.3.44", features = ["parsing", "formatting"] } +dcap-qvl = { workspace = true } +tracing = "0.1.41" +thiserror = "2.0.17" +serde = "1.0.228" +serde_json = "1.0.145" +hex = "0.4.3" +anyhow = "1.0.100" +reqwest = { version = "0.12.23", default-features = false, features = [ + "rustls-tls-webpki-roots-no-provider", +] } + +[dev-dependencies] +axum = "0.8.6" diff --git a/crates/pccs/src/lib.rs b/crates/pccs/src/lib.rs new file mode 100644 index 0000000..43044e1 --- /dev/null +++ b/crates/pccs/src/lib.rs @@ -0,0 +1,783 @@ +use std::{ + collections::HashMap, + sync::{ + Arc, + Weak, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::{SystemTime, UNIX_EPOCH}, +}; + +use dcap_qvl::{QuoteCollateralV3, collateral::get_collateral_for_fmspc, tcb_info::TcbInfo}; +use thiserror::Error; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tokio::{ + sync::{RwLock, Semaphore}, + task::{JoinHandle, JoinSet}, + time::{Duration, sleep}, +}; + +/// For fetching collateral directly from Intel +pub const PCS_URL: &str = "https://api.trustedservices.intel.com"; + +const REFRESH_MARGIN_SECS: i64 = 300; +const REFRESH_RETRY_SECS: u64 = 60; +const STARTUP_PREWARM_CONCURRENCY: usize = 8; + +/// PCCS collateral cache with proactive background refresh +#[derive(Clone)] +pub struct Pccs { + /// The URL of the service used to fetch collateral (PCS / PCCS) + pccs_url: String, + /// The internal cache + cache: Arc>>, + prewarm_stats: Arc, +} + +impl std::fmt::Debug for Pccs { + /// Formats PCCS config for debug output without exposing cache + /// internals + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Pccs").field("pccs_url", &self.pccs_url).finish_non_exhaustive() + } +} + +impl Pccs { + /// Creates a new PCCS cache using the provided URL or Intel PCS default + pub fn new(pccs_url: Option) -> Self { + let pccs_url = pccs_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 pccs = Self { + pccs_url, + cache: RwLock::new(HashMap::new()).into(), + prewarm_stats: Arc::new(PrewarmStats::default()), + }; + + // Start filling the cache right away + let pccs_for_prewarm = pccs.clone(); + tokio::spawn(async move { + pccs_for_prewarm.startup_prewarm_all_tdx().await; + }); + + pccs + } + + /// Returns collateral from cache when valid, otherwise fetches and + /// caches fresh collateral + pub async fn get_collateral( + &self, + fmspc: String, + ca: &'static str, + now: i64, + ) -> Result<(QuoteCollateralV3, bool), PccsError> { + let cache_key = PccsInput::new(fmspc.clone(), ca); + + { + let cache = self.cache.read().await; + if let Some(entry) = cache.get(&cache_key) { + if now < entry.next_update { + return Ok((entry.collateral.clone(), false)); + } + tracing::warn!( + fmspc, + next_update = entry.next_update, + now, + "Cached collateral expired, refreshing from PCCS" + ); + } + } + + let collateral = fetch_collateral(&self.pccs_url, fmspc.clone(), ca).await?; + let next_update = extract_next_update(&collateral, now)?; + + let mut cache = self.cache.write().await; + if let Some(existing) = cache.get(&cache_key) && + now < existing.next_update + { + return Ok((existing.collateral.clone(), false)); + } + + upsert_cache_entry(&mut cache, cache_key.clone(), collateral.clone(), next_update); + drop(cache); + self.ensure_refresh_task(&cache_key).await; + Ok((collateral, true)) + } + + /// Fetches fresh collateral, overwrites cache, and ensures proactive + /// refresh is scheduled + pub async fn refresh_collateral( + &self, + fmspc: String, + ca: &'static str, + now: i64, + ) -> Result { + let collateral = fetch_collateral(&self.pccs_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().await; + upsert_cache_entry(&mut cache, cache_key.clone(), collateral.clone(), next_update); + } + self.ensure_refresh_task(&cache_key).await; + Ok(collateral) + } + + /// Starts a background refresh loop for a cache key when no task is + /// active + async fn ensure_refresh_task(&self, cache_key: &PccsInput) { + let mut cache = self.cache.write().await; + let Some(entry) = cache.get_mut(cache_key) else { + return; + }; + if entry.refresh_task.is_some() { + return; + } + + let weak_cache = Arc::downgrade(&self.cache); + let key = cache_key.clone(); + let pccs_url = self.pccs_url.clone(); + entry.refresh_task = Some(tokio::spawn(async move { + refresh_loop(weak_cache, pccs_url, key).await; + })); + } + + /// Pre-provisions TDX collateral for discovered FMSPC values to reduce + /// hot-path fetches + async fn startup_prewarm_all_tdx(&self) { + // First get all FMSPCs + let fmspcs = match self.fetch_fmspcs().await { + Ok(fmspcs) => fmspcs, + Err(e) => { + tracing::warn!(error = %e, "Failed to fetch FMSPC list for startup pre-provision"); + self.prewarm_stats.completed.store(true, Ordering::SeqCst); + return; + } + }; + self.prewarm_stats.discovered_fmspcs.store(fmspcs.len(), Ordering::SeqCst); + + if fmspcs.is_empty() { + tracing::warn!("No FMSPC entries returned during startup pre-provision"); + self.prewarm_stats.completed.store(true, Ordering::SeqCst); + return; + } + + // For each FMSPC, get the 'processor' and 'platform' collateral + // concurrently + let semaphore = Arc::new(Semaphore::new(STARTUP_PREWARM_CONCURRENCY)); + let mut join_set = JoinSet::new(); + for entry in fmspcs { + for ca in ["processor", "platform"] { + let permit = semaphore.clone().acquire_owned().await; + let Ok(permit) = permit else { + continue; + }; + self.prewarm_stats.attempted.fetch_add(1, Ordering::SeqCst); + let pccs = self.clone(); + let fmspc = entry.fmspc.clone(); + join_set.spawn(async move { + let _permit = permit; + let now = unix_now()?; + let result = pccs.refresh_collateral(fmspc.clone(), ca, now).await; + Ok::<(String, &'static str, Result<(), PccsError>), PccsError>(( + fmspc, + ca, + result.map(|_| ()), + )) + }); + } + } + + // Collect results + let mut successes = 0usize; + let mut failures = 0usize; + while let Some(task_result) = join_set.join_next().await { + match task_result { + Ok(Ok((_, _, Ok(())))) => { + successes += 1; + self.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); + tracing::debug!( + fmspc, + ca, + error = %e, + "Startup pre-provision failed for FMSPC/CA" + ); + } + Ok(Err(e)) => { + failures += 1; + self.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); + tracing::debug!(error = %e, "Startup pre-provision join error"); + } + } + } + self.prewarm_stats.completed.store(true, Ordering::SeqCst); + + tracing::info!( + discovered_fmspcs = self.prewarm_stats.discovered_fmspcs.load(Ordering::SeqCst), + attempted = self.prewarm_stats.attempted.load(Ordering::SeqCst), + successes, + failures, + "Completed PCCS startup pre-provisioning for TDX collateral" + ); + } + + /// Fetches available FMSPC entries from configured PCCS/PCS endpoint + async fn fetch_fmspcs(&self) -> Result, PccsError> { + let url = format!("{}/sgx/certification/v4/fmspcs", self.pccs_url); + let client = reqwest::Client::builder().timeout(Duration::from_secs(15)).build()?; + let response = client.get(&url).send().await?; + if !response.status().is_success() { + return Err(PccsError::FmspcFetch(response.status())); + } + let body = response.text().await?; + let entries: Vec = serde_json::from_str(&body)?; + Ok(entries) + } +} + +/// Cache key for PCCS collateral entries +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +struct PccsInput { + fmspc: String, + ca: String, +} + +impl PccsInput { + /// Builds a cache key from FMSPC and CA identifier + fn new(fmspc: String, ca: &'static str) -> Self { + Self { fmspc, ca: ca.to_string() } + } +} + +/// Fetches collateral from PCCS for a given FMSPC and CA +async fn fetch_collateral( + pccs_url: &str, + fmspc: String, + ca: &'static str, +) -> Result { + get_collateral_for_fmspc( + pccs_url, fmspc, ca, false, // Indicates not SGX + ) + .await + .map_err(Into::into) +} + +/// Extracts the earliest next update timestamp from collateral metadata +fn extract_next_update(collateral: &QuoteCollateralV3, now: i64) -> Result { + let tcb_info: TcbInfo = serde_json::from_str(&collateral.tcb_info).map_err(|e| { + PccsError::PccsCollateralParse(format!("Failed to parse TCB info JSON: {e}")) + })?; + let qe_identity: QeIdentityNextUpdate = + serde_json::from_str(&collateral.qe_identity).map_err(|e| { + PccsError::PccsCollateralParse(format!("Failed to parse QE identity JSON: {e}")) + })?; + + let tcb_next_update = parse_next_update("tcb_info.nextUpdate", &tcb_info.next_update)?; + let qe_next_update = parse_next_update("qe_identity.nextUpdate", &qe_identity.next_update)?; + let next_update = tcb_next_update.min(qe_next_update); + + if now >= next_update { + return Err(PccsError::PccsCollateralExpired(format!( + "Collateral expired (tcb_next_update={}, qe_next_update={}, now={now})", + tcb_info.next_update, qe_identity.next_update + ))); + } + + Ok(next_update) +} + +/// Parses an RFC3339 nextUpdate value into a unix timestamp +fn parse_next_update(field: &str, value: &str) -> Result { + OffsetDateTime::parse(value, &Rfc3339) + .map_err(|e| { + PccsError::PccsCollateralParse(format!("Failed to parse {field} as RFC3339: {e}")) + }) + .map(|parsed| parsed.unix_timestamp()) +} + +/// Returns current unix time in seconds +fn unix_now() -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64) +} + +/// Computes how many seconds to sleep before refresh should start +fn refresh_sleep_seconds(next_update: i64, now: i64) -> u64 { + let refresh_at = next_update - REFRESH_MARGIN_SECS; + if refresh_at <= now { 0 } else { (refresh_at - now) as u64 } +} + +/// Inserts or updates a cache entry while preserving any active refresh +/// task +fn upsert_cache_entry( + cache: &mut HashMap, + key: PccsInput, + collateral: QuoteCollateralV3, + next_update: i64, +) { + match cache.get_mut(&key) { + Some(existing) => { + existing.collateral = collateral; + existing.next_update = next_update; + } + None => { + cache.insert(key, CacheEntry { collateral, next_update, refresh_task: None }); + } + } +} + +/// Converts CA identifier string into the expected static literal +fn ca_as_static(ca: &str) -> Option<&'static str> { + match ca { + "processor" => Some("processor"), + "platform" => Some("platform"), + _ => None, + } +} + +/// Background loop that refreshes collateral for a single cache key +async fn refresh_loop( + weak_cache: Weak>>, + pccs_url: String, + key: PccsInput, +) { + let Some(ca_static) = ca_as_static(&key.ca) else { + tracing::warn!(ca = key.ca, "Unsupported collateral CA value, refresh loop stopping"); + return; + }; + + loop { + let Some(cache) = weak_cache.upgrade() else { + return; + }; + let next_update = { + let cache_guard = cache.read().await; + let Some(entry) = cache_guard.get(&key) else { + return; + }; + entry.next_update + }; + + let now = match unix_now() { + Ok(now) => now, + Err(e) => { + tracing::warn!(error = %e, "Failed to read system time for PCCS refresh"); + sleep(Duration::from_secs(REFRESH_RETRY_SECS)).await; + continue; + } + }; + let sleep_secs = refresh_sleep_seconds(next_update, now); + sleep(Duration::from_secs(sleep_secs)).await; + + match fetch_collateral(&pccs_url, key.fmspc.clone(), ca_static).await { + Ok(collateral) => match extract_next_update(&collateral, now) { + Ok(new_next_update) => { + let Some(cache) = weak_cache.upgrade() else { + return; + }; + let mut cache_guard = cache.write().await; + let Some(entry) = cache_guard.get_mut(&key) else { + return; + }; + entry.collateral = collateral; + entry.next_update = new_next_update; + tracing::debug!( + fmspc = key.fmspc, + ca = key.ca, + next_update = new_next_update, + "Refreshed PCCS collateral in background" + ); + } + Err(e) => { + tracing::warn!( + fmspc = key.fmspc, + ca = key.ca, + error = %e, + "Fetched PCCS collateral but nextUpdate validation failed" + ); + sleep(Duration::from_secs(REFRESH_RETRY_SECS)).await; + } + }, + Err(e) => { + tracing::warn!( + fmspc = key.fmspc, + ca = key.ca, + error = %e, + "Background PCCS collateral refresh failed" + ); + sleep(Duration::from_secs(REFRESH_RETRY_SECS)).await; + } + } + } +} + +/// Cached collateral entry with refresh metadata +struct CacheEntry { + collateral: QuoteCollateralV3, + next_update: i64, + refresh_task: Option>, +} + +/// Minimal QE identity shape needed to read nextUpdate +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct QeIdentityNextUpdate { + next_update: String, +} + +#[derive(Debug, serde::Deserialize)] +struct FmspcEntry { + fmspc: String, + #[allow(dead_code)] + platform: String, +} + +#[derive(Default)] +struct PrewarmStats { + discovered_fmspcs: AtomicUsize, + attempted: AtomicUsize, + successes: AtomicUsize, + failures: AtomicUsize, + completed: AtomicBool, +} + +#[derive(Error, Debug)] +pub enum PccsError { + #[error("DCAP quote verification: {0}")] + DcapQvl(#[from] anyhow::Error), + #[error("PCCS collateral parse error: {0}")] + PccsCollateralParse(String), + #[error("PCCS collateral expired: {0}")] + PccsCollateralExpired(String), + #[error("System Time: {0}")] + SystemTime(#[from] std::time::SystemTimeError), + #[error("HTTP client: {0}")] + Reqwest(#[from] reqwest::Error), + #[error("Failed to fetch FMSPC: {0}")] + FmspcFetch(reqwest::StatusCode), + #[error("JSON: {0}")] + Json(#[from] serde_json::Error), +} + +#[cfg(test)] +mod tests { + use std::{ + collections::HashMap as StdHashMap, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + }; + + use axum::{ + Json, + Router, + extract::{Query, State}, + response::IntoResponse, + routing::get, + }; + use dcap_qvl::QuoteCollateralV3; + use serde_json::{Value, json}; + use tokio::{net::TcpListener, task::JoinHandle, time::Duration}; + + use super::*; + + #[derive(Clone)] + struct MockPcsConfig { + fmspc: String, + ca: &'static str, + tcb_next_update: String, + qe_next_update: String, + refreshed_tcb_next_update: Option, + refreshed_qe_next_update: Option, + } + + /// A mock PCS server so we can run tests without using the Intel PCS + struct MockPcsServer { + base_url: String, + _task: JoinHandle<()>, + tcb_calls: Arc, + qe_calls: Arc, + } + + impl Drop for MockPcsServer { + fn drop(&mut self) { + self._task.abort(); + } + } + + impl MockPcsServer { + fn tcb_call_count(&self) -> usize { + self.tcb_calls.load(Ordering::SeqCst) + } + + fn qe_call_count(&self) -> usize { + self.qe_calls.load(Ordering::SeqCst) + } + } + + #[derive(Clone)] + struct MockPcsState { + fmspc: String, + ca: String, + base_tcb_info: Value, + base_qe_identity: Value, + tcb_signature_hex: String, + qe_signature_hex: String, + tcb_next_update: String, + qe_next_update: String, + refreshed_tcb_next_update: Option, + refreshed_qe_next_update: Option, + pck_crl: Vec, + pck_crl_issuer_chain: String, + tcb_issuer_chain: String, + qe_issuer_chain: String, + root_ca_crl_hex: String, + tcb_calls: Arc, + qe_calls: Arc, + } + + async fn spawn_mock_pcs_server(config: MockPcsConfig) -> MockPcsServer { + let base_collateral: QuoteCollateralV3 = serde_json::from_slice(include_bytes!( + "../../attestation/test-assets/dcap-quote-collateral-00.json" + )) + .unwrap(); + + let mut tcb_info: Value = serde_json::from_str(&base_collateral.tcb_info).unwrap(); + tcb_info["nextUpdate"] = Value::String(config.tcb_next_update.clone()); + + let mut qe_identity: Value = serde_json::from_str(&base_collateral.qe_identity).unwrap(); + qe_identity["nextUpdate"] = Value::String(config.qe_next_update.clone()); + + let tcb_calls = Arc::new(AtomicUsize::new(0)); + let qe_calls = Arc::new(AtomicUsize::new(0)); + let state = Arc::new(MockPcsState { + fmspc: config.fmspc, + ca: config.ca.to_string(), + base_tcb_info: tcb_info, + base_qe_identity: qe_identity, + tcb_signature_hex: hex::encode(&base_collateral.tcb_info_signature), + qe_signature_hex: hex::encode(&base_collateral.qe_identity_signature), + tcb_next_update: config.tcb_next_update, + 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, + pck_crl: base_collateral.pck_crl, + pck_crl_issuer_chain: "mock-pck-crl-issuer-chain".to_string(), + tcb_issuer_chain: "mock-tcb-info-issuer-chain".to_string(), + qe_issuer_chain: "mock-qe-issuer-chain".to_string(), + root_ca_crl_hex: hex::encode(base_collateral.root_ca_crl), + tcb_calls: tcb_calls.clone(), + qe_calls: qe_calls.clone(), + }); + + let app = Router::new() + .route("/sgx/certification/v4/pckcrl", get(mock_pck_crl_handler)) + .route("/tdx/certification/v4/tcb", get(mock_tcb_handler)) + .route("/tdx/certification/v4/qe/identity", get(mock_qe_identity_handler)) + .route("/sgx/certification/v4/rootcacrl", get(mock_root_ca_crl_handler)) + .with_state(state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + MockPcsServer { base_url: format!("http://{addr}"), _task: task, tcb_calls, qe_calls } + } + + async fn mock_pck_crl_handler( + State(state): State>, + Query(params): Query>, + ) -> impl IntoResponse { + assert_eq!(params.get("ca"), Some(&state.ca)); + assert_eq!(params.get("encoding"), Some(&"der".to_string())); + ([("SGX-PCK-CRL-Issuer-Chain", state.pck_crl_issuer_chain.clone())], state.pck_crl.clone()) + } + + async fn mock_tcb_handler( + State(state): State>, + Query(params): Query>, + ) -> impl IntoResponse { + 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(); + let next_update = if call_number == 1 { + state.tcb_next_update.clone() + } else { + state.refreshed_tcb_next_update.clone().unwrap_or_else(|| state.tcb_next_update.clone()) + }; + tcb_info["nextUpdate"] = Value::String(next_update); + ( + [("SGX-TCB-Info-Issuer-Chain", state.tcb_issuer_chain.clone())], + Json(json!({ + "tcbInfo": tcb_info, + "signature": state.tcb_signature_hex, + })), + ) + } + + async fn mock_qe_identity_handler( + State(state): State>, + Query(params): Query>, + ) -> impl IntoResponse { + 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(); + let next_update = if call_number == 1 { + state.qe_next_update.clone() + } else { + state.refreshed_qe_next_update.clone().unwrap_or_else(|| state.qe_next_update.clone()) + }; + qe_identity["nextUpdate"] = Value::String(next_update); + ( + [("SGX-Enclave-Identity-Issuer-Chain", state.qe_issuer_chain.clone())], + Json(json!({ + "enclaveIdentity": qe_identity, + "signature": state.qe_signature_hex, + })), + ) + } + + async fn mock_root_ca_crl_handler(State(state): State>) -> impl IntoResponse { + state.root_ca_crl_hex.clone() + } + + #[tokio::test] + async fn test_mock_pcs_server_helper_with_get_collateral() { + let mock = spawn_mock_pcs_server(MockPcsConfig { + fmspc: "00806F050000".to_string(), + ca: "processor", + 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; + + let pccs = Pccs::new(Some(mock.base_url.clone())); + let now = 1_700_000_000_i64; + let (_, is_fresh) = + pccs.get_collateral("00806F050000".to_string(), "processor", now).await.unwrap(); + assert!(is_fresh); + } + + #[tokio::test] + async fn test_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(); + let refreshed_next_update = OffsetDateTime::from_unix_timestamp(initial_now + 3600) + .unwrap() + .format(&Rfc3339) + .unwrap(); + + let mock = spawn_mock_pcs_server(MockPcsConfig { + fmspc: "00806F050000".to_string(), + ca: "processor", + tcb_next_update: initial_next_update.clone(), + qe_next_update: initial_next_update, + refreshed_tcb_next_update: Some(refreshed_next_update.clone()), + refreshed_qe_next_update: Some(refreshed_next_update), + }) + .await; + + let pccs = Pccs::new(Some(mock.base_url.clone())); + let (_, is_fresh) = pccs + .get_collateral("00806F050000".to_string(), "processor", initial_now) + .await + .unwrap(); + assert!(is_fresh); + assert_eq!(mock.tcb_call_count(), 1); + assert_eq!(mock.qe_call_count(), 1); + + let (_, is_fresh_second) = pccs + .get_collateral("00806F050000".to_string(), "processor", initial_now) + .await + .unwrap(); + assert!(!is_fresh_second); + assert_eq!(mock.tcb_call_count(), 1); + assert_eq!(mock.qe_call_count(), 1); + + for _ in 0..60 { + if mock.tcb_call_count() >= 2 && mock.qe_call_count() >= 2 { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + assert!(mock.tcb_call_count() >= 2, "expected proactive TCB refresh to run"); + assert!(mock.qe_call_count() >= 2, "expected proactive QE identity refresh to run"); + + let before_check_calls = mock.tcb_call_count(); + let now_after_background = unix_now().unwrap(); + let (_, is_fresh_again) = pccs + .get_collateral("00806F050000".to_string(), "processor", now_after_background) + .await + .unwrap(); + assert!(!is_fresh_again); + assert_eq!(mock.tcb_call_count(), before_check_calls); + } + + #[tokio::test] + async fn test_startup_prewarm_populates_cache_from_intel_pcs() { + let pccs = Pccs::new(None); + + let mut prewarm_completed = false; + for _ in 0..40 { + if pccs.prewarm_stats.completed.load(Ordering::SeqCst) { + prewarm_completed = true; + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + let cache_guard = pccs.cache.read().await; + let total_entries = cache_guard.len(); + let unique_fmspcs: std::collections::HashSet<_> = + cache_guard.keys().map(|k| k.fmspc.clone()).collect(); + println!( + "startup prewarm summary: completed={}, discovered_fmspcs={}, attempted={}, successes={}, failures={}, cache_entries_total={}, cache_unique_fmspcs={}", + prewarm_completed, + pccs.prewarm_stats.discovered_fmspcs.load(Ordering::SeqCst), + pccs.prewarm_stats.attempted.load(Ordering::SeqCst), + pccs.prewarm_stats.successes.load(Ordering::SeqCst), + pccs.prewarm_stats.failures.load(Ordering::SeqCst), + total_entries, + unique_fmspcs.len() + ); + if pccs.prewarm_stats.discovered_fmspcs.load(Ordering::SeqCst) == 0 { + println!( + "startup prewarm made no discovery progress in test window, skipping cache assertions" + ); + return; + } + assert!(total_entries > 0, "expected startup pre-provision to populate PCCS cache"); + + let (fmspc, ca) = cache_guard + .keys() + .next() + .map(|k| (k.fmspc.clone(), k.ca.clone())) + .expect("expected startup pre-provision to populate PCCS cache"); + drop(cache_guard); + let ca_static = ca_as_static(&ca).expect("unexpected CA value in warmed cache entry"); + let now = unix_now().unwrap(); + let (_, is_fresh) = pccs.get_collateral(fmspc, ca_static, now).await.unwrap(); + assert!(!is_fresh); + } +} From 76cc306b4c740316519ec46693affb980fdc48fc Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 9 Mar 2026 10:46:59 +0100 Subject: [PATCH 2/6] Fix time reading in update loop --- crates/pccs/src/lib.rs | 67 +++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/crates/pccs/src/lib.rs b/crates/pccs/src/lib.rs index 43044e1..3a92f00 100644 --- a/crates/pccs/src/lib.rs +++ b/crates/pccs/src/lib.rs @@ -383,34 +383,47 @@ async fn refresh_loop( sleep(Duration::from_secs(sleep_secs)).await; match fetch_collateral(&pccs_url, key.fmspc.clone(), ca_static).await { - Ok(collateral) => match extract_next_update(&collateral, now) { - Ok(new_next_update) => { - let Some(cache) = weak_cache.upgrade() else { - return; - }; - let mut cache_guard = cache.write().await; - let Some(entry) = cache_guard.get_mut(&key) else { - return; - }; - entry.collateral = collateral; - entry.next_update = new_next_update; - tracing::debug!( - fmspc = key.fmspc, - ca = key.ca, - next_update = new_next_update, - "Refreshed PCCS collateral in background" - ); - } - Err(e) => { - tracing::warn!( - fmspc = key.fmspc, - ca = key.ca, - error = %e, - "Fetched PCCS collateral but nextUpdate validation failed" - ); - sleep(Duration::from_secs(REFRESH_RETRY_SECS)).await; + Ok(collateral) => { + let validate_now = match unix_now() { + Ok(timestamp) => timestamp, + Err(e) => { + tracing::warn!( + error = %e, + "Failed to read system time for PCCS refresh validation" + ); + sleep(Duration::from_secs(REFRESH_RETRY_SECS)).await; + continue; + } + }; + match extract_next_update(&collateral, validate_now) { + Ok(new_next_update) => { + let Some(cache) = weak_cache.upgrade() else { + return; + }; + let mut cache_guard = cache.write().await; + let Some(entry) = cache_guard.get_mut(&key) else { + return; + }; + entry.collateral = collateral; + entry.next_update = new_next_update; + tracing::debug!( + fmspc = key.fmspc, + ca = key.ca, + next_update = new_next_update, + "Refreshed PCCS collateral in background" + ); + } + Err(e) => { + tracing::warn!( + fmspc = key.fmspc, + ca = key.ca, + error = %e, + "Fetched PCCS collateral but nextUpdate validation failed" + ); + sleep(Duration::from_secs(REFRESH_RETRY_SECS)).await; + } } - }, + } Err(e) => { tracing::warn!( fmspc = key.fmspc, From a19f5fb8656986f99bfad6b232d25c0e9bfa6899 Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 9 Mar 2026 11:26:48 +0100 Subject: [PATCH 3/6] Refactor mock PCS server into separate module --- crates/pccs/src/lib.rs | 196 +++--------------------------------- crates/pccs/src/mock_pcs.rs | 182 +++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 182 deletions(-) create mode 100644 crates/pccs/src/mock_pcs.rs diff --git a/crates/pccs/src/lib.rs b/crates/pccs/src/lib.rs index 3a92f00..4aaa6a0 100644 --- a/crates/pccs/src/lib.rs +++ b/crates/pccs/src/lib.rs @@ -19,9 +19,12 @@ use tokio::{ /// For fetching collateral directly from Intel pub const PCS_URL: &str = "https://api.trustedservices.intel.com"; - +/// How long before expiry to refresh collateral const REFRESH_MARGIN_SECS: i64 = 300; +/// How long to wait before retrying when failing to fetch collateral const REFRESH_RETRY_SECS: u64 = 60; +/// How many collateral fetches to perform concurrently during initial +/// pre-warm const STARTUP_PREWARM_CONCURRENCY: usize = 8; /// PCCS collateral cache with proactive background refresh @@ -31,6 +34,7 @@ pub struct Pccs { pccs_url: String, /// The internal cache cache: Arc>>, + /// The state of the initial pre-warm fetch prewarm_stats: Arc, } @@ -486,190 +490,18 @@ pub enum PccsError { } #[cfg(test)] -mod tests { - use std::{ - collections::HashMap as StdHashMap, - net::SocketAddr, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, - }; - - use axum::{ - Json, - Router, - extract::{Query, State}, - response::IntoResponse, - routing::get, - }; - use dcap_qvl::QuoteCollateralV3; - use serde_json::{Value, json}; - use tokio::{net::TcpListener, task::JoinHandle, time::Duration}; - - use super::*; - - #[derive(Clone)] - struct MockPcsConfig { - fmspc: String, - ca: &'static str, - tcb_next_update: String, - qe_next_update: String, - refreshed_tcb_next_update: Option, - refreshed_qe_next_update: Option, - } - - /// A mock PCS server so we can run tests without using the Intel PCS - struct MockPcsServer { - base_url: String, - _task: JoinHandle<()>, - tcb_calls: Arc, - qe_calls: Arc, - } - - impl Drop for MockPcsServer { - fn drop(&mut self) { - self._task.abort(); - } - } - - impl MockPcsServer { - fn tcb_call_count(&self) -> usize { - self.tcb_calls.load(Ordering::SeqCst) - } - - fn qe_call_count(&self) -> usize { - self.qe_calls.load(Ordering::SeqCst) - } - } +mod mock_pcs; - #[derive(Clone)] - struct MockPcsState { - fmspc: String, - ca: String, - base_tcb_info: Value, - base_qe_identity: Value, - tcb_signature_hex: String, - qe_signature_hex: String, - tcb_next_update: String, - qe_next_update: String, - refreshed_tcb_next_update: Option, - refreshed_qe_next_update: Option, - pck_crl: Vec, - pck_crl_issuer_chain: String, - tcb_issuer_chain: String, - qe_issuer_chain: String, - root_ca_crl_hex: String, - tcb_calls: Arc, - qe_calls: Arc, - } - - async fn spawn_mock_pcs_server(config: MockPcsConfig) -> MockPcsServer { - let base_collateral: QuoteCollateralV3 = serde_json::from_slice(include_bytes!( - "../../attestation/test-assets/dcap-quote-collateral-00.json" - )) - .unwrap(); - - let mut tcb_info: Value = serde_json::from_str(&base_collateral.tcb_info).unwrap(); - tcb_info["nextUpdate"] = Value::String(config.tcb_next_update.clone()); - - let mut qe_identity: Value = serde_json::from_str(&base_collateral.qe_identity).unwrap(); - qe_identity["nextUpdate"] = Value::String(config.qe_next_update.clone()); - - let tcb_calls = Arc::new(AtomicUsize::new(0)); - let qe_calls = Arc::new(AtomicUsize::new(0)); - let state = Arc::new(MockPcsState { - fmspc: config.fmspc, - ca: config.ca.to_string(), - base_tcb_info: tcb_info, - base_qe_identity: qe_identity, - tcb_signature_hex: hex::encode(&base_collateral.tcb_info_signature), - qe_signature_hex: hex::encode(&base_collateral.qe_identity_signature), - tcb_next_update: config.tcb_next_update, - 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, - pck_crl: base_collateral.pck_crl, - pck_crl_issuer_chain: "mock-pck-crl-issuer-chain".to_string(), - tcb_issuer_chain: "mock-tcb-info-issuer-chain".to_string(), - qe_issuer_chain: "mock-qe-issuer-chain".to_string(), - root_ca_crl_hex: hex::encode(base_collateral.root_ca_crl), - tcb_calls: tcb_calls.clone(), - qe_calls: qe_calls.clone(), - }); - - let app = Router::new() - .route("/sgx/certification/v4/pckcrl", get(mock_pck_crl_handler)) - .route("/tdx/certification/v4/tcb", get(mock_tcb_handler)) - .route("/tdx/certification/v4/qe/identity", get(mock_qe_identity_handler)) - .route("/sgx/certification/v4/rootcacrl", get(mock_root_ca_crl_handler)) - .with_state(state); - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr: SocketAddr = listener.local_addr().unwrap(); - let task = tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - MockPcsServer { base_url: format!("http://{addr}"), _task: task, tcb_calls, qe_calls } - } - - async fn mock_pck_crl_handler( - State(state): State>, - Query(params): Query>, - ) -> impl IntoResponse { - assert_eq!(params.get("ca"), Some(&state.ca)); - assert_eq!(params.get("encoding"), Some(&"der".to_string())); - ([("SGX-PCK-CRL-Issuer-Chain", state.pck_crl_issuer_chain.clone())], state.pck_crl.clone()) - } +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; - async fn mock_tcb_handler( - State(state): State>, - Query(params): Query>, - ) -> impl IntoResponse { - 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(); - let next_update = if call_number == 1 { - state.tcb_next_update.clone() - } else { - state.refreshed_tcb_next_update.clone().unwrap_or_else(|| state.tcb_next_update.clone()) - }; - tcb_info["nextUpdate"] = Value::String(next_update); - ( - [("SGX-TCB-Info-Issuer-Chain", state.tcb_issuer_chain.clone())], - Json(json!({ - "tcbInfo": tcb_info, - "signature": state.tcb_signature_hex, - })), - ) - } + use tokio::time::Duration; - async fn mock_qe_identity_handler( - State(state): State>, - Query(params): Query>, - ) -> impl IntoResponse { - 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(); - let next_update = if call_number == 1 { - state.qe_next_update.clone() - } else { - state.refreshed_qe_next_update.clone().unwrap_or_else(|| state.qe_next_update.clone()) - }; - qe_identity["nextUpdate"] = Value::String(next_update); - ( - [("SGX-Enclave-Identity-Issuer-Chain", state.qe_issuer_chain.clone())], - Json(json!({ - "enclaveIdentity": qe_identity, - "signature": state.qe_signature_hex, - })), - ) - } - - async fn mock_root_ca_crl_handler(State(state): State>) -> impl IntoResponse { - state.root_ca_crl_hex.clone() - } + use super::{ + mock_pcs::{MockPcsConfig, spawn_mock_pcs_server}, + *, + }; #[tokio::test] async fn test_mock_pcs_server_helper_with_get_collateral() { diff --git a/crates/pccs/src/mock_pcs.rs b/crates/pccs/src/mock_pcs.rs new file mode 100644 index 0000000..feee32c --- /dev/null +++ b/crates/pccs/src/mock_pcs.rs @@ -0,0 +1,182 @@ +//! A mock PCS server used for testing +use std::{ + collections::HashMap as StdHashMap, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use axum::{ + Json, + Router, + extract::{Query, State}, + response::IntoResponse, + routing::get, +}; +use dcap_qvl::QuoteCollateralV3; +use serde_json::{Value, json}; +use tokio::{net::TcpListener, task::JoinHandle}; + +#[derive(Clone)] +pub(super) struct MockPcsConfig { + pub(super) fmspc: String, + pub(super) ca: &'static str, + pub(super) tcb_next_update: String, + pub(super) qe_next_update: String, + pub(super) refreshed_tcb_next_update: Option, + pub(super) refreshed_qe_next_update: Option, +} + +/// A mock PCS server so we can run tests without using the Intel PCS. +pub(super) struct MockPcsServer { + pub(super) base_url: String, + _task: JoinHandle<()>, + tcb_calls: Arc, + qe_calls: Arc, +} + +impl Drop for MockPcsServer { + fn drop(&mut self) { + self._task.abort(); + } +} + +impl MockPcsServer { + pub(super) fn tcb_call_count(&self) -> usize { + self.tcb_calls.load(Ordering::SeqCst) + } + + pub(super) fn qe_call_count(&self) -> usize { + self.qe_calls.load(Ordering::SeqCst) + } +} + +#[derive(Clone)] +struct MockPcsState { + fmspc: String, + ca: String, + base_tcb_info: Value, + base_qe_identity: Value, + tcb_signature_hex: String, + qe_signature_hex: String, + tcb_next_update: String, + qe_next_update: String, + refreshed_tcb_next_update: Option, + refreshed_qe_next_update: Option, + pck_crl: Vec, + pck_crl_issuer_chain: String, + tcb_issuer_chain: String, + qe_issuer_chain: String, + root_ca_crl_hex: String, + tcb_calls: Arc, + qe_calls: Arc, +} + +pub(super) async fn spawn_mock_pcs_server(config: MockPcsConfig) -> MockPcsServer { + let base_collateral: QuoteCollateralV3 = serde_json::from_slice(include_bytes!( + "../../attestation/test-assets/dcap-quote-collateral-00.json" + )) + .unwrap(); + + let mut tcb_info: Value = serde_json::from_str(&base_collateral.tcb_info).unwrap(); + tcb_info["nextUpdate"] = Value::String(config.tcb_next_update.clone()); + + let mut qe_identity: Value = serde_json::from_str(&base_collateral.qe_identity).unwrap(); + qe_identity["nextUpdate"] = Value::String(config.qe_next_update.clone()); + + let tcb_calls = Arc::new(AtomicUsize::new(0)); + let qe_calls = Arc::new(AtomicUsize::new(0)); + let state = Arc::new(MockPcsState { + fmspc: config.fmspc, + ca: config.ca.to_string(), + base_tcb_info: tcb_info, + base_qe_identity: qe_identity, + tcb_signature_hex: hex::encode(&base_collateral.tcb_info_signature), + qe_signature_hex: hex::encode(&base_collateral.qe_identity_signature), + tcb_next_update: config.tcb_next_update, + 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, + pck_crl: base_collateral.pck_crl, + pck_crl_issuer_chain: "mock-pck-crl-issuer-chain".to_string(), + tcb_issuer_chain: "mock-tcb-info-issuer-chain".to_string(), + qe_issuer_chain: "mock-qe-issuer-chain".to_string(), + root_ca_crl_hex: hex::encode(base_collateral.root_ca_crl), + tcb_calls: tcb_calls.clone(), + qe_calls: qe_calls.clone(), + }); + + let app = Router::new() + .route("/sgx/certification/v4/pckcrl", get(mock_pck_crl_handler)) + .route("/tdx/certification/v4/tcb", get(mock_tcb_handler)) + .route("/tdx/certification/v4/qe/identity", get(mock_qe_identity_handler)) + .route("/sgx/certification/v4/rootcacrl", get(mock_root_ca_crl_handler)) + .with_state(state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + MockPcsServer { base_url: format!("http://{addr}"), _task: task, tcb_calls, qe_calls } +} + +async fn mock_pck_crl_handler( + State(state): State>, + Query(params): Query>, +) -> impl IntoResponse { + assert_eq!(params.get("ca"), Some(&state.ca)); + assert_eq!(params.get("encoding"), Some(&"der".to_string())); + ([("SGX-PCK-CRL-Issuer-Chain", state.pck_crl_issuer_chain.clone())], state.pck_crl.clone()) +} + +async fn mock_tcb_handler( + State(state): State>, + Query(params): Query>, +) -> impl IntoResponse { + 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(); + let next_update = if call_number == 1 { + state.tcb_next_update.clone() + } else { + state.refreshed_tcb_next_update.clone().unwrap_or_else(|| state.tcb_next_update.clone()) + }; + tcb_info["nextUpdate"] = Value::String(next_update); + ( + [("SGX-TCB-Info-Issuer-Chain", state.tcb_issuer_chain.clone())], + Json(json!({ + "tcbInfo": tcb_info, + "signature": state.tcb_signature_hex, + })), + ) +} + +async fn mock_qe_identity_handler( + State(state): State>, + Query(params): Query>, +) -> impl IntoResponse { + 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(); + let next_update = if call_number == 1 { + state.qe_next_update.clone() + } else { + state.refreshed_qe_next_update.clone().unwrap_or_else(|| state.qe_next_update.clone()) + }; + qe_identity["nextUpdate"] = Value::String(next_update); + ( + [("SGX-Enclave-Identity-Issuer-Chain", state.qe_issuer_chain.clone())], + Json(json!({ + "enclaveIdentity": qe_identity, + "signature": state.qe_signature_hex, + })), + ) +} + +async fn mock_root_ca_crl_handler(State(state): State>) -> impl IntoResponse { + state.root_ca_crl_hex.clone() +} From 4ff46ce391bac012a16ef6f94df4f500b9b5f562 Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 9 Mar 2026 12:44:39 +0100 Subject: [PATCH 4/6] Add ready method to await pre-warming, and fix tests to not actually hit up intel when testing --- crates/pccs/src/lib.rs | 156 ++++++++++++++++++++++++++---------- crates/pccs/src/mock_pcs.rs | 23 +++++- 2 files changed, 132 insertions(+), 47 deletions(-) diff --git a/crates/pccs/src/lib.rs b/crates/pccs/src/lib.rs index 4aaa6a0..a486cce 100644 --- a/crates/pccs/src/lib.rs +++ b/crates/pccs/src/lib.rs @@ -12,7 +12,7 @@ use dcap_qvl::{QuoteCollateralV3, collateral::get_collateral_for_fmspc, tcb_info use thiserror::Error; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::{ - sync::{RwLock, Semaphore}, + sync::{RwLock, Semaphore, watch}, task::{JoinHandle, JoinSet}, time::{Duration, sleep}, }; @@ -36,6 +36,8 @@ pub struct Pccs { cache: Arc>>, /// The state of the initial pre-warm fetch prewarm_stats: Arc, + /// Completion signal for startup pre-warm, shared across all clones + prewarm_outcome_tx: watch::Sender>, } impl std::fmt::Debug for Pccs { @@ -56,21 +58,40 @@ impl Pccs { .trim_end_matches("/tdx/certification/v4") .to_string(); + let (prewarm_outcome_tx, _) = watch::channel(None); let pccs = Self { pccs_url, cache: RwLock::new(HashMap::new()).into(), prewarm_stats: Arc::new(PrewarmStats::default()), + prewarm_outcome_tx, }; // Start filling the cache right away let pccs_for_prewarm = pccs.clone(); tokio::spawn(async move { - pccs_for_prewarm.startup_prewarm_all_tdx().await; + let outcome = pccs_for_prewarm.startup_prewarm_all_tdx().await; + pccs_for_prewarm.finish_prewarm(outcome); }); pccs } + /// Resolves when cache is pre-warmed with all available collateral + pub async fn ready(&self) -> Result { + let mut outcome_rx = self.prewarm_outcome_tx.subscribe(); + loop { + if let Some(outcome) = outcome_rx.borrow_and_update().clone() { + return match outcome { + PrewarmOutcome::Ready(summary) => Ok(summary), + PrewarmOutcome::Failed(message) => Err(PccsError::PrewarmFailed(message)), + }; + } + if outcome_rx.changed().await.is_err() { + return Err(PccsError::PrewarmSignalClosed); + } + } + } + /// Returns collateral from cache when valid, otherwise fetches and /// caches fresh collateral pub async fn get_collateral( @@ -153,22 +174,22 @@ impl Pccs { /// Pre-provisions TDX collateral for discovered FMSPC values to reduce /// hot-path fetches - async fn startup_prewarm_all_tdx(&self) { + async fn startup_prewarm_all_tdx(&self) -> PrewarmOutcome { // First get all FMSPCs let fmspcs = match self.fetch_fmspcs().await { Ok(fmspcs) => fmspcs, Err(e) => { tracing::warn!(error = %e, "Failed to fetch FMSPC list for startup pre-provision"); - self.prewarm_stats.completed.store(true, Ordering::SeqCst); - return; + return PrewarmOutcome::Failed(format!( + "Failed to fetch FMSPC list for prewarm: {e}" + )); } }; self.prewarm_stats.discovered_fmspcs.store(fmspcs.len(), Ordering::SeqCst); if fmspcs.is_empty() { tracing::warn!("No FMSPC entries returned during startup pre-provision"); - self.prewarm_stats.completed.store(true, Ordering::SeqCst); - return; + return PrewarmOutcome::Ready(self.prewarm_stats.snapshot()); } // For each FMSPC, get the 'processor' and 'platform' collateral @@ -228,8 +249,6 @@ impl Pccs { } } } - self.prewarm_stats.completed.store(true, Ordering::SeqCst); - tracing::info!( discovered_fmspcs = self.prewarm_stats.discovered_fmspcs.load(Ordering::SeqCst), attempted = self.prewarm_stats.attempted.load(Ordering::SeqCst), @@ -237,6 +256,12 @@ impl Pccs { failures, "Completed PCCS startup pre-provisioning for TDX collateral" ); + PrewarmOutcome::Ready(self.prewarm_stats.snapshot()) + } + + fn finish_prewarm(&self, outcome: PrewarmOutcome) { + self.prewarm_stats.completed.store(true, Ordering::SeqCst); + let _ = self.prewarm_outcome_tx.send(Some(outcome)); } /// Fetches available FMSPC entries from configured PCCS/PCS endpoint @@ -253,6 +278,21 @@ impl Pccs { } } +/// Final startup pre-warm status and counters. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PrewarmSummary { + pub discovered_fmspcs: usize, + pub attempted: usize, + pub successes: usize, + pub failures: usize, +} + +#[derive(Clone, Debug)] +enum PrewarmOutcome { + Ready(PrewarmSummary), + Failed(String), +} + /// Cache key for PCCS collateral entries #[derive(Clone, Debug, Hash, PartialEq, Eq)] struct PccsInput { @@ -471,6 +511,17 @@ struct PrewarmStats { completed: AtomicBool, } +impl PrewarmStats { + fn snapshot(&self) -> PrewarmSummary { + PrewarmSummary { + discovered_fmspcs: self.discovered_fmspcs.load(Ordering::SeqCst), + attempted: self.attempted.load(Ordering::SeqCst), + successes: self.successes.load(Ordering::SeqCst), + failures: self.failures.load(Ordering::SeqCst), + } + } +} + #[derive(Error, Debug)] pub enum PccsError { #[error("DCAP quote verification: {0}")] @@ -487,6 +538,10 @@ pub enum PccsError { FmspcFetch(reqwest::StatusCode), #[error("JSON: {0}")] Json(#[from] serde_json::Error), + #[error("PCCS prewarm failed: {0}")] + PrewarmFailed(String), + #[error("PCCS prewarm signal channel closed before completion")] + PrewarmSignalClosed, } #[cfg(test)] @@ -494,8 +549,6 @@ mod mock_pcs; #[cfg(test)] mod tests { - use std::sync::atomic::Ordering; - use tokio::time::Duration; use super::{ @@ -507,7 +560,7 @@ mod tests { async fn test_mock_pcs_server_helper_with_get_collateral() { let mock = spawn_mock_pcs_server(MockPcsConfig { fmspc: "00806F050000".to_string(), - ca: "processor", + 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, @@ -534,7 +587,7 @@ mod tests { let mock = spawn_mock_pcs_server(MockPcsConfig { fmspc: "00806F050000".to_string(), - ca: "processor", + include_fmspcs_listing: false, tcb_next_update: initial_next_update.clone(), qe_next_update: initial_next_update, refreshed_tcb_next_update: Some(refreshed_next_update.clone()), @@ -580,39 +633,27 @@ mod tests { } #[tokio::test] - async fn test_startup_prewarm_populates_cache_from_intel_pcs() { - let pccs = Pccs::new(None); - - let mut prewarm_completed = false; - for _ in 0..40 { - if pccs.prewarm_stats.completed.load(Ordering::SeqCst) { - prewarm_completed = true; - break; - } - tokio::time::sleep(Duration::from_millis(500)).await; - } + async fn test_ready_waits_for_startup_prewarm() { + let mock = spawn_mock_pcs_server(MockPcsConfig { + fmspc: "00806F050000".to_string(), + include_fmspcs_listing: true, + 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; + let pccs = Pccs::new(Some(mock.base_url.clone())); + 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); let cache_guard = pccs.cache.read().await; let total_entries = cache_guard.len(); - let unique_fmspcs: std::collections::HashSet<_> = - cache_guard.keys().map(|k| k.fmspc.clone()).collect(); - println!( - "startup prewarm summary: completed={}, discovered_fmspcs={}, attempted={}, successes={}, failures={}, cache_entries_total={}, cache_unique_fmspcs={}", - prewarm_completed, - pccs.prewarm_stats.discovered_fmspcs.load(Ordering::SeqCst), - pccs.prewarm_stats.attempted.load(Ordering::SeqCst), - pccs.prewarm_stats.successes.load(Ordering::SeqCst), - pccs.prewarm_stats.failures.load(Ordering::SeqCst), - total_entries, - unique_fmspcs.len() - ); - if pccs.prewarm_stats.discovered_fmspcs.load(Ordering::SeqCst) == 0 { - println!( - "startup prewarm made no discovery progress in test window, skipping cache assertions" - ); - return; - } - assert!(total_entries > 0, "expected startup pre-provision to populate PCCS cache"); + assert_eq!(total_entries, 2, "expected startup pre-provision to cache processor+platform"); let (fmspc, ca) = cache_guard .keys() @@ -625,4 +666,33 @@ mod tests { let (_, is_fresh) = pccs.get_collateral(fmspc, ca_static, now).await.unwrap(); assert!(!is_fresh); } + + #[tokio::test] + async fn test_ready_supports_multiple_waiters() { + let mock = spawn_mock_pcs_server(MockPcsConfig { + fmspc: "00806F050000".to_string(), + include_fmspcs_listing: true, + 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; + let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs_clone = pccs.clone(); + + let (first, second) = tokio::join!(pccs.ready(), pccs_clone.ready()); + let first = first.unwrap(); + let second = second.unwrap(); + assert_eq!(first, second); + assert_eq!(first.discovered_fmspcs, 1); + } + + #[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 ready_result = + tokio::time::timeout(Duration::from_secs(2), pccs.ready()).await.unwrap(); + assert!(matches!(ready_result, Err(PccsError::PrewarmFailed(_)))); + } } diff --git a/crates/pccs/src/mock_pcs.rs b/crates/pccs/src/mock_pcs.rs index feee32c..da96b7f 100644 --- a/crates/pccs/src/mock_pcs.rs +++ b/crates/pccs/src/mock_pcs.rs @@ -22,7 +22,7 @@ use tokio::{net::TcpListener, task::JoinHandle}; #[derive(Clone)] pub(super) struct MockPcsConfig { pub(super) fmspc: String, - pub(super) ca: &'static str, + pub(super) include_fmspcs_listing: bool, pub(super) tcb_next_update: String, pub(super) qe_next_update: String, pub(super) refreshed_tcb_next_update: Option, @@ -56,7 +56,7 @@ impl MockPcsServer { #[derive(Clone)] struct MockPcsState { fmspc: String, - ca: String, + include_fmspcs_listing: bool, base_tcb_info: Value, base_qe_identity: Value, tcb_signature_hex: String, @@ -90,7 +90,7 @@ pub(super) async fn spawn_mock_pcs_server(config: MockPcsConfig) -> MockPcsServe let qe_calls = Arc::new(AtomicUsize::new(0)); let state = Arc::new(MockPcsState { fmspc: config.fmspc, - ca: config.ca.to_string(), + include_fmspcs_listing: config.include_fmspcs_listing, base_tcb_info: tcb_info, base_qe_identity: qe_identity, tcb_signature_hex: hex::encode(&base_collateral.tcb_info_signature), @@ -109,6 +109,7 @@ pub(super) async fn spawn_mock_pcs_server(config: MockPcsConfig) -> MockPcsServe }); let app = Router::new() + .route("/sgx/certification/v4/fmspcs", get(mock_fmspcs_handler)) .route("/sgx/certification/v4/pckcrl", get(mock_pck_crl_handler)) .route("/tdx/certification/v4/tcb", get(mock_tcb_handler)) .route("/tdx/certification/v4/qe/identity", get(mock_qe_identity_handler)) @@ -128,11 +129,25 @@ async fn mock_pck_crl_handler( State(state): State>, Query(params): Query>, ) -> impl IntoResponse { - assert_eq!(params.get("ca"), Some(&state.ca)); + assert!( + matches!(params.get("ca").map(String::as_str), Some("processor") | Some("platform")), + "unexpected ca query value for pckcrl" + ); assert_eq!(params.get("encoding"), Some(&"der".to_string())); ([("SGX-PCK-CRL-Issuer-Chain", state.pck_crl_issuer_chain.clone())], state.pck_crl.clone()) } +async fn mock_fmspcs_handler(State(state): State>) -> impl IntoResponse { + if state.include_fmspcs_listing { + Json(json!([{ + "fmspc": state.fmspc, + "platform": "all", + }])) + } else { + Json(json!([])) + } +} + async fn mock_tcb_handler( State(state): State>, Query(params): Query>, From 7745ae717509d9810a623009ff191fe55cda4bcd Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 9 Mar 2026 13:34:38 +0100 Subject: [PATCH 5/6] Fix pccs workspace dep --- Cargo.lock | 1 + Cargo.toml | 1 + crates/attestation/Cargo.toml | 1 + crates/attestation/src/azure/mod.rs | 9 +- crates/attestation/src/dcap.rs | 123 +++++++++++++++++++++++----- crates/attestation/src/lib.rs | 29 +++++-- 6 files changed, 132 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 22015b2..5bcd881 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,7 @@ dependencies = [ "once_cell", "openssl", "parity-scale-codec", + "pccs", "pem-rfc7468", "rand_core 0.6.4", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 0d3b4ca..568ebd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ tokio = "1.48.0" tokio-rustls = { version = "0.26.4", default-features = false } dcap-qvl = { git = "https://github.com/Phala-Network/dcap-qvl.git" } +pccs = { path = "crates/pccs" } [workspace.lints.rust] unreachable_pub = "deny" diff --git a/crates/attestation/Cargo.toml b/crates/attestation/Cargo.toml index d47c104..16bdc31 100644 --- a/crates/attestation/Cargo.toml +++ b/crates/attestation/Cargo.toml @@ -8,6 +8,7 @@ repository = "https://github.com/flashbots/attested-tls" keywords = ["attestation", "CVM", "TDX"] [dependencies] +pccs = { workspace = true } tokio = { workspace = true, features = ["fs"] } tokio-rustls = { workspace = true, default-features = false } x509-parser = "0.18.0" diff --git a/crates/attestation/src/azure/mod.rs b/crates/attestation/src/azure/mod.rs index 7d3a7ab..c16e94e 100644 --- a/crates/attestation/src/azure/mod.rs +++ b/crates/attestation/src/azure/mod.rs @@ -7,6 +7,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE as BASE64_URL_SAFE}; use dcap_qvl::QuoteCollateralV3; use num_bigint::BigUint; use openssl::{error::ErrorStack, pkey::PKey}; +use pccs::Pccs; use serde::{Deserialize, Serialize}; use thiserror::Error; use x509_parser::prelude::*; @@ -80,7 +81,7 @@ pub fn create_azure_attestation(input_data: [u8; 64]) -> Result, MaaErro pub async fn verify_azure_attestation( input: Vec, expected_input_data: [u8; 64], - pccs_url: Option, + pccs: Option, override_azure_outdated_tcb: bool, ) -> Result { let now = std::time::SystemTime::now() @@ -91,7 +92,7 @@ pub async fn verify_azure_attestation( verify_azure_attestation_with_given_timestamp( input, expected_input_data, - pccs_url, + pccs, None, now, override_azure_outdated_tcb, @@ -105,7 +106,7 @@ pub async fn verify_azure_attestation( async fn verify_azure_attestation_with_given_timestamp( input: Vec, expected_input_data: [u8; 64], - pccs_url: Option, + pccs: Option, collateral: Option, now: u64, override_azure_outdated_tcb: bool, @@ -127,7 +128,7 @@ async fn verify_azure_attestation_with_given_timestamp( let _dcap_measurements = verify_dcap_attestation_with_given_timestamp( tdx_quote_bytes, expected_tdx_input_data, - pccs_url, + pccs, collateral, now, override_azure_outdated_tcb, diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index bf29dcd..752377d 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -6,7 +6,9 @@ use dcap_qvl::{ collateral::get_collateral_for_fmspc, quote::{Quote, Report}, tcb_info::TcbInfo, + verify::VerifiedReport, }; +use pccs::{Pccs, PccsError}; use thiserror::Error; use crate::{AttestationError, measurements::MultiMeasurements}; @@ -30,14 +32,14 @@ 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_url: Option, + pccs: Option, ) -> Result { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); let override_azure_outdated_tcb = false; verify_dcap_attestation_with_given_timestamp( input, expected_input_data, - pccs_url, + pccs, None, now, override_azure_outdated_tcb, @@ -53,7 +55,7 @@ pub async fn verify_dcap_attestation( pub async fn verify_dcap_attestation_with_given_timestamp( input: Vec, expected_input_data: [u8; 64], - pccs_url: Option, + pccs_option: Option, collateral: Option, now: u64, override_azure_outdated_tcb: bool, @@ -61,6 +63,8 @@ pub async fn verify_dcap_attestation_with_given_timestamp( let quote = Quote::parse(&input)?; tracing::info!("Verifying DCAP attestation: {quote:?}"); + let now_i64 = i64::try_from(now).map_err(|_| DcapVerificationError::TimeStampExceedsI64)?; + let ca = quote.ca()?; let fmspc = hex::encode_upper(quote.fmspc()?); @@ -81,26 +85,68 @@ pub async fn verify_dcap_attestation_with_given_timestamp( |tcb_info: TcbInfo| tcb_info }; - let collateral = match collateral { - Some(c) => c, + match collateral { + Some(given_collateral) => { + let verified_report = dcap_qvl::verify::dangerous_verify_with_tcb_override( + &input, + &given_collateral, + now, + override_outdated_tcb, + )?; + warn_if_non_uptodate(&verified_report, &fmspc, CollateralSource::Provided); + } None => { - get_collateral_for_fmspc( - &pccs_url.clone().unwrap_or(PCS_URL.to_string()), - fmspc, - ca, - false, // Indicates not SGX - ) - .await? + let (collateral, is_fresh) = if let Some(ref pccs) = pccs_option { + pccs.get_collateral(fmspc.clone(), ca, now_i64).await? + } else { + let collateral = get_collateral_for_fmspc( + PCS_URL, + fmspc.clone(), + ca, + false, // Indicates not SGX + ) + .await?; + (collateral, true) + }; + + let initial_source = + if is_fresh { CollateralSource::Fresh } else { CollateralSource::Cached }; + let initial_verification = dcap_qvl::verify::dangerous_verify_with_tcb_override( + &input, + &collateral, + now, + override_outdated_tcb, + ); + + match initial_verification { + Ok(verified_report) => { + warn_if_non_uptodate(&verified_report, &fmspc, initial_source); + } + Err(e) => { + if is_fresh { + return Err(e.into()); + } + tracing::warn!("Verification failed - trying with fresh collateral: {e}"); + if let Some(pccs) = pccs_option { + let collateral = + pccs.refresh_collateral(fmspc.clone(), ca, now_i64).await?; + let verified_report = dcap_qvl::verify::dangerous_verify_with_tcb_override( + &input, + &collateral, + now, + override_outdated_tcb, + )?; + warn_if_non_uptodate( + &verified_report, + &fmspc, + CollateralSource::RefreshedAfterFailure, + ); + } + } + } } }; - let _verified_report = dcap_qvl::verify::dangerous_verify_with_tcb_override( - &input, - &collateral, - now, - override_outdated_tcb, - )?; - let measurements = MultiMeasurements::from_dcap_qvl_quote("e)?; if get_quote_input_data(quote.report) != expected_input_data { @@ -114,7 +160,7 @@ pub async fn verify_dcap_attestation_with_given_timestamp( pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], - _pccs_url: Option, + _pccs: Option, ) -> Result { // In tests we use mock quotes which will fail to verify let quote = tdx_quote::Quote::from_bytes(&input)?; @@ -153,6 +199,39 @@ pub fn get_quote_input_data(report: Report) -> [u8; 64] { } } +/// Origin of collateral used for a verification attempt +#[derive(Clone, Copy, Debug)] +enum CollateralSource { + Provided, + Cached, + Fresh, + RefreshedAfterFailure, +} + +impl CollateralSource { + /// Returns a stable source label for structured logs + fn as_str(self) -> &'static str { + match self { + Self::Provided => "provided", + Self::Cached => "cached", + Self::Fresh => "fresh", + Self::RefreshedAfterFailure => "refreshed_after_failure", + } + } +} + +/// Logs a warning when verification succeeds with a non-UpToDate TCB status +fn warn_if_non_uptodate(report: &VerifiedReport, fmspc: &str, source: CollateralSource) { + if report.status != "UpToDate" { + tracing::warn!( + status = %report.status, + advisory_ids = ?report.advisory_ids, + fmspc, + collateral_source = source.as_str(), + "DCAP verification succeeded with non-UpToDate TCB status" + ); + } +} /// An error when verifying a DCAP attestation #[derive(Error, Debug)] pub enum DcapVerificationError { @@ -167,6 +246,10 @@ pub enum DcapVerificationError { #[cfg(any(test, feature = "mock"))] #[error("Quote parse: {0}")] QuoteParse(#[from] tdx_quote::QuoteParseError), + #[error("PCCS: {0}")] + Pccs(#[from] PccsError), + #[error("Timestamp exceeds i64 range")] + TimeStampExceedsI64, } #[cfg(test)] diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index df5c5ae..f472b02 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -13,6 +13,7 @@ use std::{ use measurements::MultiMeasurements; use parity_scale_codec::{Decode, Encode}; +use pccs::Pccs; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -244,25 +245,37 @@ impl AttestationGenerator { pub struct AttestationVerifier { /// The measurement policy with accepted values and attestation types pub measurement_policy: MeasurementPolicy, - /// If this is empty, anything will be accepted - but measurements are - /// always injected into HTTP headers, so that they can be verified - /// upstream A PCCS service to use - defaults to Intel PCS - pub pccs_url: Option, /// Whether to log quotes to a file pub log_dcap_quote: bool, /// Whether to override outdated TCB when on Azure pub override_azure_outdated_tcb: bool, + /// Internal cache for collateral + pub internal_pccs: Option, } impl AttestationVerifier { + pub fn new( + measurement_policy: MeasurementPolicy, + pccs_url: Option, + log_dcap_quote: bool, + override_azure_outdated_tcb: bool, + ) -> Self { + Self { + measurement_policy, + log_dcap_quote, + override_azure_outdated_tcb, + internal_pccs: Some(Pccs::new(pccs_url)), + } + } + /// Create an [AttestationVerifier] which will allow no remote /// attestation pub fn expect_none() -> Self { Self { measurement_policy: MeasurementPolicy::expect_none(), - pccs_url: None, log_dcap_quote: false, override_azure_outdated_tcb: false, + internal_pccs: None, } } @@ -271,9 +284,9 @@ impl AttestationVerifier { pub fn mock() -> Self { Self { measurement_policy: MeasurementPolicy::mock(), - pccs_url: None, log_dcap_quote: false, override_azure_outdated_tcb: false, + internal_pccs: None, } } @@ -308,7 +321,7 @@ impl AttestationVerifier { azure::verify_azure_attestation( attestation_exchange_message.attestation, expected_input_data, - self.pccs_url.clone(), + self.internal_pccs.clone(), self.override_azure_outdated_tcb, ) .await? @@ -322,7 +335,7 @@ impl AttestationVerifier { dcap::verify_dcap_attestation( attestation_exchange_message.attestation, expected_input_data, - self.pccs_url.clone(), + self.internal_pccs.clone(), ) .await? } From c6f7d6b9a0bc961378a68160c66582dd08171aed Mon Sep 17 00:00:00 2001 From: peg Date: Tue, 10 Mar 2026 09:19:28 +0100 Subject: [PATCH 6/6] Add example which demonstrates getting collateral from Intel PCS --- Cargo.lock | 73 +++++++++++++++++++++++++++++++ crates/pccs/Cargo.toml | 1 + crates/pccs/examples/intel_pcs.rs | 34 ++++++++++++++ crates/pccs/src/lib.rs | 4 +- 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 crates/pccs/examples/intel_pcs.rs diff --git a/Cargo.lock b/Cargo.lock index 5bcd881..fe3d734 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1601,6 +1601,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" @@ -1686,6 +1695,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1944,6 +1962,7 @@ dependencies = [ "time", "tokio", "tracing", + "tracing-subscriber", ] [[package]] @@ -2675,6 +2694,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2873,6 +2901,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -3071,6 +3108,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -3198,6 +3265,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/crates/pccs/Cargo.toml b/crates/pccs/Cargo.toml index d8669cb..e78a189 100644 --- a/crates/pccs/Cargo.toml +++ b/crates/pccs/Cargo.toml @@ -22,3 +22,4 @@ reqwest = { version = "0.12.23", default-features = false, features = [ [dev-dependencies] axum = "0.8.6" +tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] } diff --git a/crates/pccs/examples/intel_pcs.rs b/crates/pccs/examples/intel_pcs.rs new file mode 100644 index 0000000..4e6b3d0 --- /dev/null +++ b/crates/pccs/examples/intel_pcs.rs @@ -0,0 +1,34 @@ +//! Demonstrates setting up a PCCS cache using Intel PCS +use std::time::Instant; + +use pccs::{PCS_URL, Pccs}; +use tracing::info; +use tracing_subscriber::{EnvFilter, fmt}; + +fn init_logging() { + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,pccs=debug")); + + fmt().with_env_filter(env_filter).with_target(false).compact().init(); +} + +#[tokio::main] +async fn main() -> Result<(), pccs::PccsError> { + init_logging(); + + info!(pcs_url = PCS_URL, "Starting PCCS with Intel PCS"); + + let pccs = Pccs::new(None); + let started_at = Instant::now(); + let summary = pccs.ready().await?; + let elapsed = started_at.elapsed().as_secs_f64(); + + println!("Intel PCS startup prewarm complete"); + println!("Elapsed seconds: {elapsed:.2}"); + println!("Discovered FMSPC entries: {}", summary.discovered_fmspcs); + println!("Collateral fetch attempts: {}", summary.attempted); + println!("Collateral fetch successes: {}", summary.successes); + println!("Collateral fetch failures: {}", summary.failures); + + Ok(()) +} diff --git a/crates/pccs/src/lib.rs b/crates/pccs/src/lib.rs index a486cce..fcd6850 100644 --- a/crates/pccs/src/lib.rs +++ b/crates/pccs/src/lib.rs @@ -16,6 +16,7 @@ use tokio::{ task::{JoinHandle, JoinSet}, time::{Duration, sleep}, }; +use tracing::debug; /// For fetching collateral directly from Intel pub const PCS_URL: &str = "https://api.trustedservices.intel.com"; @@ -223,8 +224,9 @@ impl Pccs { let mut failures = 0usize; while let Some(task_result) = join_set.join_next().await { match task_result { - Ok(Ok((_, _, Ok(())))) => { + Ok(Ok((fmspc, ca, Ok(())))) => { successes += 1; + debug!("Successfully cached: {fmspc} {ca}"); self.prewarm_stats.successes.fetch_add(1, Ordering::SeqCst); } Ok(Ok((fmspc, ca, Err(e)))) => {