From 98133557c536f448be68a3dbc311bd38d8493715 Mon Sep 17 00:00:00 2001 From: Hang Yin Date: Thu, 11 Jun 2026 18:49:19 +0000 Subject: [PATCH] feat(verifier): report dev/prod OS image and deployment identity in result the verification result never told a relying party whether the attested CVM runs a dev or prod OS image, nor a few other identity signals it already had in hand. surface them in `details`: - `os_image_is_dev`: dev vs prod, read from the image metadata.json (bound to os_image_hash, so as trustworthy as the os-image-hash check) - `os_image_version`: dstack OS version, same measurement-bound source - `tee_platform`: tdx | gcp-tdx | nitro, from the verified quote variant - `key_provider`: decoded {name, id} from app_info.key_provider_info (name e.g. "kms" or "local"); raw bytes still available in app_info also fold the metadata into a single image download in the dstack-tdx path (was downloading twice) and collapse the two VerificationDetails struct literals to Default so future fields aren't added in three places. Co-Authored-By: Claude Opus 4.8 --- dstack-types/src/lib.rs | 3 ++ verifier/README.md | 15 ++++++++ verifier/src/main.rs | 12 +----- verifier/src/types.rs | 9 +++++ verifier/src/verification.rs | 75 +++++++++++++++++++++++++++--------- 5 files changed, 85 insertions(+), 29 deletions(-) diff --git a/dstack-types/src/lib.rs b/dstack-types/src/lib.rs index fc493125b..61615044f 100644 --- a/dstack-types/src/lib.rs +++ b/dstack-types/src/lib.rs @@ -309,6 +309,9 @@ pub struct ImageInfo { /// may omit it, so callers should treat its absence as "unknown". #[serde(default)] pub version: String, + /// dev vs prod image. absent in older metadata.json => prod. + #[serde(default)] + pub is_dev: bool, /// Optional OVMF measurement layout declared by the image. Older /// metadata.json files do not carry this — treat absence as "unknown" and /// fall back to version-based heuristics. diff --git a/verifier/README.md b/verifier/README.md index 70f271ae1..e70fd7c77 100644 --- a/verifier/README.md +++ b/verifier/README.md @@ -33,9 +33,13 @@ or "quote_verified": true, "event_log_verified": true, // See "Verification Process" for semantics "os_image_hash_verified": true, + "os_image_is_dev": false, // true=dev image, false=prod, null=unknown/N/A + "os_image_version": "0.5.10", // dstack OS version, null if unknown + "tee_platform": "tdx", // tdx | gcp-tdx | nitro "report_data": "hex-encoded-64-byte-report-data", "tcb_status": "UpToDate", "advisory_ids": [], + "key_provider": { "name": "kms", "id": "hex-string" }, // decoded; null if absent "app_info": { "app_id": "hex-string", "compose_hash": "hex-string", @@ -185,3 +189,14 @@ The verifier performs three main verification steps: - Compares against the verified measurements from the quote All three steps must pass for the verification to be considered valid. + +### Identifying the deployment + +Beyond pass/fail, the result carries a few descriptive fields so a relying party can apply its own policy: + +- **`os_image_is_dev`** — `true` for a development OS image, `false` for production. Dev images are built for local testing and are not hardened for production use, so a relying party generally wants to reject them. +- **`os_image_version`** — the dstack OS version (e.g. `0.5.10`), useful for enforcing a minimum version. +- **`tee_platform`** — which TEE produced the verified quote: `tdx`, `gcp-tdx`, or `nitro`. +- **`key_provider`** — the decoded `app_info.key_provider_info` (`{name, id}`); `name` is e.g. `kms` or `local`. A `local` key provider means the CVM is not KMS-backed, which is itself a dev/insecure posture signal. The raw bytes remain in `app_info.key_provider_info`. + +`os_image_is_dev` and `os_image_version` are read from the image's `metadata.json`, which is part of `sha256sum.txt` and therefore bound to the `os_image_hash` that step 3 verifies against the quote — so they are as trustworthy as the os-image-hash check itself. They are `null` when the platform does not expose them (e.g. GCP TDX / Nitro Enclave) or when the image predates the field (images without `is_dev` are always production). diff --git a/verifier/src/main.rs b/verifier/src/main.rs index ea9cabd54..49d698ac1 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -50,17 +50,7 @@ async fn verify_cvm( error!("Verification failed: {:?}", e); Json(VerificationResponse { is_valid: false, - details: VerificationDetails { - quote_verified: false, - event_log_verified: false, - os_image_hash_verified: false, - report_data: None, - tcb_status: None, - advisory_ids: vec![], - app_info: None, - acpi_tables: None, - rtmr_debug: None, - }, + details: VerificationDetails::default(), reason: Some(format!("Internal error: {}", e)), }) } diff --git a/verifier/src/types.rs b/verifier/src/types.rs index 40aa95728..3acbfa1c5 100644 --- a/verifier/src/types.rs +++ b/verifier/src/types.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: Apache-2.0 +use dstack_types::KeyProviderInfo; use ra_tls::attestation::AppInfo; use serde::{Deserialize, Serialize}; @@ -40,9 +41,17 @@ pub struct VerificationDetails { /// event log payloads. pub event_log_verified: bool, pub os_image_hash_verified: bool, + /// dev vs prod OS image, from metadata.json (bound to os_image_hash). None if not exposed. + pub os_image_is_dev: Option, + /// dstack OS version, from the same metadata.json. + pub os_image_version: Option, + /// "tdx" | "gcp-tdx" | "nitro". + pub tee_platform: Option, pub report_data: Option, pub tcb_status: Option, pub advisory_ids: Vec, + /// decoded app_info.key_provider_info; name is e.g. "kms" or "local". + pub key_provider: Option, pub app_info: Option, #[serde(skip_serializing_if = "Option::is_none")] pub acpi_tables: Option, diff --git a/verifier/src/verification.rs b/verifier/src/verification.rs index 8ff4804af..00c0e1991 100644 --- a/verifier/src/verification.rs +++ b/verifier/src/verification.rs @@ -27,6 +27,22 @@ use crate::types::{ VerificationRequest, VerificationResponse, }; +fn tee_platform_name(quote: &AttestationQuote) -> &'static str { + match quote { + AttestationQuote::DstackTdx(_) => "tdx", + AttestationQuote::DstackGcpTdx(_) => "gcp-tdx", + AttestationQuote::DstackNitroEnclave(_) => "nitro", + } +} + +/// best-effort: None for empty/malformed blobs. +fn decode_key_provider_info(bytes: &[u8]) -> Option { + if bytes.is_empty() { + return None; + } + serde_json::from_slice(bytes).ok() +} + fn collect_rtmr_mismatch( rtmr_label: &str, expected: &[u8], @@ -136,6 +152,8 @@ struct ImagePaths { kernel_path: PathBuf, initrd_path: PathBuf, kernel_cmdline: String, + is_dev: bool, + version: String, } pub struct CvmVerifier { @@ -376,6 +394,8 @@ impl CvmVerifier { kernel_path, initrd_path, kernel_cmdline, + is_dev: image_info.is_dev, + version: image_info.version, }) } @@ -413,23 +433,14 @@ impl CvmVerifier { } else { bail!("Quote is required"); }; - let mut details = VerificationDetails { - quote_verified: false, - event_log_verified: false, - os_image_hash_verified: false, - report_data: None, - tcb_status: None, - advisory_ids: vec![], - app_info: None, - acpi_tables: None, - rtmr_debug: None, - }; + let mut details = VerificationDetails::default(); let debug = request.debug.unwrap_or(false); let verified = attestation.into_v1().verify(self.pccs_url.as_deref()).await; let verified_attestation = match verified { Ok(att) => { details.quote_verified = true; + details.tee_platform = Some(tee_platform_name(&att.quote).to_string()); details.tcb_status = att.report.tdx_report().map(|r| r.status.clone()); details.advisory_ids = att .report @@ -471,6 +482,7 @@ impl CvmVerifier { Ok(mut info) => { info.os_image_hash = vm_config.os_image_hash; details.event_log_verified = true; + details.key_provider = decode_key_provider_info(&info.key_provider_info); details.app_info = Some(info); } Err(e) => { @@ -546,11 +558,16 @@ impl CvmVerifier { rtmr2: report.rt_mr2.to_vec(), }; - // Compute expected measurements (reusing the public API) + // one download serves both measurement computation and the dev/version flags + let image_paths = self.ensure_image_downloaded(vm_config).await?; + details.os_image_is_dev = Some(image_paths.is_dev); + if !image_paths.version.is_empty() { + details.os_image_version = Some(image_paths.version.clone()); + } + + // Compute expected measurements let (mrs, expected_logs) = if debug { // For debug mode, we need detailed logs and ACPI tables - let image_paths = self.ensure_image_downloaded(vm_config).await?; - let TdxMeasurementDetails { measurements, rtmr_logs, @@ -573,11 +590,16 @@ impl CvmVerifier { (measurements, Some(rtmr_logs)) } else { - // For non-debug mode, reuse the public API with caching + // For non-debug mode, use the cached-measurement path. ( - self.compute_measurements_for_config(vm_config) - .await - .context("Failed to compute expected measurements")?, + self.load_or_compute_measurements( + vm_config, + &image_paths.fw_path, + &image_paths.kernel_path, + &image_paths.initrd_path, + &image_paths.kernel_cmdline, + ) + .context("Failed to compute expected measurements")?, None, ) }; @@ -885,3 +907,20 @@ impl Mrs { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_key_provider_info_parses_json_and_tolerates_garbage() { + let info = + decode_key_provider_info(br#"{"name":"kms","id":"abcd"}"#).expect("should parse"); + assert_eq!(info.name, "kms"); + assert_eq!(info.id, "abcd"); + + // empty/malformed must degrade to None, not fail the verify. + assert!(decode_key_provider_info(b"").is_none()); + assert!(decode_key_provider_info(b"not json").is_none()); + } +}