diff --git a/crates/attestation/README.md b/crates/attestation/README.md index 2fe0194..e3747fb 100644 --- a/crates/attestation/README.md +++ b/crates/attestation/README.md @@ -12,14 +12,39 @@ This crate provides: ## Runtime Requirements -Verification uses the [`pccs`](../pccs) crate for collateral caching and -background refresh. As a result, constructing an `AttestationVerifier` with -PCCS enabled and calling verification APIs is expected to happen from within a -Tokio runtime and might panic if called outside of one. - -Note that although some of the verification API methods are synchronous (for -example `verify_attestation_sync`), still their functionality depends on -Tokio-backed background tasks such as PCCS pre-warm and cache refresh. +Verification uses the [`pccs`](../pccs) crate to fetch DCAP collateral and, +depending on the selected mode, cache and refresh it. Asynchronous +verification requires a Tokio runtime. Constructing an `AttestationVerifier` +in `Prewarmed` mode also requires an active runtime because pre-warming starts +immediately; constructing it in `Remote` or `Lazy` mode does not itself spawn +a task. + +Synchronous verification requires a cached mode (`Lazy` or `Prewarmed`) with +the required collateral already cached. Cache misses and expired entries may +start Tokio-backed background refresh tasks. `Remote` mode cannot be used for +synchronous verification because fetching collateral requires asynchronous +I/O. + +## DCAP collateral modes + +Every `AttestationVerifier` has a PCCS collateral source configured through +`AttestationVerifierBuilder::with_pccs_mode`. The default is +`PccsMode::Remote`. + +- `Remote` keeps no internal cache and fetches collateral from the configured + endpoint for every asynchronous verification. +- `Lazy` starts with an empty internal cache and fetches collateral on demand. +- `Prewarmed` immediately starts discovering and caching available TDX + collateral, then refreshes cached entries before expiry. + +Use `with_pccs_url` to select an Intel PCS or PCCS-compatible endpoint. Without +an explicit URL, the endpoint defaults to Intel PCS. + +`AttestationVerifier::ready()` waits for initial work only in `Prewarmed` +mode. It returns immediately for `Remote` and `Lazy`. A successful return in +`Remote` or `Lazy` does not mean later verification will avoid fetching +collateral. In `Prewarmed` mode it means pre-warm bootstrap completed, but +individual collateral fetches can still have failed. ## Feature flags @@ -64,6 +89,10 @@ must be explicitly enabled via the `override_azure_outdated_tcb` flag on Enables mock quote support via the local `mock-tdx` crate for tests and development on non-TDX hardware. +In mock builds, `Remote` mode uses embedded mock collateral rather than making +an external request. Cached modes can be pointed at a local mock PCCS when +testing cache behavior. + Do not use in production. Disabled by default. ## Attestation Types @@ -90,11 +119,12 @@ attempted. Alternatively, an external 'attestation provider service' URL can be provided which outsources the attestation generation to another process. -When verifying DCAP attestations, the Intel PCS is used to retrieve collateral -unless a PCCS URL is provided via a command line argument. If outdated TCB is -used, the quote will fail to verify. For special cases where outdated TCB -should be allowed, a custom override function can be passed when verifying which -may modify collateral before it is validated against the TCB. +When verifying DCAP attestations, collateral is retrieved according to the +configured PCCS mode. The endpoint defaults to Intel PCS unless a PCCS URL is +provided through the verifier builder. If outdated TCB is used, the quote will +fail to verify. For special cases where outdated TCB should be allowed, a +custom override function can be passed when verifying which may modify +collateral before it is validated against the TCB. ## Measurements File diff --git a/crates/attestation/src/azure/attester/mod.rs b/crates/attestation/src/azure/attester/mod.rs index 4e16a96..a1e48a5 100644 --- a/crates/attestation/src/azure/attester/mod.rs +++ b/crates/attestation/src/azure/attester/mod.rs @@ -286,9 +286,9 @@ fn fetch_certificate_der(url: &str) -> Result, MaaError> { #[cfg(test)] mod test_utils { use base64::{Engine as _, engine::general_purpose::URL_SAFE as BASE64_URL_SAFE}; + use pccs::PCS_URL; use super::{super::AttestationDocument, create_azure_attestation}; - use crate::dcap::PCS_URL; /// Capture a complete Azure TDX attestation fixture from inside an /// Azure TDX CVM. diff --git a/crates/attestation/src/azure/verify.rs b/crates/attestation/src/azure/verify.rs index 49daf37..c3210f3 100644 --- a/crates/attestation/src/azure/verify.rs +++ b/crates/attestation/src/azure/verify.rs @@ -41,7 +41,7 @@ struct PreparedAzureAttestation { pub async fn verify_azure_attestation( input: Vec, expected_input_data: [u8; 64], - pccs: Option, + pccs: Pccs, override_azure_outdated_tcb: bool, ) -> Result { let now = unix_time_now_secs()?; @@ -61,6 +61,9 @@ pub async fn verify_azure_attestation( /// /// This relies on having DCAP collateral already present in the cache /// +/// [`PccsMode::Remote`](pccs::PccsMode::Remote) is not supported because +/// fetching collateral requires asynchronous I/O. +/// /// If possible, prefer the async version pub fn verify_azure_attestation_sync( input: Vec, @@ -86,7 +89,7 @@ pub fn verify_azure_attestation_sync( async fn verify_azure_attestation_with_given_timestamp( input: Vec, expected_input_data: [u8; 64], - pccs: Option, + pccs: Pccs, collateral: Option, now: u64, override_azure_outdated_tcb: bool, @@ -385,13 +388,20 @@ mod tests { let actual = MAX_AZURE_ATTESTATION_PAYLOAD_SIZE + 1; let input = vec![b'{'; actual]; - let err = verify_azure_attestation(input.clone(), [0; 64], None, false).await.unwrap_err(); + let err = verify_azure_attestation( + input.clone(), + [0; 64], + Pccs::new(None, pccs::PccsMode::Remote), + false, + ) + .await + .unwrap_err(); assert_payload_too_large(err, actual); let err = verify_azure_attestation_sync( input.clone(), [0; 64], - Pccs::new_without_prewarm(None), + Pccs::new(None, pccs::PccsMode::Lazy), false, ) .unwrap_err(); @@ -400,7 +410,7 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp( input.clone(), [0; 64], - None, + Pccs::new(None, pccs::PccsMode::Remote), None, 0, false, @@ -412,7 +422,7 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp_sync( input, [0; 64], - Pccs::new_without_prewarm(None), + Pccs::new(None, pccs::PccsMode::Lazy), None, 0, false, @@ -462,7 +472,7 @@ mod tests { let async_measurements = verify_azure_attestation_with_given_timestamp( attestation_json.clone(), [0; 64], - None, + Pccs::new(None, pccs::PccsMode::Remote), Some(async_collateral), now, false, @@ -473,7 +483,7 @@ mod tests { let sync_measurements = verify_azure_attestation_with_given_timestamp_sync( attestation_json, [0; 64], - Pccs::new_without_prewarm(None), + Pccs::new(None, pccs::PccsMode::Lazy), Some(sync_collateral), now, false, @@ -503,7 +513,7 @@ mod tests { let err = verify_azure_attestation_with_given_timestamp( attestation_json, expected_input_data, - None, + Pccs::new(None, pccs::PccsMode::Remote), Some(collateral), now, false, diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index 832822f..3bfe8b5 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -2,13 +2,14 @@ //! verification use dcap_qvl::{ QuoteCollateralV3, - collateral::CollateralClient, intel::{quote_ca, quote_fmspc}, quote::{Quote, Report}, tcb_info::TcbInfo, }; #[cfg(any(test, feature = "mock"))] use mock_tdx::generate_mock_tdx_quote; +#[cfg(test)] +use pccs::PccsMode; use pccs::{Pccs, PccsError}; use thiserror::Error; @@ -18,9 +19,6 @@ use crate::{AttestationError, measurements::MultiMeasurements}; /// or other platforms) const AZURE_BAD_FMSPC: &str = "90C06F000000"; -/// For fetching collateral directly from Intel, if no PCCS is specified -pub const PCS_URL: &str = "https://api.trustedservices.intel.com"; - /// Generate a TDX quote pub fn create_dcap_attestation(input_data: [u8; 64]) -> Result, AttestationError> { let quote = generate_quote(input_data)?; @@ -33,7 +31,7 @@ pub fn create_dcap_attestation(input_data: [u8; 64]) -> Result, Attestat pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], - pccs: Option, + pccs: Pccs, ) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); let override_azure_outdated_tcb = false; @@ -53,6 +51,9 @@ pub async fn verify_dcap_attestation( /// /// This relies on having DCAP collateral already present in the cache /// +/// [`PccsMode::Remote`](pccs::PccsMode::Remote) is not supported because +/// fetching collateral requires asynchronous I/O. +/// /// If possible, prefer the async version #[cfg(not(any(test, feature = "mock")))] pub fn verify_dcap_attestation_sync( @@ -77,6 +78,9 @@ pub fn verify_dcap_attestation_sync( /// /// This relies on having DCAP collateral already present in the cache /// +/// [`PccsMode::Remote`](pccs::PccsMode::Remote) is not supported unless +/// `collateral` is provided. +/// /// If possible, prefer the async version pub fn verify_dcap_attestation_with_timestamp_sync( input: Vec, @@ -115,7 +119,7 @@ pub fn verify_dcap_attestation_with_timestamp_sync( pub async fn verify_dcap_attestation_with_given_timestamp( input: Vec, expected_input_data: [u8; 64], - pccs_option: Option, + pccs: Pccs, collateral: Option, now: u64, override_azure_outdated_tcb: bool, @@ -127,13 +131,9 @@ pub async fn verify_dcap_attestation_with_given_timestamp( let collateral = if let Some(given_collateral) = collateral { given_collateral - } else if let Some(ref pccs) = pccs_option { + } else { let (collateral, _is_fresh) = pccs.get_collateral(fmspc.clone(), ca, now).await?; collateral - } else { - CollateralClient::with_default_http(PCS_URL)? - .fetch_for_fmspc_without_pck_chain(&fmspc, ca, false) - .await? }; verify_dcap_attestation_with_collateral_and_timestamp( @@ -205,17 +205,18 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], - pccs: Option, + pccs: Pccs, ) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); - let collateral = if let Some(ref pccs) = pccs { + + let collateral = if pccs.is_remote() { + mock_tdx::mock_collateral() + } else { let (collateral, _is_fresh) = pccs.get_collateral(fmspc, ca, now).await?; collateral - } else { - mock_tdx::mock_collateral() }; let verifier = mock_tdx::mock_dcap_verifier(); verifier.verify(&input, &collateral, now)?; @@ -238,7 +239,13 @@ pub fn verify_dcap_attestation_sync( let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); - let collateral = pccs.get_collateral_sync(fmspc, ca, now)?; + + let collateral = if pccs.is_remote() { + mock_tdx::mock_collateral() + } else { + pccs.get_collateral_sync(fmspc, ca, now)? + }; + let verifier = mock_tdx::mock_dcap_verifier(); verifier.verify(&input, &collateral, now)?; @@ -334,7 +341,7 @@ mod tests { 37, 136, 57, 29, 25, 86, 182, 246, 70, 106, 216, 184, 220, 205, 85, 245, 114, 33, 173, 129, 180, 32, 247, 70, 250, 141, 176, 248, 99, 125, ], - None, + Pccs::new(None, PccsMode::Remote), Some(async_collateral), now, false, @@ -350,7 +357,7 @@ mod tests { 37, 136, 57, 29, 25, 86, 182, 246, 70, 106, 216, 184, 220, 205, 85, 245, 114, 33, 173, 129, 180, 32, 247, 70, 250, 141, 176, 248, 99, 125, ], - Pccs::new_without_prewarm(None), + Pccs::new(None, PccsMode::Lazy), Some(sync_collateral), now, false, @@ -383,7 +390,7 @@ mod tests { 248, 104, 204, 187, 101, 49, 203, 40, 218, 185, 220, 228, 119, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ], - None, + Pccs::new(None, PccsMode::Remote), Some(collateral), now, true, @@ -400,12 +407,12 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock_pcs.base_url.clone())); + let pccs = Pccs::new(Some(mock_pcs.base_url.clone()), PccsMode::Lazy); let expected_input_data = [0xA5; 64]; let quote = create_dcap_attestation(expected_input_data).unwrap(); let (measurements, _) = - verify_dcap_attestation(quote, expected_input_data, Some(pccs)).await.unwrap(); + verify_dcap_attestation(quote, expected_input_data, pccs).await.unwrap(); assert_eq!(measurements, crate::measurements::mock_dcap_measurements()); assert_eq!(mock_pcs.tcb_call_count(), 1); diff --git a/crates/attestation/src/gcp/firmware.rs b/crates/attestation/src/gcp/firmware.rs index 4aa56fe..047200b 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -156,7 +156,7 @@ mod tests { let (measurements, _) = verify_dcap_attestation_with_given_timestamp( attestation_bytes.to_vec(), expected_input_data, - None, + pccs::Pccs::new(None, pccs::PccsMode::Remote), Some(collateral), GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, false, diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index e8115e0..5a4d9ea 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -21,6 +21,7 @@ use attest_measure::platform::PlatformError; pub use attest_types::{AttestationEvidence, PlatformMetadata}; use measurements::MultiMeasurements; use parity_scale_codec::{Decode, Encode}; +pub use pccs::PccsMode; use pccs::{Pccs, PccsError}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -338,19 +339,6 @@ impl AttestationGenerator { } } -/// How the verifier obtains DCAP collateral -#[derive(Clone, Debug)] -pub enum PccsMode { - /// No internal collateral cache. Collateral is always fetched from - /// remote source. - None, - /// Internal cache pre-filled with all available collateral at build - /// time. - Prewarmed, - /// Internal cache that starts empty and fetches on demand. - Lazy, -} - /// Allows remote attestations to be verified #[derive(Clone, Debug)] pub struct AttestationVerifier { @@ -362,8 +350,8 @@ pub struct AttestationVerifier { /// /// This provides a workaround for a known outdated FMSPC used by Azure override_azure_outdated_tcb: bool, - /// Internal cache for collateral - internal_pccs: Option, + /// PCCS collateral source, optionally backed by an internal cache + internal_pccs: Pccs, /// Cached GCP firmware blobs indexed by MRTD known_gcp_firmware: GcpFirmwareCache, /// Cached PPIDs that have a valid GCP host-registry document @@ -374,9 +362,9 @@ pub struct AttestationVerifier { pub struct AttestationVerifierBuilder { /// The measurement policy with accepted values and attestation types measurement_policy: MeasurementPolicy, - /// Internal PCCS setting + /// How DCAP collateral is fetched and cached pccs_mode: PccsMode, - /// A PCCS service to use - defaults to Intel PCS + /// Collateral endpoint; defaults to Intel PCS pccs_url: Option, dump_dcap_quotes: bool, /// Whether to override outdated TCB when on Azure @@ -385,17 +373,11 @@ pub struct AttestationVerifierBuilder { impl AttestationVerifierBuilder { pub fn build(self) -> AttestationVerifier { - let internal_pccs = match self.pccs_mode { - PccsMode::None => None, - PccsMode::Prewarmed => Some(Pccs::new(self.pccs_url)), - PccsMode::Lazy => Some(Pccs::new_without_prewarm(self.pccs_url)), - }; - AttestationVerifier { measurement_policy: self.measurement_policy, dump_dcap_quotes: self.dump_dcap_quotes, override_azure_outdated_tcb: self.override_azure_outdated_tcb, - internal_pccs, + internal_pccs: Pccs::new(self.pccs_url, self.pccs_mode), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), } @@ -417,12 +399,15 @@ impl AttestationVerifierBuilder { self } + /// Configures how DCAP collateral is fetched and cached. + /// + /// The default is [`PccsMode::Remote`]. pub fn with_pccs_mode(mut self, pccs_mode: PccsMode) -> Self { self.pccs_mode = pccs_mode; self } - /// Set the URL used by internal PCCS + /// Sets the Intel PCS or PCCS endpoint used to fetch collateral. pub fn with_pccs_url(mut self, pccs_url: String) -> Self { self.pccs_url = Some(pccs_url); self @@ -433,7 +418,7 @@ impl AttestationVerifier { pub fn builder(measurement_policy: MeasurementPolicy) -> AttestationVerifierBuilder { AttestationVerifierBuilder { measurement_policy, - pccs_mode: PccsMode::None, + pccs_mode: PccsMode::Remote, pccs_url: None, dump_dcap_quotes: false, override_azure_outdated_tcb: false, @@ -447,7 +432,7 @@ impl AttestationVerifier { measurement_policy: MeasurementPolicy::expect_none(), dump_dcap_quotes: false, override_azure_outdated_tcb: false, - internal_pccs: None, + internal_pccs: Pccs::new(None, PccsMode::Remote), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), } @@ -460,7 +445,7 @@ impl AttestationVerifier { measurement_policy: MeasurementPolicy::mock(), dump_dcap_quotes: false, override_azure_outdated_tcb: false, - internal_pccs: None, + internal_pccs: Pccs::new(None, PccsMode::Remote), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), } @@ -469,30 +454,33 @@ impl AttestationVerifier { /// Expect mock measurements used in tests, and use a PCCS #[cfg(any(test, feature = "mock"))] pub fn mock_with_pccs(pccs_url: String) -> Self { + #[cfg(test)] + install_test_crypto_provider(); + Self { measurement_policy: MeasurementPolicy::mock(), dump_dcap_quotes: false, override_azure_outdated_tcb: false, - internal_pccs: Some(Pccs::new(Some(pccs_url))), + internal_pccs: Pccs::new(Some(pccs_url), PccsMode::Prewarmed), known_gcp_firmware: GcpFirmwareCache::new(), gcp_provenance_checker: GcpProvenanceChecker::new(), } } - /// 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 + /// In [`PccsMode::Prewarmed`], this waits for the initial pre-warm and + /// returns an error if pre-warm bootstrap failed. In + /// [`PccsMode::Remote`] and [`PccsMode::Lazy`], there is no initial + /// pre-warm to await, so this returns immediately. + /// + /// Only `Prewarmed` mode is intended to avoid on-demand collateral + /// fetches during verification. Initial pre-warming can complete even + /// if individual collateral fetches failed, so it is not an + /// absolute guarantee that verification will avoid a fetch. pub async fn ready(&self) -> Result<(), AttestationError> { - // If 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()), } @@ -576,6 +564,12 @@ impl AttestationVerifier { Ok(Some(measurements)) } + /// Synchronously verifies an attestation against the configured policy. + /// + /// DCAP and Azure verification require `Lazy` or `Prewarmed` mode with + /// the requested collateral already cached. `Remote` mode cannot fetch + /// collateral synchronously. A cache miss returns an error and starts a + /// background fetch for a later attempt. pub fn verify_attestation_sync( &self, attestation_exchange_message: AttestationExchangeMessage, @@ -606,11 +600,10 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - let pccs = self.internal_pccs.clone().ok_or(AttestationError::NoPccs)?; azure::verify_azure_attestation_sync( attestation_evidence.quote.clone(), expected_input_data, - pccs, + self.internal_pccs.clone(), self.override_azure_outdated_tcb, )? } @@ -624,11 +617,7 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - #[cfg(any(test, feature = "mock"))] - let pccs = - self.internal_pccs.clone().unwrap_or_else(|| Pccs::new_without_prewarm(None)); - #[cfg(not(any(test, feature = "mock")))] - let pccs = self.internal_pccs.clone().ok_or(AttestationError::NoPccs)?; + let pccs = self.internal_pccs.clone(); let (measurements, quote) = dcap::verify_dcap_attestation_sync( attestation_evidence.quote.clone(), @@ -809,8 +798,6 @@ pub enum AttestationError { Reqwest(#[from] reqwest::Error), #[error("PCCS: {0}")] Pccs(#[from] PccsError), - #[error("Sync verification requested but no PCCS configured")] - NoPccs, #[cfg(any(test, feature = "mock"))] #[error("Cannot create mock attestation: {0}")] Mock(String), @@ -820,8 +807,6 @@ pub enum AttestationError { #[cfg(test)] mod tests { - use mock_tdx::mock_pcs::{MockPcsConfig, spawn_mock_pcs_server}; - use super::*; #[test] @@ -837,7 +822,7 @@ mod tests { } #[tokio::test] - async fn mock_verifier_supports_sync_verification() { + async fn mock_verifier_uses_mock_collateral_for_async_and_sync_verification() { let input_data = [7u8; 64]; let quote = dcap::create_dcap_attestation(input_data).unwrap(); let attestation_evidence = AttestationEvidence { @@ -845,15 +830,16 @@ mod tests { platform: mock_platform_metadata(AttestationType::DcapTdx).unwrap(), }; - let mock_pcs_server = spawn_mock_pcs_server(MockPcsConfig::default()).await.unwrap(); - - let verifier = AttestationVerifier::mock_with_pccs(mock_pcs_server.base_url.clone()); - if let Some(ref pccs) = verifier.internal_pccs { - pccs.ready().await.unwrap(); - } + let verifier = AttestationVerifier::mock(); + let message: AttestationExchangeMessage = attestation_evidence.into(); - let result = verifier.verify_attestation_sync(attestation_evidence.into(), input_data); + let async_result = verifier.verify_attestation(message.clone(), input_data).await; + let sync_result = verifier.verify_attestation_sync(message, input_data); - assert!(result.is_ok(), "expected sync mock verification to succeed: {result:?}"); + assert!( + async_result.is_ok(), + "expected async mock verification to succeed: {async_result:?}" + ); + assert!(sync_result.is_ok(), "expected sync mock verification to succeed: {sync_result:?}"); } } diff --git a/crates/pccs/README.md b/crates/pccs/README.md index e1a3cfd..b902d79 100644 --- a/crates/pccs/README.md +++ b/crates/pccs/README.md @@ -1,7 +1,7 @@ # pccs -An internal Provisioning Certificate Caching Service implementation for DCAP -collateral fetching and caching. +A DCAP collateral client with optional in-process caching and proactive +refresh. This crate is used by attestation verification code that needs Intel TDX/SGX collateral such as TCB info, QE identity, and certificate revocation lists. @@ -9,20 +9,58 @@ collateral such as TCB info, QE identity, and certificate revocation lists. It can: - Fetch collateral from Intel PCS or a configured PCCS endpoint -- Cache collateral in-process -- Pre-warm the cache at startup -- Refresh cached collateral in the background before expiry +- Operate as a remote pass-through without caching +- Cache collateral lazily on demand +- Pre-warm and proactively refresh an in-process cache -This is an alternative to Intel's reference PCCS server implementation which -can be embedded in Rust services that verify quotes. +The caching modes provide an embeddable alternative to deploying Intel's +reference PCCS server alongside services that verify quotes. For Intel's terminology and architecture, see the Intel documentation for the [Provisioning Certificate Caching Service (PCCS)](https://cc-enabling.trustedservices.intel.com/intel-sgx-tdx-pccs/01/introduction/). +## Modes + +Every `Pccs` has a [`PccsMode`](src/lib.rs): + +- `Remote` keeps no internal cache. Every `get_collateral()` call fetches from + the configured endpoint. `get_collateral_sync()` returns `CacheDisabled` + because it cannot perform asynchronous network I/O. +- `Lazy` starts with an empty cache. Asynchronous cache misses are fetched + immediately; synchronous misses return an error and start a background + fetch for a later attempt. +- `Prewarmed` starts the same cache and immediately begins pre-warming it with + discovered TDX collateral. Call `ready()` to wait for that initial work. + +The endpoint passed to `Pccs::new` may be Intel PCS or another PCCS-compatible +service. Passing `None` uses [`PCS_URL`](src/lib.rs), the Intel PCS default. + +```rust,no_run +use pccs::{Pccs, PccsMode}; + +#[tokio::main] +async fn main() -> Result<(), pccs::PccsError> { + let _remote = Pccs::new(None, PccsMode::Remote); + let _lazy = Pccs::new(Some("https://pccs.example".into()), PccsMode::Lazy); + let prewarmed = Pccs::new(None, PccsMode::Prewarmed); + let _summary = prewarmed.ready().await?; + + Ok(()) +} +``` + +`ready()` only waits for `Prewarmed` mode. It returns `PrewarmDisabled` for +`Remote` and `Lazy`. A successful pre-warm result includes failure counters; +it does not guarantee that every possible collateral item was cached. + ## Runtime Requirements -This crate expects to be used from within a Tokio runtime. +Asynchronous collateral fetching requires a Tokio runtime. Constructing a +`Prewarmed` instance also requires an active runtime because it immediately +spawns the initial pre-warm task. Constructing `Remote` or `Lazy` does not +itself spawn a task. -The above applies even when calling synchronous-looking APIs such as -`get_collateral_sync()` because cache miss repair, proactive refresh, and -startup pre-warm are all driven by Tokio background tasks. +`get_collateral_sync()` is available only with a cache (`Lazy` or +`Prewarmed`). A cache miss or expired entry may spawn a Tokio background task, +so applications that can encounter either condition must have an active +runtime. diff --git a/crates/pccs/examples/intel_pcs.rs b/crates/pccs/examples/intel_pcs.rs index 4e6b3d0..0a07b39 100644 --- a/crates/pccs/examples/intel_pcs.rs +++ b/crates/pccs/examples/intel_pcs.rs @@ -1,7 +1,7 @@ //! Demonstrates setting up a PCCS cache using Intel PCS use std::time::Instant; -use pccs::{PCS_URL, Pccs}; +use pccs::{PCS_URL, Pccs, PccsMode}; use tracing::info; use tracing_subscriber::{EnvFilter, fmt}; @@ -18,7 +18,7 @@ async fn main() -> Result<(), pccs::PccsError> { info!(pcs_url = PCS_URL, "Starting PCCS with Intel PCS"); - let pccs = Pccs::new(None); + let pccs = Pccs::new(None, PccsMode::Prewarmed); let started_at = Instant::now(); let summary = pccs.ready().await?; let elapsed = started_at.elapsed().as_secs_f64(); diff --git a/crates/pccs/src/lib.rs b/crates/pccs/src/lib.rs index 9ebd6f4..d1c5303 100644 --- a/crates/pccs/src/lib.rs +++ b/crates/pccs/src/lib.rs @@ -42,7 +42,24 @@ const REFRESH_RETRY_SECS: u64 = 60; /// pre-warm const STARTUP_PREWARM_CONCURRENCY: usize = 8; -/// PCCS collateral cache with proactive background refresh +/// How PCCS obtains and stores DCAP collateral. +#[derive(Clone, Debug)] +pub enum PccsMode { + /// Fetch collateral from the configured endpoint for every asynchronous + /// lookup, without keeping an internal cache. + /// + /// Synchronous lookups are unavailable in this mode because fetching + /// collateral requires asynchronous I/O. + Remote, + /// Start pre-warming an internal cache when [`Pccs`] is constructed. + /// + /// Call [`Pccs::ready`] to wait for the initial pre-warm to complete. + Prewarmed, + /// Start with an empty internal cache and fetch collateral on demand. + Lazy, +} + +/// 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, @@ -54,6 +71,12 @@ const STARTUP_PREWARM_CONCURRENCY: usize = 8; pub struct Pccs { /// The URL of the service used to fetch collateral (PCS / PCCS) url: String, + /// An internal cache if configured + inner: Option, +} + +#[derive(Clone)] +struct PccsInner { /// The internal cache cache: Arc>>, /// Dedupes one-shot background refreshes for cache misses @@ -73,26 +96,12 @@ impl std::fmt::Debug for Pccs { } impl Pccs { - /// Creates a new PCCS cache using the provided URL or Intel PCS default - pub fn new(url: Option) -> Self { - let mut pccs = Self::new_without_prewarm(url); - - let (prewarm_outcome_tx, _) = watch::channel(None); - pccs.prewarm_outcome_tx = Some(prewarm_outcome_tx); - - // Start filling the cache right away - let pccs_for_prewarm = pccs.clone(); - tokio::spawn(async move { - let outcome = pccs_for_prewarm.startup_prewarm_all_tdx().await; - pccs_for_prewarm.finish_prewarm(outcome); - }); - - pccs - } - - /// Creates a new PCCS cache using the provided URL or Intel PCS default - /// and does not pre-warm by proactively fetching collateral - pub fn new_without_prewarm(url: Option) -> Self { + /// Creates a collateral source in the requested mode. + /// + /// The endpoint defaults to Intel PCS when `url` is `None`. + /// Constructing [`PccsMode::Prewarmed`] immediately spawns its initial + /// fetch task and therefore requires an active Tokio runtime. + pub fn new(url: Option, mode: PccsMode) -> Self { let url = url .unwrap_or(PCS_URL.to_string()) .trim_end_matches('/') @@ -100,18 +109,58 @@ impl Pccs { .trim_end_matches("/tdx/certification/v4") .to_string(); - Self { - url, - cache: RwLock::new(HashMap::new()).into(), - pending_refreshes: RwLock::new(HashSet::new()).into(), - prewarm_stats: Arc::new(PrewarmStats::default()), - prewarm_outcome_tx: None, + match mode { + PccsMode::Remote => Self { url, inner: None }, + PccsMode::Lazy => Self { + url, + inner: Some(PccsInner { + cache: RwLock::new(HashMap::new()).into(), + pending_refreshes: RwLock::new(HashSet::new()).into(), + prewarm_stats: Arc::new(PrewarmStats::default()), + prewarm_outcome_tx: None, + }), + }, + PccsMode::Prewarmed => { + let (prewarm_outcome_tx, _) = watch::channel(None); + + let pccs = Self { + url, + inner: Some(PccsInner { + cache: RwLock::new(HashMap::new()).into(), + pending_refreshes: RwLock::new(HashSet::new()).into(), + prewarm_stats: Arc::new(PrewarmStats::default()), + prewarm_outcome_tx: Some(prewarm_outcome_tx), + }), + }; + + // Start filling the cache right away + let pccs_for_prewarm = pccs.clone(); + tokio::spawn(async move { + let outcome = pccs_for_prewarm.startup_prewarm_all_tdx().await; + pccs_for_prewarm.finish_prewarm(outcome); + }); + + pccs + } } } - /// Resolves when cache is pre-warmed with all available collateral + /// Returns whether this PCCS fetches collateral directly without an + /// internal cache. + pub fn is_remote(&self) -> bool { + self.inner.is_none() + } + + /// Waits for the initial pre-warm to complete. + /// + /// Returns [`PccsError::PrewarmDisabled`] for [`PccsMode::Remote`] and + /// [`PccsMode::Lazy`]. A successful result means the initial pre-warm + /// 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() { @@ -124,26 +173,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. + /// Remote mode always fetches from the configured endpoint. + /// /// Returns collateral together with a flag indicating whether it is - /// fresh (true) or from the cache (false) + /// freshly fetched (`true`) or from the cache (`false`). Remote mode + /// always returns `true`. pub async fn get_collateral( &self, fmspc: String, ca: &'static str, now: u64, ) -> Result<(QuoteCollateralV3, bool), PccsError> { + let Some(inner) = &self.inner else { + let collateral = fetch_collateral(&self.url, fmspc, ca).await?; + return Ok((collateral, true)); + }; + let now = i64::try_from(now).map_err(|_| PccsError::TimeStampExceedsI64)?; let cache_key = PccsInput::new(fmspc.clone(), ca); { - let cache = self.cache.read().map_err(|_| PccsError::CachePoisoned)?; + let cache = inner.cache.read().map_err(|_| PccsError::CachePoisoned)?; if let Some(entry) = cache.get(&cache_key) { if now < entry.next_update { return Ok((entry.collateral.clone(), false)); @@ -161,7 +216,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 { @@ -176,6 +231,10 @@ impl Pccs { /// A synchronous method to get collateral from the cache. /// + /// In [`PccsMode::Remote`], this returns [`PccsError::CacheDisabled`] + /// because a synchronous call cannot perform the required asynchronous + /// fetch. + /// /// If the requested collateral is not present in the cache, this will /// return an error rather than waiting to fetch it. But it does /// begin fetching it in a background task. @@ -188,9 +247,13 @@ impl Pccs { ca: &'static str, now: u64, ) -> Result { + let Some(inner) = &self.inner else { + return Err(PccsError::CacheDisabled); + }; + let now = i64::try_from(now).map_err(|_| PccsError::TimeStampExceedsI64)?; let cache_key = PccsInput::new(fmspc.clone(), ca); - let cache = self.cache.read().map_err(|_| PccsError::CachePoisoned)?; + let cache = inner.cache.read().map_err(|_| PccsError::CachePoisoned)?; if let Some(entry) = cache.get(&cache_key) { if now >= entry.next_update { let collateral = entry.collateral.clone(); @@ -225,13 +288,17 @@ impl Pccs { fmspc: String, ca: &'static str, ) -> Result { + let Some(inner) = &self.inner else { + return fetch_collateral(&self.url, fmspc, ca).await; + }; + let now = unix_now()?; let collateral = fetch_collateral(&self.url, fmspc.clone(), ca).await?; let next_update = extract_next_update(&collateral, now)?; let cache_key = PccsInput::new(fmspc, ca); { - let mut cache = self.cache.write().map_err(|_| PccsError::CachePoisoned)?; + let mut cache = inner.cache.write().map_err(|_| PccsError::CachePoisoned)?; upsert_cache_entry(&mut cache, cache_key.clone(), collateral.clone(), next_update); } self.ensure_refresh_task(&cache_key).await; @@ -241,7 +308,10 @@ impl Pccs { /// Starts a background refresh loop for a cache key when no task is /// active async fn ensure_refresh_task(&self, cache_key: &PccsInput) { - let Ok(mut cache) = self.cache.write() else { + let Some(inner) = &self.inner else { + return; + }; + let Ok(mut cache) = inner.cache.write() else { tracing::warn!("PCCS cache lock poisoned, cannot ensure refresh task"); return; }; @@ -252,7 +322,7 @@ impl Pccs { return; } - let weak_cache = Arc::downgrade(&self.cache); + let weak_cache = Arc::downgrade(&inner.cache); let key = cache_key.clone(); let url = self.url.clone(); entry.refresh_task = Some(tokio::spawn(async move { @@ -262,8 +332,11 @@ impl Pccs { /// Starts a one-shot background fetch to populate a missing cache entry fn spawn_background_refresh_for_cache_miss(&self, cache_key: PccsInput) { + let Some(inner) = &self.inner else { + return; + }; { - let Ok(mut pending_refreshes) = self.pending_refreshes.write() else { + let Ok(mut pending_refreshes) = inner.pending_refreshes.write() else { tracing::warn!("PCCS pending-refresh lock poisoned, cannot start sync refresh"); return; }; @@ -292,7 +365,10 @@ impl Pccs { // Always clear the dedupe marker so a later sync miss can // retry if this repair attempt failed. - if let Ok(mut pending_refreshes) = pccs.pending_refreshes.write() { + let Some(inner) = &pccs.inner else { + return; + }; + if let Ok(mut pending_refreshes) = inner.pending_refreshes.write() { pending_refreshes.remove(&cache_key); } else { tracing::warn!("PCCS pending-refresh lock poisoned during cleanup"); @@ -303,6 +379,10 @@ impl Pccs { /// Pre-provisions TDX collateral for discovered FMSPC values to reduce /// hot-path fetches async fn startup_prewarm_all_tdx(&self) -> PrewarmOutcome { + let Some(inner) = &self.inner else { + return PrewarmOutcome::Failed("PCCS cache is disabled".to_string()); + }; + // First get all FMSPCs let fmspcs = match self.fetch_fmspcs().await { Ok(fmspcs) => fmspcs, @@ -317,11 +397,11 @@ impl Pccs { )); } }; - self.prewarm_stats.discovered_fmspcs.store(fmspcs.len(), Ordering::SeqCst); + inner.prewarm_stats.discovered_fmspcs.store(fmspcs.len(), Ordering::SeqCst); if fmspcs.is_empty() { tracing::warn!("No FMSPC entries returned during startup pre-provision"); - return PrewarmOutcome::Ready(self.prewarm_stats.snapshot()); + return PrewarmOutcome::Ready(inner.prewarm_stats.snapshot()); } // For each FMSPC, get the 'processor' and 'platform' collateral @@ -334,7 +414,7 @@ impl Pccs { let Ok(permit) = permit else { continue; }; - self.prewarm_stats.attempted.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.attempted.fetch_add(1, Ordering::SeqCst); let pccs = self.clone(); let fmspc = entry.fmspc.clone(); join_set.spawn(async move { @@ -357,11 +437,11 @@ impl Pccs { Ok(Ok((fmspc, ca, Ok(())))) => { successes += 1; debug!("Successfully cached: {fmspc} {ca}"); - self.prewarm_stats.successes.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.successes.fetch_add(1, Ordering::SeqCst); } Ok(Ok((fmspc, ca, Err(e)))) => { failures += 1; - self.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); tracing::debug!( fmspc, ca, @@ -371,29 +451,32 @@ impl Pccs { } Ok(Err(e)) => { failures += 1; - self.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); tracing::debug!(error = %e, "Startup pre-provision task failed"); } Err(e) => { failures += 1; - self.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); + inner.prewarm_stats.failures.fetch_add(1, Ordering::SeqCst); tracing::debug!(error = %e, "Startup pre-provision join error"); } } } tracing::info!( - discovered_fmspcs = self.prewarm_stats.discovered_fmspcs.load(Ordering::SeqCst), - attempted = self.prewarm_stats.attempted.load(Ordering::SeqCst), + discovered_fmspcs = inner.prewarm_stats.discovered_fmspcs.load(Ordering::SeqCst), + attempted = inner.prewarm_stats.attempted.load(Ordering::SeqCst), successes, failures, "Completed PCCS startup pre-provisioning for TDX collateral" ); - PrewarmOutcome::Ready(self.prewarm_stats.snapshot()) + PrewarmOutcome::Ready(inner.prewarm_stats.snapshot()) } fn finish_prewarm(&self, outcome: PrewarmOutcome) { - if let Some(prewarm_outcome_tx) = &self.prewarm_outcome_tx { - self.prewarm_stats.completed.store(true, Ordering::SeqCst); + let Some(inner) = &self.inner else { + return; + }; + if let Some(prewarm_outcome_tx) = &inner.prewarm_outcome_tx { + inner.prewarm_stats.completed.store(true, Ordering::SeqCst); let _ = prewarm_outcome_tx.send(Some(outcome)); } } @@ -457,6 +540,9 @@ async fn fetch_collateral( fmspc: String, ca: &'static str, ) -> Result { + #[cfg(test)] + install_test_crypto_provider(); + CollateralClient::with_default_http(url)? .fetch_for_fmspc_without_pck_chain(&fmspc, ca, false) .await @@ -756,6 +842,8 @@ pub enum PccsError { TimeStampExceedsI64, #[error("PCCS cache lock poisoned")] CachePoisoned, + #[error("PCCS cache is disabled; synchronous collateral lookup is unavailable")] + CacheDisabled, #[error("No collateral in cache for FMSPC {0}")] NoCollateralForFmspc(String), } @@ -786,12 +874,42 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Lazy); let now = 1_700_000_000_u64; let (_, is_fresh) = pccs.get_collateral(fmspc, "processor", now).await.unwrap(); assert!(is_fresh); } + #[tokio::test] + async fn test_remote_mode_fetches_collateral_every_time() { + let fmspc = mock_tdx_fmspc(); + let mock = spawn_mock_pcs_server(MockPcsConfig { + include_fmspcs_listing: false, + tcb_next_update: "2999-01-01T00:00:00Z".to_string(), + qe_next_update: "2999-01-01T00:00:00Z".to_string(), + refreshed_tcb_next_update: None, + refreshed_qe_next_update: None, + }) + .await + .unwrap(); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Remote); + + let (_, first_is_fresh) = + pccs.get_collateral(fmspc.clone(), "processor", 1_700_000_000).await.unwrap(); + let (_, second_is_fresh) = + pccs.get_collateral(fmspc.clone(), "processor", 1_700_000_000).await.unwrap(); + + assert!(pccs.inner.is_none()); + assert!(first_is_fresh); + assert!(second_is_fresh); + assert_eq!(mock.tcb_call_count(), 2); + assert_eq!(mock.qe_call_count(), 2); + assert!(matches!( + pccs.get_collateral_sync(fmspc, "processor", 1_700_000_000), + Err(PccsError::CacheDisabled) + )); + } + #[test] fn test_extract_next_update_includes_crl_expiry() { let mut collateral: QuoteCollateralV3 = mock_collateral(); @@ -833,7 +951,7 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Lazy); let (_, is_fresh) = pccs.get_collateral(fmspc.clone(), "processor", initial_now as u64).await.unwrap(); assert!(is_fresh); @@ -875,7 +993,7 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Prewarmed); let summary = tokio::time::timeout(Duration::from_secs(5), pccs.ready()).await.unwrap().unwrap(); assert_eq!(summary.discovered_fmspcs, 1); @@ -884,7 +1002,7 @@ mod tests { assert_eq!(summary.failures, 0); let (total_entries, fmspc, ca) = { - let cache_guard = pccs.cache.read().unwrap(); + let cache_guard = pccs.inner.as_ref().unwrap().cache.read().unwrap(); let total_entries = cache_guard.len(); let (fmspc, ca) = cache_guard .keys() @@ -911,7 +1029,7 @@ mod tests { }) .await .unwrap(); - let pccs = Pccs::new(Some(mock.base_url.clone())); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Prewarmed); let pccs_clone = pccs.clone(); let (first, second) = tokio::join!(pccs.ready(), pccs_clone.ready()); @@ -923,7 +1041,7 @@ mod tests { #[tokio::test] async fn test_ready_returns_error_when_prewarm_bootstrap_fails() { - let pccs = Pccs::new(Some("http://127.0.0.1:1".to_string())); + let pccs = Pccs::new(Some("http://127.0.0.1:1".to_string()), PccsMode::Prewarmed); let ready_result = tokio::time::timeout(Duration::from_secs(2), pccs.ready()).await.unwrap(); assert!(matches!(ready_result, Err(PccsError::PrewarmFailed(_)))); @@ -931,7 +1049,7 @@ mod tests { #[tokio::test] async fn test_ready_returns_error_when_prewarm_disabled() { - let pccs = Pccs::new_without_prewarm(None); + let pccs = Pccs::new(None, PccsMode::Lazy); let ready_result = pccs.ready().await; assert!(matches!(ready_result, Err(PccsError::PrewarmDisabled))); } @@ -949,7 +1067,7 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new_without_prewarm(Some(mock.base_url.clone())); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Lazy); let now = unix_now().unwrap() as u64; let err = pccs.get_collateral_sync(fmspc.clone(), "processor", now); @@ -989,13 +1107,13 @@ mod tests { .await .unwrap(); - let pccs = Pccs::new_without_prewarm(Some(mock.base_url.clone())); + let pccs = Pccs::new(Some(mock.base_url.clone()), PccsMode::Lazy); let (_, is_fresh) = pccs.get_collateral(fmspc.clone(), "processor", initial_now as u64).await.unwrap(); assert!(is_fresh); { - let mut cache = pccs.cache.write().unwrap(); + let mut cache = pccs.inner.as_ref().unwrap().cache.write().unwrap(); let entry = cache .get_mut(&PccsInput::new(fmspc.clone(), "processor")) .expect("expected cached collateral entry"); 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