From 9b834789c2b6962390e31539f14dde26e3cfe5ba Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 23 Sep 2026 00:34:41 -0700 Subject: [PATCH] fix(tpm-qvl): expose only the attested event log in VerifiedReport The TPM event log carries no signature; an entry is attested only when tpm-qvl replays it against a quoted PCR value, and only the PCRs in the signed selection are replayed. The GCP image check nevertheless read the UKI Authenticode digest from the raw `TpmQuote::event_log`, so for a quote over e.g. {0, 14} its PCR-2 entries were never checked and could carry any digest. `VerifiedReport` now carries `event_log`: the entries of the quoted PCRs, all of which were replayed. The verifier reads the GCP image identity from there instead of from the raw quote, like the Nitro arms already read from the verified report. Rejecting logs with entries for unquoted PCRs is not an option: GCP CVMs ship the whole firmware log (PCRs 0-11) while quoting only {0, 2, 14}. --- dstack/tpm-qvl/src/verify.rs | 39 +++++++++++++++++++++---- dstack/verifier/src/verification.rs | 45 ++++++++++++++--------------- 2 files changed, 55 insertions(+), 29 deletions(-) diff --git a/dstack/tpm-qvl/src/verify.rs b/dstack/tpm-qvl/src/verify.rs index 04bda0329..9a81d0bb2 100644 --- a/dstack/tpm-qvl/src/verify.rs +++ b/dstack/tpm-qvl/src/verify.rs @@ -25,6 +25,9 @@ pub struct VerifiedReport { pub attest: TpmAttest, pub platform: Platform, pub pcr_values: Vec, + /// Event-log entries of the quoted PCRs, all replayed against `pcr_values`. + /// Entries of unquoted PCRs are not attested and are dropped. + pub event_log: Vec, } impl VerifiedReport { @@ -160,10 +163,11 @@ pub fn verify_quote_with_ca( }); } - verify_event_log("e.pcr_values, "e.event_log).map_err(|e| VerificationError { - status: status.clone(), - error: e.context("event log verification failed"), - })?; + let event_log = + verify_event_log("e.pcr_values, "e.event_log).map_err(|e| VerificationError { + status: status.clone(), + error: e.context("event log verification failed"), + })?; debug!("✓ Event Log replay verification successful"); status.pcr_verified = true; @@ -211,6 +215,7 @@ pub fn verify_quote_with_ca( attest, platform: quote.platform, pcr_values: quote.pcr_values.clone(), + event_log, }) } @@ -347,7 +352,9 @@ fn compute_pcr_digest(pcr_values: &[PcrValue]) -> Result> { Ok(hasher.finalize().to_vec()) } -fn verify_event_log(pcr_values: &[PcrValue], event_log: &[TpmEvent]) -> Result<()> { +/// Replay the event log against the quoted PCR values and return the replayed +/// entries. +fn verify_event_log(pcr_values: &[PcrValue], event_log: &[TpmEvent]) -> Result> { for pcr in pcr_values { let pcr_events: Vec<&TpmEvent> = event_log .iter() @@ -394,7 +401,11 @@ fn verify_event_log(pcr_values: &[PcrValue], event_log: &[TpmEvent]) -> Result<( } } - Ok(()) + Ok(event_log + .iter() + .filter(|e| pcr_values.iter().any(|p| p.index == e.pcr_index)) + .cloned() + .collect()) } fn extract_ak_public_key_from_cert(ak_cert_der: &[u8]) -> Result { @@ -843,4 +854,20 @@ mod tests { "unexpected error: {err}" ); } + + #[test] + fn event_log_keeps_only_quoted_pcrs() { + let event = |pcr_index, digest: u8| TpmEvent { + pcr_index, + digest: vec![digest; 32], + }; + let pcr0 = PcrValue { + index: 0, + algorithm: "sha256".into(), + value: Sha256::digest([[0u8; 32], [1u8; 32]].concat()).to_vec(), + }; + let log = [event(0, 1), event(2, 2), event(7, 3)]; + let kept = verify_event_log(&[pcr0], &log).unwrap(); + assert_eq!(kept.iter().map(|e| e.pcr_index).collect::>(), [0]); + } } diff --git a/dstack/verifier/src/verification.rs b/dstack/verifier/src/verification.rs index 77752d4fc..91442b3cd 100644 --- a/dstack/verifier/src/verification.rs +++ b/dstack/verifier/src/verification.rs @@ -22,11 +22,12 @@ use dstack_types::{TdxAttestationVariant, VmConfig}; use hex_literal::hex; use ra_tls::attestation::{ AppInfo, Attestation, AttestationQuote, AttestationVerifier, DstackVerifiedReport, NitroPcrs, - TpmQuote, VerifiedAttestation, VersionedAttestation, + VerifiedAttestation, VersionedAttestation, }; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use tokio::{io::AsyncWriteExt, process::Command}; +use tpm_qvl::verify::VerifiedReport as TpmVerifiedReport; use tracing::{debug, info, warn}; use crate::types::{ @@ -677,8 +678,12 @@ impl CvmVerifier { .decode_vm_config(&vm_config) .context("Failed to decode VM config")?; match &attestation.quote { - AttestationQuote::DstackGcpTdx(quote) => { - self.verify_os_image_hash_for_gcp_tdx(&vm_config, "e.tpm_quote)?; + AttestationQuote::DstackGcpTdx(_) => { + let DstackVerifiedReport::DstackGcpTdx { tpm_report, .. } = &attestation.report + else { + bail!("GCP TDX quote without a GCP TDX report"); + }; + self.verify_os_image_hash_for_gcp_tdx(&vm_config, tpm_report)?; } // The declared scheme alone selects the path, matched exhaustively // so a new variant fails the build here instead of taking one. @@ -925,17 +930,13 @@ impl CvmVerifier { fn verify_os_image_hash_for_gcp_tdx( &self, vm_config: &VmConfig, - tpm_quote: &TpmQuote, + tpm_report: &TpmVerifiedReport, ) -> Result<()> { // Verify PCR 0 (GCP OVMF firmware) const EXPECTED_PCR0: [u8; 32] = hex!("0cca9ec161b09288802e5a112255d21340ed5b797f5fe29cecccfd8f67b9f802"); - let pcr0 = tpm_quote - .pcr_values - .iter() - .find(|p| p.index == 0) - .context("PCR 0 not found in TPM quote")?; + let pcr0 = tpm_report.get_pcr(0)?; let document = vm_config .gcp_measurement @@ -951,7 +952,7 @@ impl CvmVerifier { .context("failed to decode vm_config.gcp_measurement CBOR")?; let expected_uki_hash = &measurement.uki_authenticode_sha256; - let pcr2_events: Vec<_> = tpm_quote + let pcr2_events: Vec<_> = tpm_report .event_log .iter() .filter(|e| e.pcr_index == 2) @@ -960,10 +961,10 @@ impl CvmVerifier { // Extract Event 28 (3rd event, 0-indexed as 2) // NOTE: This is GCP OVMF-specific behavior let event_28_digest = { - if pcr0.value != EXPECTED_PCR0 { + if pcr0 != EXPECTED_PCR0 { bail!( "PCR 0 mismatch: expected GCP OVMF v2, got {}", - hex::encode(&pcr0.value) + hex::encode(&pcr0) ); } &pcr2_events.get(2).context("Event 28 not found")?.digest @@ -1133,9 +1134,9 @@ mod tests { use dstack_attest::amd_sev_snp::{AmdSnpTcbInfo, VerifiedAmdSnpReport}; use ra_tls::attestation::{ AwsNitroTpmVerifiedReport, DstackAwsNitroTpmQuote, DstackGcpTdxQuote, DstackNitroQuote, - NitroVerifiedReport, SnpQuote, TdxQuote, + NitroVerifiedReport, SnpQuote, TdxQuote, TpmQuote, }; - use tpm_qvl::verify::{ClockInfo, QuoteInfo, TpmAttest, VerifiedReport as TpmVerifiedReport}; + use tpm_qvl::verify::{ClockInfo, QuoteInfo, TpmAttest}; fn aws_boot_pcrs(pcr4: u8) -> BTreeMap> { BTreeMap::from([ @@ -1240,6 +1241,7 @@ mod tests { }, platform: dstack_types::Platform::Gcp, pcr_values: Vec::new(), + event_log: Vec::new(), } } @@ -1469,16 +1471,12 @@ mod tests { .unwrap(); let expected_pcr0 = hex!("0cca9ec161b09288802e5a112255d21340ed5b797f5fe29cecccfd8f67b9f802"); - let gcp_quote = |pcr0: Vec, event_28: Vec| TpmQuote { - message: Vec::new(), - signature: Vec::new(), + let gcp_report = |pcr0: Vec, event_28: Vec| TpmVerifiedReport { pcr_values: vec![tpm_types::PcrValue { index: 0, algorithm: "sha256".into(), value: pcr0, }], - ak_cert: Vec::new(), - platform: dstack_types::Platform::Gcp, event_log: vec![ tpm_types::TpmEvent { pcr_index: 2, @@ -1493,23 +1491,24 @@ mod tests { digest: event_28, }, ], + ..tpm_report_for_gcp() }; verifier .verify_os_image_hash_for_gcp_tdx( &gcp_config, - &gcp_quote(expected_pcr0.to_vec(), uki_hash.clone()), + &gcp_report(expected_pcr0.to_vec(), uki_hash.clone()), ) .unwrap(); assert!(verifier .verify_os_image_hash_for_gcp_tdx( &gcp_config, - &gcp_quote(vec![0; 32], uki_hash.clone()), + &gcp_report(vec![0; 32], uki_hash.clone()), ) .is_err()); assert!(verifier .verify_os_image_hash_for_gcp_tdx( &gcp_config, - &gcp_quote(expected_pcr0.to_vec(), vec![0; 32]), + &gcp_report(expected_pcr0.to_vec(), vec![0; 32]), ) .is_err()); let mut missing_document = gcp_config; @@ -1517,7 +1516,7 @@ mod tests { assert!(verifier .verify_os_image_hash_for_gcp_tdx( &missing_document, - &gcp_quote(expected_pcr0.to_vec(), uki_hash), + &gcp_report(expected_pcr0.to_vec(), uki_hash), ) .is_err()); }