diff --git a/Cargo.lock b/Cargo.lock index 3883368..1f2d40a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,6 +400,7 @@ dependencies = [ "pccs", "pem-rfc7468", "rand_core 0.6.4", + "rcgen 0.14.7", "reqwest 0.13.4", "rustls", "rustls-webpki", diff --git a/crates/attestation/Cargo.toml b/crates/attestation/Cargo.toml index 72c7be7..1cbebf6 100644 --- a/crates/attestation/Cargo.toml +++ b/crates/attestation/Cargo.toml @@ -53,6 +53,7 @@ az-tdx-vtpm = { version = "0.7.4", optional = true } tss-esapi = { version = "7.6.0", optional = true } [dev-dependencies] +rcgen = "0.14.7" mock-tdx = { workspace = true } tempfile = "3.23.0" tokio-rustls = { workspace = true, default-features = true } diff --git a/crates/attestation/src/azure/mod.rs b/crates/attestation/src/azure/mod.rs index fd0e778..e940b93 100644 --- a/crates/attestation/src/azure/mod.rs +++ b/crates/attestation/src/azure/mod.rs @@ -50,6 +50,16 @@ struct TpmAttest { instance_info: Option>, } +/// The DCAP quote and AK certificate material used by Azure verification. +#[derive(Clone, Debug)] +pub struct AzureVerifiedEvidence { + pub quote: dcap_qvl::quote::Quote, + // Read from the already-parsed leaf; retain decoded intermediates for + // lazy expiry calculation without copying or parsing them again here. + pub(crate) ak_not_after: u64, + pub(crate) ak_intermediates: Vec>, +} + /// Maximum serialized Azure attestation evidence payload size produced /// during generation and accepted during verification. /// diff --git a/crates/attestation/src/azure/verify.rs b/crates/attestation/src/azure/verify.rs index 234196a..76ce317 100644 --- a/crates/attestation/src/azure/verify.rs +++ b/crates/attestation/src/azure/verify.rs @@ -13,6 +13,7 @@ use x509_parser::prelude::*; use super::{ AttestationDocument, + AzureVerifiedEvidence, MaaError, TpmAttest, ak_certificate::verify_ak_cert_with_azure_roots, @@ -21,6 +22,7 @@ use super::{ }; use crate::{ VerifiedAttestation, + VerifiedEvidence, dcap::{ verify_dcap_attestation_with_given_timestamp, verify_dcap_attestation_with_timestamp_sync, @@ -103,9 +105,9 @@ async fn verify_azure_attestation_with_given_timestamp( tpm_attestation, } = prepare_azure_attestation(input)?; - // Only the endorsements travel upward: this platform is judged on the - // vTPM PCRs, not the TD quote - let (dcap, _) = verify_dcap_attestation_with_given_timestamp( + // Retain the DCAP evidence and endorsements, but use vTPM PCRs for + // this platform's measurement policy. + let dcap = verify_dcap_attestation_with_given_timestamp( tdx_quote_bytes, expected_tdx_input_data, pccs, @@ -117,16 +119,22 @@ async fn verify_azure_attestation_with_given_timestamp( // The vTPM leg fetches nothing — AK chain in the evidence, roots // compiled in — so it adds no endorsements of its own - let measurements = finish_azure_attestation_verification( + let quote = match dcap.evidence { + VerifiedEvidence::Dcap(quote) => quote, + VerifiedEvidence::Azure(_) => unreachable!("DCAP verification returns DCAP evidence"), + }; + let (measurements, evidence) = finish_azure_attestation_verification( hcl_report, var_data_hash, tpm_attestation, expected_input_data, now, + quote, )?; Ok(VerifiedAttestation { measurements, expected_measurements: None, + evidence: VerifiedEvidence::Azure(evidence), endorsements: dcap.endorsements, }) } @@ -148,7 +156,7 @@ fn verify_azure_attestation_with_given_timestamp_sync( tpm_attestation, } = prepare_azure_attestation(input)?; - let (dcap, _) = verify_dcap_attestation_with_timestamp_sync( + let dcap = verify_dcap_attestation_with_timestamp_sync( tdx_quote_bytes, expected_tdx_input_data, pccs, @@ -157,16 +165,22 @@ fn verify_azure_attestation_with_given_timestamp_sync( override_azure_outdated_tcb, )?; - let measurements = finish_azure_attestation_verification( + let quote = match dcap.evidence { + VerifiedEvidence::Dcap(quote) => quote, + VerifiedEvidence::Azure(_) => unreachable!("DCAP verification returns DCAP evidence"), + }; + let (measurements, evidence) = finish_azure_attestation_verification( hcl_report, var_data_hash, tpm_attestation, expected_input_data, now, + quote, )?; Ok(VerifiedAttestation { measurements, expected_measurements: None, + evidence: VerifiedEvidence::Azure(evidence), endorsements: dcap.endorsements, }) } @@ -206,7 +220,8 @@ fn finish_azure_attestation_verification( tpm_attestation: TpmAttest, expected_input_data: [u8; 64], now: u64, -) -> Result { + quote: dcap_qvl::quote::Quote, +) -> Result<(MultiMeasurements, AzureVerifiedEvidence), MaaError> { let hcl_ak_pub = hcl_report.ak_pub()?; // Get attestation key from runtime claims @@ -273,7 +288,15 @@ fn finish_azure_attestation_verification( now, )?; - Ok(MultiMeasurements::from_indexed_pcrs(pcrs)) + let ak_not_after = u64::try_from(ak_certificate.validity().not_after.timestamp()).unwrap_or(0); + Ok(( + MultiMeasurements::from_indexed_pcrs(pcrs), + AzureVerifiedEvidence { + quote, + ak_not_after, + ak_intermediates: ak_intermediate_certificate_ders, + }, + )) } /// Extract the measurements from the attestation, but do not verify @@ -497,11 +520,7 @@ mod tests { let fixture_collateral: QuoteCollateralV3 = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let VerifiedAttestation { - measurements: async_measurements, - endorsements: async_endorsements, - .. - } = verify_azure_attestation_with_given_timestamp( + let async_verified = verify_azure_attestation_with_given_timestamp( attestation_json.clone(), [0; 64], Pccs::new( @@ -515,11 +534,7 @@ mod tests { .await .unwrap(); - let VerifiedAttestation { - measurements: sync_measurements, - endorsements: sync_endorsements, - .. - } = verify_azure_attestation_with_given_timestamp_sync( + let sync_verified = verify_azure_attestation_with_given_timestamp_sync( attestation_json, [0; 64], Pccs::new( @@ -532,7 +547,21 @@ mod tests { ) .unwrap(); + let async_expiry = async_verified.cache_expires_at().unwrap(); + let sync_expiry = sync_verified.cache_expires_at().unwrap(); + let VerifiedAttestation { + measurements: async_measurements, + endorsements: async_endorsements, + .. + } = async_verified; + let VerifiedAttestation { + measurements: sync_measurements, + endorsements: sync_endorsements, + .. + } = sync_verified; assert_eq!(async_measurements, sync_measurements); + assert_eq!(async_expiry, sync_expiry); + assert!(now < async_expiry); // The bundle handed back is the one the DCAP leg consumed, which is // what makes archiving it provenance rather than a second copy, and // it arrives paired with the instant both legs were held to @@ -541,6 +570,38 @@ mod tests { assert_eq!(sync_endorsements, expected); } + #[tokio::test] + async fn expired_extra_ak_certificate_disables_caching_without_changing_trust() { + let mut document: AttestationDocument = serde_saphyr::from_slice(include_bytes!( + "../../test-assets/azure-tdx-with-ak-intermediates-1780922561.yaml" + )) + .unwrap(); + let collateral: QuoteCollateralV3 = serde_saphyr::from_slice(include_bytes!( + "../../test-assets/azure-collateral-with-ak-intermediates-1780922561.yaml" + )) + .unwrap(); + let now = 1_780_922_561; + let mut params = rcgen::CertificateParams::new(vec!["unused".into()]).unwrap(); + params.not_before = ::time::OffsetDateTime::from_unix_timestamp(0).unwrap(); + params.not_after = ::time::OffsetDateTime::from_unix_timestamp(1000).unwrap(); + let certificate = params.self_signed(&rcgen::KeyPair::generate().unwrap()).unwrap(); + document.tpm_attestation.ak_intermediate_certificates_pem.push(certificate.pem()); + let verified = verify_azure_attestation_with_given_timestamp( + serde_json::to_vec(&document).unwrap(), + [0; 64], + Pccs::new( + pccs::CollateralSource::IntelPcs { subscription_key: None }, + pccs::CachePolicy::Passthrough, + ), + Some(collateral), + now, + false, + ) + .await + .unwrap(); + assert_eq!(verified.cache_expires_at().unwrap(), 1000); + } + #[tokio::test] async fn test_verify_fails_on_input_mismatch() { let attestation_bytes: &'static [u8] = diff --git a/crates/attestation/src/cache_expiry.rs b/crates/attestation/src/cache_expiry.rs new file mode 100644 index 0000000..8100c30 --- /dev/null +++ b/crates/attestation/src/cache_expiry.rs @@ -0,0 +1,131 @@ +//! Conservative cache deadlines, derived from the material already +//! verified. These helpers do not establish trust or replace signature +//! verification. + +use dcap_qvl::{QuoteCollateralV3, quote::Quote}; +use x509_parser::{ + asn1_rs::Err as ParseError, + error::X509Error, + pem::Pem, + prelude::{FromDer, X509Certificate}, +}; + +use crate::dcap::DcapVerificationError; + +/// Given a der-encoded x509 certificate, return the expiry date in seconds +pub(crate) fn certificate_not_after(der: &[u8]) -> Result> { + let (_, cert) = X509Certificate::from_der(der)?; + // An already-expired, unused certificate may be present in a verified + // chain. It disables caching rather than introducing a new trust rule. + Ok(u64::try_from(cert.validity().not_after.timestamp()).unwrap_or(0)) +} + +fn pem_chain_not_after(chain: &str) -> Result { + let mut earliest = None; + for pem in Pem::iter_from_buffer(chain.as_bytes()) { + let pem = pem?; + if pem.label != "CERTIFICATE" { + return Err(DcapVerificationError::UnexpectedPemLabel(pem.label)); + } + let expiry = certificate_not_after(&pem.contents)?; + earliest = Some(earliest.map_or(expiry, |previous: u64| previous.min(expiry))); + } + earliest.ok_or(DcapVerificationError::EmptyCertificateChain) +} + +/// Given a TDX quote and associated collateral, return the earliest +/// associated expiry date +pub(crate) fn dcap_cache_expires_at( + collateral: &QuoteCollateralV3, + quote: &Quote, +) -> Result { + let mut expiry = pccs::collateral_next_update(collateral)?; + for chain in [ + &collateral.tcb_info_issuer_chain, + &collateral.qe_identity_issuer_chain, + &collateral.pck_crl_issuer_chain, + ] { + expiry = expiry.min(pem_chain_not_after(chain)?); + } + + // Match dcap-qvl's chain selection: collateral takes precedence. + if let Some(chain) = &collateral.pck_certificate_chain { + expiry = expiry.min(pem_chain_not_after(chain)?); + } else { + let chain = dcap_qvl::intel::extract_cert_chain(quote)?; + if chain.is_empty() { + return Err(DcapVerificationError::EmptyCertificateChain); + } + for certificate in chain { + expiry = expiry.min(certificate_not_after(&certificate)?); + } + } + Ok(expiry) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn certificate(expiry: i64) -> rcgen::Certificate { + let mut params = rcgen::CertificateParams::new(vec!["test".into()]).unwrap(); + params.not_before = time::OffsetDateTime::from_unix_timestamp(expiry - 100).unwrap(); + params.not_after = time::OffsetDateTime::from_unix_timestamp(expiry).unwrap(); + params.self_signed(&rcgen::KeyPair::generate().unwrap()).unwrap() + } + + #[test] + fn each_certificate_chain_can_limit_the_deadline() { + let quote = Quote::parse(&mock_tdx::generate_mock_tdx_quote([0; 64]).unwrap()).unwrap(); + let baseline = mock_tdx::mock_collateral(); + let initial_expiry = dcap_cache_expires_at(&baseline, "e).unwrap(); + let earlier = initial_expiry - 100; + let short_chain = certificate(earlier as i64).pem(); + for chain in 0..4 { + let mut collateral = baseline.clone(); + match chain { + 0 => collateral.tcb_info_issuer_chain = short_chain.clone(), + 1 => collateral.qe_identity_issuer_chain = short_chain.clone(), + 2 => collateral.pck_crl_issuer_chain = short_chain.clone(), + _ => collateral.pck_certificate_chain = Some(short_chain.clone()), + } + assert_eq!(dcap_cache_expires_at(&collateral, "e).unwrap(), earlier); + } + } + + #[test] + fn pck_chain_in_collateral_takes_precedence_over_quote() { + let mut quote = Quote::parse(&mock_tdx::generate_mock_tdx_quote([0; 64]).unwrap()).unwrap(); + let mut auth = quote.auth_data.clone().into_v3(); + auth.certification_data.body.data = certificate(1000).pem().into_bytes(); + quote.auth_data = dcap_qvl::quote::AuthData::V3(auth); + let mut collateral = mock_tdx::mock_collateral(); + collateral.pck_certificate_chain = None; + assert_eq!(dcap_cache_expires_at(&collateral, "e).unwrap(), 1000); + collateral.pck_certificate_chain = Some(certificate(2000).pem()); + assert_eq!(dcap_cache_expires_at(&collateral, "e).unwrap(), 2000); + } + + #[test] + fn pem_chain_uses_earliest_certificate_and_rejects_bad_input() { + let chain = format!("{}{}", certificate(2000).pem(), certificate(1000).pem()); + assert_eq!(pem_chain_not_after(&chain).unwrap(), 1000); + assert!(matches!( + pem_chain_not_after(""), + Err(DcapVerificationError::EmptyCertificateChain) + )); + assert!(matches!( + pem_chain_not_after("-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----"), + Err(DcapVerificationError::Pem(_)) + )); + assert!(matches!( + pem_chain_not_after("-----BEGIN CERTIFICATE-----\nAA==\n-----END CERTIFICATE-----"), + Err(DcapVerificationError::X509Parse(_)) + )); + let wrong_label = certificate(1000).pem().replace("CERTIFICATE", "PUBLIC KEY"); + assert!(matches!(pem_chain_not_after(&wrong_label), + Err(DcapVerificationError::UnexpectedPemLabel(label)) if label == "PUBLIC KEY")); + assert!(certificate_not_after(b"invalid DER").is_err()); + assert_eq!(certificate_not_after(certificate(-1).der()).unwrap(), 0); + } +} diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index 98f96e4..e30498d 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -1,10 +1,9 @@ //! Data Center Attestation Primitives (DCAP) evidence generation and //! verification //! -//! Every verify function returns the parsed [Quote] beside the -//! [VerifiedAttestation]: verification parses it anyway, and the GCP -//! provenance check needs the PPID from its PCK leaf. Other callers drop -//! it. +//! Every verify function retains the parsed [Quote] in the +//! [VerifiedAttestation], for provenance checks and optional expiry +//! calculation. use dcap_qvl::{ QuoteCollateralV3, intel::{quote_ca, quote_fmspc}, @@ -22,6 +21,7 @@ use crate::{ AttestationError, EndorsementSnapshot, VerifiedAttestation, + VerifiedEvidence, measurements::MultiMeasurements, }; @@ -42,7 +42,7 @@ pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { +) -> Result { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); let override_azure_outdated_tcb = false; verify_dcap_attestation_with_given_timestamp( @@ -70,7 +70,7 @@ pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { +) -> Result { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); let override_azure_outdated_tcb = false; verify_dcap_attestation_with_timestamp_sync( @@ -99,7 +99,7 @@ pub fn verify_dcap_attestation_with_timestamp_sync( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { +) -> Result { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); @@ -133,7 +133,7 @@ pub async fn verify_dcap_attestation_with_given_timestamp( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { +) -> Result { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); @@ -163,7 +163,7 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( collateral: QuoteCollateralV3, now: u64, override_azure_outdated_tcb: bool, -) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { +) -> Result { tracing::info!("Verifying DCAP attestation: {quote:?}"); let fmspc = hex::encode_upper(quote_fmspc("e)?); @@ -208,14 +208,12 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( return Err(DcapVerificationError::InputMismatch); } - Ok(( - VerifiedAttestation { - measurements, - expected_measurements: None, - endorsements: EndorsementSnapshot::dcap(collateral, now), - }, - quote, - )) + Ok(VerifiedAttestation { + measurements, + expected_measurements: None, + evidence: VerifiedEvidence::Dcap(quote), + endorsements: EndorsementSnapshot::dcap(collateral, now), + }) } #[cfg(any(test, feature = "mock"))] @@ -223,7 +221,7 @@ pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { +) -> Result { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); @@ -243,14 +241,12 @@ pub async fn verify_dcap_attestation( return Err(DcapVerificationError::InputMismatch); } - Ok(( - VerifiedAttestation { - measurements, - expected_measurements: None, - endorsements: EndorsementSnapshot::dcap(collateral, now), - }, - quote, - )) + Ok(VerifiedAttestation { + measurements, + expected_measurements: None, + evidence: VerifiedEvidence::Dcap(quote), + endorsements: EndorsementSnapshot::dcap(collateral, now), + }) } #[cfg(any(test, feature = "mock"))] @@ -258,7 +254,7 @@ pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result<(VerifiedAttestation, Quote), DcapVerificationError> { +) -> Result { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); let fmspc = hex::encode_upper(quote_fmspc("e)?); @@ -277,14 +273,12 @@ pub fn verify_dcap_attestation_sync( if get_quote_input_data("e.report) != expected_input_data { return Err(DcapVerificationError::InputMismatch); } - Ok(( - VerifiedAttestation { - measurements, - expected_measurements: None, - endorsements: EndorsementSnapshot::dcap(collateral, now), - }, - quote, - )) + Ok(VerifiedAttestation { + measurements, + expected_measurements: None, + evidence: VerifiedEvidence::Dcap(quote), + endorsements: EndorsementSnapshot::dcap(collateral, now), + }) } /// Create a mock quote for testing on non-confidential hardware @@ -311,6 +305,16 @@ pub fn get_quote_input_data(report: &Report) -> [u8; 64] { /// An error when verifying a DCAP attestation #[derive(Error, Debug)] pub enum DcapVerificationError { + #[error("Missing DCAP collateral for expiry calculation")] + MissingCollateral, + #[error("Cannot parse cache dependency certificate: {0}")] + X509Parse(#[from] x509_parser::asn1_rs::Err), + #[error("Cannot parse cache dependency PEM: {0}")] + Pem(#[from] x509_parser::error::PEMError), + #[error("Expected certificate PEM, found {0}")] + UnexpectedPemLabel(String), + #[error("Empty cache dependency certificate chain")] + EmptyCertificateChain, #[error("Quote input is not as expected")] InputMismatch, #[error("SGX quote given when TDX quote expected")] @@ -364,46 +368,48 @@ mod tests { let fixture_collateral: QuoteCollateralV3 = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let (VerifiedAttestation { measurements: async_measurements, endorsements, .. }, _) = - verify_dcap_attestation_with_given_timestamp( - attestation_bytes.to_vec(), - [ - 116, 39, 106, 100, 143, 31, 212, 145, 244, 116, 162, 213, 44, 114, 216, 80, - 227, 118, 129, 87, 180, 62, 194, 151, 169, 145, 116, 130, 189, 119, 39, 139, - 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( - CollateralSource::IntelPcs { subscription_key: None }, - CachePolicy::Passthrough, - ), - Some(fixture_collateral.clone()), - now, - false, - ) - .await - .unwrap(); + let async_verified = verify_dcap_attestation_with_given_timestamp( + attestation_bytes.to_vec(), + [ + 116, 39, 106, 100, 143, 31, 212, 145, 244, 116, 162, 213, 44, 114, 216, 80, 227, + 118, 129, 87, 180, 62, 194, 151, 169, 145, 116, 130, 189, 119, 39, 139, 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( + CollateralSource::IntelPcs { subscription_key: None }, + CachePolicy::Passthrough, + ), + Some(fixture_collateral.clone()), + now, + false, + ) + .await + .unwrap(); - let (VerifiedAttestation { measurements: sync_measurements, .. }, _) = - verify_dcap_attestation_with_timestamp_sync( - attestation_bytes.to_vec(), - [ - 116, 39, 106, 100, 143, 31, 212, 145, 244, 116, 162, 213, 44, 114, 216, 80, - 227, 118, 129, 87, 180, 62, 194, 151, 169, 145, 116, 130, 189, 119, 39, 139, - 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( - CollateralSource::IntelPcs { subscription_key: None }, - CachePolicy::OnDemand, - ), - Some(fixture_collateral.clone()), - now, - false, - ) - .unwrap(); + let sync_verified = verify_dcap_attestation_with_timestamp_sync( + attestation_bytes.to_vec(), + [ + 116, 39, 106, 100, 143, 31, 212, 145, 244, 116, 162, 213, 44, 114, 216, 80, 227, + 118, 129, 87, 180, 62, 194, 151, 169, 145, 116, 130, 189, 119, 39, 139, 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(CollateralSource::IntelPcs { subscription_key: None }, CachePolicy::OnDemand), + Some(fixture_collateral.clone()), + now, + false, + ) + .unwrap(); + let async_expiry = async_verified.cache_expires_at().unwrap(); + let sync_expiry = sync_verified.cache_expires_at().unwrap(); + let VerifiedAttestation { measurements: async_measurements, endorsements, .. } = + async_verified; + let VerifiedAttestation { measurements: sync_measurements, .. } = sync_verified; assert_eq!(async_measurements, sync_measurements); + assert_eq!(async_expiry, sync_expiry); + assert!(now < async_expiry); // A caller archiving provenance gets back the bundle the // verification consumed, not a second copy of it assert_eq!(endorsements.dcap, Some(fixture_collateral)); @@ -467,8 +473,7 @@ mod tests { let expected_input_data = [0xA5; 64]; let quote = create_dcap_attestation(expected_input_data).unwrap(); - let (verified, _) = - verify_dcap_attestation(quote, expected_input_data, pccs).await.unwrap(); + let verified = 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 e57b2e1..fc9fe03 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -156,7 +156,7 @@ mod tests { let collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); let firmware = serde_saphyr::from_slice(firmware_bytes).unwrap(); - let (VerifiedAttestation { measurements, .. }, _) = + let VerifiedAttestation { measurements, .. } = verify_dcap_attestation_with_given_timestamp( attestation_bytes.to_vec(), expected_input_data, diff --git a/crates/attestation/src/gcp/provenance.rs b/crates/attestation/src/gcp/provenance.rs index 166fc03..16ab588 100644 --- a/crates/attestation/src/gcp/provenance.rs +++ b/crates/attestation/src/gcp/provenance.rs @@ -43,23 +43,24 @@ impl GcpProvenanceChecker { /// /// If a tokio runtime is available the blocking check is offloaded to /// its blocking pool; otherwise it runs inline on the current thread - pub(crate) async fn verify_provenance(&self, quote: Quote) -> Result<(), GcpProvenanceError> { + pub(crate) async fn verify_provenance(&self, quote: &Quote) -> Result<(), GcpProvenanceError> { self.verify_provenance_with_registry_url(quote, GCP_PROVENANCE_REGISTRY_URL.to_string()) .await } async fn verify_provenance_with_registry_url( &self, - quote: Quote, + quote: &Quote, registry_url: String, ) -> Result<(), GcpProvenanceError> { + let ppid = extract_ppid_from_quote(quote)?; match tokio::runtime::Handle::try_current() { Ok(handle) => { let checker = self.clone(); handle .spawn_blocking(move || { - checker.verify_provenance_with_registry_url_blocking_at( - "e, + checker.verify_ppid_with_registry_url_blocking_at( + ppid, ®istry_url, Instant::now(), ) @@ -67,11 +68,9 @@ impl GcpProvenanceChecker { .await .map_err(|err| GcpProvenanceError::TaskJoin(err.to_string()))? } - Err(_) => self.verify_provenance_with_registry_url_blocking_at( - "e, - ®istry_url, - Instant::now(), - ), + Err(_) => { + self.verify_ppid_with_registry_url_blocking_at(ppid, ®istry_url, Instant::now()) + } } } @@ -117,6 +116,15 @@ impl GcpProvenanceChecker { now: Instant, ) -> Result<(), GcpProvenanceError> { let ppid = extract_ppid_from_quote(quote)?; + self.verify_ppid_with_registry_url_blocking_at(ppid, registry_url, now) + } + + fn verify_ppid_with_registry_url_blocking_at( + &self, + ppid: [u8; GCP_PPID_BYTES], + registry_url: &str, + now: Instant, + ) -> Result<(), GcpProvenanceError> { let stale_entry = { let known_gcp_ppids = self .known_gcp_ppids @@ -369,7 +377,7 @@ mod tests { let checker = GcpProvenanceChecker::new(); let task = tokio::spawn(async move { - checker.verify_provenance_with_registry_url(quote, format!("http://{addr}")).await + checker.verify_provenance_with_registry_url("e, format!("http://{addr}")).await }); request_started.recv_timeout(Duration::from_secs(1)).unwrap(); diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 44ce5a2..549744b 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -6,6 +6,7 @@ // reads exists. #[cfg(feature = "azure-verifier")] pub mod azure; +mod cache_expiry; pub mod dcap; mod gcp; pub mod measurements; @@ -397,6 +398,9 @@ impl EndorsementSnapshot { /// [RFC 9334]: https://www.rfc-editor.org/rfc/rfc9334.html #[derive(Clone, Debug)] pub struct VerifiedAttestation { + /// Parsed evidence retained for inspection and optional expiry + /// calculation. + pub evidence: VerifiedEvidence, /// MRTD and RTMR0–3 from the quote on DCAP and GCP. On Azure the vTPM /// PCRs, which measure the guest boot rather than the launched TD and /// chain to the TD quote: its report data commits to the HCL var data @@ -411,6 +415,47 @@ pub struct VerifiedAttestation { pub endorsements: EndorsementSnapshot, } +/// Platform-specific evidence retained after successful verification. +/// DCAP and GCP both use the `Dcap` variant. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub enum VerifiedEvidence { + Dcap(dcap_qvl::quote::Quote), + #[cfg(feature = "azure-verifier")] + Azure(azure::AzureVerifiedEvidence), +} + +impl VerifiedAttestation { + /// Computes the exclusive Unix-seconds deadline for reusing this + /// result. Parses retained collateral and certificates only when + /// called; the result is not memoized. An elapsed deadline permits + /// no caching, even if verification at `endorsements.at` succeeded. + /// + /// This excludes the local GCP provenance cache lifetime. Consumers + /// must also apply their own freshness limits and TLS certificate + /// expiry. Evidence and endorsements must remain as returned by + /// verification. + pub fn cache_expires_at(&self) -> Result { + let collateral = self + .endorsements + .dcap + .as_ref() + .ok_or(dcap::DcapVerificationError::MissingCollateral)?; + match &self.evidence { + VerifiedEvidence::Dcap(quote) => cache_expiry::dcap_cache_expires_at(collateral, quote), + #[cfg(feature = "azure-verifier")] + VerifiedEvidence::Azure(evidence) => { + let mut expiry = cache_expiry::dcap_cache_expires_at(collateral, &evidence.quote)? + .min(evidence.ak_not_after); + for certificate in &evidence.ak_intermediates { + expiry = expiry.min(cache_expiry::certificate_not_after(certificate)?); + } + Ok(expiry) + } + } + } +} + /// Allows remote attestations to be verified #[derive(Clone, Debug)] pub struct AttestationVerifier { @@ -679,14 +724,22 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - let (verified, quote) = dcap::verify_dcap_attestation( + let verified = dcap::verify_dcap_attestation( attestation_evidence.quote.clone(), expected_input_data, self.internal_pccs.clone(), ) .await?; if attestation_type == AttestationType::GcpTdx { - self.gcp_provenance_checker.verify_provenance(quote).await?; + match &verified.evidence { + VerifiedEvidence::Dcap(quote) => { + self.gcp_provenance_checker.verify_provenance(quote).await?; + } + #[cfg(feature = "azure-verifier")] + VerifiedEvidence::Azure(_) => { + unreachable!("DCAP verification returns DCAP evidence") + } + } } verified } @@ -787,13 +840,21 @@ impl AttestationVerifier { .ok_or(AttestationError::AttestationTypeNotAccepted)?; let pccs = self.internal_pccs.clone(); - let (verified, quote) = dcap::verify_dcap_attestation_sync( + let verified = dcap::verify_dcap_attestation_sync( attestation_evidence.quote.clone(), expected_input_data, pccs, )?; if attestation_type == AttestationType::GcpTdx { - self.gcp_provenance_checker.verify_provenance_sync("e)?; + match &verified.evidence { + VerifiedEvidence::Dcap(quote) => { + self.gcp_provenance_checker.verify_provenance_sync(quote)?; + } + #[cfg(feature = "azure-verifier")] + VerifiedEvidence::Azure(_) => { + unreachable!("DCAP verification returns DCAP evidence") + } + } } verified } @@ -1415,6 +1476,51 @@ mod tests { } } + #[tokio::test] + async fn expiry_is_computed_from_retained_evidence() { + let policy = + MeasurementPolicy::from_json_bytes(br#"[{"attestation_type":"dcap-tdx"}]"#.to_vec()) + .unwrap(); + let verifier = AttestationVerifier::builder(policy) + .with_cache_policy(CachePolicy::Passthrough) + .build(); + let input_data = [7u8; 64]; + let message: AttestationExchangeMessage = AttestationEvidence { + quote: dcap::create_dcap_attestation(input_data).unwrap(), + platform: mock_platform_metadata(AttestationType::DcapTdx).unwrap(), + } + .into(); + let mut verified = + verifier.verify_attestation(message.clone(), input_data).await.unwrap().unwrap(); + let sync_verified = verifier.verify_attestation_sync(message, input_data).unwrap().unwrap(); + match &verified.evidence { + VerifiedEvidence::Dcap(quote) => { + assert_eq!(dcap::get_quote_input_data("e.report), input_data) + } + #[cfg(feature = "azure-verifier")] + _ => panic!("expected retained DCAP quote"), + } + let expiry = verified.cache_expires_at().unwrap(); + assert!(expiry > verified.endorsements.at); + assert_eq!(expiry, sync_verified.cache_expires_at().unwrap()); + assert_eq!(verified.measurements, sync_verified.measurements); + assert_eq!(verified.endorsements.dcap, sync_verified.endorsements.dcap); + + // Deliberately damage retained material to check that the method + // parses it on demand and returns errors rather than an + // eagerly stored date. + verified.endorsements.dcap.as_mut().unwrap().tcb_info_issuer_chain.clear(); + assert!(matches!( + verified.cache_expires_at(), + Err(dcap::DcapVerificationError::EmptyCertificateChain) + )); + verified.endorsements.dcap = None; + assert!(matches!( + verified.cache_expires_at(), + Err(dcap::DcapVerificationError::MissingCollateral) + )); + } + #[tokio::test] async fn sync_verification_refetches_dynamic_measurement_policy_on_mismatch() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/crates/attested-tls/src/lib.rs b/crates/attested-tls/src/lib.rs index 46a9b19..a410bbc 100644 --- a/crates/attested-tls/src/lib.rs +++ b/crates/attested-tls/src/lib.rs @@ -468,7 +468,8 @@ pub struct AttestedCertificateVerifier { crypto_provider: Arc, /// Configured for verifying attestations attestation_verifier: AttestationVerifier, - /// Report data of pre-trusted certificates with cache expiry time + /// Report data of verified certificates, cached until the earlier of + /// certificate expiry and the attestation's dependency deadlines. trusted_certs: Arc>>, /// Whether self-signed certificates should be accepted accept_self_signed_certs: bool, @@ -667,7 +668,7 @@ impl AttestedCertificateVerifier { let trusted_certs = self.trusted_certs.read().map_err(|_| { rustls::Error::General("Trusted certificate cache lock poisoned".into()) })?; - if trusted_certs.get(&expected_input_data).is_some_and(|expiry| *expiry >= now) { + if trusted_certs.get(&expected_input_data).is_some_and(|expiry| now < *expiry) { tracing::debug!("Skipping attestation verification for trusted certificate"); return Ok(()); } @@ -675,7 +676,8 @@ impl AttestedCertificateVerifier { let attestation = Self::extract_custom_attestation_from_cert(cert)?; - self.attestation_verifier + let verified = self + .attestation_verifier .verify_attestation_sync(attestation, expected_input_data) .map_err(|err| { tracing::warn!( @@ -684,14 +686,29 @@ impl AttestedCertificateVerifier { InvalidCertificate(CertificateError::ApplicationVerificationFailure) })?; + // Bound reuse by the exact collateral/evidence used to verify this + // certificate. A background PCCS refresh cannot extend this + // verdict. + let expiry = if let Some(verified) = verified { + let deadline = verified.cache_expires_at().map_err(|err| { + tracing::warn!("Cannot determine attestation cache expiry: {err}"); + InvalidCertificate(CertificateError::ApplicationVerificationFailure) + })?; + expiry.min(UnixTime::since_unix_epoch(Duration::from_secs(deadline))) + } else { + expiry + }; + let mut trusted_certs = self.trusted_certs.write().map_err(|_| { rustls::Error::General("Trusted certificate cache lock poisoned".into()) })?; // Remove any expired entries - trusted_certs.retain(|_, cached_expiry| *cached_expiry >= now); + trusted_certs.retain(|_, cached_expiry| now < *cached_expiry); // Write trusted certificate details to cache - trusted_certs.insert(expected_input_data, expiry); + if now < expiry { + trusted_certs.insert(expected_input_data, expiry); + } Ok(()) } @@ -1737,6 +1754,100 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn attestation_deadline_limits_long_lived_certificates() { + install_test_crypto_provider(); + let provider: Arc = aws_lc_rs::default_provider().into(); + let resolver = AttestedCertificateResolver::build( + "foo", + AttestationGenerator::new(AttestationType::DcapTdx, None).unwrap(), + ) + .with_crypto_provider(provider.clone()) + .with_certificate_validity(Duration::from_secs(100 * 365 * 24 * 60 * 60)) + .finish() + .unwrap(); + let mut verifier = ready_mock_attested_verifier(None, provider).await; + let cert = resolver.state.certificate.read().unwrap().first().unwrap().clone(); + let parsed = AttestedCertificateVerifier::parse_x509_certificate(&cert).unwrap(); + let (binding, cert_expiry) = + AttestedCertificateVerifier::cert_binding_data(&parsed).unwrap(); + let evidence = + AttestedCertificateVerifier::extract_custom_attestation_from_cert(&parsed).unwrap(); + let verified = verifier + .attestation_verifier + .verify_attestation_sync(evidence, binding) + .unwrap() + .unwrap(); + let deadline = + UnixTime::since_unix_epoch(Duration::from_secs(verified.cache_expires_at().unwrap())); + assert!(deadline < cert_expiry); + let now = UnixTime::now(); + let name = ServerName::try_from("foo").unwrap(); + verify_server_cert_direct(&verifier, &cert, &name, now).unwrap(); + assert_eq!(verifier.trusted_certs.read().unwrap().get(&binding), Some(&deadline)); + + // At the exact boundary, a fresh successful verification with the + // same deadline must not put the elapsed verdict back in the cache. + verify_server_cert_direct(&verifier, &cert, &name, deadline).unwrap(); + assert!(!verifier.trusted_certs.read().unwrap().contains_key(&binding)); + + // Simulate an old verdict reaching its deadline while valid + // replacement collateral is available to a fresh verification. + verifier.trusted_certs.write().unwrap().insert(binding, now); + verify_client_cert_direct(&verifier, &cert, now).unwrap(); + assert_eq!(verifier.trusted_certs.read().unwrap().get(&binding), Some(&deadline)); + + // A replacement PCCS bundle with invalid signatures must reject + // once the old verdict expires, for both server and client auth. + let bad_pcs = spawn_mock_pcs_server(MockPcsConfig { + tcb_next_update: "2999-01-01T00:00:00Z".into(), + ..MockPcsConfig::default() + }) + .await + .unwrap(); + let bad_verifier = AttestationVerifier::mock_with_pccs(bad_pcs.base_url.clone()); + bad_verifier.ready().await.unwrap(); + verifier.attestation_verifier = bad_verifier; + let before = UnixTime::since_unix_epoch(Duration::from_secs(deadline.as_secs() - 1)); + verify_server_cert_direct(&verifier, &cert, &name, before).unwrap(); + for at in + [deadline, UnixTime::since_unix_epoch(Duration::from_secs(deadline.as_secs() + 1))] + { + assert_eq!( + verify_server_cert_direct(&verifier, &cert, &name, at).unwrap_err(), + InvalidCertificate(CertificateError::ApplicationVerificationFailure) + ); + assert_eq!( + verify_client_cert_direct(&verifier, &cert, at).unwrap_err(), + InvalidCertificate(CertificateError::ApplicationVerificationFailure) + ); + } + } + + #[tokio::test] + async fn no_attestation_cache_uses_certificate_expiry() { + install_test_crypto_provider(); + let provider: Arc = aws_lc_rs::default_provider().into(); + let resolver = + AttestedCertificateResolver::build("foo", AttestationGenerator::with_no_attestation()) + .with_crypto_provider(provider.clone()) + .finish() + .unwrap(); + let verifier = AttestedCertificateVerifier::build(AttestationVerifier::expect_none()) + .with_crypto_provider(provider) + .finish() + .unwrap(); + let cert = resolver.state.certificate.read().unwrap().first().unwrap().clone(); + let (binding, expiry) = AttestedCertificateVerifier::cert_binding_data( + &AttestedCertificateVerifier::parse_x509_certificate(&cert).unwrap(), + ) + .unwrap(); + verify_client_cert_direct(&verifier, &cert, UnixTime::now()).unwrap(); + assert_eq!(verifier.trusted_certs.read().unwrap().get(&binding), Some(&expiry)); + let after = UnixTime::since_unix_epoch(Duration::from_secs(expiry.as_secs() + 1)); + assert!(verify_client_cert_direct(&verifier, &cert, after).is_err()); + } + #[test] fn attested_certificate_verifier_rejects_dynamic_measurement_policies() { let dynamic_verifier = AttestationVerifier::builder( diff --git a/crates/pccs/src/lib.rs b/crates/pccs/src/lib.rs index 11bdcc5..99279a9 100644 --- a/crates/pccs/src/lib.rs +++ b/crates/pccs/src/lib.rs @@ -16,7 +16,6 @@ use dcap_qvl::{ collateral::CollateralClient, configs::DefaultConfig, http::{HttpClient as DcapHttpClient, HttpResponse}, - tcb_info::TcbInfo, }; use reqwest::{ Url, @@ -694,18 +693,21 @@ async fn fetch_collateral( client.fetch_for_fmspc_without_pck_chain(&fmspc, ca, false).await.map_err(Into::into) } -/// Extracts the earliest next update timestamp from collateral metadata +/// Extracts the earliest next update timestamp from collateral metadata, +/// in Unix seconds. This parses dates only; it does not verify signatures +/// or check whether the collateral is currently valid. /// /// This returns the soonest timestamp from either: /// - The TCB /// - The Quoting enclave /// - The root CA certificate revocation list /// - The PCK certificate revocation list -fn extract_next_update(collateral: &QuoteCollateralV3, now: i64) -> Result { - let tcb_info: TcbInfo = serde_json::from_str(&collateral.tcb_info).map_err(|e| { - PccsError::PccsCollateralParse(format!("Failed to parse TCB info JSON: {e}")) - })?; - let qe_identity: QeIdentityNextUpdate = +pub fn collateral_next_update(collateral: &QuoteCollateralV3) -> Result { + let tcb_info: CollateralNextUpdate = + serde_json::from_str(&collateral.tcb_info).map_err(|e| { + PccsError::PccsCollateralParse(format!("Failed to parse TCB info JSON: {e}")) + })?; + let qe_identity: CollateralNextUpdate = serde_json::from_str(&collateral.qe_identity).map_err(|e| { PccsError::PccsCollateralParse(format!("Failed to parse QE identity JSON: {e}")) })?; @@ -718,16 +720,19 @@ fn extract_next_update(collateral: &QuoteCollateralV3, now: i64) -> Result Result { + let next_update = i64::try_from(collateral_next_update(collateral)?) + .map_err(|_| PccsError::TimeStampExceedsI64)?; if now >= next_update { return Err(PccsError::PccsCollateralExpired(format!( - "Collateral expired (tcb_next_update={}, qe_next_update={}, root_ca_crl_next_update={}, pck_crl_next_update={}, now={now})", - tcb_info.next_update, - qe_identity.next_update, - root_ca_crl_next_update, - pck_crl_next_update + "Collateral expired (next_update={next_update}, now={now})" ))); } - Ok(next_update) } @@ -922,10 +927,10 @@ struct CacheEntry { refresh_task: Option>, } -/// Minimal QE identity shape needed to read nextUpdate +/// Minimal TCB info / QE identity shape needed to read nextUpdate #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] -struct QeIdentityNextUpdate { +struct CollateralNextUpdate { next_update: String, } @@ -1015,7 +1020,8 @@ mod tests { fn mock_tdx_fmspc() -> String { let collateral = mock_collateral(); - let tcb_info: TcbInfo = serde_json::from_str(&collateral.tcb_info).unwrap(); + let tcb_info: dcap_qvl::tcb_info::TcbInfo = + serde_json::from_str(&collateral.tcb_info).unwrap(); tcb_info.fmspc } @@ -1263,6 +1269,63 @@ mod tests { assert_eq!(extract_next_update(&collateral, 0).unwrap(), expected); } + #[test] + fn every_collateral_next_update_can_be_the_earliest() { + use rcgen::{ + CertificateParams, + CertificateRevocationListParams, + Issuer, + KeyIdMethod, + KeyPair, + }; + + fn crl(expires_at: i64) -> Vec { + let issuer = Issuer::new(CertificateParams::default(), KeyPair::generate().unwrap()); + CertificateRevocationListParams { + this_update: OffsetDateTime::from_unix_timestamp(0).unwrap(), + next_update: OffsetDateTime::from_unix_timestamp(expires_at).unwrap(), + crl_number: 1.into(), + issuing_distribution_point: None, + revoked_certs: vec![], + key_identifier_method: KeyIdMethod::Sha256, + } + .signed_by(&issuer) + .unwrap() + .der() + .to_vec() + } + + for earliest in 0..4 { + let deadlines = + std::array::from_fn::<_, 4, _>(|i| if i == earliest { 1000 } else { 2000 }); + let mut collateral = mock_collateral(); + let mut tcb: serde_json::Value = serde_json::from_str(&collateral.tcb_info).unwrap(); + tcb["nextUpdate"] = OffsetDateTime::from_unix_timestamp(deadlines[0]) + .unwrap() + .format(&Rfc3339) + .unwrap() + .into(); + collateral.tcb_info = tcb.to_string(); + let mut qe: serde_json::Value = serde_json::from_str(&collateral.qe_identity).unwrap(); + qe["nextUpdate"] = OffsetDateTime::from_unix_timestamp(deadlines[1]) + .unwrap() + .format(&Rfc3339) + .unwrap() + .into(); + collateral.qe_identity = qe.to_string(); + collateral.root_ca_crl = crl(deadlines[2]); + collateral.pck_crl = crl(deadlines[3]); + assert_eq!(collateral_next_update(&collateral).unwrap(), 1000); + assert_eq!(extract_next_update(&collateral, 999).unwrap(), 1000); + assert!(matches!( + extract_next_update(&collateral, 1000), + Err(PccsError::PccsCollateralExpired(_)) + )); + collateral.pck_crl.clear(); + assert!(collateral_next_update(&collateral).is_err()); + } + } + #[tokio::test] async fn test_authenticated_proactive_refresh_updates_cached_entry() { let initial_now = unix_now().unwrap();