diff --git a/crates/attestation/README.md b/crates/attestation/README.md index c7df79e..4ea6efb 100644 --- a/crates/attestation/README.md +++ b/crates/attestation/README.md @@ -28,14 +28,44 @@ Matched expected measurements can be transported in an HTTP header using ## Runtime Requirements -Verification uses the [`pccs`](../pccs) crate for collateral caching and -background refresh. As a result, constructing an `AttestationVerifier` with -PCCS enabled and calling verification APIs is expected to happen from within a -Tokio runtime and might panic if called outside of one. - -Note that although some of the verification API methods are synchronous (for -example `verify_attestation_sync`), still their functionality depends on -Tokio-backed background tasks such as PCCS pre-warm and cache refresh. +Verification uses the [`pccs`](../pccs) crate to fetch DCAP collateral and, +depending on the selected cache policy, cache and refresh it. Asynchronous +verification requires a Tokio runtime. Constructing an `AttestationVerifier` +with the `Prewarmed` policy also requires an active runtime because pre-warming +starts immediately; constructing it with the `Passthrough` or `OnDemand` +policy does not itself spawn a task. + +Synchronous verification requires a cached policy (`OnDemand` or `Prewarmed`) +with the required collateral already cached. Cache misses and expired entries +may start Tokio-backed background refresh tasks. `Passthrough` cannot be used +for synchronous verification because fetching collateral requires +asynchronous I/O. + +## DCAP collateral configuration + +Every `AttestationVerifier` has an independent collateral source and cache +policy configured through `AttestationVerifierBuilder::with_collateral_source` +and `AttestationVerifierBuilder::with_cache_policy`. The default is anonymous +Intel PCS with `CachePolicy::Passthrough`. + +- `Passthrough` keeps no internal cache and fetches collateral from the + configured endpoint for every asynchronous verification. +- `OnDemand` starts with an empty internal cache and fetches collateral on + demand. +- `Prewarmed` immediately starts discovering and caching available TDX + collateral, then refreshes cached entries before expiry. + +Use `CollateralSource::IntelPcs` with an optional subscription key, or +`CollateralSource::Pccs` with the URL of a compatible service. +`AttestationVerifierBuilder::with_intel_pcs_subscription_key` and +`AttestationVerifierBuilder::with_pccs_url` provide shortcuts for these common +configurations. + +`AttestationVerifier::ready()` waits for initial work only with `Prewarmed`. +It returns immediately for `Passthrough` and `OnDemand`. A successful +return with either policy does not mean later verification will avoid fetching +collateral. With `Prewarmed` it means pre-warm bootstrap completed, but +individual collateral fetches can still have failed. ## Feature flags @@ -80,6 +110,11 @@ must be explicitly enabled via the `override_azure_outdated_tcb` flag on Enables mock quote support via the local `mock-tdx` crate for tests and development on non-TDX hardware. +In mock builds, `CachePolicy::Passthrough` uses embedded mock collateral rather +than making an external request. `CachePolicy::OnDemand` and +`CachePolicy::Prewarmed` can be pointed at a local mock PCCS when testing cache +behavior. + Do not use in production. Disabled by default. ## Attestation Types @@ -106,11 +141,12 @@ attempted. Alternatively, an external 'attestation provider service' URL can be provided which outsources the attestation generation to another process. -When verifying DCAP attestations, the Intel PCS is used to retrieve collateral -unless a PCCS URL is provided via a command line argument. If outdated TCB is -used, the quote will fail to verify. For special cases where outdated TCB -should be allowed, a custom override function can be passed when verifying which -may modify collateral before it is validated against the TCB. +When verifying DCAP attestations, collateral is retrieved according to the +configured collateral source and cache policy. The source defaults to Intel PCS +unless a PCCS URL is provided through the verifier builder. If outdated TCB is +used, the quote will fail to verify. For special cases where outdated TCB +should be allowed, a custom override function can be passed when verifying +which may modify collateral before it is validated against the TCB. ## Measurements File diff --git a/crates/attestation/src/azure/attester/mod.rs b/crates/attestation/src/azure/attester/mod.rs index 0a557b7..bf4de2a 100644 --- a/crates/attestation/src/azure/attester/mod.rs +++ b/crates/attestation/src/azure/attester/mod.rs @@ -286,9 +286,9 @@ fn fetch_certificate_der(url: &str) -> Result, MaaError> { #[cfg(test)] mod test_utils { use base64::{Engine as _, engine::general_purpose::URL_SAFE as BASE64_URL_SAFE}; + use pccs::PCS_URL; use super::{super::AttestationDocument, create_azure_attestation}; - use crate::dcap::PCS_URL; /// Capture a complete Azure TDX attestation fixture from inside an /// Azure TDX CVM. diff --git a/crates/attestation/src/azure/verify.rs b/crates/attestation/src/azure/verify.rs index 4b85786..234196a 100644 --- a/crates/attestation/src/azure/verify.rs +++ b/crates/attestation/src/azure/verify.rs @@ -42,7 +42,7 @@ struct PreparedAzureAttestation { pub async fn verify_azure_attestation( input: Vec, expected_input_data: [u8; 64], - pccs: Option, + pccs: Pccs, override_azure_outdated_tcb: bool, ) -> Result { let now = unix_time_now_secs()?; @@ -62,6 +62,9 @@ pub async fn verify_azure_attestation( /// /// This relies on having DCAP collateral already present in the cache /// +/// [`CachePolicy::Passthrough`](pccs::CachePolicy::Passthrough) is not +/// supported because fetching collateral requires asynchronous I/O. +/// /// If possible, prefer the async version pub fn verify_azure_attestation_sync( input: Vec, @@ -87,7 +90,7 @@ pub fn verify_azure_attestation_sync( async fn verify_azure_attestation_with_given_timestamp( input: Vec, expected_input_data: [u8; 64], - pccs: Option, + pccs: Pccs, collateral: Option, now: u64, override_azure_outdated_tcb: bool, @@ -401,13 +404,26 @@ mod tests { let actual = MAX_AZURE_ATTESTATION_PAYLOAD_SIZE + 1; let input = vec![b'{'; actual]; - let err = verify_azure_attestation(input.clone(), [0; 64], None, false).await.unwrap_err(); + let err = verify_azure_attestation( + input.clone(), + [0; 64], + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::Passthrough, + ), + false, + ) + .await + .unwrap_err(); assert_payload_too_large(err, actual); let err = verify_azure_attestation_sync( input.clone(), [0; 64], - Pccs::new_without_prewarm(None), + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::OnDemand, + ), false, ) .unwrap_err(); @@ -416,7 +432,10 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp( input.clone(), [0; 64], - None, + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::Passthrough, + ), None, 0, false, @@ -428,7 +447,10 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp_sync( input, [0; 64], - Pccs::new_without_prewarm(None), + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::OnDemand, + ), None, 0, false, @@ -482,7 +504,10 @@ mod tests { } = verify_azure_attestation_with_given_timestamp( attestation_json.clone(), [0; 64], - None, + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::Passthrough, + ), Some(fixture_collateral.clone()), now, false, @@ -497,7 +522,10 @@ mod tests { } = verify_azure_attestation_with_given_timestamp_sync( attestation_json, [0; 64], - Pccs::new_without_prewarm(None), + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::OnDemand, + ), Some(fixture_collateral.clone()), now, false, @@ -533,7 +561,10 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp( attestation_json, expected_input_data, - None, + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::Passthrough, + ), Some(collateral), now, false, diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index 9d2e3d7..98f96e4 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -7,13 +7,14 @@ //! it. use dcap_qvl::{ QuoteCollateralV3, - collateral::CollateralClient, intel::{quote_ca, quote_fmspc}, quote::{Quote, Report}, tcb_info::TcbInfo, }; #[cfg(any(test, feature = "mock"))] use mock_tdx::generate_mock_tdx_quote; +#[cfg(test)] +use pccs::{CachePolicy, CollateralSource}; use pccs::{Pccs, PccsError}; use thiserror::Error; @@ -28,9 +29,6 @@ use crate::{ /// or other platforms) const AZURE_BAD_FMSPC: &str = "90C06F000000"; -/// For fetching collateral directly from Intel, if no PCCS is specified -pub const PCS_URL: &str = "https://api.trustedservices.intel.com"; - /// Generate a TDX quote pub fn create_dcap_attestation(input_data: [u8; 64]) -> Result, AttestationError> { let quote = generate_quote(input_data)?; @@ -43,7 +41,7 @@ pub fn create_dcap_attestation(input_data: [u8; 64]) -> Result, Attestat pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], - pccs: Option, + pccs: Pccs, ) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); let override_azure_outdated_tcb = false; @@ -62,6 +60,10 @@ pub async fn verify_dcap_attestation( /// /// This relies on having DCAP collateral already present in the cache /// +/// [`CachePolicy::Passthrough`](pccs::CachePolicy::Passthrough) is not +/// supported because +/// fetching collateral requires asynchronous I/O. +/// /// If possible, prefer the async version #[cfg(not(any(test, feature = "mock")))] pub fn verify_dcap_attestation_sync( @@ -86,6 +88,9 @@ pub fn verify_dcap_attestation_sync( /// /// This relies on having DCAP collateral already present in the cache /// +/// [`CachePolicy::Passthrough`](pccs::CachePolicy::Passthrough) is not +/// supported unless `collateral` is provided. +/// /// If possible, prefer the async version pub fn verify_dcap_attestation_with_timestamp_sync( input: Vec, @@ -124,7 +129,7 @@ pub fn verify_dcap_attestation_with_timestamp_sync( pub async fn verify_dcap_attestation_with_given_timestamp( input: Vec, expected_input_data: [u8; 64], - pccs_option: Option, + pccs: Pccs, collateral: Option, now: u64, override_azure_outdated_tcb: bool, @@ -136,13 +141,9 @@ pub async fn verify_dcap_attestation_with_given_timestamp( let collateral = if let Some(given_collateral) = collateral { given_collateral - } else if let Some(ref pccs) = pccs_option { + } else { let (collateral, _is_fresh) = pccs.get_collateral(fmspc.clone(), ca, now).await?; collateral - } else { - CollateralClient::with_default_http(PCS_URL)? - .fetch_for_fmspc_without_pck_chain(&fmspc, ca, false) - .await? }; verify_dcap_attestation_with_collateral_and_timestamp( @@ -221,17 +222,18 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], - pccs: Option, + pccs: Pccs, ) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); - let collateral = if let Some(ref pccs) = pccs { + + let collateral = if pccs.is_passthrough() { + mock_tdx::mock_collateral() + } else { let (collateral, _is_fresh) = pccs.get_collateral(fmspc, ca, now).await?; collateral - } else { - mock_tdx::mock_collateral() }; let verifier = mock_tdx::mock_dcap_verifier(); verifier.verify(&input, &collateral, now)?; @@ -261,7 +263,13 @@ pub fn verify_dcap_attestation_sync( let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); - let collateral = pccs.get_collateral_sync(fmspc, ca, now)?; + + let collateral = if pccs.is_passthrough() { + mock_tdx::mock_collateral() + } else { + pccs.get_collateral_sync(fmspc, ca, now)? + }; + let verifier = mock_tdx::mock_dcap_verifier(); verifier.verify(&input, &collateral, now)?; @@ -365,7 +373,10 @@ mod tests { 161, 136, 37, 136, 57, 29, 25, 86, 182, 246, 70, 106, 216, 184, 220, 205, 85, 245, 114, 33, 173, 129, 180, 32, 247, 70, 250, 141, 176, 248, 99, 125, ], - None, + Pccs::new( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::Passthrough, + ), Some(fixture_collateral.clone()), now, false, @@ -382,7 +393,10 @@ mod tests { 161, 136, 37, 136, 57, 29, 25, 86, 182, 246, 70, 106, 216, 184, 220, 205, 85, 245, 114, 33, 173, 129, 180, 32, 247, 70, 250, 141, 176, 248, 99, 125, ], - Pccs::new_without_prewarm(None), + Pccs::new( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::OnDemand, + ), Some(fixture_collateral.clone()), now, false, @@ -426,7 +440,10 @@ mod tests { 248, 104, 204, 187, 101, 49, 203, 40, 218, 185, 220, 228, 119, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ], - None, + Pccs::new( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::Passthrough, + ), Some(collateral), now, true, @@ -443,12 +460,15 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock_pcs.base_url.clone())); + let pccs = Pccs::new( + CollateralSource::Pccs { url: mock_pcs.base_url.clone() }, + CachePolicy::OnDemand, + ); let expected_input_data = [0xA5; 64]; let quote = create_dcap_attestation(expected_input_data).unwrap(); let (verified, _) = - verify_dcap_attestation(quote, expected_input_data, Some(pccs)).await.unwrap(); + verify_dcap_attestation(quote, expected_input_data, pccs).await.unwrap(); assert_eq!(verified.measurements, crate::measurements::mock_dcap_measurements()); assert_eq!(mock_pcs.tcb_call_count(), 1); diff --git a/crates/attestation/src/gcp/firmware.rs b/crates/attestation/src/gcp/firmware.rs index 5e5bd4f..e57b2e1 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -160,7 +160,10 @@ mod tests { verify_dcap_attestation_with_given_timestamp( attestation_bytes.to_vec(), expected_input_data, - None, + pccs::Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::Passthrough, + ), Some(collateral), GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, false, diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index b5706c4..3574463 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -26,6 +26,7 @@ pub use attest_types::{AttestationEvidence, PlatformMetadata}; pub use dcap_qvl::QuoteCollateralV3; use measurements::{ExpectedMeasurements, MultiMeasurements}; use parity_scale_codec::{Decode, Encode}; +pub use pccs::{CachePolicy, CollateralSource}; use pccs::{Pccs, PccsError}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -347,19 +348,6 @@ impl AttestationGenerator { } } -/// How the verifier obtains DCAP collateral -#[derive(Clone, Debug)] -pub enum PccsMode { - /// No internal collateral cache. Collateral is always fetched from - /// remote source. - None, - /// Internal cache pre-filled with all available collateral at build - /// time. - Prewarmed, - /// Internal cache that starts empty and fetches on demand. - Lazy, -} - /// Fetched endorsement material, bound to the instant it was evaluated at /// /// Everything fetched expires — `nextUpdate` on TCB Info, QE Identity and @@ -435,8 +423,8 @@ pub struct AttestationVerifier { /// This provides a workaround for a known outdated FMSPC used by Azure #[cfg_attr(not(feature = "azure-verifier"), allow(dead_code))] override_azure_outdated_tcb: bool, - /// Internal cache for collateral - internal_pccs: Option, + /// PCCS collateral source, optionally backed by an internal cache + internal_pccs: Pccs, /// Cached GCP firmware blobs indexed by MRTD known_gcp_firmware: GcpFirmwareCache, /// Cached PPIDs that have a valid GCP host-registry document @@ -463,12 +451,12 @@ impl MeasurementPolicyState { pub struct AttestationVerifierBuilder { /// The measurement policy with accepted values and attestation types measurement_policy: MeasurementPolicy, - /// Internal PCCS setting - pccs_mode: PccsMode, + /// Service from which DCAP collateral is fetched + collateral_source: CollateralSource, + /// Whether and how DCAP collateral is cached in process + cache_policy: CachePolicy, /// A dynamic measurement policy file or URL dynamic_measurement_policy: Option, - /// A PCCS service to use - defaults to Intel PCS - pccs_url: Option, dump_dcap_quotes: bool, /// Whether to override outdated TCB when on Azure override_azure_outdated_tcb: bool, @@ -476,19 +464,13 @@ pub struct AttestationVerifierBuilder { impl AttestationVerifierBuilder { pub fn build(self) -> AttestationVerifier { - let internal_pccs = match self.pccs_mode { - PccsMode::None => None, - PccsMode::Prewarmed => Some(Pccs::new(self.pccs_url)), - PccsMode::Lazy => Some(Pccs::new_without_prewarm(self.pccs_url)), - }; - let verifier = AttestationVerifier { measurement_policy: Arc::new(RwLock::new(MeasurementPolicyState::new( self.measurement_policy, ))), dump_dcap_quotes: self.dump_dcap_quotes, override_azure_outdated_tcb: self.override_azure_outdated_tcb, - internal_pccs, + internal_pccs: Pccs::new(self.collateral_source, self.cache_policy), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), dynamic_measurement_policy: self.dynamic_measurement_policy, @@ -514,14 +496,32 @@ impl AttestationVerifierBuilder { self } - pub fn with_pccs_mode(mut self, pccs_mode: PccsMode) -> Self { - self.pccs_mode = pccs_mode; + /// Configures the service from which DCAP collateral is fetched. + /// + /// The default is anonymous Intel PCS. + pub fn with_collateral_source(mut self, collateral_source: CollateralSource) -> Self { + self.collateral_source = collateral_source; + self + } + + /// Fetches DCAP collateral from Intel PCS using the provided + /// subscription key. + pub fn with_intel_pcs_subscription_key(mut self, key: impl Into) -> Self { + self.collateral_source = CollateralSource::IntelPcs { subscription_key: Some(key.into()) }; self } - /// Set the URL used by internal PCCS - pub fn with_pccs_url(mut self, pccs_url: String) -> Self { - self.pccs_url = Some(pccs_url); + /// Fetches DCAP collateral from the PCCS-compatible service at `url`. + pub fn with_pccs_url(mut self, url: impl Into) -> Self { + self.collateral_source = CollateralSource::Pccs { url: url.into() }; + self + } + + /// Configures whether and how DCAP collateral is cached in process. + /// + /// The default is [`CachePolicy::Passthrough`]. + pub fn with_cache_policy(mut self, cache_policy: CachePolicy) -> Self { + self.cache_policy = cache_policy; self } @@ -541,8 +541,8 @@ impl AttestationVerifier { pub fn builder(measurement_policy: MeasurementPolicy) -> AttestationVerifierBuilder { AttestationVerifierBuilder { measurement_policy, - pccs_mode: PccsMode::None, - pccs_url: None, + collateral_source: CollateralSource::IntelPcs { subscription_key: None }, + cache_policy: CachePolicy::Passthrough, dump_dcap_quotes: false, override_azure_outdated_tcb: false, dynamic_measurement_policy: None, @@ -558,7 +558,10 @@ impl AttestationVerifier { ))), dump_dcap_quotes: false, override_azure_outdated_tcb: false, - internal_pccs: None, + internal_pccs: Pccs::new( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::Passthrough, + ), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), dynamic_measurement_policy: None, @@ -574,7 +577,10 @@ impl AttestationVerifier { ))), dump_dcap_quotes: false, override_azure_outdated_tcb: false, - internal_pccs: None, + internal_pccs: Pccs::new( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::Passthrough, + ), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), dynamic_measurement_policy: None, @@ -584,33 +590,39 @@ impl AttestationVerifier { /// Expect mock measurements used in tests, and use a PCCS #[cfg(any(test, feature = "mock"))] pub fn mock_with_pccs(pccs_url: String) -> Self { + #[cfg(test)] + install_test_crypto_provider(); + Self { measurement_policy: Arc::new(RwLock::new(MeasurementPolicyState::new( MeasurementPolicy::mock(), ))), dump_dcap_quotes: false, override_azure_outdated_tcb: false, - internal_pccs: Some(Pccs::new(Some(pccs_url))), + internal_pccs: Pccs::new( + CollateralSource::Pccs { url: pccs_url }, + CachePolicy::Prewarmed, + ), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), dynamic_measurement_policy: None, } } - /// Resolves once the internal PCCS cache is ready to verify - /// attestations + /// Waits for initial PCCS pre-warming when configured. /// - /// Calling this is optional - it is only really needed when you want to - /// guarantee that collateral will not be fetched during - /// verification + /// With [`CachePolicy::Prewarmed`], this waits for the initial pre-warm + /// and returns an error if pre-warm bootstrap failed. With + /// [`CachePolicy::Passthrough`] and [`CachePolicy::OnDemand`], there is + /// no initial pre-warm to await, so this returns immediately. + /// + /// Only the `Prewarmed` policy is intended to avoid on-demand + /// collateral fetches during verification. Initial pre-warming can + /// complete even if individual collateral fetches failed, so it is + /// not an absolute guarantee that verification will avoid a fetch. pub async fn ready(&self) -> Result<(), AttestationError> { - // If we have no PCCS then we are ready - let Some(pccs) = &self.internal_pccs else { - return Ok(()); - }; - - // If we have pccs, and pre-warm is disabled we are also ready - match pccs.ready().await { + // If pre-warm is disabled we are ready + match self.internal_pccs.ready().await { Ok(_) | Err(PccsError::PrewarmDisabled) => Ok(()), Err(err) => Err(err.into()), } @@ -718,8 +730,13 @@ impl AttestationVerifier { Ok(Some(verified)) } - /// Verify an attestation synchronously, and return the expected - /// measurements from the matching policy record. + /// Synchronously verifies an attestation against the configured policy. + /// + /// DCAP and Azure verification require [`CachePolicy::OnDemand`] or + /// [`CachePolicy::Prewarmed`] with the requested collateral already + /// cached. [`CachePolicy::Passthrough`] cannot fetch collateral + /// synchronously. A cache miss returns an error and starts a background + /// fetch for a later attempt. pub fn verify_attestation_sync( &self, attestation_exchange_message: AttestationExchangeMessage, @@ -750,11 +767,10 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - let pccs = self.internal_pccs.clone().ok_or(AttestationError::NoPccs)?; azure::verify_azure_attestation_sync( attestation_evidence.quote.clone(), expected_input_data, - pccs, + self.internal_pccs.clone(), self.override_azure_outdated_tcb, )? } @@ -768,11 +784,7 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - #[cfg(any(test, feature = "mock"))] - let pccs = - self.internal_pccs.clone().unwrap_or_else(|| Pccs::new_without_prewarm(None)); - #[cfg(not(any(test, feature = "mock")))] - let pccs = self.internal_pccs.clone().ok_or(AttestationError::NoPccs)?; + let pccs = self.internal_pccs.clone(); let (verified, quote) = dcap::verify_dcap_attestation_sync( attestation_evidence.quote.clone(), @@ -1123,8 +1135,6 @@ pub enum AttestationError { Reqwest(#[from] reqwest::Error), #[error("PCCS: {0}")] Pccs(#[from] PccsError), - #[error("Sync verification requested but no PCCS configured")] - NoPccs, #[cfg(any(test, feature = "mock"))] #[error("Cannot create mock attestation: {0}")] Mock(String), @@ -1150,6 +1160,32 @@ mod tests { let _ = running_on_gcp(); } + #[test] + fn verifier_builder_configures_intel_pcs_subscription_key() { + let builder = AttestationVerifier::builder(MeasurementPolicy::tdx()) + .with_cache_policy(CachePolicy::OnDemand) + .with_intel_pcs_subscription_key("subscription-key"); + + assert_eq!(builder.cache_policy, CachePolicy::OnDemand); + assert_eq!( + builder.collateral_source, + CollateralSource::IntelPcs { subscription_key: Some("subscription-key".to_string()) } + ); + } + + #[test] + fn verifier_builder_configures_pccs_url() { + let builder = AttestationVerifier::builder(MeasurementPolicy::tdx()) + .with_cache_policy(CachePolicy::Prewarmed) + .with_pccs_url("https://pccs.example"); + + assert_eq!(builder.cache_policy, CachePolicy::Prewarmed); + assert_eq!( + builder.collateral_source, + CollateralSource::Pccs { url: "https://pccs.example".to_string() } + ); + } + #[tokio::test] async fn verifier_returns_no_verified_attestation_when_none_is_expected() { let verifier = AttestationVerifier::expect_none(); @@ -1163,7 +1199,7 @@ mod tests { } #[tokio::test] - async fn mock_verifier_supports_sync_verification() { + async fn mock_verifier_uses_mock_collateral_for_async_and_sync_verification() { let input_data = [7u8; 64]; let quote = dcap::create_dcap_attestation(input_data).unwrap(); let attestation_evidence = AttestationEvidence { @@ -1171,24 +1207,31 @@ mod tests { platform: mock_platform_metadata(AttestationType::DcapTdx).unwrap(), }; - let mock_pcs_server = spawn_mock_pcs_server(MockPcsConfig::default()).await.unwrap(); - - let verifier = AttestationVerifier::mock_with_pccs(mock_pcs_server.base_url.clone()); - if let Some(ref pccs) = verifier.internal_pccs { - pccs.ready().await.unwrap(); - } + let verifier = AttestationVerifier::mock(); + let message: AttestationExchangeMessage = attestation_evidence.into(); - let result = verifier.verify_attestation_sync(attestation_evidence.into(), input_data); + let async_result = verifier.verify_attestation(message.clone(), input_data).await; + let sync_result = verifier.verify_attestation_sync(message, input_data); assert!( matches!( - result, + async_result, Ok(Some(VerifiedAttestation { expected_measurements: Some(ExpectedMeasurements::Dcap(_)), .. })) ), - "expected sync mock verification to return matched DCAP measurements: {result:?}" + "expected async mock verification to return matched DCAP measurements: {async_result:?}" + ); + assert!( + matches!( + sync_result, + Ok(Some(VerifiedAttestation { + expected_measurements: Some(ExpectedMeasurements::Dcap(_)), + .. + })) + ), + "expected sync mock verification to return matched DCAP measurements: {sync_result:?}" ); } @@ -1224,8 +1267,6 @@ mod tests { // different bundle let (served, _is_fresh) = verifier .internal_pccs - .as_ref() - .unwrap() .get_collateral( fmspc, ca, @@ -1239,7 +1280,7 @@ mod tests { #[test] fn measurement_policy_can_be_updated_between_verification_attempts() { let verifier = AttestationVerifier::builder(MeasurementPolicy::tdx()) - .with_pccs_mode(PccsMode::None) + .with_cache_policy(CachePolicy::Passthrough) .build(); let verifier_clone = verifier.clone(); let message = AttestationExchangeMessage::without_attestation(); @@ -1277,7 +1318,7 @@ mod tests { let initial_policy = MeasurementPolicy::from_file(policy_path.clone()).await.unwrap(); let verifier = AttestationVerifier::builder(initial_policy) - .with_pccs_mode(PccsMode::None) + .with_cache_policy(CachePolicy::Passthrough) .with_dynamic_measurements_file_or_url(policy_path.to_string_lossy().into_owned()) .build(); @@ -1313,7 +1354,7 @@ mod tests { let policy_source = policy_path.to_string_lossy().into_owned(); let initial_policy = MeasurementPolicy::from_file(policy_path.clone()).await.unwrap(); let verifier = AttestationVerifier::builder(initial_policy) - .with_pccs_mode(PccsMode::None) + .with_cache_policy(CachePolicy::Passthrough) .with_dynamic_measurements_file_or_url(policy_source.clone()) .build(); let measurements = measurements::mock_dcap_measurements(); @@ -1349,7 +1390,7 @@ mod tests { let initial_policy = MeasurementPolicy::from_file_or_url_sync(policy_source.clone()).unwrap(); let verifier = AttestationVerifier::builder(initial_policy) - .with_pccs_mode(PccsMode::None) + .with_cache_policy(CachePolicy::Passthrough) .with_dynamic_measurements_file_or_url(policy_source.clone()) .build(); let measurements = measurements::mock_dcap_measurements(); @@ -1384,8 +1425,10 @@ mod tests { MeasurementPolicy::from_file_or_url_sync(policy_source.clone()).unwrap(); let mock_pcs_server = spawn_mock_pcs_server(MockPcsConfig::default()).await.unwrap(); let verifier = AttestationVerifier::builder(initial_policy) - .with_pccs_mode(PccsMode::Prewarmed) - .with_pccs_url(mock_pcs_server.base_url.clone()) + .with_collateral_source(CollateralSource::Pccs { + url: mock_pcs_server.base_url.clone(), + }) + .with_cache_policy(CachePolicy::Prewarmed) .with_dynamic_measurements_file_or_url(policy_source) .build(); verifier.ready().await.unwrap(); diff --git a/crates/attested-tls/src/lib.rs b/crates/attested-tls/src/lib.rs index 7931430..46a9b19 100644 --- a/crates/attested-tls/src/lib.rs +++ b/crates/attested-tls/src/lib.rs @@ -1742,7 +1742,7 @@ mod tests { let dynamic_verifier = AttestationVerifier::builder( attestation::measurements::MeasurementPolicy::expect_none(), ) - .with_pccs_mode(attestation::PccsMode::None) + .with_cache_policy(attestation::CachePolicy::Passthrough) .with_dynamic_measurements_file_or_url("measurements.json".into()) .build(); diff --git a/crates/mock-tdx/src/mock_pcs.rs b/crates/mock-tdx/src/mock_pcs.rs index c9fba83..234b91d 100644 --- a/crates/mock-tdx/src/mock_pcs.rs +++ b/crates/mock-tdx/src/mock_pcs.rs @@ -3,6 +3,7 @@ use std::{ net::SocketAddr, sync::{ Arc, + Mutex, atomic::{AtomicUsize, Ordering}, }, }; @@ -11,6 +12,7 @@ use axum::{ Json, Router, extract::{Query, State}, + http::HeaderMap, response::IntoResponse, routing::get, }; @@ -59,6 +61,7 @@ pub struct MockPcsServer { _task: JoinHandle<()>, tcb_calls: Arc, qe_calls: Arc, + subscription_keys: Arc>>>, } impl Drop for MockPcsServer { @@ -77,6 +80,11 @@ impl MockPcsServer { pub fn qe_call_count(&self) -> usize { self.qe_calls.load(Ordering::SeqCst) } + + /// Returns the Intel PCS subscription key observed on each request. + pub fn subscription_keys(&self) -> Vec> { + self.subscription_keys.lock().unwrap().clone() + } } /// Shared state served by the mock PCS routes @@ -92,6 +100,7 @@ struct MockPcsState { qe_next_update: String, refreshed_tcb_next_update: Option, refreshed_qe_next_update: Option, + subscription_keys: Arc>>>, pck_crl: Vec, pck_crl_issuer_chain: String, tcb_issuer_chain: String, @@ -115,6 +124,7 @@ pub async fn spawn_mock_pcs_server( let tcb_calls = Arc::new(AtomicUsize::new(0)); let qe_calls = Arc::new(AtomicUsize::new(0)); + let subscription_keys = Arc::new(Mutex::new(Vec::new())); let state = Arc::new(MockPcsState { fmspc: tcb_info["fmspc"].as_str().ok_or("mock collateral missing fmspc")?.to_string(), include_fmspcs_listing: config.include_fmspcs_listing, @@ -126,6 +136,7 @@ pub async fn spawn_mock_pcs_server( qe_next_update: config.qe_next_update, refreshed_tcb_next_update: config.refreshed_tcb_next_update, refreshed_qe_next_update: config.refreshed_qe_next_update, + subscription_keys: subscription_keys.clone(), pck_crl: base_collateral.pck_crl, pck_crl_issuer_chain: urlencoding::encode(&base_collateral.pck_crl_issuer_chain).into(), tcb_issuer_chain: urlencoding::encode(&base_collateral.tcb_info_issuer_chain).into(), @@ -149,14 +160,22 @@ pub async fn spawn_mock_pcs_server( axum::serve(listener, app).await.unwrap(); }); - Ok(MockPcsServer { base_url: format!("http://{addr}"), _task: task, tcb_calls, qe_calls }) + Ok(MockPcsServer { + base_url: format!("http://{addr}"), + _task: task, + tcb_calls, + qe_calls, + subscription_keys, + }) } /// Serves the mock PCK CRL and issuer chain async fn mock_pck_crl_handler( State(state): State>, + headers: HeaderMap, Query(params): Query>, ) -> impl IntoResponse { + record_subscription_key(&headers, &state); assert!( matches!(params.get("ca").map(String::as_str), Some("processor") | Some("platform")), "unexpected ca query value for pckcrl" @@ -166,7 +185,11 @@ async fn mock_pck_crl_handler( } /// Serves the optional FMSPC listing used by PCCS prewarm tests -async fn mock_fmspcs_handler(State(state): State>) -> impl IntoResponse { +async fn mock_fmspcs_handler( + State(state): State>, + headers: HeaderMap, +) -> impl IntoResponse { + record_subscription_key(&headers, &state); if state.include_fmspcs_listing { Json(json!([{ "fmspc": state.fmspc, @@ -180,8 +203,10 @@ async fn mock_fmspcs_handler(State(state): State>) -> impl Int /// Serves signed TCB info with configurable refresh behavior async fn mock_tcb_handler( State(state): State>, + headers: HeaderMap, Query(params): Query>, ) -> impl IntoResponse { + record_subscription_key(&headers, &state); assert_eq!(params.get("fmspc"), Some(&state.fmspc)); let call_number = state.tcb_calls.fetch_add(1, Ordering::SeqCst) + 1; let mut tcb_info = state.base_tcb_info.clone(); @@ -203,8 +228,10 @@ async fn mock_tcb_handler( /// Serves signed QE identity collateral with configurable refresh behavior async fn mock_qe_identity_handler( State(state): State>, + headers: HeaderMap, Query(params): Query>, ) -> impl IntoResponse { + record_subscription_key(&headers, &state); assert_eq!(params.get("update"), Some(&"standard".to_string())); let call_number = state.qe_calls.fetch_add(1, Ordering::SeqCst) + 1; let mut qe_identity = state.base_qe_identity.clone(); @@ -224,6 +251,17 @@ async fn mock_qe_identity_handler( } /// Serves the root CA CRL expected by the PCS client -async fn mock_root_ca_crl_handler(State(state): State>) -> impl IntoResponse { +async fn mock_root_ca_crl_handler( + State(state): State>, + headers: HeaderMap, +) -> impl IntoResponse { + record_subscription_key(&headers, &state); state.root_ca_crl_hex.clone() } + +fn record_subscription_key(headers: &HeaderMap, state: &MockPcsState) { + let subscription_key = headers + .get("Ocp-Apim-Subscription-Key") + .map(|value| value.to_str().expect("subscription key header should be ASCII").to_string()); + state.subscription_keys.lock().unwrap().push(subscription_key); +} diff --git a/crates/pccs/README.md b/crates/pccs/README.md index e1a3cfd..1d2c2be 100644 --- a/crates/pccs/README.md +++ b/crates/pccs/README.md @@ -1,7 +1,7 @@ # pccs -An internal Provisioning Certificate Caching Service implementation for DCAP -collateral fetching and caching. +A DCAP collateral client with optional in-process caching and proactive +refresh. This crate is used by attestation verification code that needs Intel TDX/SGX collateral such as TCB info, QE identity, and certificate revocation lists. @@ -9,20 +9,71 @@ collateral such as TCB info, QE identity, and certificate revocation lists. It can: - Fetch collateral from Intel PCS or a configured PCCS endpoint -- Cache collateral in-process -- Pre-warm the cache at startup -- Refresh cached collateral in the background before expiry +- Operate as a remote pass-through without caching +- Cache collateral lazily on demand +- Pre-warm and proactively refresh an in-process cache -This is an alternative to Intel's reference PCCS server implementation which -can be embedded in Rust services that verify quotes. +The caching modes provide an embeddable alternative to deploying Intel's +reference PCCS server alongside services that verify quotes. For Intel's terminology and architecture, see the Intel documentation for the [Provisioning Certificate Caching Service (PCCS)](https://cc-enabling.trustedservices.intel.com/intel-sgx-tdx-pccs/01/introduction/). +## Collateral source and cache policy + +Every `Pccs` has a [`CollateralSource`](src/lib.rs) and an independent +[`CachePolicy`](src/lib.rs). `CollateralSource::IntelPcs` uses Intel's canonical +endpoint and accepts an optional subscription key to lift anonymous rate +limits. `CollateralSource::Pccs` accepts the URL of any compatible service. + +The cache policies are: + +- `Passthrough` keeps no internal cache. Every `get_collateral()` call fetches + from the configured endpoint. `get_collateral_sync()` returns + `CacheDisabled` because it cannot perform asynchronous network I/O. +- `OnDemand` starts with an empty cache. Asynchronous cache misses are fetched + immediately; synchronous misses return an error and start a background + fetch for a later attempt. +- `Prewarmed` starts the same cache and immediately begins pre-warming it with + discovered TDX collateral. Call `ready()` to wait for that initial work. + +```rust,no_run +use pccs::{CachePolicy, CollateralSource, Pccs}; + +#[tokio::main] +async fn main() -> Result<(), pccs::PccsError> { + let subscription_key = std::env::var("INTEL_PCS_SUBSCRIPTION_KEY").ok(); + let _passthrough = Pccs::new( + CollateralSource::IntelPcs { subscription_key }, + CachePolicy::Passthrough, + ); + let _on_demand = Pccs::new( + CollateralSource::Pccs { url: "https://pccs.example".into() }, + CachePolicy::OnDemand, + ); + let prewarmed = Pccs::new( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::Prewarmed, + ); + let _summary = prewarmed.ready().await?; + + Ok(()) +} +``` + +`ready()` only waits for the `Prewarmed` policy. It returns `PrewarmDisabled` +for `Passthrough` and `OnDemand`. A successful pre-warm result includes failure +counters; it does not guarantee that every possible collateral item was +cached. + ## Runtime Requirements -This crate expects to be used from within a Tokio runtime. +Asynchronous collateral fetching requires a Tokio runtime. Constructing a +`Prewarmed` instance also requires an active runtime because it immediately +spawns the initial pre-warm task. Constructing `Passthrough` or `OnDemand` +does not itself spawn a task. -The above applies even when calling synchronous-looking APIs such as -`get_collateral_sync()` because cache miss repair, proactive refresh, and -startup pre-warm are all driven by Tokio background tasks. +`get_collateral_sync()` is available only with a cache (`OnDemand` or +`Prewarmed`). A cache miss or expired entry may spawn a Tokio background task, +so applications that can encounter either condition must have an active +runtime. diff --git a/crates/pccs/examples/intel_pcs.rs b/crates/pccs/examples/intel_pcs.rs index 4e6b3d0..47d64a0 100644 --- a/crates/pccs/examples/intel_pcs.rs +++ b/crates/pccs/examples/intel_pcs.rs @@ -1,7 +1,7 @@ //! Demonstrates setting up a PCCS cache using Intel PCS use std::time::Instant; -use pccs::{PCS_URL, Pccs}; +use pccs::{CachePolicy, CollateralSource, PCS_URL, Pccs}; use tracing::info; use tracing_subscriber::{EnvFilter, fmt}; @@ -18,7 +18,8 @@ async fn main() -> Result<(), pccs::PccsError> { info!(pcs_url = PCS_URL, "Starting PCCS with Intel PCS"); - let pccs = Pccs::new(None); + let subscription_key = std::env::var("INTEL_PCS_SUBSCRIPTION_KEY").ok(); + let pccs = Pccs::new(CollateralSource::IntelPcs { subscription_key }, CachePolicy::Prewarmed); let started_at = Instant::now(); let summary = pccs.ready().await?; let elapsed = started_at.elapsed().as_secs_f64(); diff --git a/crates/pccs/src/lib.rs b/crates/pccs/src/lib.rs index 4c678ac..d757061 100644 --- a/crates/pccs/src/lib.rs +++ b/crates/pccs/src/lib.rs @@ -18,6 +18,11 @@ use dcap_qvl::{ http::{HttpClient as DcapHttpClient, HttpResponse}, tcb_info::TcbInfo, }; +use reqwest::{ + Url, + header::{HeaderValue, InvalidHeaderValue}, + redirect::Policy, +}; use thiserror::Error; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::{ @@ -40,6 +45,8 @@ fn install_test_crypto_provider() { /// For fetching collateral directly from Intel pub const PCS_URL: &str = "https://api.trustedservices.intel.com"; +/// Header used to authenticate requests to Intel PCS. +const PCS_SUBSCRIPTION_KEY_HEADER: &str = "Ocp-Apim-Subscription-Key"; /// How long before expiry to refresh collateral const REFRESH_MARGIN_SECS: i64 = 300; /// How long to wait before retrying when failing to fetch collateral @@ -50,9 +57,43 @@ const PCCS_HTTP_TIMEOUT_SECS: u64 = 180; /// pre-warm const STARTUP_PREWARM_CONCURRENCY: usize = 8; +/// Service from which DCAP collateral is fetched. +#[derive(Clone, PartialEq, Eq)] +pub enum CollateralSource { + /// Intel PCS. A subscription key lifts the anonymous rate limit. + IntelPcs { subscription_key: Option }, + /// Any PCCS-compatible service, such as a self-hosted instance or local + /// sidecar. + Pccs { url: String }, +} + +impl std::fmt::Debug for CollateralSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::IntelPcs { subscription_key } => f + .debug_struct("IntelPcs") + .field("subscription_key_configured", &subscription_key.is_some()) + .finish(), + Self::Pccs { url } => f.debug_struct("Pccs").field("url", url).finish(), + } + } +} + +/// Whether and how [`Pccs`] keeps an in-process collateral cache. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CachePolicy { + /// No in-process cache. Every asynchronous lookup goes directly to the + /// configured endpoint. + Passthrough, + /// Start with an empty cache and fill it on demand. + OnDemand, + /// Fill the cache at construction, then refresh it proactively. + Prewarmed, +} + type SharedCollateralClient = CollateralClient; -/// PCCS collateral cache with proactive background refresh +/// DCAP collateral source with optional caching and background refresh. /// /// Fetching runs over rustls-backed HTTP, so the application must install a /// process-level rustls [crypto provider] before collateral can be fetched, @@ -65,9 +106,15 @@ pub struct Pccs { /// The URL of the service used to fetch collateral (PCS / PCCS) url: String, /// HTTP client used for FMSPC fetches - http_client: reqwest::Client, + http_client: SharedReqwestHttp, /// HTTP client for collateral fetches collateral_client: SharedCollateralClient, + /// An internal cache if configured + inner: Option, +} + +#[derive(Clone)] +struct PccsInner { /// The internal cache cache: Arc>>, /// Dedupes one-shot background refreshes for cache misses @@ -87,50 +134,92 @@ impl std::fmt::Debug for Pccs { } impl Pccs { - /// Creates a new PCCS cache using the provided URL or Intel PCS default - pub fn new(url: Option) -> Self { - let mut pccs = Self::new_without_prewarm(url); - - let (prewarm_outcome_tx, _) = watch::channel(None); - pccs.prewarm_outcome_tx = Some(prewarm_outcome_tx); - - // Start filling the cache right away - let pccs_for_prewarm = pccs.clone(); - tokio::spawn(async move { - let outcome = pccs_for_prewarm.startup_prewarm_all_tdx().await; - pccs_for_prewarm.finish_prewarm(outcome); - }); - - pccs + /// Creates a collateral client with the requested source and cache + /// policy. + /// + /// Constructing [`CachePolicy::Prewarmed`] immediately spawns its + /// initial fetch task and therefore requires an active Tokio + /// runtime. + pub fn new(source: CollateralSource, cache: CachePolicy) -> Self { + let (url, subscription_key) = match source { + CollateralSource::IntelPcs { subscription_key } => { + (PCS_URL.to_string(), subscription_key) + } + CollateralSource::Pccs { url } => (url, None), + }; + Self::new_with_endpoint(url, subscription_key, cache) } - /// Creates a new PCCS cache using the provided URL or Intel PCS default - /// and does not pre-warm by proactively fetching collateral - pub fn new_without_prewarm(url: Option) -> Self { + fn new_with_endpoint( + url: String, + subscription_key: Option, + cache: CachePolicy, + ) -> Self { let url = url - .unwrap_or(PCS_URL.to_string()) .trim_end_matches('/') .trim_end_matches("/sgx/certification/v4") .trim_end_matches("/tdx/certification/v4") .to_string(); - let http_client = reqwest::Client::new(); - let collateral_client = - CollateralClient::new(SharedReqwestHttp { client: http_client.clone() }, url.clone()); + let http_client = SharedReqwestHttp::new(&url, subscription_key); + let collateral_client = CollateralClient::new(http_client.clone(), url.clone()); + + match cache { + CachePolicy::Passthrough => Self { url, http_client, collateral_client, inner: None }, + CachePolicy::OnDemand => Self { + url, + http_client, + collateral_client, + inner: Some(PccsInner { + cache: RwLock::new(HashMap::new()).into(), + pending_refreshes: RwLock::new(HashSet::new()).into(), + prewarm_stats: Arc::new(PrewarmStats::default()), + prewarm_outcome_tx: None, + }), + }, + CachePolicy::Prewarmed => { + let (prewarm_outcome_tx, _) = watch::channel(None); + + let pccs = Self { + url, + http_client, + collateral_client, + inner: Some(PccsInner { + cache: RwLock::new(HashMap::new()).into(), + pending_refreshes: RwLock::new(HashSet::new()).into(), + prewarm_stats: Arc::new(PrewarmStats::default()), + prewarm_outcome_tx: Some(prewarm_outcome_tx), + }), + }; - Self { - url, - http_client, - collateral_client, - cache: RwLock::new(HashMap::new()).into(), - pending_refreshes: RwLock::new(HashSet::new()).into(), - prewarm_stats: Arc::new(PrewarmStats::default()), - prewarm_outcome_tx: None, + // Start filling the cache right away + let pccs_for_prewarm = pccs.clone(); + tokio::spawn(async move { + let outcome = pccs_for_prewarm.startup_prewarm_all_tdx().await; + pccs_for_prewarm.finish_prewarm(outcome); + }); + + pccs + } } } - /// Resolves when cache is pre-warmed with all available collateral + /// Returns whether this PCCS fetches collateral directly without an + /// internal cache. + pub fn is_passthrough(&self) -> bool { + self.inner.is_none() + } + + /// Waits for the initial pre-warm to complete. + /// + /// Returns [`PccsError::PrewarmDisabled`] for + /// [`CachePolicy::Passthrough`] and [`CachePolicy::OnDemand`]. A + /// successful result means the initial pre-warm + /// completed; individual collateral fetches may still have failed, as + /// reported in [`PrewarmSummary`]. pub async fn ready(&self) -> Result { - if let Some(prewarm_outcome_tx) = &self.prewarm_outcome_tx { + if let Some(ref inner) = self.inner && + let Some(prewarm_outcome_tx) = &inner.prewarm_outcome_tx + { let mut outcome_rx = prewarm_outcome_tx.subscribe(); loop { if let Some(outcome) = outcome_rx.borrow_and_update().clone() { @@ -143,26 +232,32 @@ impl Pccs { return Err(PccsError::PrewarmSignalClosed); } } - } else { - Err(PccsError::PrewarmDisabled) } + Err(PccsError::PrewarmDisabled) } - /// Returns collateral from cache when valid, otherwise fetches and - /// caches fresh collateral + /// Fetches collateral, using the internal cache when configured. + /// Passthrough always fetches from the configured endpoint. + /// /// Returns collateral together with a flag indicating whether it is - /// fresh (true) or from the cache (false) + /// freshly fetched (`true`) or from the cache (`false`). Passthrough + /// always returns `true`. pub async fn get_collateral( &self, fmspc: String, ca: &'static str, now: u64, ) -> Result<(QuoteCollateralV3, bool), PccsError> { + let Some(inner) = &self.inner else { + let collateral = fetch_collateral(&self.collateral_client, fmspc, ca).await?; + return Ok((collateral, true)); + }; + let now = i64::try_from(now).map_err(|_| PccsError::TimeStampExceedsI64)?; let cache_key = PccsInput::new(fmspc.clone(), ca); { - let cache = self.cache.read().map_err(|_| PccsError::CachePoisoned)?; + let cache = inner.cache.read().map_err(|_| PccsError::CachePoisoned)?; if let Some(entry) = cache.get(&cache_key) { if now < entry.next_update { return Ok((entry.collateral.clone(), false)); @@ -180,7 +275,7 @@ impl Pccs { let next_update = extract_next_update(&collateral, now)?; { - let mut cache = self.cache.write().map_err(|_| PccsError::CachePoisoned)?; + let mut cache = inner.cache.write().map_err(|_| PccsError::CachePoisoned)?; if let Some(existing) = cache.get(&cache_key) && now < existing.next_update { @@ -195,6 +290,11 @@ impl Pccs { /// A synchronous method to get collateral from the cache. /// + /// With [`CachePolicy::Passthrough`], this returns + /// [`PccsError::CacheDisabled`] + /// because a synchronous call cannot perform the required asynchronous + /// fetch. + /// /// If the requested collateral is not present in the cache, this will /// return an error rather than waiting to fetch it. But it does /// begin fetching it in a background task. @@ -207,9 +307,13 @@ impl Pccs { ca: &'static str, now: u64, ) -> Result { + let Some(inner) = &self.inner else { + return Err(PccsError::CacheDisabled); + }; + let now = i64::try_from(now).map_err(|_| PccsError::TimeStampExceedsI64)?; let cache_key = PccsInput::new(fmspc.clone(), ca); - let cache = self.cache.read().map_err(|_| PccsError::CachePoisoned)?; + let cache = inner.cache.read().map_err(|_| PccsError::CachePoisoned)?; if let Some(entry) = cache.get(&cache_key) { if now >= entry.next_update { let collateral = entry.collateral.clone(); @@ -244,13 +348,17 @@ impl Pccs { fmspc: String, ca: &'static str, ) -> Result { + let Some(inner) = &self.inner else { + return fetch_collateral(&self.collateral_client, fmspc, ca).await; + }; + let now = unix_now()?; let collateral = fetch_collateral(&self.collateral_client, fmspc.clone(), ca).await?; let next_update = extract_next_update(&collateral, now)?; let cache_key = PccsInput::new(fmspc, ca); { - let mut cache = self.cache.write().map_err(|_| PccsError::CachePoisoned)?; + let mut cache = inner.cache.write().map_err(|_| PccsError::CachePoisoned)?; upsert_cache_entry(&mut cache, cache_key.clone(), collateral.clone(), next_update); } self.ensure_refresh_task(&cache_key).await; @@ -260,7 +368,10 @@ impl Pccs { /// Starts a background refresh loop for a cache key when no task is /// active async fn ensure_refresh_task(&self, cache_key: &PccsInput) { - let Ok(mut cache) = self.cache.write() else { + let Some(inner) = &self.inner else { + return; + }; + let Ok(mut cache) = inner.cache.write() else { tracing::warn!("PCCS cache lock poisoned, cannot ensure refresh task"); return; }; @@ -271,7 +382,7 @@ impl Pccs { return; } - let weak_cache = Arc::downgrade(&self.cache); + let weak_cache = Arc::downgrade(&inner.cache); let key = cache_key.clone(); let collateral_client = self.collateral_client.clone(); entry.refresh_task = Some(tokio::spawn(async move { @@ -281,8 +392,11 @@ impl Pccs { /// Starts a one-shot background fetch to populate a missing cache entry fn spawn_background_refresh_for_cache_miss(&self, cache_key: PccsInput) { + let Some(inner) = &self.inner else { + return; + }; { - let Ok(mut pending_refreshes) = self.pending_refreshes.write() else { + let Ok(mut pending_refreshes) = inner.pending_refreshes.write() else { tracing::warn!("PCCS pending-refresh lock poisoned, cannot start sync refresh"); return; }; @@ -311,7 +425,10 @@ impl Pccs { // Always clear the dedupe marker so a later sync miss can // retry if this repair attempt failed. - if let Ok(mut pending_refreshes) = pccs.pending_refreshes.write() { + let Some(inner) = &pccs.inner else { + return; + }; + if let Ok(mut pending_refreshes) = inner.pending_refreshes.write() { pending_refreshes.remove(&cache_key); } else { tracing::warn!("PCCS pending-refresh lock poisoned during cleanup"); @@ -322,6 +439,10 @@ impl Pccs { /// Pre-provisions TDX collateral for discovered FMSPC values to reduce /// hot-path fetches async fn startup_prewarm_all_tdx(&self) -> PrewarmOutcome { + let Some(inner) = &self.inner else { + return PrewarmOutcome::Failed("PCCS cache is disabled".to_string()); + }; + // First get all FMSPCs let fmspcs = match self.fetch_fmspcs().await { Ok(fmspcs) => fmspcs, @@ -336,11 +457,11 @@ impl Pccs { )); } }; - self.prewarm_stats.discovered_fmspcs.store(fmspcs.len(), Ordering::SeqCst); + inner.prewarm_stats.discovered_fmspcs.store(fmspcs.len(), Ordering::SeqCst); if fmspcs.is_empty() { tracing::warn!("No FMSPC entries returned during startup pre-provision"); - return PrewarmOutcome::Ready(self.prewarm_stats.snapshot()); + return PrewarmOutcome::Ready(inner.prewarm_stats.snapshot()); } // For each FMSPC, get the 'processor' and 'platform' collateral @@ -353,7 +474,7 @@ impl Pccs { let Ok(permit) = permit else { continue; }; - self.prewarm_stats.attempted.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.attempted.fetch_add(1, Ordering::SeqCst); let pccs = self.clone(); let fmspc = entry.fmspc.clone(); join_set.spawn(async move { @@ -376,11 +497,11 @@ impl Pccs { Ok(Ok((fmspc, ca, Ok(())))) => { successes += 1; debug!("Successfully cached: {fmspc} {ca}"); - self.prewarm_stats.successes.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.successes.fetch_add(1, Ordering::SeqCst); } Ok(Ok((fmspc, ca, Err(e)))) => { failures += 1; - self.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); tracing::debug!( fmspc, ca, @@ -390,29 +511,32 @@ impl Pccs { } Ok(Err(e)) => { failures += 1; - self.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); tracing::debug!(error = %e, "Startup pre-provision task failed"); } Err(e) => { failures += 1; - self.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); tracing::debug!(error = %e, "Startup pre-provision join error"); } } } tracing::info!( - discovered_fmspcs = self.prewarm_stats.discovered_fmspcs.load(Ordering::SeqCst), - attempted = self.prewarm_stats.attempted.load(Ordering::SeqCst), + discovered_fmspcs = inner.prewarm_stats.discovered_fmspcs.load(Ordering::SeqCst), + attempted = inner.prewarm_stats.attempted.load(Ordering::SeqCst), successes, failures, "Completed PCCS startup pre-provisioning for TDX collateral" ); - PrewarmOutcome::Ready(self.prewarm_stats.snapshot()) + PrewarmOutcome::Ready(inner.prewarm_stats.snapshot()) } fn finish_prewarm(&self, outcome: PrewarmOutcome) { - if let Some(prewarm_outcome_tx) = &self.prewarm_outcome_tx { - self.prewarm_stats.completed.store(true, Ordering::SeqCst); + let Some(inner) = &self.inner else { + return; + }; + if let Some(prewarm_outcome_tx) = &inner.prewarm_outcome_tx { + inner.prewarm_stats.completed.store(true, Ordering::SeqCst); let _ = prewarm_outcome_tx.send(Some(outcome)); } } @@ -430,12 +554,7 @@ impl Pccs { } let url = format!("{}/sgx/certification/v4/fmspcs", self.url); - let response = self - .http_client - .get(&url) - .timeout(Duration::from_secs(PCCS_HTTP_TIMEOUT_SECS)) - .send() - .await?; + let response = self.http_client.request(&url)?.send().await?; if !response.status().is_success() { return Err(PccsError::FmspcFetch(response.status())); } @@ -450,16 +569,76 @@ impl Pccs { #[derive(Clone)] struct SharedReqwestHttp { client: reqwest::Client, + authenticated_client: Option, + subscription_key_origin: Option, + subscription_key: Option, +} + +impl SharedReqwestHttp { + fn new(base_url: &str, subscription_key: Option) -> Self { + let subscription_key_origin = + subscription_key.as_ref().and_then(|_| Url::parse(base_url).ok()); + let authenticated_client = subscription_key.as_ref().map(|_| { + reqwest::Client::builder() + .redirect(Policy::custom(|attempt| { + let same_origin_redirect = attempt + .previous() + .last() + .is_some_and(|previous| same_origin(previous, attempt.url())); + if !same_origin_redirect { + attempt.stop() + } else if attempt.previous().len() > 10 { + attempt.error("too many redirects") + } else { + attempt.follow() + } + })) + .build() + .expect("failed to build authenticated HTTP client") + }); + + Self { + client: reqwest::Client::new(), + authenticated_client, + subscription_key_origin, + subscription_key, + } + } + + fn request(&self, url: &str) -> Result { + let Some(subscription_key) = &self.subscription_key else { + return Ok(self.client.get(url).timeout(Duration::from_secs(PCCS_HTTP_TIMEOUT_SECS))); + }; + let authenticated_request = Url::parse(url) + .ok() + .zip(self.subscription_key_origin.as_ref()) + .is_some_and(|(url, origin)| same_origin(&url, origin)); + if !authenticated_request { + return Ok(self.client.get(url).timeout(Duration::from_secs(PCCS_HTTP_TIMEOUT_SECS))); + } + + let request = self + .authenticated_client + .as_ref() + .expect("subscription key client should be configured") + .get(url) + .timeout(Duration::from_secs(PCCS_HTTP_TIMEOUT_SECS)); + + let mut header = HeaderValue::from_bytes(subscription_key.as_bytes())?; + header.set_sensitive(true); + Ok(request.header(PCS_SUBSCRIPTION_KEY_HEADER, header)) + } +} + +fn same_origin(left: &Url, right: &Url) -> bool { + left.scheme() == right.scheme() && + left.host_str() == right.host_str() && + left.port_or_known_default() == right.port_or_known_default() } impl DcapHttpClient for SharedReqwestHttp { async fn get(&self, url: &str) -> anyhow::Result { - let resp = self - .client - .get(url) - .timeout(Duration::from_secs(PCCS_HTTP_TIMEOUT_SECS)) - .send() - .await?; + let resp = self.request(url)?.send().await?; let status = resp.status().as_u16(); let headers = resp .headers() @@ -508,6 +687,9 @@ async fn fetch_collateral( fmspc: String, ca: &'static str, ) -> Result { + #[cfg(test)] + install_test_crypto_provider(); + client.fetch_for_fmspc_without_pck_chain(&fmspc, ca, false).await.map_err(Into::into) } @@ -785,6 +967,8 @@ pub enum PccsError { SystemTime(#[from] std::time::SystemTimeError), #[error("HTTP client: {0}")] Reqwest(#[from] reqwest::Error), + #[error("Invalid Intel PCS subscription key: {0}")] + InvalidSubscriptionKey(#[from] InvalidHeaderValue), #[error( "no process-level rustls crypto provider is installed; install one at application \ startup, e.g. `rustls::crypto::aws_lc_rs::default_provider().install_default()` — \ @@ -805,13 +989,25 @@ pub enum PccsError { TimeStampExceedsI64, #[error("PCCS cache lock poisoned")] CachePoisoned, + #[error("PCCS cache is disabled; synchronous collateral lookup is unavailable")] + CacheDisabled, #[error("No collateral in cache for FMSPC {0}")] NoCollateralForFmspc(String), } #[cfg(test)] mod tests { - use mock_tdx::{MockPcsConfig, mock_collateral, spawn_mock_pcs_server}; + use std::{ + io::{Read, Write}, + net::TcpListener, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + thread, + }; + + use mock_tdx::{MockPcsConfig, MockPcsServer, mock_collateral, spawn_mock_pcs_server}; use tokio::time::Duration; use super::*; @@ -822,6 +1018,148 @@ mod tests { tcb_info.fmspc } + fn assert_observed_subscription_keys(mock: &MockPcsServer, expected: Option<&str>) { + let observed = mock.subscription_keys(); + assert!(!observed.is_empty(), "expected the mock PCS to receive requests"); + assert!( + observed.iter().all(|key| key.as_deref() == expected), + "observed keys: {observed:?}" + ); + } + + #[test] + fn intel_pcs_source_uses_canonical_url_and_redacts_key() { + let source = + CollateralSource::IntelPcs { subscription_key: Some("super-secret-key".to_string()) }; + let debug = format!("{source:?}"); + assert!(debug.contains("subscription_key_configured: true")); + assert!(!debug.contains("super-secret-key")); + + let pccs = Pccs::new(source, CachePolicy::Passthrough); + assert_eq!(pccs.url, PCS_URL); + assert_eq!(pccs.http_client.subscription_key.as_deref(), Some("super-secret-key")); + } + + #[test] + fn subscription_key_is_scoped_to_the_configured_origin() { + let pccs = Pccs::new( + CollateralSource::IntelPcs { subscription_key: Some("origin-key".to_string()) }, + CachePolicy::Passthrough, + ); + + let pcs_request = pccs + .http_client + .request(&format!("{PCS_URL}/tdx/certification/v4/tcb")) + .unwrap() + .build() + .unwrap(); + assert_eq!(pcs_request.headers().get(PCS_SUBSCRIPTION_KEY_HEADER).unwrap(), "origin-key"); + + for url in [ + "https://certificates.trustedservices.intel.com/IntelSGXRootCA.der", + "https://api.trustedservices.intel.com.attacker.example/", + "http://api.trustedservices.intel.com/", + "https://api.trustedservices.intel.com:444/", + ] { + let request = pccs.http_client.request(url).unwrap().build().unwrap(); + assert!( + request.headers().get(PCS_SUBSCRIPTION_KEY_HEADER).is_none(), + "subscription key attached to off-origin URL: {url}" + ); + } + } + + #[tokio::test] + async fn authenticated_client_does_not_follow_cross_origin_redirects() { + let redirect_listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let redirect_addr = redirect_listener.local_addr().unwrap(); + let redirect_task = thread::spawn(move || { + let (mut stream, _) = redirect_listener.accept().unwrap(); + let mut request = [0; 4096]; + let bytes_read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..bytes_read]).to_ascii_lowercase(); + assert!(request.contains("ocp-apim-subscription-key: redirect-key")); + stream + .write_all( + b"HTTP/1.1 307 Temporary Redirect\r\n\ + Location: http://127.0.0.1:1/\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n", + ) + .unwrap(); + }); + + let base_url = format!("http://{redirect_addr}"); + let client = SharedReqwestHttp::new(&base_url, Some("redirect-key".to_string())); + let response = client.request(&base_url).unwrap().send().await.unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::TEMPORARY_REDIRECT); + redirect_task.join().unwrap(); + } + + #[tokio::test] + async fn authenticated_client_limits_same_origin_redirects() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(AtomicUsize::new(0)); + let stop_server = stop.clone(); + let requests_server = requests.clone(); + let server = thread::spawn(move || { + while !stop_server.load(Ordering::SeqCst) { + let Ok((mut stream, _)) = listener.accept() else { + thread::sleep(std::time::Duration::from_millis(1)); + continue; + }; + requests_server.fetch_add(1, Ordering::SeqCst); + let mut request = [0; 4096]; + stream.read(&mut request).unwrap(); + stream + .write_all( + b"HTTP/1.1 307 Temporary Redirect\r\n\ + Location: /\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n", + ) + .unwrap(); + } + }); + + let base_url = format!("http://{addr}"); + let client = SharedReqwestHttp::new(&base_url, Some("redirect-key".to_string())); + let result = + tokio::time::timeout(Duration::from_secs(2), client.request(&base_url).unwrap().send()) + .await; + stop.store(true, Ordering::SeqCst); + server.join().unwrap(); + + let error = result.expect("redirect loop should terminate promptly").unwrap_err(); + assert!(error.is_redirect()); + assert_eq!(requests.load(Ordering::SeqCst), 11); + } + + #[test] + fn pccs_source_never_configures_an_intel_subscription_key() { + let pccs = Pccs::new( + CollateralSource::Pccs { url: "https://pccs.example/".to_string() }, + CachePolicy::Passthrough, + ); + assert_eq!(pccs.url, "https://pccs.example"); + assert!(pccs.http_client.subscription_key.is_none()); + } + + #[tokio::test] + async fn malformed_subscription_key_is_rejected_without_panicking_or_leaking() { + let pccs = Pccs::new( + CollateralSource::IntelPcs { subscription_key: Some("invalid\nkey".to_string()) }, + CachePolicy::Passthrough, + ); + let error = + pccs.get_collateral("000000000000".to_string(), "processor", 0).await.unwrap_err(); + assert!(!error.to_string().contains("invalid\nkey")); + } + #[tokio::test] async fn test_mock_pcs_server_helper_with_get_collateral() { let fmspc = mock_tdx_fmspc(); @@ -835,10 +1173,45 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs = + Pccs::new(CollateralSource::Pccs { url: mock.base_url.clone() }, CachePolicy::OnDemand); let now = 1_700_000_000_u64; let (_, is_fresh) = pccs.get_collateral(fmspc, "processor", now).await.unwrap(); assert!(is_fresh); + assert_observed_subscription_keys(&mock, None); + } + + #[tokio::test] + async fn test_passthrough_policy_fetches_collateral_every_time() { + let fmspc = mock_tdx_fmspc(); + let mock = spawn_mock_pcs_server(MockPcsConfig { + include_fmspcs_listing: false, + tcb_next_update: "2999-01-01T00:00:00Z".to_string(), + qe_next_update: "2999-01-01T00:00:00Z".to_string(), + refreshed_tcb_next_update: None, + refreshed_qe_next_update: None, + }) + .await + .unwrap(); + let pccs = Pccs::new( + CollateralSource::Pccs { url: mock.base_url.clone() }, + CachePolicy::Passthrough, + ); + + let (_, first_is_fresh) = + pccs.get_collateral(fmspc.clone(), "processor", 1_700_000_000).await.unwrap(); + let (_, second_is_fresh) = + pccs.get_collateral(fmspc.clone(), "processor", 1_700_000_000).await.unwrap(); + + assert!(pccs.inner.is_none()); + assert!(first_is_fresh); + assert!(second_is_fresh); + assert_eq!(mock.tcb_call_count(), 2); + assert_eq!(mock.qe_call_count(), 2); + assert!(matches!( + pccs.get_collateral_sync(fmspc, "processor", 1_700_000_000), + Err(PccsError::CacheDisabled) + )); } #[test] @@ -862,7 +1235,7 @@ mod tests { } #[tokio::test] - async fn test_proactive_refresh_updates_cached_entry() { + async fn test_authenticated_proactive_refresh_updates_cached_entry() { let initial_now = unix_now().unwrap(); let initial_next_update = OffsetDateTime::from_unix_timestamp(initial_now + 2).unwrap().format(&Rfc3339).unwrap(); @@ -882,7 +1255,11 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs = Pccs::new_with_endpoint( + mock.base_url.clone(), + Some("refresh-key".to_string()), + CachePolicy::OnDemand, + ); let (_, is_fresh) = pccs.get_collateral(fmspc.clone(), "processor", initial_now as u64).await.unwrap(); assert!(is_fresh); @@ -911,10 +1288,11 @@ mod tests { pccs.get_collateral(fmspc, "processor", now_after_background as u64).await.unwrap(); assert!(!is_fresh_again); assert_eq!(mock.tcb_call_count(), before_check_calls); + assert_observed_subscription_keys(&mock, Some("refresh-key")); } #[tokio::test] - async fn test_ready_waits_for_startup_prewarm() { + async fn test_authenticated_ready_waits_for_startup_prewarm() { let mock = spawn_mock_pcs_server(MockPcsConfig { include_fmspcs_listing: true, tcb_next_update: "2999-01-01T00:00:00Z".to_string(), @@ -924,16 +1302,21 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs = Pccs::new_with_endpoint( + mock.base_url.clone(), + Some("prewarm-key".to_string()), + CachePolicy::Prewarmed, + ); let summary = tokio::time::timeout(Duration::from_secs(5), pccs.ready()).await.unwrap().unwrap(); assert_eq!(summary.discovered_fmspcs, 1); assert_eq!(summary.attempted, 2); assert_eq!(summary.successes, 2); assert_eq!(summary.failures, 0); + assert_observed_subscription_keys(&mock, Some("prewarm-key")); let (total_entries, fmspc, ca) = { - let cache_guard = pccs.cache.read().unwrap(); + let cache_guard = pccs.inner.as_ref().unwrap().cache.read().unwrap(); let total_entries = cache_guard.len(); let (fmspc, ca) = cache_guard .keys() @@ -960,7 +1343,10 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs = Pccs::new( + CollateralSource::Pccs { url: mock.base_url.clone() }, + CachePolicy::Prewarmed, + ); let pccs_clone = pccs.clone(); let (first, second) = tokio::join!(pccs.ready(), pccs_clone.ready()); @@ -972,7 +1358,10 @@ mod tests { #[tokio::test] async fn test_ready_returns_error_when_prewarm_bootstrap_fails() { - let pccs = Pccs::new(Some("http://127.0.0.1:1".to_string())); + let pccs = Pccs::new( + CollateralSource::Pccs { url: "http://127.0.0.1:1".to_string() }, + CachePolicy::Prewarmed, + ); let ready_result = tokio::time::timeout(Duration::from_secs(2), pccs.ready()).await.unwrap(); assert!(matches!(ready_result, Err(PccsError::PrewarmFailed(_)))); @@ -980,7 +1369,8 @@ mod tests { #[tokio::test] async fn test_ready_returns_error_when_prewarm_disabled() { - let pccs = Pccs::new_without_prewarm(None); + let pccs = + Pccs::new(CollateralSource::IntelPcs { subscription_key: None }, CachePolicy::OnDemand); let ready_result = pccs.ready().await; assert!(matches!(ready_result, Err(PccsError::PrewarmDisabled))); } @@ -998,7 +1388,8 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new_without_prewarm(Some(mock.base_url.clone())); + let pccs = + Pccs::new(CollateralSource::Pccs { url: mock.base_url.clone() }, CachePolicy::OnDemand); let now = unix_now().unwrap() as u64; let err = pccs.get_collateral_sync(fmspc.clone(), "processor", now); @@ -1038,13 +1429,14 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new_without_prewarm(Some(mock.base_url.clone())); + let pccs = + Pccs::new(CollateralSource::Pccs { url: mock.base_url.clone() }, CachePolicy::OnDemand); let (_, is_fresh) = pccs.get_collateral(fmspc.clone(), "processor", initial_now as u64).await.unwrap(); assert!(is_fresh); { - let mut cache = pccs.cache.write().unwrap(); + let mut cache = pccs.inner.as_ref().unwrap().cache.write().unwrap(); let entry = cache .get_mut(&PccsInput::new(fmspc.clone(), "processor")) .expect("expected cached collateral entry"); diff --git a/readme.md b/readme.md index 2416f3d..ef975d0 100644 --- a/readme.md +++ b/readme.md @@ -41,7 +41,8 @@ More details in the individual READMEs of the provided crates: session for attestation. - [`attestation`](./crates/attestation) - provides attestation generation, verification and measurement handling. -- [`pccs`](./crates/pccs) provides collateral fetching and caching for DCAP +- [`pccs`](./crates/pccs) provides collateral fetching and optional caching + for DCAP verification. - [`mock-tdx`](./crates/mock-tdx) - generates deterministic mock TDX DCAP quotes, collateral, and trust roots for tests and development on non-TDX