diff --git a/Cargo.lock b/Cargo.lock index 60c1b6f..9f012d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -526,6 +526,7 @@ dependencies = [ "alloy-rpc-client", "alloy-transport-http", "anyhow", + "axum", "az-tdx-vtpm", "base64 0.22.1", "bytes", diff --git a/attestation-provider-server/src/main.rs b/attestation-provider-server/src/main.rs index b7c5c13..1f9b94f 100644 --- a/attestation-provider-server/src/main.rs +++ b/attestation-provider-server/src/main.rs @@ -94,12 +94,8 @@ async fn main() -> anyhow::Result<()> { None => MeasurementPolicy::accept_anything(), }; - let attestation_verifier = AttestationVerifier { - measurement_policy, - pccs_url: None, - log_dcap_quote: cli.log_dcap_quote, - override_azure_outdated_tcb: false, - }; + let attestation_verifier = + AttestationVerifier::new(measurement_policy, None, cli.log_dcap_quote, false); let attestation_message = attestation_provider_client(server_addr, attestation_verifier).await?; diff --git a/attested-tls/Cargo.toml b/attested-tls/Cargo.toml index c1a3273..be6bba7 100644 --- a/attested-tls/Cargo.toml +++ b/attested-tls/Cargo.toml @@ -34,7 +34,7 @@ parity-scale-codec = "3.7.5" openssl = "0.10.75" num-bigint = "0.4.6" webpki = { package = "rustls-webpki", version = "0.103.8" } -time = "0.3.44" +time = { version = "0.3.44", features = ["parsing", "formatting"] } once_cell = "1.21.3" # Used for azure vTPM attestation support @@ -60,6 +60,7 @@ rcgen = { version = "0.14.5", optional = true } tdx-quote = { version = "0.0.5", features = ["mock"], optional = true } [dev-dependencies] +axum = "0.8.6" rcgen = "0.14.5" tempfile = "3.23.0" tdx-quote = { version = "0.0.5", features = ["mock"] } diff --git a/attested-tls/src/attestation/azure/mod.rs b/attested-tls/src/attestation/azure/mod.rs index 63486b1..cd32816 100644 --- a/attested-tls/src/attestation/azure/mod.rs +++ b/attested-tls/src/attestation/azure/mod.rs @@ -13,7 +13,7 @@ use thiserror::Error; use x509_parser::prelude::*; use crate::attestation::{ - dcap::verify_dcap_attestation_with_given_timestamp, measurements::MultiMeasurements, + Pccs, dcap::verify_dcap_attestation_with_given_timestamp, measurements::MultiMeasurements, }; /// The attestation evidence payload that gets sent over the channel @@ -81,7 +81,7 @@ pub async fn create_azure_attestation(input_data: [u8; 64]) -> Result, M pub async fn verify_azure_attestation( input: Vec, expected_input_data: [u8; 64], - pccs_url: Option, + pccs: Pccs, override_azure_outdated_tcb: bool, ) -> Result { let now = std::time::SystemTime::now() @@ -92,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 +105,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: Pccs, collateral: Option, now: u64, override_azure_outdated_tcb: bool, @@ -127,7 +127,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, @@ -418,8 +418,8 @@ mod tests { let measurements = verify_azure_attestation_with_given_timestamp( attestation_bytes.to_vec(), [0; 64], // Input data - None, - collateral, + Pccs::new(None), + Some(collateral), now, false, ) @@ -445,7 +445,7 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp( attestation_bytes.to_vec(), expected_input_data, - None, + Pccs::new(None), Some(collateral), now, false, diff --git a/attested-tls/src/attestation/dcap.rs b/attested-tls/src/attestation/dcap.rs index 2632557..6d6ccad 100644 --- a/attested-tls/src/attestation/dcap.rs +++ b/attested-tls/src/attestation/dcap.rs @@ -1,12 +1,13 @@ //! Data Center Attestation Primitives (DCAP) evidence generation and verification +use crate::attestation::pccs::Pccs; use crate::attestation::{AttestationError, measurements::MultiMeasurements}; use configfs_tsm::QuoteGenerationError; use dcap_qvl::{ QuoteCollateralV3, - collateral::get_collateral_for_fmspc, quote::{Quote, Report}, tcb_info::TcbInfo, + verify::VerifiedReport, }; use thiserror::Error; @@ -28,7 +29,7 @@ pub async fn create_dcap_attestation(input_data: [u8; 64]) -> Result, At pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], - pccs_url: Option, + pccs: Pccs, ) -> Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? @@ -37,7 +38,7 @@ pub async fn verify_dcap_attestation( verify_dcap_attestation_with_given_timestamp( input, expected_input_data, - pccs_url, + pccs, None, now, override_azure_outdated_tcb, @@ -51,13 +52,16 @@ 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: Pccs, collateral: Option, now: u64, override_azure_outdated_tcb: bool, ) -> Result { let quote = Quote::parse(&input)?; tracing::info!("Verifying DCAP attestation: {quote:?}"); + let now_i64 = i64::try_from(now).map_err(|_| { + DcapVerificationError::PccsCollateralParse(format!("Timestamp {now} exceeds i64 range")) + })?; let ca = quote.ca()?; let fmspc = hex::encode_upper(quote.fmspc()?); @@ -78,26 +82,56 @@ 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::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) = pccs.get_collateral(fmspc.clone(), ca, now_i64).await?; + let initial_source = if is_fresh { + CollateralSource::Fresh + } else { + CollateralSource::Cached + }; + let initial_verification = dcap_qvl::verify::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}"); + let collateral = pccs.refresh_collateral(fmspc.clone(), ca, now_i64).await?; + let verified_report = dcap_qvl::verify::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::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 { @@ -111,7 +145,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: Pccs, ) -> Result { // In tests we use mock quotes which will fail to verify let quote = tdx_quote::Quote::from_bytes(&input)?; @@ -161,11 +195,49 @@ pub enum DcapVerificationError { SystemTime(#[from] std::time::SystemTimeError), #[error("DCAP quote verification: {0}")] DcapQvl(#[from] anyhow::Error), + #[error("PCCS collateral parse error: {0}")] + PccsCollateralParse(String), + #[error("PCCS collateral expired: {0}")] + PccsCollateralExpired(String), #[cfg(any(test, feature = "mock"))] #[error("Quote parse: {0}")] QuoteParse(#[from] tdx_quote::QuoteParseError), } +/// 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" + ); + } +} + #[cfg(test)] mod tests { use crate::attestation::measurements::MeasurementPolicy; @@ -211,7 +283,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), Some(collateral), now, false, @@ -244,7 +316,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), Some(collateral), now, true, diff --git a/attested-tls/src/attestation/mod.rs b/attested-tls/src/attestation/mod.rs index f017f2b..311149d 100644 --- a/attested-tls/src/attestation/mod.rs +++ b/attested-tls/src/attestation/mod.rs @@ -4,6 +4,7 @@ pub mod azure; pub mod dcap; pub mod measurements; +pub(crate) mod pccs; use measurements::MultiMeasurements; use parity_scale_codec::{Decode, Encode}; @@ -16,7 +17,9 @@ use std::{ use thiserror::Error; -use crate::attestation::{dcap::DcapVerificationError, measurements::MeasurementPolicy}; +use crate::attestation::{ + dcap::DcapVerificationError, measurements::MeasurementPolicy, pccs::Pccs, +}; const GCP_METADATA_API: &str = "http://metadata.google.internal/computeMetadata/v1/project/project-id"; @@ -250,24 +253,36 @@ 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 + internal_pccs: Pccs, } 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: 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: Pccs::new(None), } } @@ -276,9 +291,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: Pccs::new(None), } } @@ -312,7 +327,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? @@ -326,7 +341,7 @@ impl AttestationVerifier { dcap::verify_dcap_attestation( attestation_exchange_message.attestation, expected_input_data, - self.pccs_url.clone(), + self.internal_pccs.clone(), ) .await? } diff --git a/attested-tls/src/attestation/pccs.rs b/attested-tls/src/attestation/pccs.rs new file mode 100644 index 0000000..be2837d --- /dev/null +++ b/attested-tls/src/attestation/pccs.rs @@ -0,0 +1,643 @@ +use std::{ + collections::HashMap, + sync::{Arc, Weak}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use dcap_qvl::{QuoteCollateralV3, collateral::get_collateral_for_fmspc, tcb_info::TcbInfo}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tokio::{ + sync::RwLock, + task::JoinHandle, + time::{Duration, sleep}, +}; + +use crate::attestation::dcap::{DcapVerificationError, PCS_URL}; + +const REFRESH_MARGIN_SECS: i64 = 300; +const REFRESH_RETRY_SECS: u64 = 60; + +/// PCCS collateral cache with proactive background refresh +#[derive(Clone)] +pub struct Pccs { + pccs_url: String, + cache: 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 { + Self { + pccs_url: pccs_url.unwrap_or(PCS_URL.to_string()), + cache: RwLock::new(HashMap::new()).into(), + } + } + + /// 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), DcapVerificationError> { + 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; + })); + } +} + +/// 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| { + DcapVerificationError::PccsCollateralParse(format!("Failed to parse TCB info JSON: {e}")) + })?; + let qe_identity: QeIdentityNextUpdate = + serde_json::from_str(&collateral.qe_identity).map_err(|e| { + DcapVerificationError::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(DcapVerificationError::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| { + DcapVerificationError::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, +} + +#[cfg(test)] +mod tests { + use super::*; + + use axum::{ + Json, Router, + extract::{Query, State}, + response::IntoResponse, + routing::get, + }; + use dcap_qvl::QuoteCollateralV3; + use serde_json::{Value, json}; + use std::{ + collections::HashMap as StdHashMap, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + }; + use tokio::{net::TcpListener, task::JoinHandle, time::Duration}; + + #[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, + } + + 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!( + "../../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); + } +} diff --git a/attested-tls/src/lib.rs b/attested-tls/src/lib.rs index dbd08a2..6cde85c 100644 --- a/attested-tls/src/lib.rs +++ b/attested-tls/src/lib.rs @@ -744,12 +744,7 @@ mod tests { .await .unwrap(); - let attestation_verifier = AttestationVerifier { - measurement_policy, - pccs_url: None, - log_dcap_quote: false, - override_azure_outdated_tcb: false, - }; + let attestation_verifier = AttestationVerifier::new(measurement_policy, None, false, false); let client = AttestedTlsClient::new_with_tls_config( client_config, diff --git a/src/lib.rs b/src/lib.rs index 58e0ec9..d955651 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1217,12 +1217,7 @@ mod tests { .await .unwrap(); - let attestation_verifier = AttestationVerifier { - measurement_policy, - pccs_url: None, - log_dcap_quote: false, - override_azure_outdated_tcb: false, - }; + let attestation_verifier = AttestationVerifier::new(measurement_policy, None, false, false); let proxy_client_result = ProxyClient::new_with_tls_config( client_config, diff --git a/src/main.rs b/src/main.rs index d929778..73e802c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -224,12 +224,12 @@ async fn main() -> anyhow::Result<()> { } }; - let attestation_verifier = AttestationVerifier { + let attestation_verifier = AttestationVerifier::new( measurement_policy, - pccs_url: cli.pccs_url, - log_dcap_quote: cli.log_dcap_quote, - override_azure_outdated_tcb: cli.override_azure_outdated_tcb, - }; + cli.pccs_url, + cli.log_dcap_quote, + cli.override_azure_outdated_tcb, + ); match cli.command { CliCommand::Client {