Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/attestation/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
10 changes: 10 additions & 0 deletions crates/attestation/src/azure/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ struct TpmAttest {
instance_info: Option<Vec<u8>>,
}

/// 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<Vec<u8>>,
}

/// Maximum serialized Azure attestation evidence payload size produced
/// during generation and accepted during verification.
///
Expand Down
97 changes: 79 additions & 18 deletions crates/attestation/src/azure/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use x509_parser::prelude::*;

use super::{
AttestationDocument,
AzureVerifiedEvidence,
MaaError,
TpmAttest,
ak_certificate::verify_ak_cert_with_azure_roots,
Expand All @@ -21,6 +22,7 @@ use super::{
};
use crate::{
VerifiedAttestation,
VerifiedEvidence,
dcap::{
verify_dcap_attestation_with_given_timestamp,
verify_dcap_attestation_with_timestamp_sync,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
})
}
Expand All @@ -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,
Expand All @@ -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,
})
}
Expand Down Expand Up @@ -206,7 +220,8 @@ fn finish_azure_attestation_verification(
tpm_attestation: TpmAttest,
expected_input_data: [u8; 64],
now: u64,
) -> Result<MultiMeasurements, MaaError> {
quote: dcap_qvl::quote::Quote,
) -> Result<(MultiMeasurements, AzureVerifiedEvidence), MaaError> {
let hcl_ak_pub = hcl_report.ak_pub()?;

// Get attestation key from runtime claims
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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] =
Expand Down
131 changes: 131 additions & 0 deletions crates/attestation/src/cache_expiry.rs
Original file line number Diff line number Diff line change
@@ -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<u64, ParseError<X509Error>> {
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<u64, DcapVerificationError> {
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<u64, DcapVerificationError> {
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, &quote).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, &quote).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, &quote).unwrap(), 1000);
collateral.pck_certificate_chain = Some(certificate(2000).pem());
assert_eq!(dcap_cache_expires_at(&collateral, &quote).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);
}
}
Loading
Loading