From 5d3c8ea325f937eb86cdf6a8e425fff8ab9a2730 Mon Sep 17 00:00:00 2001 From: peg Date: Thu, 11 Jun 2026 19:30:29 +0200 Subject: [PATCH 01/21] Basic GCP provenance check --- crates/attestation/src/lib.rs | 223 +++++++++++++++++++++++++++++++++- 1 file changed, 217 insertions(+), 6 deletions(-) diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 73bc5ba..93308b3 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -12,16 +12,23 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; +use dcap_qvl::{intel, quote::Quote}; use measurements::MultiMeasurements; use parity_scale_codec::{Decode, Encode}; use pccs::{Pccs, PccsError}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use thiserror::Error; use crate::{dcap::DcapVerificationError, measurements::MeasurementPolicy}; /// Used in attestation type detection to check if we are on GCP const GCP_METADATA_API: &str = "http://metadata.google.internal"; +/// Public registry of GCP Confidential VM TDX PPIDs. +const GCP_PROVENANCE_REGISTRY_URL: &str = + "https://storage.googleapis.com/confidential-host-registry"; +/// GCP provenance documents are small JSON payloads. Cap reads defensively. +const GCP_PROVENANCE_DOCUMENT_MAX_BYTES: u64 = 16 * 1024; /// An attestation payload together with its type #[derive(Clone, Debug, Serialize, Deserialize, Encode, Decode)] @@ -399,12 +406,17 @@ impl AttestationVerifier { } } AttestationType::DcapTdx | AttestationType::GcpTdx | AttestationType::QemuTdx => { - dcap::verify_dcap_attestation( - attestation_exchange_message.attestation, + let attestation = attestation_exchange_message.attestation; + let measurements = dcap::verify_dcap_attestation( + attestation.clone(), expected_input_data, self.internal_pccs.clone(), ) - .await? + .await?; + if attestation_type == AttestationType::GcpTdx { + verify_gcp_provenance(attestation).await?; + } + measurements } }; @@ -455,17 +467,22 @@ impl AttestationVerifier { } } AttestationType::DcapTdx | AttestationType::QemuTdx | AttestationType::GcpTdx => { + let attestation = attestation_exchange_message.attestation; #[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)?; - dcap::verify_dcap_attestation_sync( - attestation_exchange_message.attestation, + let measurements = dcap::verify_dcap_attestation_sync( + attestation.clone(), expected_input_data, pccs, - )? + )?; + if attestation_type == AttestationType::GcpTdx { + verify_gcp_provenance_sync(&attestation)?; + } + measurements } }; @@ -571,6 +588,101 @@ fn is_local_ip(ip: IpAddr) -> bool { } } +async fn verify_gcp_provenance(quote_bytes: Vec) -> Result<(), GcpProvenanceError> { + tokio::task::spawn_blocking(move || { + verify_gcp_provenance_with_registry_url_sync("e_bytes, GCP_PROVENANCE_REGISTRY_URL) + }) + .await + .map_err(|err| GcpProvenanceError::TaskJoin(err.to_string()))? +} + +fn verify_gcp_provenance_sync(quote_bytes: &[u8]) -> Result<(), GcpProvenanceError> { + verify_gcp_provenance_with_registry_url_sync(quote_bytes, GCP_PROVENANCE_REGISTRY_URL) +} + +fn verify_gcp_provenance_with_registry_url_sync( + quote_bytes: &[u8], + registry_url: &str, +) -> Result<(), GcpProvenanceError> { + let quote = + Quote::parse(quote_bytes).map_err(|err| GcpProvenanceError::Quote(err.to_string()))?; + + let ppid = extract_ppid_from_quote("e)?; + let provenance_url = format!("{}/{}", registry_url.trim_end_matches('/'), hex::encode(ppid)); + let document = fetch_gcp_provenance_document(&provenance_url)?; + validate_gcp_provenance_document(&document)?; + + Ok(()) +} + +fn extract_ppid_from_quote(quote: &Quote) -> Result, GcpProvenanceError> { + let cert_chain = intel::extract_cert_chain(quote) + .map_err(|err| GcpProvenanceError::PpidExtraction(err.to_string()))?; + let leaf = cert_chain.first().ok_or(GcpProvenanceError::NoPckCertificate)?; + let extension = intel::parse_pck_extension(leaf) + .map_err(|err| GcpProvenanceError::PpidExtraction(err.to_string()))?; + + if extension.ppid.is_empty() { + return Err(GcpProvenanceError::EmptyPpid); + } + + Ok(extension.ppid) +} + +fn fetch_gcp_provenance_document(url: &str) -> Result { + let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(2)).build(); + let response = + agent.get(url).call().map_err(|err| GcpProvenanceError::RegistryFetch(err.to_string()))?; + + let mut limited_reader = response.into_reader().take(GCP_PROVENANCE_DOCUMENT_MAX_BYTES + 1); + let mut document = String::new(); + limited_reader + .read_to_string(&mut document) + .map_err(|err| GcpProvenanceError::RegistryFetch(err.to_string()))?; + + if document.len() as u64 > GCP_PROVENANCE_DOCUMENT_MAX_BYTES { + return Err(GcpProvenanceError::DocumentTooLarge); + } + + Ok(document) +} + +fn validate_gcp_provenance_document(document: &str) -> Result<(), GcpProvenanceError> { + let value: Value = serde_json::from_str(document)?; + let object = value.as_object().ok_or(GcpProvenanceError::InvalidDocument)?; + + let has_zone = object.get("zone").and_then(Value::as_str).is_some_and(|zone| !zone.is_empty()); + let has_timestamp = object.get("timestamp").is_some_and(|timestamp| match timestamp { + Value::String(timestamp) => !timestamp.is_empty(), + Value::Number(_) => true, + _ => false, + }); + + if has_zone && has_timestamp { Ok(()) } else { Err(GcpProvenanceError::InvalidDocument) } +} + +#[derive(Error, Debug)] +pub enum GcpProvenanceError { + #[error("quote parse: {0}")] + Quote(String), + #[error("PCK certificate chain is empty")] + NoPckCertificate, + #[error("PPID is empty")] + EmptyPpid, + #[error("PPID extraction: {0}")] + PpidExtraction(String), + #[error("registry fetch: {0}")] + RegistryFetch(String), + #[error("provenance document is invalid")] + InvalidDocument, + #[error("provenance document exceeds maximum size")] + DocumentTooLarge, + #[error("provenance document JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("blocking task join: {0}")] + TaskJoin(String), +} + /// An error when generating or verifying an attestation #[derive(Error, Debug)] pub enum AttestationError { @@ -586,6 +698,8 @@ pub enum AttestationError { QuoteGeneration(#[from] tdx_attest::TdxAttestError), #[error("DCAP verification: {0}")] DcapVerification(#[from] DcapVerificationError), + #[error("GCP provenance: {0}")] + GcpProvenance(#[from] GcpProvenanceError), #[error("Attestation type not supported")] AttestationTypeNotSupported, #[error("Attestation type not accepted")] @@ -613,6 +727,12 @@ pub enum AttestationError { #[cfg(test)] mod tests { + use std::{ + io::{Read as StdRead, Write}, + net::SocketAddr, + thread, + }; + use mock_tdx::mock_pcs::{MockPcsConfig, spawn_mock_pcs_server}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, @@ -621,6 +741,8 @@ mod tests { use super::*; + const MOCK_PPID_HEX: &str = "d04ec06d4e6d92dc90d0ad3cf5ee2ddf"; + async fn spawn_test_attestation_provider_server(body: Vec) -> std::net::SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -643,6 +765,31 @@ mod tests { addr } + fn spawn_test_registry_server( + status: u16, + body: impl Into, + ) -> (SocketAddr, thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = body.into(); + + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0u8; 1024]; + let bytes_read = stream.read(&mut buf).unwrap(); + let request = String::from_utf8_lossy(&buf[..bytes_read]).to_string(); + let status_text = if status == 200 { "OK" } else { "Not Found" }; + let response = format!( + "HTTP/1.1 {status} {status_text}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + request + }); + + (addr, handle) + } + #[test] fn attestation_detection_does_not_panic() { // We dont enforce what platform the test is run on, only that the function @@ -655,6 +802,70 @@ mod tests { let _ = running_on_gcp(); } + #[test] + fn extracts_ppid_from_mock_tdx_quote() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let quote = Quote::parse(&attestation).unwrap(); + let ppid = extract_ppid_from_quote("e).unwrap(); + + assert_eq!(hex::encode(ppid), MOCK_PPID_HEX); + } + + #[test] + fn gcp_provenance_check_fetches_registry_document_for_ppid() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let (addr, request_handle) = spawn_test_registry_server( + 200, + r#"{"zone":"projects/test/zones/us-central1-a","timestamp":"2026-06-11T00:00:00Z"}"#, + ); + + verify_gcp_provenance_with_registry_url_sync(&attestation, &format!("http://{addr}")) + .unwrap(); + + let request = request_handle.join().unwrap(); + assert!(request.starts_with(&format!("GET /{MOCK_PPID_HEX} HTTP/1.1"))); + } + + #[test] + fn gcp_provenance_check_fails_closed_on_registry_miss() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let (addr, request_handle) = spawn_test_registry_server(404, "not found"); + + let err = + verify_gcp_provenance_with_registry_url_sync(&attestation, &format!("http://{addr}")) + .unwrap_err(); + + request_handle.join().unwrap(); + assert!(matches!(err, GcpProvenanceError::RegistryFetch(_))); + } + + #[test] + fn gcp_provenance_check_fails_closed_on_invalid_document() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let (addr, request_handle) = spawn_test_registry_server(200, r#"{"zone":""}"#); + + let err = + verify_gcp_provenance_with_registry_url_sync(&attestation, &format!("http://{addr}")) + .unwrap_err(); + + request_handle.join().unwrap(); + assert!(matches!(err, GcpProvenanceError::InvalidDocument)); + } + + #[test] + fn gcp_provenance_check_fails_closed_on_oversized_document() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let oversized_body = "x".repeat((GCP_PROVENANCE_DOCUMENT_MAX_BYTES + 1) as usize); + let (addr, request_handle) = spawn_test_registry_server(200, oversized_body); + + let err = + verify_gcp_provenance_with_registry_url_sync(&attestation, &format!("http://{addr}")) + .unwrap_err(); + + request_handle.join().unwrap(); + assert!(matches!(err, GcpProvenanceError::DocumentTooLarge)); + } + #[tokio::test(flavor = "multi_thread")] async fn attestation_provider_response_is_wrapped_if_needed() { let input_data = [0u8; 64]; From 7e12021f933e930afd7aa3b99260431cdab3b25e Mon Sep 17 00:00:00 2001 From: peg Date: Fri, 12 Jun 2026 08:02:11 +0200 Subject: [PATCH 02/21] Dont parse quote twice --- crates/attestation/src/dcap.rs | 35 +++--- crates/attestation/src/gcp.rs | 211 +++++++++++++++++++++++++++++++ crates/attestation/src/lib.rs | 218 +++------------------------------ 3 files changed, 243 insertions(+), 221 deletions(-) create mode 100644 crates/attestation/src/gcp.rs diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index dca2c10..29fdd15 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -33,7 +33,7 @@ pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Option, -) -> Result { +) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { 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( @@ -58,7 +58,7 @@ pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result { +) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { 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( @@ -84,7 +84,7 @@ pub fn verify_dcap_attestation_with_timestamp_sync( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { let quote = Quote::parse(&input)?; let ca = quote.ca()?; @@ -118,7 +118,7 @@ pub async fn verify_dcap_attestation_with_given_timestamp( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { let quote = Quote::parse(&input)?; let ca = quote.ca()?; @@ -156,7 +156,7 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( collateral: QuoteCollateralV3, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { tracing::info!("Verifying DCAP attestation: {quote:?}"); let fmspc = hex::encode_upper(quote.fmspc()?); @@ -197,11 +197,11 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( let measurements = MultiMeasurements::from_dcap_qvl_quote("e)?; - if get_quote_input_data(quote.report) != expected_input_data { + if get_quote_input_data("e.report) != expected_input_data { return Err(DcapVerificationError::InputMismatch); } - Ok(measurements) + Ok((measurements, quote)) } #[cfg(any(test, feature = "mock"))] @@ -209,7 +209,7 @@ pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Option, -) -> Result { +) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { let quote = Quote::parse(&input)?; let ca = quote.ca()?; let fmspc = hex::encode_upper(quote.fmspc()?); @@ -224,11 +224,11 @@ pub async fn verify_dcap_attestation( verifier.verify(&input, &collateral, now)?; let measurements = MultiMeasurements::from_dcap_qvl_quote("e)?; - if get_quote_input_data(quote.report) != expected_input_data { + if get_quote_input_data("e.report) != expected_input_data { return Err(DcapVerificationError::InputMismatch); } - Ok(measurements) + Ok((measurements, quote)) } #[cfg(any(test, feature = "mock"))] @@ -236,7 +236,7 @@ pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result { +) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { let quote = Quote::parse(&input)?; let ca = quote.ca()?; let fmspc = hex::encode_upper(quote.fmspc()?); @@ -246,10 +246,11 @@ pub fn verify_dcap_attestation_sync( verifier.verify(&input, &collateral, now)?; let measurements = MultiMeasurements::from_dcap_qvl_quote("e)?; - if get_quote_input_data(quote.report.clone()) != expected_input_data { + if get_quote_input_data("e.report) != expected_input_data { return Err(DcapVerificationError::InputMismatch); } - Ok(measurements) + + Ok((measurements, quote)) } /// Create a mock quote for testing on non-confidential hardware @@ -267,7 +268,7 @@ fn generate_quote(input: [u8; 64]) -> Result, tdx_attest::TdxAttestError } /// Given a [Report] get the input data regardless of report type -pub fn get_quote_input_data(report: Report) -> [u8; 64] { +pub fn get_quote_input_data(report: &Report) -> [u8; 64] { match report { Report::TD10(r) => r.report_data, Report::TD15(r) => r.base.report_data, @@ -331,7 +332,7 @@ mod tests { let async_collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); let sync_collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let async_measurements = verify_dcap_attestation_with_given_timestamp( + let (async_measurements, _) = 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, @@ -347,7 +348,7 @@ mod tests { .await .unwrap(); - let sync_measurements = verify_dcap_attestation_with_timestamp_sync( + let (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, @@ -409,7 +410,7 @@ mod tests { let expected_input_data = [0xA5; 64]; let attestation_bytes = create_dcap_attestation(expected_input_data).unwrap(); - let measurements = + let (measurements, _) = verify_dcap_attestation(attestation_bytes, expected_input_data, Some(pccs)) .await .unwrap(); diff --git a/crates/attestation/src/gcp.rs b/crates/attestation/src/gcp.rs new file mode 100644 index 0000000..57784a2 --- /dev/null +++ b/crates/attestation/src/gcp.rs @@ -0,0 +1,211 @@ +use std::{io::Read, time::Duration}; + +use dcap_qvl::{intel, quote::Quote}; +use serde_json::Value; +use thiserror::Error; + +/// Public registry of GCP Confidential VM TDX PPIDs +const GCP_PROVENANCE_REGISTRY_URL: &str = + "https://storage.googleapis.com/confidential-host-registry"; + +/// Maximum size in bytes of GCP provenance documents +const GCP_PROVENANCE_DOCUMENT_MAX_BYTES: u64 = 16 * 1024; + +/// Given a DCAP TDX quote, check if the associated PPID has a 'provenance +/// document' from GCP +pub(crate) async fn verify_provenance(quote: Quote) -> Result<(), GcpProvenanceError> { + tokio::task::spawn_blocking(move || { + verify_provenance_with_registry_url_sync("e, GCP_PROVENANCE_REGISTRY_URL) + }) + .await + .map_err(|err| GcpProvenanceError::TaskJoin(err.to_string()))? +} + +/// Given a DCAP TDX quote, check if the associated PPID has a 'provenance +/// document' from GCP +pub(crate) fn verify_provenance_sync(quote: &Quote) -> Result<(), GcpProvenanceError> { + verify_provenance_with_registry_url_sync(quote, GCP_PROVENANCE_REGISTRY_URL) +} + +fn verify_provenance_with_registry_url_sync( + quote: &Quote, + registry_url: &str, +) -> Result<(), GcpProvenanceError> { + let ppid = extract_ppid_from_quote(quote)?; + let provenance_url = format!("{}/{}", registry_url.trim_end_matches('/'), hex::encode(ppid)); + let document = fetch_provenance_document(&provenance_url)?; + validate_provenance_document(&document)?; + + Ok(()) +} + +fn extract_ppid_from_quote(quote: &Quote) -> Result, GcpProvenanceError> { + let cert_chain = intel::extract_cert_chain(quote) + .map_err(|err| GcpProvenanceError::PpidExtraction(err.to_string()))?; + let leaf = cert_chain.first().ok_or(GcpProvenanceError::NoPckCertificate)?; + let extension = intel::parse_pck_extension(leaf) + .map_err(|err| GcpProvenanceError::PpidExtraction(err.to_string()))?; + + if extension.ppid.is_empty() { + return Err(GcpProvenanceError::EmptyPpid); + } + + Ok(extension.ppid) +} + +fn fetch_provenance_document(url: &str) -> Result { + let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(2)).build(); + let response = + agent.get(url).call().map_err(|err| GcpProvenanceError::RegistryFetch(err.to_string()))?; + + let mut limited_reader = response.into_reader().take(GCP_PROVENANCE_DOCUMENT_MAX_BYTES + 1); + let mut document = String::new(); + limited_reader + .read_to_string(&mut document) + .map_err(|err| GcpProvenanceError::RegistryFetch(err.to_string()))?; + + if document.len() as u64 > GCP_PROVENANCE_DOCUMENT_MAX_BYTES { + return Err(GcpProvenanceError::DocumentTooLarge); + } + + Ok(document) +} + +fn validate_provenance_document(document: &str) -> Result<(), GcpProvenanceError> { + let value: Value = serde_json::from_str(document)?; + let object = value.as_object().ok_or(GcpProvenanceError::InvalidDocument)?; + + let has_zone = object.get("zone").and_then(Value::as_str).is_some_and(|zone| !zone.is_empty()); + let has_timestamp = object.get("timestamp").is_some_and(|timestamp| match timestamp { + Value::String(timestamp) => !timestamp.is_empty(), + Value::Number(_) => true, + _ => false, + }); + + if has_zone && has_timestamp { Ok(()) } else { Err(GcpProvenanceError::InvalidDocument) } +} + +#[derive(Error, Debug)] +pub enum GcpProvenanceError { + #[error("quote parse: {0}")] + Quote(String), + #[error("PCK certificate chain is empty")] + NoPckCertificate, + #[error("PPID is empty")] + EmptyPpid, + #[error("PPID extraction: {0}")] + PpidExtraction(String), + #[error("registry fetch: {0}")] + RegistryFetch(String), + #[error("provenance document is invalid")] + InvalidDocument, + #[error("provenance document exceeds maximum size")] + DocumentTooLarge, + #[error("provenance document JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("blocking task join: {0}")] + TaskJoin(String), +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read as _, Write as _}, + net::SocketAddr, + thread, + }; + + use super::*; + use crate::dcap; + + const MOCK_PPID_HEX: &str = "d04ec06d4e6d92dc90d0ad3cf5ee2ddf"; + + fn spawn_test_registry_server( + status: u16, + body: impl Into, + ) -> (SocketAddr, thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = body.into(); + + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0u8; 1024]; + let bytes_read = stream.read(&mut buf).unwrap(); + let request = String::from_utf8_lossy(&buf[..bytes_read]).to_string(); + let status_text = if status == 200 { "OK" } else { "Not Found" }; + let response = format!( + "HTTP/1.1 {status} {status_text}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + request + }); + + (addr, handle) + } + + #[test] + fn extracts_ppid_from_mock_tdx_quote() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let quote = Quote::parse(&attestation).unwrap(); + let ppid = extract_ppid_from_quote("e).unwrap(); + + assert_eq!(hex::encode(ppid), MOCK_PPID_HEX); + } + + #[test] + fn provenance_check_fetches_registry_document_for_ppid() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let quote = Quote::parse(&attestation).unwrap(); + let (addr, request_handle) = spawn_test_registry_server( + 200, + r#"{"zone":"projects/test/zones/us-central1-a","timestamp":"2026-06-11T00:00:00Z"}"#, + ); + + verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")).unwrap(); + + let request = request_handle.join().unwrap(); + assert!(request.starts_with(&format!("GET /{MOCK_PPID_HEX} HTTP/1.1"))); + } + + #[test] + fn provenance_check_fails_closed_on_registry_miss() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let quote = Quote::parse(&attestation).unwrap(); + let (addr, request_handle) = spawn_test_registry_server(404, "not found"); + + let err = verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) + .unwrap_err(); + + request_handle.join().unwrap(); + assert!(matches!(err, GcpProvenanceError::RegistryFetch(_))); + } + + #[test] + fn provenance_check_fails_closed_on_invalid_document() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let quote = Quote::parse(&attestation).unwrap(); + let (addr, request_handle) = spawn_test_registry_server(200, r#"{"zone":""}"#); + + let err = verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) + .unwrap_err(); + + request_handle.join().unwrap(); + assert!(matches!(err, GcpProvenanceError::InvalidDocument)); + } + + #[test] + fn provenance_check_fails_closed_on_oversized_document() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let quote = Quote::parse(&attestation).unwrap(); + let oversized_body = "x".repeat((GCP_PROVENANCE_DOCUMENT_MAX_BYTES + 1) as usize); + let (addr, request_handle) = spawn_test_registry_server(200, oversized_body); + + let err = verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) + .unwrap_err(); + + request_handle.join().unwrap(); + assert!(matches!(err, GcpProvenanceError::DocumentTooLarge)); + } +} diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 93308b3..cd7e9d0 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -3,6 +3,7 @@ #[cfg(feature = "azure")] pub mod azure; pub mod dcap; +mod gcp; pub mod measurements; use std::{ @@ -12,23 +13,20 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; -use dcap_qvl::{intel, quote::Quote}; use measurements::MultiMeasurements; use parity_scale_codec::{Decode, Encode}; use pccs::{Pccs, PccsError}; use serde::{Deserialize, Serialize}; -use serde_json::Value; use thiserror::Error; -use crate::{dcap::DcapVerificationError, measurements::MeasurementPolicy}; +use crate::{ + dcap::DcapVerificationError, + gcp::GcpProvenanceError, + measurements::MeasurementPolicy, +}; /// Used in attestation type detection to check if we are on GCP const GCP_METADATA_API: &str = "http://metadata.google.internal"; -/// Public registry of GCP Confidential VM TDX PPIDs. -const GCP_PROVENANCE_REGISTRY_URL: &str = - "https://storage.googleapis.com/confidential-host-registry"; -/// GCP provenance documents are small JSON payloads. Cap reads defensively. -const GCP_PROVENANCE_DOCUMENT_MAX_BYTES: u64 = 16 * 1024; /// An attestation payload together with its type #[derive(Clone, Debug, Serialize, Deserialize, Encode, Decode)] @@ -407,15 +405,17 @@ impl AttestationVerifier { } AttestationType::DcapTdx | AttestationType::GcpTdx | AttestationType::QemuTdx => { let attestation = attestation_exchange_message.attestation; - let measurements = dcap::verify_dcap_attestation( + let (measurements, quote) = dcap::verify_dcap_attestation( attestation.clone(), expected_input_data, self.internal_pccs.clone(), ) .await?; + if attestation_type == AttestationType::GcpTdx { - verify_gcp_provenance(attestation).await?; + gcp::verify_provenance(quote).await?; } + measurements } }; @@ -474,14 +474,16 @@ impl AttestationVerifier { #[cfg(not(any(test, feature = "mock")))] let pccs = self.internal_pccs.clone().ok_or(AttestationError::NoPccs)?; - let measurements = dcap::verify_dcap_attestation_sync( + let (measurements, quote) = dcap::verify_dcap_attestation_sync( attestation.clone(), expected_input_data, pccs, )?; + if attestation_type == AttestationType::GcpTdx { - verify_gcp_provenance_sync(&attestation)?; + gcp::verify_provenance_sync("e)?; } + measurements } }; @@ -588,101 +590,6 @@ fn is_local_ip(ip: IpAddr) -> bool { } } -async fn verify_gcp_provenance(quote_bytes: Vec) -> Result<(), GcpProvenanceError> { - tokio::task::spawn_blocking(move || { - verify_gcp_provenance_with_registry_url_sync("e_bytes, GCP_PROVENANCE_REGISTRY_URL) - }) - .await - .map_err(|err| GcpProvenanceError::TaskJoin(err.to_string()))? -} - -fn verify_gcp_provenance_sync(quote_bytes: &[u8]) -> Result<(), GcpProvenanceError> { - verify_gcp_provenance_with_registry_url_sync(quote_bytes, GCP_PROVENANCE_REGISTRY_URL) -} - -fn verify_gcp_provenance_with_registry_url_sync( - quote_bytes: &[u8], - registry_url: &str, -) -> Result<(), GcpProvenanceError> { - let quote = - Quote::parse(quote_bytes).map_err(|err| GcpProvenanceError::Quote(err.to_string()))?; - - let ppid = extract_ppid_from_quote("e)?; - let provenance_url = format!("{}/{}", registry_url.trim_end_matches('/'), hex::encode(ppid)); - let document = fetch_gcp_provenance_document(&provenance_url)?; - validate_gcp_provenance_document(&document)?; - - Ok(()) -} - -fn extract_ppid_from_quote(quote: &Quote) -> Result, GcpProvenanceError> { - let cert_chain = intel::extract_cert_chain(quote) - .map_err(|err| GcpProvenanceError::PpidExtraction(err.to_string()))?; - let leaf = cert_chain.first().ok_or(GcpProvenanceError::NoPckCertificate)?; - let extension = intel::parse_pck_extension(leaf) - .map_err(|err| GcpProvenanceError::PpidExtraction(err.to_string()))?; - - if extension.ppid.is_empty() { - return Err(GcpProvenanceError::EmptyPpid); - } - - Ok(extension.ppid) -} - -fn fetch_gcp_provenance_document(url: &str) -> Result { - let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(2)).build(); - let response = - agent.get(url).call().map_err(|err| GcpProvenanceError::RegistryFetch(err.to_string()))?; - - let mut limited_reader = response.into_reader().take(GCP_PROVENANCE_DOCUMENT_MAX_BYTES + 1); - let mut document = String::new(); - limited_reader - .read_to_string(&mut document) - .map_err(|err| GcpProvenanceError::RegistryFetch(err.to_string()))?; - - if document.len() as u64 > GCP_PROVENANCE_DOCUMENT_MAX_BYTES { - return Err(GcpProvenanceError::DocumentTooLarge); - } - - Ok(document) -} - -fn validate_gcp_provenance_document(document: &str) -> Result<(), GcpProvenanceError> { - let value: Value = serde_json::from_str(document)?; - let object = value.as_object().ok_or(GcpProvenanceError::InvalidDocument)?; - - let has_zone = object.get("zone").and_then(Value::as_str).is_some_and(|zone| !zone.is_empty()); - let has_timestamp = object.get("timestamp").is_some_and(|timestamp| match timestamp { - Value::String(timestamp) => !timestamp.is_empty(), - Value::Number(_) => true, - _ => false, - }); - - if has_zone && has_timestamp { Ok(()) } else { Err(GcpProvenanceError::InvalidDocument) } -} - -#[derive(Error, Debug)] -pub enum GcpProvenanceError { - #[error("quote parse: {0}")] - Quote(String), - #[error("PCK certificate chain is empty")] - NoPckCertificate, - #[error("PPID is empty")] - EmptyPpid, - #[error("PPID extraction: {0}")] - PpidExtraction(String), - #[error("registry fetch: {0}")] - RegistryFetch(String), - #[error("provenance document is invalid")] - InvalidDocument, - #[error("provenance document exceeds maximum size")] - DocumentTooLarge, - #[error("provenance document JSON: {0}")] - Json(#[from] serde_json::Error), - #[error("blocking task join: {0}")] - TaskJoin(String), -} - /// An error when generating or verifying an attestation #[derive(Error, Debug)] pub enum AttestationError { @@ -727,12 +634,6 @@ pub enum AttestationError { #[cfg(test)] mod tests { - use std::{ - io::{Read as StdRead, Write}, - net::SocketAddr, - thread, - }; - use mock_tdx::mock_pcs::{MockPcsConfig, spawn_mock_pcs_server}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, @@ -741,8 +642,6 @@ mod tests { use super::*; - const MOCK_PPID_HEX: &str = "d04ec06d4e6d92dc90d0ad3cf5ee2ddf"; - async fn spawn_test_attestation_provider_server(body: Vec) -> std::net::SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -765,31 +664,6 @@ mod tests { addr } - fn spawn_test_registry_server( - status: u16, - body: impl Into, - ) -> (SocketAddr, thread::JoinHandle) { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let body = body.into(); - - let handle = thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut buf = [0u8; 1024]; - let bytes_read = stream.read(&mut buf).unwrap(); - let request = String::from_utf8_lossy(&buf[..bytes_read]).to_string(); - let status_text = if status == 200 { "OK" } else { "Not Found" }; - let response = format!( - "HTTP/1.1 {status} {status_text}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).unwrap(); - request - }); - - (addr, handle) - } - #[test] fn attestation_detection_does_not_panic() { // We dont enforce what platform the test is run on, only that the function @@ -802,70 +676,6 @@ mod tests { let _ = running_on_gcp(); } - #[test] - fn extracts_ppid_from_mock_tdx_quote() { - let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); - let quote = Quote::parse(&attestation).unwrap(); - let ppid = extract_ppid_from_quote("e).unwrap(); - - assert_eq!(hex::encode(ppid), MOCK_PPID_HEX); - } - - #[test] - fn gcp_provenance_check_fetches_registry_document_for_ppid() { - let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); - let (addr, request_handle) = spawn_test_registry_server( - 200, - r#"{"zone":"projects/test/zones/us-central1-a","timestamp":"2026-06-11T00:00:00Z"}"#, - ); - - verify_gcp_provenance_with_registry_url_sync(&attestation, &format!("http://{addr}")) - .unwrap(); - - let request = request_handle.join().unwrap(); - assert!(request.starts_with(&format!("GET /{MOCK_PPID_HEX} HTTP/1.1"))); - } - - #[test] - fn gcp_provenance_check_fails_closed_on_registry_miss() { - let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); - let (addr, request_handle) = spawn_test_registry_server(404, "not found"); - - let err = - verify_gcp_provenance_with_registry_url_sync(&attestation, &format!("http://{addr}")) - .unwrap_err(); - - request_handle.join().unwrap(); - assert!(matches!(err, GcpProvenanceError::RegistryFetch(_))); - } - - #[test] - fn gcp_provenance_check_fails_closed_on_invalid_document() { - let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); - let (addr, request_handle) = spawn_test_registry_server(200, r#"{"zone":""}"#); - - let err = - verify_gcp_provenance_with_registry_url_sync(&attestation, &format!("http://{addr}")) - .unwrap_err(); - - request_handle.join().unwrap(); - assert!(matches!(err, GcpProvenanceError::InvalidDocument)); - } - - #[test] - fn gcp_provenance_check_fails_closed_on_oversized_document() { - let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); - let oversized_body = "x".repeat((GCP_PROVENANCE_DOCUMENT_MAX_BYTES + 1) as usize); - let (addr, request_handle) = spawn_test_registry_server(200, oversized_body); - - let err = - verify_gcp_provenance_with_registry_url_sync(&attestation, &format!("http://{addr}")) - .unwrap_err(); - - request_handle.join().unwrap(); - assert!(matches!(err, GcpProvenanceError::DocumentTooLarge)); - } - #[tokio::test(flavor = "multi_thread")] async fn attestation_provider_response_is_wrapped_if_needed() { let input_data = [0u8; 64]; From a4ebbb20a4f6620aec22d4a2a87d900ed4bf9981 Mon Sep 17 00:00:00 2001 From: peg Date: Fri, 12 Jun 2026 08:59:27 +0200 Subject: [PATCH 03/21] Cache known GCP PPIDs --- crates/attestation/src/gcp.rs | 113 ++++++++++++++++++++++++++-------- crates/attestation/src/lib.rs | 12 +++- 2 files changed, 95 insertions(+), 30 deletions(-) diff --git a/crates/attestation/src/gcp.rs b/crates/attestation/src/gcp.rs index 57784a2..b3ec221 100644 --- a/crates/attestation/src/gcp.rs +++ b/crates/attestation/src/gcp.rs @@ -1,4 +1,9 @@ -use std::{io::Read, time::Duration}; +use std::{ + collections::HashSet, + io::Read, + sync::{Arc, RwLock}, + time::Duration, +}; use dcap_qvl::{intel, quote::Quote}; use serde_json::Value; @@ -11,32 +16,61 @@ const GCP_PROVENANCE_REGISTRY_URL: &str = /// Maximum size in bytes of GCP provenance documents const GCP_PROVENANCE_DOCUMENT_MAX_BYTES: u64 = 16 * 1024; -/// Given a DCAP TDX quote, check if the associated PPID has a 'provenance -/// document' from GCP -pub(crate) async fn verify_provenance(quote: Quote) -> Result<(), GcpProvenanceError> { - tokio::task::spawn_blocking(move || { - verify_provenance_with_registry_url_sync("e, GCP_PROVENANCE_REGISTRY_URL) - }) - .await - .map_err(|err| GcpProvenanceError::TaskJoin(err.to_string()))? +#[derive(Clone, Debug)] +pub(crate) struct GcpProvenanceChecker { + known_gcp_ppids: Arc>>>, } -/// Given a DCAP TDX quote, check if the associated PPID has a 'provenance -/// document' from GCP -pub(crate) fn verify_provenance_sync(quote: &Quote) -> Result<(), GcpProvenanceError> { - verify_provenance_with_registry_url_sync(quote, GCP_PROVENANCE_REGISTRY_URL) -} +impl GcpProvenanceChecker { + pub(crate) fn new() -> Self { + Self { known_gcp_ppids: Default::default() } + } -fn verify_provenance_with_registry_url_sync( - quote: &Quote, - registry_url: &str, -) -> Result<(), GcpProvenanceError> { - let ppid = extract_ppid_from_quote(quote)?; - let provenance_url = format!("{}/{}", registry_url.trim_end_matches('/'), hex::encode(ppid)); - let document = fetch_provenance_document(&provenance_url)?; - validate_provenance_document(&document)?; + /// Given a DCAP TDX quote, check if the associated PPID has a + /// 'provenance document' from GCP + pub(crate) async fn verify_provenance(&self, quote: Quote) -> Result<(), GcpProvenanceError> { + let checker = self.clone(); + tokio::task::spawn_blocking(move || { + checker.verify_provenance_with_registry_url_sync("e, GCP_PROVENANCE_REGISTRY_URL) + }) + .await + .map_err(|err| GcpProvenanceError::TaskJoin(err.to_string()))? + } + + /// Given a DCAP TDX quote, check if the associated PPID has a + /// 'provenance document' from GCP + pub(crate) fn verify_provenance_sync(&self, quote: &Quote) -> Result<(), GcpProvenanceError> { + self.verify_provenance_with_registry_url_sync(quote, GCP_PROVENANCE_REGISTRY_URL) + } - Ok(()) + fn verify_provenance_with_registry_url_sync( + &self, + quote: &Quote, + registry_url: &str, + ) -> Result<(), GcpProvenanceError> { + let ppid = extract_ppid_from_quote(quote)?; + { + let known_gcp_ppids = self + .known_gcp_ppids + .read() + .map_err(|err| GcpProvenanceError::CacheLock(err.to_string()))?; + if known_gcp_ppids.contains(&ppid) { + return Ok(()); + } + } + + let provenance_url = + format!("{}/{}", registry_url.trim_end_matches('/'), hex::encode(&ppid)); + let document = fetch_provenance_document(&provenance_url)?; + validate_provenance_document(&document)?; + + self.known_gcp_ppids + .write() + .map_err(|err| GcpProvenanceError::CacheLock(err.to_string()))? + .insert(ppid); + + Ok(()) + } } fn extract_ppid_from_quote(quote: &Quote) -> Result, GcpProvenanceError> { @@ -103,6 +137,8 @@ pub enum GcpProvenanceError { DocumentTooLarge, #[error("provenance document JSON: {0}")] Json(#[from] serde_json::Error), + #[error("provenance cache lock: {0}")] + CacheLock(String), #[error("blocking task join: {0}")] TaskJoin(String), } @@ -163,7 +199,27 @@ mod tests { r#"{"zone":"projects/test/zones/us-central1-a","timestamp":"2026-06-11T00:00:00Z"}"#, ); - verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")).unwrap(); + GcpProvenanceChecker::new() + .verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) + .unwrap(); + + let request = request_handle.join().unwrap(); + assert!(request.starts_with(&format!("GET /{MOCK_PPID_HEX} HTTP/1.1"))); + } + + #[test] + fn provenance_check_caches_known_gcp_ppids() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let quote = Quote::parse(&attestation).unwrap(); + let (addr, request_handle) = spawn_test_registry_server( + 200, + r#"{"zone":"projects/test/zones/us-central1-a","timestamp":"2026-06-11T00:00:00Z"}"#, + ); + let checker = GcpProvenanceChecker::new(); + let registry_url = format!("http://{addr}"); + + checker.verify_provenance_with_registry_url_sync("e, ®istry_url).unwrap(); + checker.verify_provenance_with_registry_url_sync("e, ®istry_url).unwrap(); let request = request_handle.join().unwrap(); assert!(request.starts_with(&format!("GET /{MOCK_PPID_HEX} HTTP/1.1"))); @@ -175,7 +231,8 @@ mod tests { let quote = Quote::parse(&attestation).unwrap(); let (addr, request_handle) = spawn_test_registry_server(404, "not found"); - let err = verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) + let err = GcpProvenanceChecker::new() + .verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) .unwrap_err(); request_handle.join().unwrap(); @@ -188,7 +245,8 @@ mod tests { let quote = Quote::parse(&attestation).unwrap(); let (addr, request_handle) = spawn_test_registry_server(200, r#"{"zone":""}"#); - let err = verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) + let err = GcpProvenanceChecker::new() + .verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) .unwrap_err(); request_handle.join().unwrap(); @@ -202,7 +260,8 @@ mod tests { let oversized_body = "x".repeat((GCP_PROVENANCE_DOCUMENT_MAX_BYTES + 1) as usize); let (addr, request_handle) = spawn_test_registry_server(200, oversized_body); - let err = verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) + let err = GcpProvenanceChecker::new() + .verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) .unwrap_err(); request_handle.join().unwrap(); diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index cd7e9d0..d5615d7 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -21,7 +21,7 @@ use thiserror::Error; use crate::{ dcap::DcapVerificationError, - gcp::GcpProvenanceError, + gcp::{GcpProvenanceChecker, GcpProvenanceError}, measurements::MeasurementPolicy, }; @@ -289,6 +289,8 @@ pub struct AttestationVerifier { pub override_azure_outdated_tcb: bool, /// Internal cache for collateral pub internal_pccs: Option, + /// Internal cache for known GCP PPIDs + gcp_provenance_checker: GcpProvenanceChecker, } impl AttestationVerifier { @@ -304,6 +306,7 @@ impl AttestationVerifier { dump_dcap_quotes, override_azure_outdated_tcb, internal_pccs: Some(Pccs::new(pccs_url)), + gcp_provenance_checker: GcpProvenanceChecker::new(), } } @@ -316,6 +319,7 @@ impl AttestationVerifier { dump_dcap_quotes: false, override_azure_outdated_tcb: false, internal_pccs: None, + gcp_provenance_checker: GcpProvenanceChecker::new(), } } @@ -328,6 +332,7 @@ impl AttestationVerifier { dump_dcap_quotes: false, override_azure_outdated_tcb: false, internal_pccs: None, + gcp_provenance_checker: GcpProvenanceChecker::new(), } } @@ -340,6 +345,7 @@ impl AttestationVerifier { dump_dcap_quotes: false, override_azure_outdated_tcb: false, internal_pccs: Some(Pccs::new(Some(pccs_url))), + gcp_provenance_checker: GcpProvenanceChecker::new(), } } @@ -413,7 +419,7 @@ impl AttestationVerifier { .await?; if attestation_type == AttestationType::GcpTdx { - gcp::verify_provenance(quote).await?; + self.gcp_provenance_checker.verify_provenance(quote).await?; } measurements @@ -481,7 +487,7 @@ impl AttestationVerifier { )?; if attestation_type == AttestationType::GcpTdx { - gcp::verify_provenance_sync("e)?; + self.gcp_provenance_checker.verify_provenance_sync("e)?; } measurements From 9a4ea50475f48badedab86a86e174c4be47ffdcc Mon Sep 17 00:00:00 2001 From: peg Date: Fri, 12 Jun 2026 09:20:11 +0200 Subject: [PATCH 04/21] Measurement checks should check attestation type --- crates/attestation/src/azure/mod.rs | 4 +- crates/attestation/src/dcap.rs | 6 ++- crates/attestation/src/lib.rs | 4 +- crates/attestation/src/measurements.rs | 71 +++++++++++++++++++++----- 4 files changed, 67 insertions(+), 18 deletions(-) diff --git a/crates/attestation/src/azure/mod.rs b/crates/attestation/src/azure/mod.rs index eb31173..967347f 100644 --- a/crates/attestation/src/azure/mod.rs +++ b/crates/attestation/src/azure/mod.rs @@ -758,7 +758,9 @@ mod tests { .unwrap(); assert_eq!(async_measurements, sync_measurements); - measurement_policy.check_measurement(&async_measurements).unwrap(); + measurement_policy + .check_measurement(AttestationType::AzureTdx, &async_measurements) + .unwrap(); } /// Verify a complete observed Azure attestation payload that includes diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index 29fdd15..96ae6e0 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -298,7 +298,7 @@ mod tests { use mock_tdx::{MockPcsConfig, spawn_mock_pcs_server}; use super::*; - use crate::measurements::MeasurementPolicy; + use crate::{AttestationType, measurements::MeasurementPolicy}; #[tokio::test] async fn test_dcap_verify() { @@ -364,7 +364,9 @@ mod tests { .unwrap(); assert_eq!(async_measurements, sync_measurements); - measurement_policy.check_measurement(&async_measurements).unwrap(); + measurement_policy + .check_measurement(AttestationType::DcapTdx, &async_measurements) + .unwrap(); } // This specifically tests a quote which has outdated TCB level from Azure diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index d5615d7..f7a5600 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -427,7 +427,7 @@ impl AttestationVerifier { }; // Do a measurement / attestation type policy check - self.measurement_policy.check_measurement(&measurements)?; + self.measurement_policy.check_measurement(attestation_type, &measurements)?; tracing::debug!("Verification successful"); Ok(Some(measurements)) @@ -495,7 +495,7 @@ impl AttestationVerifier { }; // Do a measurement / attestation type policy check - self.measurement_policy.check_measurement(&measurements)?; + self.measurement_policy.check_measurement(attestation_type, &measurements)?; tracing::debug!("Verification successful"); Ok(Some(measurements)) diff --git a/crates/attestation/src/measurements.rs b/crates/attestation/src/measurements.rs index db8c2c9..2da2352 100644 --- a/crates/attestation/src/measurements.rs +++ b/crates/attestation/src/measurements.rs @@ -275,6 +275,8 @@ pub struct MeasurementRecord { /// An identifier, for example the name and version of the corresponding /// OS image pub measurement_id: String, + /// The attestation type this record accepts + pub attestation_type: AttestationType, /// The expected measurement register values pub measurements: ExpectedMeasurements, } @@ -283,6 +285,7 @@ impl MeasurementRecord { pub fn allow_no_attestation() -> Self { Self { measurement_id: "Allow no attestation".to_string(), + attestation_type: AttestationType::None, measurements: ExpectedMeasurements::NoAttestation, } } @@ -290,6 +293,7 @@ impl MeasurementRecord { pub fn allow_any_measurement(attestation_type: AttestationType) -> Self { Self { measurement_id: format!("Any measurement for {attestation_type}"), + attestation_type, measurements: match attestation_type { AttestationType::None => ExpectedMeasurements::NoAttestation, AttestationType::AzureTdx => ExpectedMeasurements::Azure(HashMap::new()), @@ -359,6 +363,7 @@ impl MeasurementPolicy { Self { accepted_measurements: vec![MeasurementRecord { measurement_id: "test".to_string(), + attestation_type: AttestationType::DcapTdx, measurements: ExpectedMeasurements::Dcap(HashMap::from([ (DcapMeasurementRegister::MRTD, vec![mock_tdx::MOCK_MRTD]), (DcapMeasurementRegister::RTMR0, vec![mock_tdx::MOCK_RTMR0]), @@ -374,10 +379,14 @@ impl MeasurementPolicy { /// they are acceptable pub fn check_measurement( &self, + attestation_type: AttestationType, measurements: &MultiMeasurements, ) -> Result<(), AttestationError> { if self.accepted_measurements.iter().any(|measurement_record| match measurements { MultiMeasurements::Dcap(dcap_measurements) => { + if measurement_record.attestation_type != attestation_type { + return false; + } if let ExpectedMeasurements::Dcap(expected) = &measurement_record.measurements { // All measurements in our policy must be given and must match for (k, v) in expected.iter() { @@ -391,6 +400,9 @@ impl MeasurementPolicy { false } MultiMeasurements::Azure(azure_measurements) => { + if measurement_record.attestation_type != attestation_type { + return false; + } if let ExpectedMeasurements::Azure(expected) = &measurement_record.measurements { for (k, v) in expected.iter() { match azure_measurements.get(k) { @@ -403,6 +415,9 @@ impl MeasurementPolicy { false } MultiMeasurements::NoAttestation => { + if measurement_record.attestation_type != attestation_type { + return false; + } matches!(measurement_record.measurements, ExpectedMeasurements::NoAttestation) } }) { @@ -547,6 +562,7 @@ impl MeasurementPolicy { measurement_policy.push(MeasurementRecord { measurement_id: record.measurement_id.unwrap_or_default(), + attestation_type, measurements: expected_measurements, }); } else { @@ -612,20 +628,27 @@ mod tests { // Will not match mock measurements assert!(matches!( - specific_measurements.check_measurement(&mock_dcap_measurements()).unwrap_err(), + specific_measurements + .check_measurement(AttestationType::DcapTdx, &mock_dcap_measurements()) + .unwrap_err(), AttestationError::MeasurementsNotAccepted )); // Will not match another attestation type assert!(matches!( - specific_measurements.check_measurement(&MultiMeasurements::NoAttestation).unwrap_err(), + specific_measurements + .check_measurement(AttestationType::None, &MultiMeasurements::NoAttestation) + .unwrap_err(), AttestationError::MeasurementsNotAccepted )); // A non-specific measurement fails assert!(matches!( specific_measurements - .check_measurement(&MultiMeasurements::Azure(HashMap::new())) + .check_measurement( + AttestationType::AzureTdx, + &MultiMeasurements::Azure(HashMap::new()) + ) .unwrap_err(), AttestationError::MeasurementsNotAccepted )); @@ -638,17 +661,32 @@ mod tests { let allowed_attestation_type = MeasurementPolicy::from_file("test-assets/measurements_2.json".into()).await.unwrap(); - allowed_attestation_type.check_measurement(&mock_dcap_measurements()).unwrap(); + allowed_attestation_type + .check_measurement(AttestationType::DcapTdx, &mock_dcap_measurements()) + .unwrap(); // Will not match another attestation type assert!(matches!( allowed_attestation_type - .check_measurement(&MultiMeasurements::NoAttestation) + .check_measurement(AttestationType::None, &MultiMeasurements::NoAttestation) .unwrap_err(), AttestationError::MeasurementsNotAccepted )); } + #[test] + fn gcp_policy_rejects_dcap_labeled_measurements() { + let policy = MeasurementPolicy::single_attestation_type(AttestationType::GcpTdx); + let measurements = mock_dcap_measurements(); + + policy.check_measurement(AttestationType::GcpTdx, &measurements).unwrap(); + + assert!(matches!( + policy.check_measurement(AttestationType::DcapTdx, &measurements).unwrap_err(), + AttestationError::MeasurementsNotAccepted + )); + } + #[tokio::test] async fn test_buildernet_measurements() { // Refresh this fixture explicitly with: @@ -662,13 +700,20 @@ mod tests { assert!(!policy.accepted_measurements.is_empty()); assert!(matches!( - policy.check_measurement(&MultiMeasurements::NoAttestation).unwrap_err(), + policy + .check_measurement(AttestationType::None, &MultiMeasurements::NoAttestation) + .unwrap_err(), AttestationError::MeasurementsNotAccepted )); // A non-specific measurement fails assert!(matches!( - policy.check_measurement(&MultiMeasurements::Azure(HashMap::new())).unwrap_err(), + policy + .check_measurement( + AttestationType::AzureTdx, + &MultiMeasurements::Azure(HashMap::new()) + ) + .unwrap_err(), AttestationError::MeasurementsNotAccepted )); } @@ -724,17 +769,17 @@ mod tests { // First value should match let measurements1 = MultiMeasurements::Dcap(HashMap::from([(DcapMeasurementRegister::MRTD, [0u8; 48])])); - assert!(policy.check_measurement(&measurements1).is_ok()); + assert!(policy.check_measurement(AttestationType::DcapTdx, &measurements1).is_ok()); // Second value should also match let measurements2 = MultiMeasurements::Dcap(HashMap::from([(DcapMeasurementRegister::MRTD, [0x11u8; 48])])); - assert!(policy.check_measurement(&measurements2).is_ok()); + assert!(policy.check_measurement(AttestationType::DcapTdx, &measurements2).is_ok()); // Different value should not match let measurements3 = MultiMeasurements::Dcap(HashMap::from([(DcapMeasurementRegister::MRTD, [0x22u8; 48])])); - assert!(policy.check_measurement(&measurements3).is_err()); + assert!(policy.check_measurement(AttestationType::DcapTdx, &measurements3).is_err()); } #[tokio::test] @@ -814,21 +859,21 @@ mod tests { (DcapMeasurementRegister::MRTD, [0u8; 48]), (DcapMeasurementRegister::RTMR0, [0x11u8; 48]), ])); - assert!(policy.check_measurement(&measurements1).is_ok()); + assert!(policy.check_measurement(AttestationType::DcapTdx, &measurements1).is_ok()); // Both match (single + second of any) let measurements2 = MultiMeasurements::Dcap(HashMap::from([ (DcapMeasurementRegister::MRTD, [0u8; 48]), (DcapMeasurementRegister::RTMR0, [0x22u8; 48]), ])); - assert!(policy.check_measurement(&measurements2).is_ok()); + assert!(policy.check_measurement(AttestationType::DcapTdx, &measurements2).is_ok()); // Single matches but any doesn't let measurements3 = MultiMeasurements::Dcap(HashMap::from([ (DcapMeasurementRegister::MRTD, [0u8; 48]), (DcapMeasurementRegister::RTMR0, [0x33u8; 48]), ])); - assert!(policy.check_measurement(&measurements3).is_err()); + assert!(policy.check_measurement(AttestationType::DcapTdx, &measurements3).is_err()); } #[tokio::test] From cfcce9b0d2578fb81d4a9dab99806c5bb64a64d8 Mon Sep 17 00:00:00 2001 From: peg Date: Fri, 12 Jun 2026 09:28:08 +0200 Subject: [PATCH 05/21] Fix azure tests, and avoid unneeded cloning --- crates/attestation/src/azure/mod.rs | 2 +- crates/attestation/src/lib.rs | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/attestation/src/azure/mod.rs b/crates/attestation/src/azure/mod.rs index 967347f..1fdd987 100644 --- a/crates/attestation/src/azure/mod.rs +++ b/crates/attestation/src/azure/mod.rs @@ -668,7 +668,7 @@ mod test_utils { #[cfg(test)] mod tests { use super::*; - use crate::measurements::MeasurementPolicy; + use crate::{AttestationType, measurements::MeasurementPolicy}; fn input_data_from_attestation(attestation_bytes: &[u8]) -> [u8; 64] { let attestation_document: AttestationDocument = diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index f7a5600..419c423 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -410,9 +410,8 @@ impl AttestationVerifier { } } AttestationType::DcapTdx | AttestationType::GcpTdx | AttestationType::QemuTdx => { - let attestation = attestation_exchange_message.attestation; let (measurements, quote) = dcap::verify_dcap_attestation( - attestation.clone(), + attestation_exchange_message.attestation, expected_input_data, self.internal_pccs.clone(), ) @@ -473,7 +472,6 @@ impl AttestationVerifier { } } AttestationType::DcapTdx | AttestationType::QemuTdx | AttestationType::GcpTdx => { - let attestation = attestation_exchange_message.attestation; #[cfg(any(test, feature = "mock"))] let pccs = self.internal_pccs.clone().unwrap_or_else(|| Pccs::new_without_prewarm(None)); @@ -481,7 +479,7 @@ impl AttestationVerifier { let pccs = self.internal_pccs.clone().ok_or(AttestationError::NoPccs)?; let (measurements, quote) = dcap::verify_dcap_attestation_sync( - attestation.clone(), + attestation_exchange_message.attestation, expected_input_data, pccs, )?; From 5abd134b9bf7f9d800d7d2f7f0092dccade9b2cd Mon Sep 17 00:00:00 2001 From: peg Date: Tue, 23 Jun 2026 09:33:34 +0200 Subject: [PATCH 06/21] Add test for PPID extraction from current DCAP fixture --- crates/attestation/src/gcp.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/attestation/src/gcp.rs b/crates/attestation/src/gcp.rs index b3ec221..efb84db 100644 --- a/crates/attestation/src/gcp.rs +++ b/crates/attestation/src/gcp.rs @@ -190,6 +190,16 @@ mod tests { assert_eq!(hex::encode(ppid), MOCK_PPID_HEX); } + #[test] + fn extracts_ppid_from_fixture_dcap_quote() { + let attestation = include_bytes!("../test-assets/dcap-tdx-1766059550570652607"); + let quote = Quote::parse(attestation).unwrap(); + let ppid = extract_ppid_from_quote("e).unwrap(); + + assert_eq!(ppid.len(), 16); + assert!(!ppid.iter().all(|byte| *byte == 0)); + } + #[test] fn provenance_check_fetches_registry_document_for_ppid() { let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); From f2f479f08fc6bf151c3f5fdab3226e23bfdc529c Mon Sep 17 00:00:00 2001 From: peg Date: Tue, 23 Jun 2026 10:26:07 +0200 Subject: [PATCH 07/21] Add expiry timestamp for cached PPIDs --- crates/attestation/src/gcp.rs | 145 ++++++++++++++++++++++++++++++---- 1 file changed, 131 insertions(+), 14 deletions(-) diff --git a/crates/attestation/src/gcp.rs b/crates/attestation/src/gcp.rs index efb84db..98c982d 100644 --- a/crates/attestation/src/gcp.rs +++ b/crates/attestation/src/gcp.rs @@ -1,8 +1,8 @@ use std::{ - collections::HashSet, + collections::HashMap, io::Read, sync::{Arc, RwLock}, - time::Duration, + time::{Duration, Instant}, }; use dcap_qvl::{intel, quote::Quote}; @@ -15,10 +15,15 @@ const GCP_PROVENANCE_REGISTRY_URL: &str = /// Maximum size in bytes of GCP provenance documents const GCP_PROVENANCE_DOCUMENT_MAX_BYTES: u64 = 16 * 1024; +/// How long a cached PPID remains trusted before revalidation +const GCP_PROVENANCE_CACHE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60); +/// Checks PPIDs extracted from DCAP quotes against Googles public bucket, +/// to establish whether this is a GCP machine #[derive(Clone, Debug)] pub(crate) struct GcpProvenanceChecker { - known_gcp_ppids: Arc>>>, + /// Cached entries with retrieval timestamp + known_gcp_ppids: Arc, Instant>>>, } impl GcpProvenanceChecker { @@ -29,9 +34,14 @@ impl GcpProvenanceChecker { /// Given a DCAP TDX quote, check if the associated PPID has a /// 'provenance document' from GCP pub(crate) async fn verify_provenance(&self, quote: Quote) -> Result<(), GcpProvenanceError> { + let now = Instant::now(); let checker = self.clone(); tokio::task::spawn_blocking(move || { - checker.verify_provenance_with_registry_url_sync("e, GCP_PROVENANCE_REGISTRY_URL) + checker.verify_provenance_with_registry_url_sync_at( + "e, + GCP_PROVENANCE_REGISTRY_URL, + now, + ) }) .await .map_err(|err| GcpProvenanceError::TaskJoin(err.to_string()))? @@ -40,13 +50,18 @@ impl GcpProvenanceChecker { /// Given a DCAP TDX quote, check if the associated PPID has a /// 'provenance document' from GCP pub(crate) fn verify_provenance_sync(&self, quote: &Quote) -> Result<(), GcpProvenanceError> { - self.verify_provenance_with_registry_url_sync(quote, GCP_PROVENANCE_REGISTRY_URL) + self.verify_provenance_with_registry_url_sync_at( + quote, + GCP_PROVENANCE_REGISTRY_URL, + Instant::now(), + ) } - fn verify_provenance_with_registry_url_sync( + fn verify_provenance_with_registry_url_sync_at( &self, quote: &Quote, registry_url: &str, + now: Instant, ) -> Result<(), GcpProvenanceError> { let ppid = extract_ppid_from_quote(quote)?; { @@ -54,7 +69,24 @@ impl GcpProvenanceChecker { .known_gcp_ppids .read() .map_err(|err| GcpProvenanceError::CacheLock(err.to_string()))?; - if known_gcp_ppids.contains(&ppid) { + if let Some(stored_at) = known_gcp_ppids.get(&ppid) && + is_cache_entry_fresh(*stored_at, now) + { + return Ok(()); + } + } + + { + let mut known_gcp_ppids = self + .known_gcp_ppids + .write() + .map_err(|err| GcpProvenanceError::CacheLock(err.to_string()))?; + if known_gcp_ppids + .get(&ppid) + .is_some_and(|stored_at| !is_cache_entry_fresh(*stored_at, now)) + { + known_gcp_ppids.remove(&ppid); + } else if known_gcp_ppids.contains_key(&ppid) { return Ok(()); } } @@ -67,12 +99,16 @@ impl GcpProvenanceChecker { self.known_gcp_ppids .write() .map_err(|err| GcpProvenanceError::CacheLock(err.to_string()))? - .insert(ppid); + .insert(ppid, now); Ok(()) } } +fn is_cache_entry_fresh(stored_at: Instant, now: Instant) -> bool { + now.checked_duration_since(stored_at).is_some_and(|age| age <= GCP_PROVENANCE_CACHE_TTL) +} + fn extract_ppid_from_quote(quote: &Quote) -> Result, GcpProvenanceError> { let cert_chain = intel::extract_cert_chain(quote) .map_err(|err| GcpProvenanceError::PpidExtraction(err.to_string()))?; @@ -149,6 +185,7 @@ mod tests { io::{Read as _, Write as _}, net::SocketAddr, thread, + time::{Duration, Instant}, }; use super::*; @@ -181,6 +218,36 @@ mod tests { (addr, handle) } + fn spawn_test_registry_server_n( + status: u16, + body: impl Into, + expected_requests: usize, + ) -> (SocketAddr, thread::JoinHandle>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = body.into(); + + let handle = thread::spawn(move || { + let mut requests = Vec::with_capacity(expected_requests); + for _ in 0..expected_requests { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0u8; 1024]; + let bytes_read = stream.read(&mut buf).unwrap(); + let request = String::from_utf8_lossy(&buf[..bytes_read]).to_string(); + let status_text = if status == 200 { "OK" } else { "Not Found" }; + let response = format!( + "HTTP/1.1 {status} {status_text}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + requests.push(request); + } + requests + }); + + (addr, handle) + } + #[test] fn extracts_ppid_from_mock_tdx_quote() { let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); @@ -210,7 +277,11 @@ mod tests { ); GcpProvenanceChecker::new() - .verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) + .verify_provenance_with_registry_url_sync_at( + "e, + &format!("http://{addr}"), + Instant::now(), + ) .unwrap(); let request = request_handle.join().unwrap(); @@ -228,13 +299,47 @@ mod tests { let checker = GcpProvenanceChecker::new(); let registry_url = format!("http://{addr}"); - checker.verify_provenance_with_registry_url_sync("e, ®istry_url).unwrap(); - checker.verify_provenance_with_registry_url_sync("e, ®istry_url).unwrap(); + checker + .verify_provenance_with_registry_url_sync_at("e, ®istry_url, Instant::now()) + .unwrap(); + checker + .verify_provenance_with_registry_url_sync_at("e, ®istry_url, Instant::now()) + .unwrap(); let request = request_handle.join().unwrap(); assert!(request.starts_with(&format!("GET /{MOCK_PPID_HEX} HTTP/1.1"))); } + #[test] + fn provenance_check_revalidates_stale_cached_ppids() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let quote = Quote::parse(&attestation).unwrap(); + let (addr, request_handle) = spawn_test_registry_server_n( + 200, + r#"{"zone":"projects/test/zones/us-central1-a","timestamp":"2026-06-11T00:00:00Z"}"#, + 2, + ); + let checker = GcpProvenanceChecker::new(); + let registry_url = format!("http://{addr}"); + let cached_at = Instant::now(); + let stale_now = cached_at + GCP_PROVENANCE_CACHE_TTL + Duration::from_secs(1); + + checker + .verify_provenance_with_registry_url_sync_at("e, ®istry_url, cached_at) + .unwrap(); + checker + .verify_provenance_with_registry_url_sync_at("e, ®istry_url, stale_now) + .unwrap(); + + let requests = request_handle.join().unwrap(); + assert_eq!(requests.len(), 2); + assert!( + requests + .iter() + .all(|request| request.starts_with(&format!("GET /{MOCK_PPID_HEX} HTTP/1.1"))) + ); + } + #[test] fn provenance_check_fails_closed_on_registry_miss() { let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); @@ -242,7 +347,11 @@ mod tests { let (addr, request_handle) = spawn_test_registry_server(404, "not found"); let err = GcpProvenanceChecker::new() - .verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) + .verify_provenance_with_registry_url_sync_at( + "e, + &format!("http://{addr}"), + Instant::now(), + ) .unwrap_err(); request_handle.join().unwrap(); @@ -256,7 +365,11 @@ mod tests { let (addr, request_handle) = spawn_test_registry_server(200, r#"{"zone":""}"#); let err = GcpProvenanceChecker::new() - .verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) + .verify_provenance_with_registry_url_sync_at( + "e, + &format!("http://{addr}"), + Instant::now(), + ) .unwrap_err(); request_handle.join().unwrap(); @@ -271,7 +384,11 @@ mod tests { let (addr, request_handle) = spawn_test_registry_server(200, oversized_body); let err = GcpProvenanceChecker::new() - .verify_provenance_with_registry_url_sync("e, &format!("http://{addr}")) + .verify_provenance_with_registry_url_sync_at( + "e, + &format!("http://{addr}"), + Instant::now(), + ) .unwrap_err(); request_handle.join().unwrap(); From 157bf2cd426d5d84caae96608b74e7425ed53846 Mon Sep 17 00:00:00 2001 From: peg Date: Tue, 23 Jun 2026 10:29:36 +0200 Subject: [PATCH 08/21] Improve timestamp check --- crates/attestation/src/gcp.rs | 56 +++++++---------------------------- 1 file changed, 10 insertions(+), 46 deletions(-) diff --git a/crates/attestation/src/gcp.rs b/crates/attestation/src/gcp.rs index 98c982d..fe328dc 100644 --- a/crates/attestation/src/gcp.rs +++ b/crates/attestation/src/gcp.rs @@ -96,10 +96,11 @@ impl GcpProvenanceChecker { let document = fetch_provenance_document(&provenance_url)?; validate_provenance_document(&document)?; + let fetched_at = Instant::now(); self.known_gcp_ppids .write() .map_err(|err| GcpProvenanceError::CacheLock(err.to_string()))? - .insert(ppid, now); + .insert(ppid, fetched_at); Ok(()) } @@ -218,36 +219,6 @@ mod tests { (addr, handle) } - fn spawn_test_registry_server_n( - status: u16, - body: impl Into, - expected_requests: usize, - ) -> (SocketAddr, thread::JoinHandle>) { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let body = body.into(); - - let handle = thread::spawn(move || { - let mut requests = Vec::with_capacity(expected_requests); - for _ in 0..expected_requests { - let (mut stream, _) = listener.accept().unwrap(); - let mut buf = [0u8; 1024]; - let bytes_read = stream.read(&mut buf).unwrap(); - let request = String::from_utf8_lossy(&buf[..bytes_read]).to_string(); - let status_text = if status == 200 { "OK" } else { "Not Found" }; - let response = format!( - "HTTP/1.1 {status} {status_text}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).unwrap(); - requests.push(request); - } - requests - }); - - (addr, handle) - } - #[test] fn extracts_ppid_from_mock_tdx_quote() { let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); @@ -314,30 +285,23 @@ mod tests { fn provenance_check_revalidates_stale_cached_ppids() { let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); let quote = Quote::parse(&attestation).unwrap(); - let (addr, request_handle) = spawn_test_registry_server_n( + let (addr, request_handle) = spawn_test_registry_server( 200, r#"{"zone":"projects/test/zones/us-central1-a","timestamp":"2026-06-11T00:00:00Z"}"#, - 2, ); let checker = GcpProvenanceChecker::new(); let registry_url = format!("http://{addr}"); - let cached_at = Instant::now(); - let stale_now = cached_at + GCP_PROVENANCE_CACHE_TTL + Duration::from_secs(1); + let ppid = extract_ppid_from_quote("e).unwrap(); + let stale_at = Instant::now() - (GCP_PROVENANCE_CACHE_TTL + Duration::from_secs(1)); + + checker.known_gcp_ppids.write().unwrap().insert(ppid, stale_at); checker - .verify_provenance_with_registry_url_sync_at("e, ®istry_url, cached_at) - .unwrap(); - checker - .verify_provenance_with_registry_url_sync_at("e, ®istry_url, stale_now) + .verify_provenance_with_registry_url_sync_at("e, ®istry_url, Instant::now()) .unwrap(); - let requests = request_handle.join().unwrap(); - assert_eq!(requests.len(), 2); - assert!( - requests - .iter() - .all(|request| request.starts_with(&format!("GET /{MOCK_PPID_HEX} HTTP/1.1"))) - ); + let request = request_handle.join().unwrap(); + assert!(request.starts_with(&format!("GET /{MOCK_PPID_HEX} HTTP/1.1"))); } #[test] From 034c1f65d473f4ec34d404ef5cf39c9e64632bf4 Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 6 Jul 2026 12:34:12 +0200 Subject: [PATCH 09/21] Minor code quality fixes --- crates/attestation/src/gcp.rs | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/crates/attestation/src/gcp.rs b/crates/attestation/src/gcp.rs index fe328dc..5cfdcb9 100644 --- a/crates/attestation/src/gcp.rs +++ b/crates/attestation/src/gcp.rs @@ -17,8 +17,11 @@ const GCP_PROVENANCE_REGISTRY_URL: &str = const GCP_PROVENANCE_DOCUMENT_MAX_BYTES: u64 = 16 * 1024; /// How long a cached PPID remains trusted before revalidation const GCP_PROVENANCE_CACHE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60); +/// Overall timeout for fetching a provenance document (covers DNS, connect, +/// TLS handshake and read) +const GCP_PROVENANCE_FETCH_TIMEOUT: Duration = Duration::from_secs(5); -/// Checks PPIDs extracted from DCAP quotes against Googles public bucket, +/// Checks PPIDs extracted from DCAP quotes against Google's public bucket, /// to establish whether this is a GCP machine #[derive(Clone, Debug)] pub(crate) struct GcpProvenanceChecker { @@ -33,18 +36,29 @@ impl GcpProvenanceChecker { /// Given a DCAP TDX quote, check if the associated PPID has a /// 'provenance document' from GCP + /// + /// If a tokio runtime is available the blocking fetch 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> { - let now = Instant::now(); - let checker = self.clone(); - tokio::task::spawn_blocking(move || { - checker.verify_provenance_with_registry_url_sync_at( + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let checker = self.clone(); + handle + .spawn_blocking(move || { + checker.verify_provenance_with_registry_url_sync_at( + "e, + GCP_PROVENANCE_REGISTRY_URL, + Instant::now(), + ) + }) + .await + .map_err(|err| GcpProvenanceError::TaskJoin(err.to_string()))? + } else { + self.verify_provenance_with_registry_url_sync_at( "e, GCP_PROVENANCE_REGISTRY_URL, - now, + Instant::now(), ) - }) - .await - .map_err(|err| GcpProvenanceError::TaskJoin(err.to_string()))? + } } /// Given a DCAP TDX quote, check if the associated PPID has a @@ -125,7 +139,7 @@ fn extract_ppid_from_quote(quote: &Quote) -> Result, GcpProvenanceError> } fn fetch_provenance_document(url: &str) -> Result { - let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(2)).build(); + let agent = ureq::AgentBuilder::new().timeout(GCP_PROVENANCE_FETCH_TIMEOUT).build(); let response = agent.get(url).call().map_err(|err| GcpProvenanceError::RegistryFetch(err.to_string()))?; From 355cc900178c25cd0f4cecb56e55313a961c1d7f Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 6 Jul 2026 12:36:58 +0200 Subject: [PATCH 10/21] Revert changes from merge commit which are unrelated to this PR --- crates/attestation/src/azure/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/attestation/src/azure/mod.rs b/crates/attestation/src/azure/mod.rs index 4c8aecc..42c88ed 100644 --- a/crates/attestation/src/azure/mod.rs +++ b/crates/attestation/src/azure/mod.rs @@ -802,7 +802,7 @@ mod tests { /// Verify a complete observed Azure attestation payload that includes /// AK intermediates fetched from the leaf certificate's AIA URLs. #[tokio::test] - async fn test_verify_with_ak_intermediates() { + async fn test_verify() { // generated using [capture_azure_fixture] above. let attestation_bytes: &'static [u8] = include_bytes!("../../test-assets/azure-tdx-with-ak-intermediates-1780922561.yaml"); From 18b5780d7a7cefd9aff6aed8ec804f49bb4dddb1 Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 6 Jul 2026 12:53:44 +0200 Subject: [PATCH 11/21] Improve code readability on cache check and tokio runtime check --- crates/attestation/src/gcp.rs | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/crates/attestation/src/gcp.rs b/crates/attestation/src/gcp.rs index 5cfdcb9..0dcc328 100644 --- a/crates/attestation/src/gcp.rs +++ b/crates/attestation/src/gcp.rs @@ -43,21 +43,11 @@ impl GcpProvenanceChecker { if let Ok(handle) = tokio::runtime::Handle::try_current() { let checker = self.clone(); handle - .spawn_blocking(move || { - checker.verify_provenance_with_registry_url_sync_at( - "e, - GCP_PROVENANCE_REGISTRY_URL, - Instant::now(), - ) - }) + .spawn_blocking(move || checker.verify_provenance_sync("e)) .await .map_err(|err| GcpProvenanceError::TaskJoin(err.to_string()))? } else { - self.verify_provenance_with_registry_url_sync_at( - "e, - GCP_PROVENANCE_REGISTRY_URL, - Instant::now(), - ) + self.verify_provenance_sync("e) } } @@ -90,18 +80,18 @@ impl GcpProvenanceChecker { } } + // Re-check under the write lock in case another thread refreshed the + // entry while we were waiting, and drop stale entries so we refetch. { let mut known_gcp_ppids = self .known_gcp_ppids .write() .map_err(|err| GcpProvenanceError::CacheLock(err.to_string()))?; - if known_gcp_ppids - .get(&ppid) - .is_some_and(|stored_at| !is_cache_entry_fresh(*stored_at, now)) - { + if let Some(stored_at) = known_gcp_ppids.get(&ppid) { + if is_cache_entry_fresh(*stored_at, now) { + return Ok(()); + } known_gcp_ppids.remove(&ppid); - } else if known_gcp_ppids.contains_key(&ppid) { - return Ok(()); } } From 8817287e9381147f3f26d94a04feff80adf96bd3 Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 17 Aug 2026 09:17:26 +0200 Subject: [PATCH 12/21] Fixes to match Go implementation --- crates/attestation/src/gcp.rs | 61 +++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/crates/attestation/src/gcp.rs b/crates/attestation/src/gcp.rs index 0dcc328..9bc0298 100644 --- a/crates/attestation/src/gcp.rs +++ b/crates/attestation/src/gcp.rs @@ -15,18 +15,21 @@ const GCP_PROVENANCE_REGISTRY_URL: &str = /// Maximum size in bytes of GCP provenance documents const GCP_PROVENANCE_DOCUMENT_MAX_BYTES: u64 = 16 * 1024; +/// PPIDs in Intel PCK certificates are 128-bit values +const GCP_PPID_BYTES: usize = 16; /// How long a cached PPID remains trusted before revalidation const GCP_PROVENANCE_CACHE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60); /// Overall timeout for fetching a provenance document (covers DNS, connect, /// TLS handshake and read) -const GCP_PROVENANCE_FETCH_TIMEOUT: Duration = Duration::from_secs(5); +/// This matches the timeout in Google's Go provenance checker tool +const GCP_PROVENANCE_FETCH_TIMEOUT: Duration = Duration::from_secs(30); /// Checks PPIDs extracted from DCAP quotes against Google's public bucket, /// to establish whether this is a GCP machine #[derive(Clone, Debug)] pub(crate) struct GcpProvenanceChecker { /// Cached entries with retrieval timestamp - known_gcp_ppids: Arc, Instant>>>, + known_gcp_ppids: Arc>>, } impl GcpProvenanceChecker { @@ -73,8 +76,8 @@ impl GcpProvenanceChecker { .known_gcp_ppids .read() .map_err(|err| GcpProvenanceError::CacheLock(err.to_string()))?; - if let Some(stored_at) = known_gcp_ppids.get(&ppid) && - is_cache_entry_fresh(*stored_at, now) + if let Some(stored_at) = known_gcp_ppids.get(&ppid) + && is_cache_entry_fresh(*stored_at, now) { return Ok(()); } @@ -114,7 +117,7 @@ fn is_cache_entry_fresh(stored_at: Instant, now: Instant) -> bool { now.checked_duration_since(stored_at).is_some_and(|age| age <= GCP_PROVENANCE_CACHE_TTL) } -fn extract_ppid_from_quote(quote: &Quote) -> Result, GcpProvenanceError> { +fn extract_ppid_from_quote(quote: &Quote) -> Result<[u8; GCP_PPID_BYTES], GcpProvenanceError> { let cert_chain = intel::extract_cert_chain(quote) .map_err(|err| GcpProvenanceError::PpidExtraction(err.to_string()))?; let leaf = cert_chain.first().ok_or(GcpProvenanceError::NoPckCertificate)?; @@ -124,14 +127,32 @@ fn extract_ppid_from_quote(quote: &Quote) -> Result, GcpProvenanceError> if extension.ppid.is_empty() { return Err(GcpProvenanceError::EmptyPpid); } - - Ok(extension.ppid) + extension + .ppid + .try_into() + .map_err(|ppid: Vec| GcpProvenanceError::InvalidPpidLength(ppid.len())) } +/// Synchronously attempt to fetch provenance document fn fetch_provenance_document(url: &str) -> Result { let agent = ureq::AgentBuilder::new().timeout(GCP_PROVENANCE_FETCH_TIMEOUT).build(); - let response = - agent.get(url).call().map_err(|err| GcpProvenanceError::RegistryFetch(err.to_string()))?; + let response = match agent.get(url).call() { + Ok(response) => response, + Err(ureq::Error::Status(status, _)) => { + return Err(GcpProvenanceError::RegistryFetch(format!("HTTP status {status}"))); + } + Err(err) => { + tracing::warn!(url, error = %err, "GCP provenance registry unavailable"); + return Err(GcpProvenanceError::RegistryUnavailable(err.to_string())); + } + }; + + if response.status() != 200 { + return Err(GcpProvenanceError::RegistryFetch(format!( + "unexpected HTTP status {}", + response.status() + ))); + } let mut limited_reader = response.into_reader().take(GCP_PROVENANCE_DOCUMENT_MAX_BYTES + 1); let mut document = String::new(); @@ -168,10 +189,14 @@ pub enum GcpProvenanceError { NoPckCertificate, #[error("PPID is empty")] EmptyPpid, + #[error("PPID has invalid length: {0} bytes (expected 16)")] + InvalidPpidLength(usize), #[error("PPID extraction: {0}")] PpidExtraction(String), #[error("registry fetch: {0}")] RegistryFetch(String), + #[error("registry unavailable: {0}")] + RegistryUnavailable(String), #[error("provenance document is invalid")] InvalidDocument, #[error("provenance document exceeds maximum size")] @@ -326,6 +351,24 @@ mod tests { assert!(matches!(err, GcpProvenanceError::RegistryFetch(_))); } + #[test] + fn provenance_check_rejects_non_200_success_status() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let quote = Quote::parse(&attestation).unwrap(); + let (addr, request_handle) = spawn_test_registry_server(201, "created"); + + let err = GcpProvenanceChecker::new() + .verify_provenance_with_registry_url_sync_at( + "e, + &format!("http://{addr}"), + Instant::now(), + ) + .unwrap_err(); + + request_handle.join().unwrap(); + assert!(matches!(err, GcpProvenanceError::RegistryFetch(_))); + } + #[test] fn provenance_check_fails_closed_on_invalid_document() { let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); From 34346c6ea4ccf3e7b6772de7ecd1005bfa5653ec Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 17 Aug 2026 09:46:52 +0200 Subject: [PATCH 13/21] Verifier should return quote to avoid parsing it twice --- crates/attestation/src/dcap.rs | 34 +++++++++++++++++----------------- crates/attestation/src/lib.rs | 10 +++------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/crates/attestation/src/dcap.rs b/crates/attestation/src/dcap.rs index fc88f22..832822f 100644 --- a/crates/attestation/src/dcap.rs +++ b/crates/attestation/src/dcap.rs @@ -34,7 +34,7 @@ pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Option, -) -> Result { +) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { 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( @@ -59,7 +59,7 @@ pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result { +) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { 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( @@ -85,7 +85,7 @@ pub fn verify_dcap_attestation_with_timestamp_sync( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); @@ -119,7 +119,7 @@ pub async fn verify_dcap_attestation_with_given_timestamp( collateral: Option, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { let quote = Quote::parse(&input)?; let ca = quote_ca("e)?.as_id_str(); @@ -153,7 +153,7 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( collateral: QuoteCollateralV3, now: u64, override_azure_outdated_tcb: bool, -) -> Result { +) -> Result<(MultiMeasurements, Quote), DcapVerificationError> { tracing::info!("Verifying DCAP attestation: {quote:?}"); let fmspc = hex::encode_upper(quote_fmspc("e)?); @@ -194,11 +194,11 @@ fn verify_dcap_attestation_with_collateral_and_timestamp( let measurements = MultiMeasurements::from_dcap_qvl_quote("e)?; - if get_quote_input_data(quote.report) != expected_input_data { + if get_quote_input_data("e.report) != expected_input_data { return Err(DcapVerificationError::InputMismatch); } - Ok(measurements) + Ok((measurements, quote)) } #[cfg(any(test, feature = "mock"))] @@ -206,7 +206,7 @@ pub async fn verify_dcap_attestation( input: Vec, expected_input_data: [u8; 64], pccs: Option, -) -> Result { +) -> 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)?); @@ -221,11 +221,11 @@ pub async fn verify_dcap_attestation( verifier.verify(&input, &collateral, now)?; let measurements = MultiMeasurements::from_dcap_qvl_quote("e)?; - if get_quote_input_data(quote.report) != expected_input_data { + if get_quote_input_data("e.report) != expected_input_data { return Err(DcapVerificationError::InputMismatch); } - Ok(measurements) + Ok((measurements, quote)) } #[cfg(any(test, feature = "mock"))] @@ -233,7 +233,7 @@ pub fn verify_dcap_attestation_sync( input: Vec, expected_input_data: [u8; 64], pccs: Pccs, -) -> Result { +) -> 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)?); @@ -243,10 +243,10 @@ pub fn verify_dcap_attestation_sync( verifier.verify(&input, &collateral, now)?; let measurements = MultiMeasurements::from_dcap_qvl_quote("e)?; - if get_quote_input_data(quote.report.clone()) != expected_input_data { + if get_quote_input_data("e.report) != expected_input_data { return Err(DcapVerificationError::InputMismatch); } - Ok(measurements) + Ok((measurements, quote)) } /// Create a mock quote for testing on non-confidential hardware @@ -262,7 +262,7 @@ fn generate_quote(input: [u8; 64]) -> Result, AttestationError> { } /// Given a [Report] get the input data regardless of report type -pub fn get_quote_input_data(report: Report) -> [u8; 64] { +pub fn get_quote_input_data(report: &Report) -> [u8; 64] { match report { Report::TD10(r) => r.report_data, Report::TD15(r) => r.base.report_data, @@ -326,7 +326,7 @@ mod tests { let async_collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); let sync_collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); - let async_measurements = verify_dcap_attestation_with_given_timestamp( + let (async_measurements, _) = 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, @@ -342,7 +342,7 @@ mod tests { .await .unwrap(); - let sync_measurements = verify_dcap_attestation_with_timestamp_sync( + let (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, @@ -404,7 +404,7 @@ mod tests { let expected_input_data = [0xA5; 64]; let quote = create_dcap_attestation(expected_input_data).unwrap(); - let measurements = + let (measurements, _) = verify_dcap_attestation(quote, expected_input_data, Some(pccs)).await.unwrap(); assert_eq!(measurements, crate::measurements::mock_dcap_measurements()); diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index e47b2ef..3d08e9a 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -494,15 +494,13 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - let measurements = dcap::verify_dcap_attestation( + let (measurements, quote) = dcap::verify_dcap_attestation( attestation_evidence.quote.clone(), expected_input_data, self.internal_pccs.clone(), ) .await?; if attestation_type == AttestationType::GcpTdx { - let quote = dcap_qvl::quote::Quote::parse(&attestation_evidence.quote) - .map_err(DcapVerificationError::from)?; self.gcp_provenance_checker.verify_provenance(quote).await?; } measurements @@ -578,14 +576,12 @@ impl AttestationVerifier { #[cfg(not(any(test, feature = "mock")))] let pccs = self.internal_pccs.clone().ok_or(AttestationError::NoPccs)?; - let measurements = dcap::verify_dcap_attestation_sync( + let (measurements, quote) = dcap::verify_dcap_attestation_sync( attestation_evidence.quote.clone(), expected_input_data, pccs, )?; if attestation_type == AttestationType::GcpTdx { - let quote = dcap_qvl::quote::Quote::parse(&attestation_evidence.quote) - .map_err(DcapVerificationError::from)?; self.gcp_provenance_checker.verify_provenance_sync("e)?; } measurements @@ -787,7 +783,7 @@ mod tests { let quote = dcap::create_dcap_attestation(input_data).unwrap(); let attestation_evidence = AttestationEvidence { quote, - platform: mock_platform_metadata(AttestationType::GcpTdx).unwrap(), + platform: mock_platform_metadata(AttestationType::DcapTdx).unwrap(), }; let mock_pcs_server = spawn_mock_pcs_server(MockPcsConfig::default()).await.unwrap(); From b4b0aa336d2a09329cd7c726a2a8db5732d0f31f Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 17 Aug 2026 09:58:32 +0200 Subject: [PATCH 14/21] MeasurementRecord must track attestation type for GCP provenance check to be enforced --- crates/attestation/src/measurements.rs | 63 ++++++++++++++++++++++--- quote.bin | Bin 8000 -> 0 bytes quote_shell.bin | Bin 8000 -> 0 bytes 3 files changed, 56 insertions(+), 7 deletions(-) delete mode 100644 quote.bin delete mode 100644 quote_shell.bin diff --git a/crates/attestation/src/measurements.rs b/crates/attestation/src/measurements.rs index 878ce87..1c2d6b5 100644 --- a/crates/attestation/src/measurements.rs +++ b/crates/attestation/src/measurements.rs @@ -319,6 +319,8 @@ pub struct MeasurementRecord { /// An identifier, for example the name and version of the corresponding /// OS image pub measurement_id: String, + /// The attestation type this record accepts + pub attestation_type: AttestationType, /// The expected measurement register values pub measurements: ExpectedMeasurements, } @@ -327,6 +329,7 @@ impl MeasurementRecord { pub fn allow_no_attestation() -> Self { Self { measurement_id: "Allow no attestation".to_string(), + attestation_type: AttestationType::None, measurements: ExpectedMeasurements::NoAttestation, } } @@ -334,6 +337,7 @@ impl MeasurementRecord { pub fn allow_any_measurement(attestation_type: AttestationType) -> Self { Self { measurement_id: format!("Any measurement for {attestation_type}"), + attestation_type, measurements: match attestation_type { AttestationType::None => ExpectedMeasurements::NoAttestation, AttestationType::AzureTdx => ExpectedMeasurements::Azure(HashMap::new()), @@ -401,6 +405,7 @@ impl MeasurementPolicy { Self { accepted_measurements: vec![MeasurementRecord { measurement_id: "test".to_string(), + attestation_type: AttestationType::DcapTdx, measurements: ExpectedMeasurements::Dcap(HashMap::from([ (DcapMeasurementRegister::MRTD, vec![mock_tdx::MOCK_MRTD]), (DcapMeasurementRegister::RTMR0, vec![mock_tdx::MOCK_RTMR0]), @@ -431,9 +436,20 @@ impl MeasurementPolicy { platform_metadata: Option<&PlatformMetadata>, known_gcp_firmware: Option<&GcpFirmwareCache>, ) -> Result<(), AttestationError> { + let attestation_type = platform_metadata + .map(|metadata| metadata.attestation_type.into()) + .unwrap_or_else(|| match measurements { + MultiMeasurements::Dcap(_) => AttestationType::DcapTdx, + MultiMeasurements::Azure(_) => AttestationType::AzureTdx, + MultiMeasurements::NoAttestation => AttestationType::None, + }); + if self.accepted_measurements.iter().any(|measurement_record| match measurements { MultiMeasurements::Dcap(dcap_measurements) => match &measurement_record.measurements { ExpectedMeasurements::Dcap(expected) => { + if measurement_record.attestation_type != attestation_type { + return false; + } // All measurements in our policy must be given and must match for (k, v) in expected.iter() { let actual_value = dcap_measurements.get(k); @@ -443,15 +459,21 @@ impl MeasurementPolicy { } true } - ExpectedMeasurements::Image(image_hashes) => compare_portable_dcap_measurement( - image_hashes, - dcap_measurements, - platform_metadata, - known_gcp_firmware, - ), + ExpectedMeasurements::Image(image_hashes) => { + measurement_record.attestation_type == attestation_type && + compare_portable_dcap_measurement( + image_hashes, + dcap_measurements, + platform_metadata, + known_gcp_firmware, + ) + } ExpectedMeasurements::Azure(_) | ExpectedMeasurements::NoAttestation => false, }, MultiMeasurements::Azure(azure_measurements) => { + if measurement_record.attestation_type != attestation_type { + return false; + } if let ExpectedMeasurements::Azure(expected) = &measurement_record.measurements { for (k, v) in expected.iter() { match azure_measurements.get(k) { @@ -464,7 +486,11 @@ impl MeasurementPolicy { false } MultiMeasurements::NoAttestation => { - matches!(measurement_record.measurements, ExpectedMeasurements::NoAttestation) + measurement_record.attestation_type == attestation_type && + matches!( + measurement_record.measurements, + ExpectedMeasurements::NoAttestation + ) } }) { Ok(()) @@ -591,6 +617,7 @@ impl MeasurementPolicy { if let Some(azure) = portable.azure { measurement_policy.push(MeasurementRecord { measurement_id: String::new(), + attestation_type: AttestationType::AzureTdx, measurements: ExpectedMeasurements::Azure(HashMap::from([ (4, vec![azure.pcr4]), (9, vec![azure.pcr9]), @@ -601,12 +628,14 @@ impl MeasurementPolicy { measurement_policy.push(MeasurementRecord { measurement_id: String::new(), + attestation_type: AttestationType::GcpTdx, measurements: ExpectedMeasurements::Image(portable.dcap), }); } MeasurementOutput::Azure(azure) => { measurement_policy.push(MeasurementRecord { measurement_id: String::new(), + attestation_type: AttestationType::AzureTdx, measurements: ExpectedMeasurements::Azure(HashMap::from([ (4, vec![azure.pcr4]), (9, vec![azure.pcr9]), @@ -683,6 +712,7 @@ impl MeasurementPolicy { measurement_policy.push(MeasurementRecord { measurement_id: record.measurement_id.unwrap_or_default(), + attestation_type, measurements: expected_measurements, }); } @@ -974,6 +1004,24 @@ mod tests { )); } + #[test] + fn gcp_policy_rejects_dcap_labeled_measurements() { + let policy = MeasurementPolicy::single_attestation_type(AttestationType::GcpTdx); + let measurements = mock_dcap_measurements(); + let gcp_metadata = PlatformMetadata { + attestation_type: attest_types::AttestationType::GcpTdx, + ram_bytes: 0, + num_disks: 0, + acpi: None, + }; + + policy.check_measurement(&measurements, Some(&gcp_metadata)).unwrap(); + assert!(matches!( + policy.check_measurement(&measurements, None).unwrap_err(), + AttestationError::MeasurementsNotAccepted + )); + } + #[test] fn test_gcp_image_hash_measurement_policy_accepts_matching_measurements() { fn decode_hash(input: &str) -> [u8; 48] { @@ -1001,6 +1049,7 @@ mod tests { let policy = MeasurementPolicy { accepted_measurements: vec![MeasurementRecord { measurement_id: "image-hash-policy".to_string(), + attestation_type: AttestationType::GcpTdx, measurements: ExpectedMeasurements::Image(image_hashes.clone()), }], }; diff --git a/quote.bin b/quote.bin deleted file mode 100644 index 20ab36edfaf599e7cca40dc4012fb74f198befe7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8000 zcmeHKd$1GL8NUUgx)l&bVQf2c*}O@S>N&fc&F#%Dk$0qQs7!zMX;7CwTh*_I^|JtM41uWvFKQ+Vga>MdXjsu7zVrLe@7vQlMs?iO(f|BtS+D)@L);_xV&m4|cjMTv55PmdCVUMW@_$W2 zK7aD}UfH(r?Fk;!fR{hemA}J%pdH! zc>g6wZ+Nxu@x5d28MmhRQT4&8%VP^~TKxJ`Pt4x%j+DC6zH;n}g5B+9m z?#Mr>Q^jCT)$-3)3did@-OSgR}O!)YUk|Pcke#)^0%i=@~_kntlx6w z+$BZVkEcER+7dPR1~(bk% zl2?owJ7XLB4d;IKv8_k0+PKemuw%?~v--ZXK6}l&YaSlA_|Ts3uexj5pPubF{UxG! z?VjrSy%(>VIB`?o`h2kOnKS;e7F^mpZvONKuGjya+B3F;LSA6l(Ql4D+R;y|lSsq4 z19SEce80V7^{-#UH{Cbueu>kS6)OU}9q-x?AG~7`cI%eYZ^^yJFym>kjNZB60de&? z>()NC@sp3A|KYdqe1F=#!QZ=HJvyvW8<^R@<(aF~%av}bW=-MNFIjr$FK#;WUiW#OPfR}Y*9+R#8vf^) zj%&tubX?YaAVo9&3kXUKFkSu5R+~%D(q)6t6sD^fW6Ug0q4@@;l2nE#01d2R1Q;2SSLkr_ch4DgCYCs`D zd+}J_(@02=6hc&-hgxesp&24JXdu`;4VCsZl&IK~usP~p5=c<<%MhqMVfCjI1u@M~ z{<0Ef)4YH@5foAu1O_7k+gz37L9VqbO!I^+^M)_2`uVJ41W17MSzry2f)QzACO-tj zB3Wqg5@Rr+wIWGEokhFp2?B;htEPo1KZkbK)q>a)%j;R>q0Wm%gYKW-g^|!ZXh0_H ztbso1B(2vHSt`s!rr#^W;KDXzqVt4$jQ6oq-r#B2ynuOv2vQWN{`HBVmT82`yTJYFh6p zCVirzn#CGt^BQr>mH5`tioxNy)8peP|I2;D| zAsi*B-_!OG7&Jf+4zz{9wi~qJT}^zr5V#W<@WbKF5d6apAUtpaJlKx6haj{Mw7DuG zoa5ol<4;F|5U>~_Fa!h?fExJhY2^W(@v%jCbOMM-P-va~FH5$ODJLs;80SplgWg;|z?enLiLLm4o zgLqpGR05zf7#uj}rb@O9K=HF12?8VpxC(emATS29MW@ACcE)35pkxMIvR$rqr)DZz zDdJAsL!>7}s5;jJVN3@ZWEz((jKM;?GiL4N*%W4pk}xIr5-y38tc56)V(zdRt>!di z_H3*rUX-8wyr8v4PAqnW-mJH*ZBXoFz{<6#YV**Yz8--pu-2epnwmFCc${JWxZp6J z*6MT?D9zgdxy(~x9bM>hneqC45dumHn+Hcq0o&D~T^z74qYmxjfT-2dr~!2VDX)Ro zk-%B`ELG>pu)&959APGJ4#jvMs54ohA!_xzP7ifm5b&}Vrt3(zVl_37YXVNSgc5M5Q7|a2<&}>Fm5_q&Zv_cGx{e!4n=BImNj6#(8&o|Aln~J65bwW15qVxikcHAq^EID4Z+rEaUp zA+F>~cja7WXE-Cqh(bwp_ml%Qt>jHfbv+-_SshD-@)=u1G3})QteOO@eoo+oOiVB% zksch+bO*=^D@!#iNCYI6?j@nrN}>-5oMb|lr&5|2hh#eEZ4}l(Mn+QVy<~*TheZd$c>GvB!;c&hlaO6wnhLY7`To1`g-5a!(S=i+?^{xQKzD&LL zH*fvrihY@SZSPGn^mfzVoStO89?w#&ou(&Sul?mpwP=mCy?~Jts$o%M(jXAAIE9?p zDS3#DM5_K`(cr2XkqnGvU?c-085qgHNCrkS@PCzo F{{klq+r$6> diff --git a/quote_shell.bin b/quote_shell.bin deleted file mode 100644 index 7584bc69d0b6c7d0090bc09fe9f4d4e14bfbb7e7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8000 zcmeHKeXtYN6@Nl`)g3`m&}wyR(YE4Kv-x@-E;r2h z`2L*Qn7(IBXVYrqyLX@Qw}&@(Zruepuk$TUi}Nn?#OFS|W+1a+BKOIpccxr?XZ*&# zJ^R+&_RQ6buJ2oRd|P#Nd_Lh4b?o&EADQ`|{j1`?y!CsNCfszzE^^GgiHl5i>|;yk z3_M^J=FIJJT|If5vE#so?Z>_TY3Drqm;0}M;l-CvPnAB{|LB>?6~e2lX8j=jvy*?k zed@ax9lZ9Hm5=Nh_tWvKiXT^g@!k8?xwGfLw*JxS>)tkZ+`pH7ZD93pZqPP$g1H|( zzODbF`#z;S>;CbA<5cP)79_4&wEfBHFFyD$`-UwCK3=|U`t&<@oP6o-DU$-rjD2gj zUNLJ?vHPAWPrtfI3%$d2!E5EZgJ7dj^2gc9;XxHV-?^yEZr#nu3o+w_m zt8#Y#)a4T=Zd$oEA6ogllm59HT--nYw(}QXWBfa{tFwbbUSQb4Z+9N-Xwm8z(lB{& z&Yr>V-Ah;e_Emh-!pnXxafY&VX>f<@9p`}$e=-ldY3qqM=Kl0$OYXS$qM5}7^UkCL zt`(mVd&izKbF%iq`lohkSN`$R3E`ESzs&yYj*mI^&_~?r_UGqZ@FZA5Z=17NTygrE z)$2EW`pGl5oO}EGQx=4N-~GzL5sliw(`LW7{~6nJr!L!9D|%+bTj<9MFgD}aEvr{g zT7DMGgj+BECJnbPc&eB$31zW3RQ<(r?X9H5u!8{g_YxAUcUtn=E|8u{nAju{g= zIxcBEkfNEuc?2bf86QJ{Fx5mco?$4DMG%mU>%e3n(3wawuLoWI8P?)*R;JYB^F<0J zyQAC4(q)s-6{e@CGFBF+(0miqNGihJdDtFg{31btoigAFk%T z^@IdTAxy=2s5j>mx+!8q27<#|*Jy8DiHUs)hpXlzfdut{41vZI_CPvO5Yr45C@V2G z%?rpAK_O*9U@#J}ja4}ww1I=aA;>kD~Nq+-pC>k4PGpobZdSOMnd0^0hw^JCi#Q#3&g{x&H;z!?Dj!D(1_z?pN_IEmC#6qT=;DM`gTuIWTpv@E-9H&hZAjYEjMIM6QX%xr;fbv2F1%++YLb{p` z00D;>L|{*rAV5Yj{kqcAJ_rOspktJccO_&yTdifPy(PUIc4r#%qbN(65{*er!W9-M zv>IdTx-X3Oqlw1ZAI1??D0T##XyDig10)(CM!>0&a18D2Lj%+Z1~kIq2)G~NC_ybx z+e2W`06jF&76RLD(1!Ok@R35`j$*)%gf~J6j5L7o;8E~UJKi3G&_2-Ss)%rohtrNc z9SK6fVuZjD5KsW>;Ly{|13Kfwi}2_K5RstJJp04AKw(fPK*EdwK!Om|i=}ifLw8X) zx;-f$(L4p$5k?9{`O>{Py07ZNZ4j&1f!}b^Jp(2%MKGL+&;pJxo{9#6;I|FoZ8^{g zfXZNK;INw-*)#yfLpKrxNCwt`9=h02yQ&mu-y6LZ@4`ckyfrv&Bf5lKY8niIePw7?e~`#EMmNx;cG1))X%) zKz>osnj=RSJHlwxTh=iob~0e)YD{x@=`MetKowYfNU$v37bCpx2!BLy7*FdpIt!G> zZGc?nsfd9tbh*s<0{$ogrG&$aBc*`t>d-C@*jG`9c5y(|YHHM=I)IeV#A`_4?0lB0 z@npp0!!V98i?@bjydTtT^9trtVie?(k->B<#AoWskTsyheE|rd{wOg z(WKU0%!`6a=}oWvBp3)Q=>Ara(4*_PuC&QA0gz;)MX@2(gFp!bLk{!4oJpCAf3U4U zbUV~yAc|?EpaUu(L7Qj5VK-t@5OWX}tFLByTqzcM98{g8^{Bf)Mhw&(19F%vb*FoB z-Bwp5BdSE9BzpSF!Kz;JrKFmX4;!q3rNa4)BdS==QV>=w0#?2ta6(2EtZ1|k$1}Y_ za)6bksvRVPl1BHFP--U8j|5J#Aj?xJU5rCAo%6K`-0*Hes~?Nl;Vm*XI;Tk9TTLP& zae2|uvLM-%AHhZWbRl9yy-CbU;0agC0FqXSc*#%@$QtJ~1e0u>gouY4wTUXaarvVw zxY>dvsg|ZOJR&fLgbZaGoyqWqjue|W2BXKyaxR0LU72oKp`*@-&*pGc;;|I2uw>RA zNnr)h70#0Oc*d;}451|K63CP8TCO)N_f?CoNU@%glv+O-eP^ptWvdCN za8%MaIFEfD(lwK6p17xJe`-%t?+qDMnjo#^a>$SaHv16@QqPswuJjp0(Fl)<%qGiI0Q#~0EW)Eh95-0l7Q97F_ zK$-283pvGAaWdH)sVE%I*MhEmsa#jG28rv38msYrVG0m1@!&YkL7BB~-(r&ZI#wYI6%Yu}kt28Hv;a z#iGeoLLyg=(!Dk% Date: Mon, 17 Aug 2026 10:05:39 +0200 Subject: [PATCH 15/21] Restore GCP firmware test --- crates/attestation/src/gcp/firmware.rs | 112 +++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/crates/attestation/src/gcp/firmware.rs b/crates/attestation/src/gcp/firmware.rs index 789ce39..a34004a 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -68,3 +68,115 @@ pub(crate) enum GcpFirmwareCacheError { #[error("Firmware fetch task join: {0}")] Join(String), } + +#[cfg(test)] +mod tests { + use attest_measure::dcap::DcapFirmware; + use attest_types::{AcpiHashes, DcapImageHashes}; + use dcap_qvl::quote::Quote; + + use super::GcpFirmwareCache; + use crate::{ + AttestationType, + PlatformMetadata, + dcap::{get_quote_input_data, verify_dcap_attestation_with_given_timestamp}, + measurements::{ExpectedMeasurements, MeasurementPolicy, MeasurementRecord}, + }; + + /// Timestamp used with test fixture. + const GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP: u64 = 1_782_809_233; + + fn create_cache_with_firmware(firmware: DcapFirmware) -> GcpFirmwareCache { + let cache = GcpFirmwareCache::new(); + cache.cache.write().unwrap().insert(firmware.mrtd, firmware); + cache + } + + fn decode_dcap_hash(input: &str) -> [u8; 48] { + hex::decode(input).unwrap().try_into().unwrap() + } + + fn gcp_portable_image_hashes() -> DcapImageHashes { + DcapImageHashes { + uki_authenticode: decode_dcap_hash( + "82500f977e16a1e3fd47db792ac9c9fdd69caa73d8e719fe4489416355f23f5d0863ad796febfc1241bc3e868c3649a6", + ), + kernel_authenticode: decode_dcap_hash( + "b2a6076ae199d325e553a5102cf1f4a18b5e67e36b33261ef20352052199ec5853b5133c0231b16f1198bb086f1cbfac", + ), + cmdline_hash: decode_dcap_hash( + "e03b89abf354a38976537b7a9138fd312e4cbf73b61eebc44086491701b1d167b9f6cb97a922325866c93e0834723d87", + ), + initrd_hash: decode_dcap_hash( + "99251a9997f552ce98364e3f7311ca47471e299b6fdb31226d738a10577959ab741cc2e7b8c268236153de568265d3f2", + ), + gpt_disk_guid_hash: decode_dcap_hash( + "488fa3f08aae01c1a46b497319e8a7d3b7335c9ff4f4d7fe6a3dd62c844b03de22157c0303be58f10e3152687778e68d", + ), + } + } + + fn gcp_portable_platform_metadata() -> PlatformMetadata { + PlatformMetadata { + attestation_type: attest_types::AttestationType::GcpTdx, + ram_bytes: 17_179_869_184, + num_disks: 1, + acpi: Some(AcpiHashes { + loader: decode_dcap_hash( + "f60c35e53bb21b4675cfa8db310ec88e38cd369d8d463acdde815122fa8b893b8896a5783b538856693ed7645ddb897e", + ), + rsdp: decode_dcap_hash( + "509dcfe10beb5d470c40f25e30895370948831b9cf79db15d977e7bba8eb42f7200212071ad8b19d6011759779eced5a", + ), + tables: decode_dcap_hash( + "0bb0afa008873bdc20dee0f741da7896c2bfeee94ae52e9bdbf94bc87c32d04a4b1f1d824490f1dae574ff6d4e4bb0b3", + ), + }), + } + } + + #[tokio::test] + async fn gcp_tdx_portable_policy_with_stored_collateral() { + let attestation_bytes: &'static [u8] = + include_bytes!("../../test-assets/gcp-tdx-1782809233226668671"); + let collateral_bytes: &'static [u8] = + include_bytes!("../../test-assets/gcp-tdx-collateral-1782809233226668671.yaml"); + let firmware_bytes: &'static [u8] = + include_bytes!("../../test-assets/gcp-tdx-firmware-1782809233226668671.yaml"); + + let expected_input_data = { + let quote = Quote::parse(attestation_bytes).unwrap(); + get_quote_input_data("e.report) + }; + + let collateral = serde_saphyr::from_slice(collateral_bytes).unwrap(); + let firmware = serde_saphyr::from_slice(firmware_bytes).unwrap(); + let (measurements, _) = verify_dcap_attestation_with_given_timestamp( + attestation_bytes.to_vec(), + expected_input_data, + None, + Some(collateral), + GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP, + false, + ) + .await + .unwrap(); + + let measurement_policy = MeasurementPolicy { + accepted_measurements: vec![MeasurementRecord { + measurement_id: "gcp-tdx-portable-image-hashes".to_string(), + attestation_type: AttestationType::GcpTdx, + measurements: ExpectedMeasurements::Image(gcp_portable_image_hashes()), + }], + }; + let gcp_firmware_cache = create_cache_with_firmware(firmware); + + measurement_policy + .check_measurement_with_gcp_cache( + &measurements, + Some(&gcp_portable_platform_metadata()), + Some(&gcp_firmware_cache), + ) + .unwrap(); + } +} From 2edc2cfe0ec57487544a7f58c4c665e7533f199a Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 17 Aug 2026 10:12:49 +0200 Subject: [PATCH 16/21] Rm comments changes to firmware checker --- crates/attestation/src/gcp/firmware.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/attestation/src/gcp/firmware.rs b/crates/attestation/src/gcp/firmware.rs index a34004a..9bcc30e 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -8,7 +8,7 @@ use attest_measure::dcap::DcapFirmware; use thiserror::Error; /// Maps MRTD values to GCP firmware to avoid re-fetching on subsequent -/// verification. +/// verification #[derive(Clone, Debug, Default)] pub(crate) struct GcpFirmwareCache { cache: Arc>>, @@ -19,7 +19,7 @@ impl GcpFirmwareCache { Self { cache: Default::default() } } - /// Retrieve firmware from cache or fetch it from Google if absent. + /// Retrieve firmware from cache or fetch if not present pub(crate) fn get_or_fetch( &self, mrtd: [u8; 48], @@ -39,8 +39,8 @@ impl GcpFirmwareCache { } } -/// Fetch firmware from Google, offloading the blocking request when -/// possible. +/// Fetch firmware from Google. If we are running inside a mutli-threaded +/// tokio runtime the blocking HTTP fetch is wrapped in `spawn_blocking` pub(crate) fn fetch_firmware(mrtd: [u8; 48]) -> Result { match tokio::runtime::Handle::try_current() { Ok(handle) @@ -77,15 +77,15 @@ mod tests { use super::GcpFirmwareCache; use crate::{ - AttestationType, - PlatformMetadata, + AttestationType, PlatformMetadata, dcap::{get_quote_input_data, verify_dcap_attestation_with_given_timestamp}, measurements::{ExpectedMeasurements, MeasurementPolicy, MeasurementRecord}, }; - /// Timestamp used with test fixture. + /// Timestamp used with test fixture const GCP_TDX_PORTABLE_FIXTURE_TIMESTAMP: u64 = 1_782_809_233; + /// Create a firmware cache with given firmware loaded fn create_cache_with_firmware(firmware: DcapFirmware) -> GcpFirmwareCache { let cache = GcpFirmwareCache::new(); cache.cache.write().unwrap().insert(firmware.mrtd, firmware); @@ -116,6 +116,7 @@ mod tests { } } + /// Platform metadata associated with test fixture fn gcp_portable_platform_metadata() -> PlatformMetadata { PlatformMetadata { attestation_type: attest_types::AttestationType::GcpTdx, @@ -136,7 +137,7 @@ mod tests { } #[tokio::test] - async fn gcp_tdx_portable_policy_with_stored_collateral() { + async fn test_gcp_tdx_portable_policy_with_stored_collateral() { let attestation_bytes: &'static [u8] = include_bytes!("../../test-assets/gcp-tdx-1782809233226668671"); let collateral_bytes: &'static [u8] = From 345c69544c6cca9684772bef5d0d6083af6a73ba Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 17 Aug 2026 10:15:30 +0200 Subject: [PATCH 17/21] Fmt --- crates/attestation/src/gcp/firmware.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/attestation/src/gcp/firmware.rs b/crates/attestation/src/gcp/firmware.rs index 9bcc30e..f489d4c 100644 --- a/crates/attestation/src/gcp/firmware.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -77,7 +77,8 @@ mod tests { use super::GcpFirmwareCache; use crate::{ - AttestationType, PlatformMetadata, + AttestationType, + PlatformMetadata, dcap::{get_quote_input_data, verify_dcap_attestation_with_given_timestamp}, measurements::{ExpectedMeasurements, MeasurementPolicy, MeasurementRecord}, }; From 59810e2e8fc4489954d419a8168f5e4d7242bcee Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 17 Aug 2026 13:33:39 +0200 Subject: [PATCH 18/21] Simplify mutex check for stale entries --- crates/attestation/src/gcp/provenance.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/crates/attestation/src/gcp/provenance.rs b/crates/attestation/src/gcp/provenance.rs index 068d2d7..f6c9996 100644 --- a/crates/attestation/src/gcp/provenance.rs +++ b/crates/attestation/src/gcp/provenance.rs @@ -72,29 +72,23 @@ impl GcpProvenanceChecker { now: Instant, ) -> Result<(), GcpProvenanceError> { let ppid = extract_ppid_from_quote(quote)?; - { + let stale_entry = { let known_gcp_ppids = self .known_gcp_ppids .read() .map_err(|err| GcpProvenanceError::CacheLock(err.to_string()))?; - if let Some(stored_at) = known_gcp_ppids.get(&ppid) && - is_cache_entry_fresh(*stored_at, now) - { - return Ok(()); + match known_gcp_ppids.get(&ppid).copied() { + Some(stored_at) if is_cache_entry_fresh(stored_at, now) => return Ok(()), + stale_entry => stale_entry, } - } + }; - // Re-check under the write lock in case another thread refreshed the - // entry while we were waiting, and drop stale entries so we refetch. - { + if let Some(stale_entry) = stale_entry { let mut known_gcp_ppids = self .known_gcp_ppids .write() .map_err(|err| GcpProvenanceError::CacheLock(err.to_string()))?; - if let Some(stored_at) = known_gcp_ppids.get(&ppid) { - if is_cache_entry_fresh(*stored_at, now) { - return Ok(()); - } + if known_gcp_ppids.get(&ppid) == Some(&stale_entry) { known_gcp_ppids.remove(&ppid); } } @@ -115,7 +109,7 @@ impl GcpProvenanceChecker { } fn is_cache_entry_fresh(stored_at: Instant, now: Instant) -> bool { - now.checked_duration_since(stored_at).is_some_and(|age| age <= GCP_PROVENANCE_CACHE_TTL) + now.saturating_duration_since(stored_at) <= GCP_PROVENANCE_CACHE_TTL } /// Given a TDX quote, extract the PPID from PCK certificate From 5f96c7f255ed127e7e1c063ccc0f6a4e48eb932b Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 17 Aug 2026 14:11:43 +0200 Subject: [PATCH 19/21] Dcap-tdx policy should allow GCP-TDX --- crates/attestation/src/lib.rs | 13 +++++++++++-- crates/attestation/src/measurements.rs | 25 +++++++++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 3d08e9a..914b8f8 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -147,6 +147,15 @@ impl AttestationType { } } + /// Whether a measurement policy record with this attestation type may + /// be used to check a peer reporting the given attestation type. + /// + /// `dcap-tdx` policy also accepts a `gcp-tdx` attestation - as dcap-tdx + /// effectively means DCAP on any platform. + pub fn accepts(&self, peer: AttestationType) -> bool { + matches!((self, peer), (AttestationType::DcapTdx, AttestationType::GcpTdx)) || *self == peer + } + /// Detect what platform we are on by attempting an attestation pub fn detect() -> Result { // First attempt azure, if the feature is present @@ -641,8 +650,8 @@ fn running_on_gcp() -> Result { let resp = agent.get(GCP_METADATA_API).call(); if let Ok(r) = resp { - return Ok(r.status() == 200 && - r.header("Metadata-Flavor").map(|v| v == "Google").unwrap_or(false)); + return Ok(r.status() == 200 + && r.header("Metadata-Flavor").map(|v| v == "Google").unwrap_or(false)); } Ok(false) diff --git a/crates/attestation/src/measurements.rs b/crates/attestation/src/measurements.rs index 1c2d6b5..8dd7e9c 100644 --- a/crates/attestation/src/measurements.rs +++ b/crates/attestation/src/measurements.rs @@ -447,7 +447,7 @@ impl MeasurementPolicy { if self.accepted_measurements.iter().any(|measurement_record| match measurements { MultiMeasurements::Dcap(dcap_measurements) => match &measurement_record.measurements { ExpectedMeasurements::Dcap(expected) => { - if measurement_record.attestation_type != attestation_type { + if !measurement_record.attestation_type.accepts(attestation_type) { return false; } // All measurements in our policy must be given and must match @@ -460,7 +460,7 @@ impl MeasurementPolicy { true } ExpectedMeasurements::Image(image_hashes) => { - measurement_record.attestation_type == attestation_type && + measurement_record.attestation_type.accepts(attestation_type) && compare_portable_dcap_measurement( image_hashes, dcap_measurements, @@ -471,7 +471,7 @@ impl MeasurementPolicy { ExpectedMeasurements::Azure(_) | ExpectedMeasurements::NoAttestation => false, }, MultiMeasurements::Azure(azure_measurements) => { - if measurement_record.attestation_type != attestation_type { + if !measurement_record.attestation_type.accepts(attestation_type) { return false; } if let ExpectedMeasurements::Azure(expected) = &measurement_record.measurements { @@ -486,7 +486,7 @@ impl MeasurementPolicy { false } MultiMeasurements::NoAttestation => { - measurement_record.attestation_type == attestation_type && + measurement_record.attestation_type.accepts(attestation_type) && matches!( measurement_record.measurements, ExpectedMeasurements::NoAttestation @@ -1022,6 +1022,23 @@ mod tests { )); } + #[test] + fn dcap_policy_accepts_gcp_labeled_measurements() { + // Policy files written before GCP was distinguished from bare metal + // label GCP hosts `dcap-tdx`, so those records must still accept a + // peer reporting `gcp-tdx` + let policy = MeasurementPolicy::single_attestation_type(AttestationType::DcapTdx); + let measurements = mock_dcap_measurements(); + let gcp_metadata = PlatformMetadata { + attestation_type: attest_types::AttestationType::GcpTdx, + ram_bytes: 0, + num_disks: 0, + acpi: None, + }; + + policy.check_measurement(&measurements, Some(&gcp_metadata)).unwrap(); + } + #[test] fn test_gcp_image_hash_measurement_policy_accepts_matching_measurements() { fn decode_hash(input: &str) -> [u8; 48] { From 2bc7dfbaa48359bb23945baa2b70aa9deb99d6b8 Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 17 Aug 2026 14:58:02 +0200 Subject: [PATCH 20/21] Fmt --- crates/attestation/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 914b8f8..f4efa9d 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -650,8 +650,8 @@ fn running_on_gcp() -> Result { let resp = agent.get(GCP_METADATA_API).call(); if let Ok(r) = resp { - return Ok(r.status() == 200 - && r.header("Metadata-Flavor").map(|v| v == "Google").unwrap_or(false)); + return Ok(r.status() == 200 && + r.header("Metadata-Flavor").map(|v| v == "Google").unwrap_or(false)); } Ok(false) From f1e2051786c2561c90e7198ca1ad94ed1095fe8a Mon Sep 17 00:00:00 2001 From: peg Date: Tue, 18 Aug 2026 09:42:22 +0200 Subject: [PATCH 21/21] Fixes for supporting differnt async runtime or no async runtime --- crates/attestation/src/gcp/provenance.rs | 138 +++++++++++++++++++++-- 1 file changed, 128 insertions(+), 10 deletions(-) diff --git a/crates/attestation/src/gcp/provenance.rs b/crates/attestation/src/gcp/provenance.rs index f6c9996..56f7786 100644 --- a/crates/attestation/src/gcp/provenance.rs +++ b/crates/attestation/src/gcp/provenance.rs @@ -41,22 +41,45 @@ impl GcpProvenanceChecker { /// Given a DCAP TDX quote, check if the associated PPID has a /// 'provenance document' from GCP /// - /// If a tokio runtime is available the blocking fetch is offloaded to - /// its blocking pool; otherwise it runs inline on the current thread. + /// 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> { - if let Ok(handle) = tokio::runtime::Handle::try_current() { - let checker = self.clone(); - handle - .spawn_blocking(move || checker.verify_provenance_sync("e)) - .await - .map_err(|err| GcpProvenanceError::TaskJoin(err.to_string()))? - } else { - self.verify_provenance_sync("e) + self.verify_provenance_with_registry_url(quote, GCP_PROVENANCE_REGISTRY_URL.to_string()) + .await + } + + async fn verify_provenance_with_registry_url( + &self, + quote: Quote, + registry_url: String, + ) -> Result<(), GcpProvenanceError> { + 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, + ®istry_url, + Instant::now(), + ) + }) + .await + .map_err(|err| GcpProvenanceError::TaskJoin(err.to_string()))? + } + Err(_) => self.verify_provenance_with_registry_url_blocking_at( + "e, + ®istry_url, + Instant::now(), + ), } } /// Given a DCAP TDX quote, check if the associated PPID has a /// 'provenance document' from GCP + /// + /// On a multi-threaded tokio runtime, mark the check as blocking so the + /// runtime can keep scheduling other tasks on another worker pub(crate) fn verify_provenance_sync(&self, quote: &Quote) -> Result<(), GcpProvenanceError> { self.verify_provenance_with_registry_url_sync_at( quote, @@ -70,6 +93,28 @@ impl GcpProvenanceChecker { quote: &Quote, registry_url: &str, now: Instant, + ) -> Result<(), GcpProvenanceError> { + let verify = + || self.verify_provenance_with_registry_url_blocking_at(quote, registry_url, now); + + match tokio::runtime::Handle::try_current() { + Ok(handle) + if matches!( + handle.runtime_flavor(), + tokio::runtime::RuntimeFlavor::MultiThread + ) => + { + tokio::task::block_in_place(verify) + } + _ => verify(), + } + } + + fn verify_provenance_with_registry_url_blocking_at( + &self, + quote: &Quote, + registry_url: &str, + now: Instant, ) -> Result<(), GcpProvenanceError> { let ppid = extract_ppid_from_quote(quote)?; let stale_entry = { @@ -211,6 +256,7 @@ mod tests { use std::{ io::{Read as _, Write as _}, net::SocketAddr, + sync::mpsc, thread, time::{Duration, Instant}, }; @@ -245,6 +291,33 @@ mod tests { (addr, handle) } + fn spawn_blocked_test_registry_server( + body: impl Into, + ) -> (SocketAddr, mpsc::Receiver<()>, mpsc::Sender<()>, thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = body.into(); + let (request_started_tx, request_started_rx) = mpsc::channel(); + let (send_response_tx, send_response_rx) = mpsc::channel(); + + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0u8; 1024]; + let bytes_read = stream.read(&mut buf).unwrap(); + let request = String::from_utf8_lossy(&buf[..bytes_read]).to_string(); + request_started_tx.send(()).unwrap(); + send_response_rx.recv().unwrap(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + request + }); + + (addr, request_started_rx, send_response_tx, handle) + } + #[test] fn extracts_ppid_from_mock_tdx_quote() { let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); @@ -285,6 +358,51 @@ mod tests { assert!(request.starts_with(&format!("GET /{MOCK_PPID_HEX} HTTP/1.1"))); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn async_provenance_check_remains_cancellable() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let quote = Quote::parse(&attestation).unwrap(); + let (addr, request_started, send_response, request_handle) = + spawn_blocked_test_registry_server( + r#"{"zone":"projects/test/zones/us-central1-a","timestamp":"2026-06-11T00:00:00Z"}"#, + ); + let checker = GcpProvenanceChecker::new(); + + let task = tokio::spawn(async move { + checker.verify_provenance_with_registry_url(quote, format!("http://{addr}")).await + }); + request_started.recv_timeout(Duration::from_secs(1)).unwrap(); + + task.abort(); + let join_result = tokio::time::timeout(Duration::from_secs(1), task).await; + send_response.send(()).unwrap(); + request_handle.join().unwrap(); + + let join_error = join_result.expect("aborted verification remained blocked").unwrap_err(); + assert!(join_error.is_cancelled()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn sync_provenance_check_runs_from_tokio_worker() { + let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap(); + let quote = Quote::parse(&attestation).unwrap(); + let (addr, request_handle) = spawn_test_registry_server( + 200, + r#"{"zone":"projects/test/zones/us-central1-a","timestamp":"2026-06-11T00:00:00Z"}"#, + ); + + GcpProvenanceChecker::new() + .verify_provenance_with_registry_url_sync_at( + "e, + &format!("http://{addr}"), + Instant::now(), + ) + .unwrap(); + + let request = request_handle.join().unwrap(); + assert!(request.starts_with(&format!("GET /{MOCK_PPID_HEX} HTTP/1.1"))); + } + #[test] fn provenance_check_caches_known_gcp_ppids() { let attestation = dcap::create_dcap_attestation([0u8; 64]).unwrap();