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/gcp.rs b/crates/attestation/src/gcp/firmware.rs similarity index 91% rename from crates/attestation/src/gcp.rs rename to crates/attestation/src/gcp/firmware.rs index a8cd3f0..f489d4c 100644 --- a/crates/attestation/src/gcp.rs +++ b/crates/attestation/src/gcp/firmware.rs @@ -1,5 +1,4 @@ -//! Google Cloud Platform specific attestation logic - +//! On GCP check MRTD values map to Google endorsed firmware use std::{ collections::HashMap, sync::{Arc, RwLock}, @@ -76,10 +75,11 @@ mod tests { 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}, - gcp::GcpFirmwareCache, measurements::{ExpectedMeasurements, MeasurementPolicy, MeasurementRecord}, }; @@ -97,7 +97,6 @@ mod tests { hex::decode(input).unwrap().try_into().unwrap() } - /// Image hashes associated with test fixture fn gcp_portable_image_hashes() -> DcapImageHashes { DcapImageHashes { uki_authenticode: decode_dcap_hash( @@ -141,20 +140,20 @@ mod tests { #[tokio::test] async fn test_gcp_tdx_portable_policy_with_stored_collateral() { let attestation_bytes: &'static [u8] = - include_bytes!("../test-assets/gcp-tdx-1782809233226668671"); + include_bytes!("../../test-assets/gcp-tdx-1782809233226668671"); let collateral_bytes: &'static [u8] = - include_bytes!("../test-assets/gcp-tdx-collateral-1782809233226668671.yaml"); + include_bytes!("../../test-assets/gcp-tdx-collateral-1782809233226668671.yaml"); let firmware_bytes: &'static [u8] = - include_bytes!("../test-assets/gcp-tdx-firmware-1782809233226668671.yaml"); + include_bytes!("../../test-assets/gcp-tdx-firmware-1782809233226668671.yaml"); let expected_input_data = { let quote = Quote::parse(attestation_bytes).unwrap(); - get_quote_input_data(quote.report) + 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( + let (measurements, _) = verify_dcap_attestation_with_given_timestamp( attestation_bytes.to_vec(), expected_input_data, None, @@ -168,6 +167,7 @@ mod tests { 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()), }], }; diff --git a/crates/attestation/src/gcp/mod.rs b/crates/attestation/src/gcp/mod.rs new file mode 100644 index 0000000..244b8c2 --- /dev/null +++ b/crates/attestation/src/gcp/mod.rs @@ -0,0 +1,6 @@ +//! Google Cloud Platform related attestation verification logic +mod firmware; +mod provenance; + +pub(crate) use firmware::{GcpFirmwareCache, fetch_firmware}; +pub(crate) use provenance::{GcpProvenanceChecker, GcpProvenanceError}; diff --git a/crates/attestation/src/gcp/provenance.rs b/crates/attestation/src/gcp/provenance.rs new file mode 100644 index 0000000..56f7786 --- /dev/null +++ b/crates/attestation/src/gcp/provenance.rs @@ -0,0 +1,523 @@ +//! GCP provenance check +use std::{ + collections::HashMap, + io::Read, + sync::{Arc, RwLock}, + time::{Duration, Instant}, +}; + +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; +/// 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) +/// 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>>, +} + +impl GcpProvenanceChecker { + pub(crate) fn new() -> Self { + Self { known_gcp_ppids: Default::default() } + } + + /// Given a DCAP TDX quote, check if the associated PPID has a + /// 'provenance document' from GCP + /// + /// 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> { + 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, + GCP_PROVENANCE_REGISTRY_URL, + Instant::now(), + ) + } + + fn verify_provenance_with_registry_url_sync_at( + &self, + 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 = { + let known_gcp_ppids = self + .known_gcp_ppids + .read() + .map_err(|err| GcpProvenanceError::CacheLock(err.to_string()))?; + match known_gcp_ppids.get(&ppid).copied() { + Some(stored_at) if is_cache_entry_fresh(stored_at, now) => return Ok(()), + stale_entry => stale_entry, + } + }; + + 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 known_gcp_ppids.get(&ppid) == Some(&stale_entry) { + known_gcp_ppids.remove(&ppid); + } + } + + let provenance_url = + format!("{}/{}", registry_url.trim_end_matches('/'), hex::encode(ppid)); + 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, fetched_at); + + Ok(()) + } +} + +fn is_cache_entry_fresh(stored_at: Instant, now: Instant) -> bool { + now.saturating_duration_since(stored_at) <= GCP_PROVENANCE_CACHE_TTL +} + +/// Given a TDX quote, extract the PPID from PCK certificate +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)?; + let extension = intel::parse_pck_extension(leaf) + .map_err(|err| GcpProvenanceError::PpidExtraction(err.to_string()))?; + + if extension.ppid.is_empty() { + return Err(GcpProvenanceError::EmptyPpid); + } + 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 = 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(); + 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) +} + +/// Basic checks that the response looks like a provenance 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 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")] + 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), +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read as _, Write as _}, + net::SocketAddr, + sync::mpsc, + thread, + time::{Duration, Instant}, + }; + + 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) + } + + 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(); + let quote = Quote::parse(&attestation).unwrap(); + let ppid = extract_ppid_from_quote("e).unwrap(); + + 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(); + 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"))); + } + + #[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(); + 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_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( + 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}"); + 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, 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_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 = 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_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(); + let quote = Quote::parse(&attestation).unwrap(); + let (addr, request_handle) = spawn_test_registry_server(200, r#"{"zone":""}"#); + + 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::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 = 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::DocumentTooLarge)); + } +} diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index a2c05f1..f4efa9d 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -22,7 +22,11 @@ use pccs::{Pccs, PccsError}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::{dcap::DcapVerificationError, measurements::MeasurementPolicy}; +use crate::{ + dcap::DcapVerificationError, + gcp::{GcpFirmwareCache, GcpProvenanceChecker, GcpProvenanceError}, + measurements::MeasurementPolicy, +}; #[cfg(test)] static TEST_CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); @@ -143,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 @@ -340,7 +353,9 @@ pub struct AttestationVerifier { /// Internal cache for collateral pub internal_pccs: Option, /// Cached GCP firmware blobs indexed by MRTD - known_gcp_firmware: gcp::GcpFirmwareCache, + known_gcp_firmware: GcpFirmwareCache, + /// Cached PPIDs that have a valid GCP host-registry document + gcp_provenance_checker: GcpProvenanceChecker, } impl AttestationVerifier { @@ -349,7 +364,7 @@ impl AttestationVerifier { pccs_url: Option, dump_dcap_quotes: bool, override_azure_outdated_tcb: bool, - known_gcp_firmware: gcp::GcpFirmwareCache, + known_gcp_firmware: GcpFirmwareCache, ) -> Self { Self { measurement_policy, @@ -358,6 +373,7 @@ impl AttestationVerifier { override_azure_outdated_tcb, internal_pccs: Some(Pccs::new(pccs_url)), known_gcp_firmware, + gcp_provenance_checker: GcpProvenanceChecker::new(), } } @@ -385,7 +401,8 @@ impl AttestationVerifier { dump_dcap_quotes: false, override_azure_outdated_tcb: false, internal_pccs: None, - known_gcp_firmware: gcp::GcpFirmwareCache::new(), + known_gcp_firmware: GcpFirmwareCache::new(), + gcp_provenance_checker: GcpProvenanceChecker::new(), } } @@ -398,7 +415,8 @@ impl AttestationVerifier { dump_dcap_quotes: false, override_azure_outdated_tcb: false, internal_pccs: None, - known_gcp_firmware: gcp::GcpFirmwareCache::new(), + known_gcp_firmware: GcpFirmwareCache::new(), + gcp_provenance_checker: GcpProvenanceChecker::new(), } } @@ -411,7 +429,8 @@ impl AttestationVerifier { dump_dcap_quotes: false, override_azure_outdated_tcb: false, internal_pccs: Some(Pccs::new(Some(pccs_url))), - known_gcp_firmware: gcp::GcpFirmwareCache::new(), + known_gcp_firmware: GcpFirmwareCache::new(), + gcp_provenance_checker: GcpProvenanceChecker::new(), } } @@ -484,12 +503,16 @@ impl AttestationVerifier { .attestation_evidence .as_ref() .ok_or(AttestationError::AttestationTypeNotAccepted)?; - dcap::verify_dcap_attestation( + let (measurements, quote) = dcap::verify_dcap_attestation( attestation_evidence.quote.clone(), expected_input_data, self.internal_pccs.clone(), ) - .await? + .await?; + if attestation_type == AttestationType::GcpTdx { + self.gcp_provenance_checker.verify_provenance(quote).await?; + } + measurements } }; @@ -562,11 +585,15 @@ impl AttestationVerifier { #[cfg(not(any(test, feature = "mock")))] let pccs = self.internal_pccs.clone().ok_or(AttestationError::NoPccs)?; - 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 { + self.gcp_provenance_checker.verify_provenance_sync("e)?; + } + measurements } }; @@ -709,6 +736,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")] @@ -763,7 +792,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(); diff --git a/crates/attestation/src/measurements.rs b/crates/attestation/src/measurements.rs index 878ce87..8dd7e9c 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.accepts(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.accepts(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.accepts(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.accepts(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,41 @@ 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 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] { @@ -1001,6 +1066,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()), }], };