diff --git a/Cargo.lock b/Cargo.lock index 7d475254e..c60356720 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2695,6 +2695,8 @@ dependencies = [ name = "dstack-types" version = "0.5.11" dependencies = [ + "ciborium", + "hex", "or-panic", "parity-scale-codec", "serde", diff --git a/dstack-attest/src/attestation.rs b/dstack-attest/src/attestation.rs index 4f177343b..f98b856a8 100644 --- a/dstack-attest/src/attestation.rs +++ b/dstack-attest/src/attestation.rs @@ -32,6 +32,10 @@ use tpm_qvl::verify::VerifiedReport as TpmVerifiedReport; pub use tpm_types::TpmQuote; use crate::amd_sev_snp::VerifiedAmdSnpReport; +use crate::v1::{ + is_tdx_acpi_data_event, is_tdx_lite_config, strip_tdx_event_log_for_config, + strip_tdx_runtime_event_log, +}; pub use crate::v1::{Attestation as AttestationV1, PlatformEvidence, StackEvidence}; pub const SNP_REPORT_DATA_RANGE: std::ops::Range = 0x50..0x90; @@ -596,17 +600,24 @@ impl VersionedAttestation { } } - /// Strip data for certificate embedding (e.g. keep RTMR3 event logs only). + /// Strip data for certificate embedding. pub fn into_stripped(self) -> Self { match self { Self::V0 { mut attestation } => { - if let Some(tdx_quote) = attestation.tdx_quote_mut() { - tdx_quote.event_log = tdx_quote - .event_log - .iter() - .filter(|e| e.imr == 3) - .map(|e| e.stripped()) - .collect(); + match &mut attestation.quote { + AttestationQuote::DstackTdx(tdx_quote) => { + tdx_quote.event_log = strip_tdx_event_log_for_config( + std::mem::take(&mut tdx_quote.event_log), + &attestation.config, + ); + } + AttestationQuote::DstackGcpTdx(quote) => { + quote.tdx_quote.event_log = strip_tdx_runtime_event_log(std::mem::take( + &mut quote.tdx_quote.event_log, + )); + } + AttestationQuote::DstackAmdSevSnp(_) + | AttestationQuote::DstackNitroEnclave(_) => {} } Self::V0 { attestation } } @@ -983,17 +994,16 @@ pub enum AttestationQuote { DstackTdx(TdxQuote), DstackGcpTdx(DstackGcpTdxQuote), DstackNitroEnclave(DstackNitroQuote), - /// Keep this last to preserve SCALE discriminants for existing variants. DstackAmdSevSnp(SnpQuote), } impl AttestationQuote { pub fn mode(&self) -> AttestationMode { match self { - AttestationQuote::DstackTdx { .. } => AttestationMode::DstackTdx, - AttestationQuote::DstackAmdSevSnp { .. } => AttestationMode::DstackAmdSevSnp, - AttestationQuote::DstackGcpTdx { .. } => AttestationMode::DstackGcpTdx, - AttestationQuote::DstackNitroEnclave { .. } => AttestationMode::DstackNitroEnclave, + AttestationQuote::DstackTdx(_) => AttestationMode::DstackTdx, + AttestationQuote::DstackAmdSevSnp(_) => AttestationMode::DstackAmdSevSnp, + AttestationQuote::DstackGcpTdx(_) => AttestationMode::DstackGcpTdx, + AttestationQuote::DstackNitroEnclave(_) => AttestationMode::DstackNitroEnclave, } } } @@ -1122,8 +1132,29 @@ impl Attestation { /// Get TDX event log string with RTMR[0-2] payloads stripped to reduce size. /// Only digests are kept for boot-time events; runtime events (RTMR3) retain full payload. pub fn get_tdx_event_log_string(&self) -> Option { + self.get_tdx_event_log_string_for_config("") + } + + /// Get TDX event log string for a vm_config. + /// + /// In lite mode, keep the `ACPI DATA` marker payloads in RTMR0 so callers + /// that still consume the top-level `event_log` can semantically identify + /// the ACPI table digest events without consulting the versioned + /// attestation field. + pub fn get_tdx_event_log_string_for_config(&self, config: &str) -> Option { self.tdx_quote().map(|q| { - let stripped: Vec<_> = q.event_log.iter().map(|e| e.stripped()).collect(); + let keep_lite_acpi_payload = is_tdx_lite_config(config); + let stripped: Vec<_> = q + .event_log + .iter() + .map(|e| { + let mut stripped = e.stripped(); + if keep_lite_acpi_payload && is_tdx_acpi_data_event(e) { + stripped.event_payload = e.event_payload.clone(); + } + stripped + }) + .collect(); serde_json::to_string(&stripped).unwrap_or_default() }) } @@ -1665,6 +1696,14 @@ impl Attestation { .map_err(|_| anyhow!("Quote lock poisoned"))?; let mode = AttestationMode::detect()?; + let config = match mode { + AttestationMode::DstackAmdSevSnp + | AttestationMode::DstackTdx + | AttestationMode::DstackGcpTdx => { + read_vm_config().context("Failed to read vm config")? + } + AttestationMode::DstackNitroEnclave => String::new(), + }; let runtime_events = match mode { AttestationMode::DstackTdx | AttestationMode::DstackGcpTdx => { RuntimeEvent::read_all().context("Failed to read runtime events")? @@ -1713,9 +1752,7 @@ impl Attestation { let config = match "e { AttestationQuote::DstackAmdSevSnp(_) | AttestationQuote::DstackTdx(_) - | AttestationQuote::DstackGcpTdx(_) => { - read_vm_config().context("Failed to read vm config")? - } + | AttestationQuote::DstackGcpTdx(_) => config, AttestationQuote::DstackNitroEnclave(quote) => { let os_image_hash = quote .decode_image_hash() @@ -2002,6 +2039,47 @@ mod tests { } } + fn tdx_event(imr: u32, event_type: u32, event_payload: &[u8]) -> TdxEvent { + TdxEvent { + imr, + event_type, + digest: vec![event_type as u8; 48], + event: String::new(), + event_payload: event_payload.to_vec(), + } + } + + #[test] + fn tdx_event_log_string_for_lite_keeps_acpi_data_payloads() { + let mut attestation = dummy_tdx_attestation([0u8; 64]); + let AttestationQuote::DstackTdx(tdx_quote) = &mut attestation.quote else { + panic!("expected TDX attestation"); + }; + tdx_quote.event_log = vec![ + tdx_event(0, 10, b"ACPI DATA"), + tdx_event(0, 4, b"boot-payload"), + tdx_event(3, 8, b"runtime-payload"), + ]; + + let lite_events: Vec = serde_json::from_str( + &attestation + .get_tdx_event_log_string_for_config(r#"{"tdx_attestation_variant":"lite"}"#) + .expect("TDX event log"), + ) + .expect("decode lite event log"); + assert_eq!(lite_events[0].event_payload, b"ACPI DATA"); + assert!(lite_events[1].event_payload.is_empty()); + assert!(lite_events[2].event_payload.is_empty()); + + let legacy_events: Vec = serde_json::from_str( + &attestation + .get_tdx_event_log_string() + .expect("TDX event log"), + ) + .expect("decode legacy event log"); + assert!(legacy_events[0].event_payload.is_empty()); + } + #[test] fn test_to_report_data_with_hash() { let content_type = QuoteContentType::AppData; diff --git a/dstack-attest/src/v1.rs b/dstack-attest/src/v1.rs index a91e9393a..a5fa5b750 100644 --- a/dstack-attest/src/v1.rs +++ b/dstack-attest/src/v1.rs @@ -10,6 +10,61 @@ use tpm_types::TpmQuote; pub const ATTESTATION_VERSION: u64 = 1; +const TDX_ACPI_DATA_EVENT_TYPE: u32 = 10; +const TDX_ACPI_DATA_EVENT_PAYLOAD: &[u8] = b"ACPI DATA"; + +pub(crate) fn is_tdx_acpi_data_event(event: &TdxEvent) -> bool { + event.imr == 0 + && event.event_type == TDX_ACPI_DATA_EVENT_TYPE + && event.event_payload == TDX_ACPI_DATA_EVENT_PAYLOAD +} + +pub(crate) fn strip_tdx_runtime_event_log(event_log: Vec) -> Vec { + event_log + .into_iter() + .filter(|event| event.imr == 3) + .map(|event| event.stripped()) + .collect() +} + +fn strip_tdx_lite_acpi_data_event(event: TdxEvent) -> TdxEvent { + let mut event = event.stripped(); + event.event_payload = TDX_ACPI_DATA_EVENT_PAYLOAD.to_vec(); + event +} + +pub(crate) fn strip_tdx_lite_event_log(event_log: Vec) -> Vec { + event_log + .into_iter() + .filter_map(|event| { + if is_tdx_acpi_data_event(&event) { + Some(strip_tdx_lite_acpi_data_event(event)) + } else if event.imr == 3 { + Some(event.stripped()) + } else { + None + } + }) + .collect() +} + +pub(crate) fn is_tdx_lite_config(config: &str) -> bool { + serde_json::from_str::(config) + .map(|config| config.tdx_attestation_variant.is_lite()) + .unwrap_or(false) +} + +pub(crate) fn strip_tdx_event_log_for_config( + event_log: Vec, + config: &str, +) -> Vec { + if is_tdx_lite_config(config) { + strip_tdx_lite_event_log(event_log) + } else { + strip_tdx_runtime_event_log(event_log) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", content = "data")] pub enum PlatformEvidence { @@ -92,14 +147,14 @@ impl PlatformEvidence { } pub fn into_stripped(self) -> Self { + self.into_stripped_for_config("") + } + + pub fn into_stripped_for_config(self, config: &str) -> Self { match self { Self::Tdx { quote, event_log } => Self::Tdx { quote, - event_log: event_log - .into_iter() - .filter(|event| event.imr == 3) - .map(|event| event.stripped()) - .collect(), + event_log: strip_tdx_event_log_for_config(event_log, config), }, Self::GcpTdx { quote, @@ -107,11 +162,7 @@ impl PlatformEvidence { tpm_quote, } => Self::GcpTdx { quote, - event_log: event_log - .into_iter() - .filter(|event| event.imr == 3) - .map(|event| event.stripped()) - .collect(), + event_log: strip_tdx_runtime_event_log(event_log), tpm_quote, }, other => other, @@ -242,9 +293,10 @@ impl Attestation { } pub fn into_stripped(self) -> Self { + let config = self.stack.config().to_string(); Self { version: self.version, - platform: self.platform.into_stripped(), + platform: self.platform.into_stripped_for_config(&config), stack: self.stack, } } @@ -414,6 +466,60 @@ mod tests { ); } + fn boot_event(idx: usize) -> TdxEvent { + TdxEvent { + imr: 0, + event_type: idx as u32, + digest: vec![idx as u8; 48], + event: String::new(), + event_payload: vec![0xff; idx + 1], + } + } + + fn acpi_data_event(idx: usize) -> TdxEvent { + TdxEvent { + imr: 0, + event_type: TDX_ACPI_DATA_EVENT_TYPE, + digest: vec![idx as u8; 48], + event: String::new(), + event_payload: TDX_ACPI_DATA_EVENT_PAYLOAD.to_vec(), + } + } + + fn runtime_event() -> TdxEvent { + RuntimeEvent { + event: "app-id".into(), + payload: vec![0x42], + } + .into() + } + + #[test] + fn lite_stripping_keeps_only_acpi_data_digests_and_runtime_payloads() { + let mut event_log = (0..20).map(boot_event).collect::>(); + event_log[3] = acpi_data_event(3); + event_log[8] = acpi_data_event(8); + event_log[15] = acpi_data_event(15); + event_log.push(runtime_event()); + + let stripped = strip_tdx_lite_event_log(event_log); + + assert_eq!(stripped.len(), 4); + assert_eq!( + stripped[0..3] + .iter() + .map(|event| event.digest.clone()) + .collect::>(), + vec![vec![3u8; 48], vec![8u8; 48], vec![15u8; 48]] + ); + assert!(stripped[0..3] + .iter() + .all(|event| event.imr == 0 && event.event_payload == TDX_ACPI_DATA_EVENT_PAYLOAD)); + assert_eq!(stripped[3].imr, 3); + assert_eq!(stripped[3].event, "app-id"); + assert_eq!(stripped[3].event_payload, vec![0x42]); + } + #[test] fn sev_snp_with_report_data_patches_report_and_stack() { let mut report = vec![0x11; 1184]; diff --git a/dstack-attest/tests/sev_snp_verify.rs b/dstack-attest/tests/sev_snp_verify.rs index 933311264..510c6b1a1 100644 --- a/dstack-attest/tests/sev_snp_verify.rs +++ b/dstack-attest/tests/sev_snp_verify.rs @@ -95,7 +95,7 @@ fn verify_sev_snp_attestation_bin() { // image build's digest.sev.txt. assert_eq!( hex::encode(&binding.os_image_hash), - "32b4767373ad7fa0f9c418925006194d5c3f5619529f309fe81156789fecd8bc", + "b6e8403b8f6167bcef4e39aa1039d8728fe624532ca6cedf2625a87fac2e5fda", "derived os_image_hash" ); // The HOST_DATA-bound app identity is recovered from the mr_config document. @@ -111,7 +111,7 @@ fn verify_sev_snp_attestation_bin() { // Forged / tampered quote coverage (all offline, using the real fixture). // --------------------------------------------------------------------------- -const OS_IMAGE_HASH: &str = "32b4767373ad7fa0f9c418925006194d5c3f5619529f309fe81156789fecd8bc"; +const OS_IMAGE_HASH: &str = "b6e8403b8f6167bcef4e39aa1039d8728fe624532ca6cedf2625a87fac2e5fda"; fn decoded_attestation() -> dstack_attest::attestation::Attestation { let versioned = diff --git a/dstack-mr/cli/src/main.rs b/dstack-mr/cli/src/main.rs index 0898e7015..fb78808e2 100644 --- a/dstack-mr/cli/src/main.rs +++ b/dstack-mr/cli/src/main.rs @@ -78,9 +78,8 @@ struct MachineConfig { #[arg(long)] qemu_version: Option, - /// dstack OS version (MAJOR.MINOR.PATCH), used to pick the OVMF measurement layout. - /// 0.5.10 <= ver < 0.6.0 and ver >= 0.6.1 use the edk2-stable202505 layout; everything - /// else uses the legacy layout. If omitted, falls back to `image_info.version`. + /// dstack OS version (MAJOR.MINOR.PATCH), validated before using the supported OVMF + /// measurement layout. If omitted, falls back to `image_info.version`. #[arg(long)] dstack_os_version: Option, diff --git a/dstack-mr/src/kernel.rs b/dstack-mr/src/kernel.rs index 878a2b012..a4e969563 100644 --- a/dstack-mr/src/kernel.rs +++ b/dstack-mr/src/kernel.rs @@ -7,6 +7,19 @@ use anyhow::{bail, Context, Result}; use object::pe; use sha2::{Digest, Sha384}; +/// QEMU's TDX setup-header patch places the initrd at a memory-dependent +/// address below this guest-memory size. At and above this threshold the +/// patched kernel Authenticode hash is stable for a given kernel/initrd pair. +pub const TDX_KERNEL_HASH_STABLE_MIN_MEMORY: u64 = 0xB0000000; +/// QEMU's low-memory initrd placement also resolves to the same below-4G +/// placement at exactly 2 GiB, so it shares the high-memory patched kernel hash. +pub const TDX_KERNEL_HASH_COMPAT_2G_MEMORY: u64 = 0x80000000; + +pub fn tdx_kernel_hash_uses_precomputed_high_mem(memory_size: u64) -> bool { + memory_size == TDX_KERNEL_HASH_COMPAT_2G_MEMORY + || memory_size >= TDX_KERNEL_HASH_STABLE_MIN_MEMORY +} + /// Calculates the Authenticode hash of a PE/COFF file fn authenticode_sha384_hash(data: &[u8]) -> Result> { let lfanew_offset = 0x3c; @@ -177,8 +190,8 @@ fn patch_kernel( 0x37ffffff }; - let lowmem = if mem_size < 0xb0000000 { - 0xb0000000 + let lowmem = if mem_size < TDX_KERNEL_HASH_STABLE_MIN_MEMORY { + TDX_KERNEL_HASH_STABLE_MIN_MEMORY } else { 0x80000000 }; @@ -211,6 +224,19 @@ fn patch_kernel( Ok(kd) } +/// Compute the first RTMR[1] event digest: the Authenticode SHA-384 hash of the +/// kernel after QEMU applies its setup-header patches. +pub(crate) fn patched_kernel_authenticode_sha384( + kernel_data: &[u8], + initrd_size: u32, + mem_size: u64, + acpi_data_size: u32, +) -> Result> { + let kd = patch_kernel(kernel_data, initrd_size, mem_size, acpi_data_size) + .context("Failed to patch kernel")?; + authenticode_sha384_hash(&kd).context("Failed to compute kernel hash") +} + /// Measures a QEMU-patched TDX kernel image. pub(crate) fn rtmr1_log( kernel_data: &[u8], @@ -218,9 +244,8 @@ pub(crate) fn rtmr1_log( mem_size: u64, acpi_data_size: u32, ) -> Result>> { - let kd = patch_kernel(kernel_data, initrd_size, mem_size, acpi_data_size) - .context("Failed to patch kernel")?; - let kernel_hash = authenticode_sha384_hash(&kd).context("Failed to compute kernel hash")?; + let kernel_hash = + patched_kernel_authenticode_sha384(kernel_data, initrd_size, mem_size, acpi_data_size)?; Ok(vec![ kernel_hash, measure_sha384(b"Calling EFI Application from Boot Option"), @@ -236,3 +261,53 @@ pub(crate) fn measure_cmdline(cmdline: &str) -> Vec { utf16_cmdline.extend([0, 0]); measure_sha384(&utf16_cmdline) } + +#[cfg(test)] +mod tests { + use super::*; + + fn initrd_addr(kernel: &[u8]) -> u32 { + u32::from_le_bytes(kernel[0x218..0x21c].try_into().unwrap()) + } + + #[test] + fn tdx_kernel_patch_uses_precomputed_digest_at_2g_and_high_memory() { + let mut kernel = vec![0u8; 0x1000]; + // Linux boot protocol >= 2.12 with XLF_CAN_BE_LOADED_ABOVE_4G makes + // QEMU derive the initrd address from available low memory. + kernel[0x206..0x208].copy_from_slice(&0x020cu16.to_le_bytes()); + kernel[0x236..0x238].copy_from_slice(&0x0040u16.to_le_bytes()); + + let below_2g = patch_kernel(&kernel, 0x100000, 0x80000000 - 0x1000, 0x28000).unwrap(); + let at_2g = patch_kernel(&kernel, 0x100000, 0x80000000, 0x28000).unwrap(); + let between_2g_and_high_mem = patch_kernel( + &kernel, + 0x100000, + TDX_KERNEL_HASH_STABLE_MIN_MEMORY - 0x1000, + 0x28000, + ) + .unwrap(); + let at_threshold = patch_kernel( + &kernel, + 0x100000, + TDX_KERNEL_HASH_STABLE_MIN_MEMORY, + 0x28000, + ) + .unwrap(); + let above_threshold = patch_kernel( + &kernel, + 0x100000, + TDX_KERNEL_HASH_STABLE_MIN_MEMORY + 0x4000_0000, + 0x28000, + ) + .unwrap(); + + assert_ne!(initrd_addr(&below_2g), initrd_addr(&at_2g)); + assert_ne!( + initrd_addr(&between_2g_and_high_mem), + initrd_addr(&at_threshold) + ); + assert_eq!(initrd_addr(&at_2g), initrd_addr(&at_threshold)); + assert_eq!(initrd_addr(&at_threshold), initrd_addr(&above_threshold)); + } +} diff --git a/dstack-mr/src/lib.rs b/dstack-mr/src/lib.rs index ad71c0aee..00385a05f 100644 --- a/dstack-mr/src/lib.rs +++ b/dstack-mr/src/lib.rs @@ -17,16 +17,17 @@ pub type RtmrLogs = [RtmrLog; 3]; mod acpi; mod kernel; mod machine; +pub mod measurement; mod num; pub mod sev; mod tdvf; -mod uefi_var; +pub mod tdx; mod util; -/// Pick the OVMF variant for a given dstack OS version string ("MAJOR.MINOR.PATCH"). +/// Return the supported OVMF variant for a dstack OS version string ("MAJOR.MINOR.PATCH"). /// -/// Treats `0.5.10 <= v < 0.6.0` and `v >= 0.6.1` as `Stable202505`, everything else as -/// `Pre202505`. Used as a fallback when `VmConfig::ovmf_variant` is absent. +/// The version is still parsed for compatibility with callers that validate the +/// OS version through this helper, but all valid versions use `Pre202505`. pub fn ovmf_variant_for_version(version: &str) -> Result { let parts: Vec = version .split('.') @@ -38,13 +39,7 @@ pub fn ovmf_variant_for_version(version: &str) -> Result { if parts.len() != 3 { bail!("expected MAJOR.MINOR.PATCH, got {version}"); } - let v = (parts[0], parts[1], parts[2]); - let stable = ((0, 5, 10)..(0, 6, 0)).contains(&v) || v >= (0, 6, 1); - Ok(if stable { - OvmfVariant::Stable202505 - } else { - OvmfVariant::Pre202505 - }) + Ok(OvmfVariant::Pre202505) } /// Extract the `MAJOR.MINOR.PATCH` version suffix from a dstack image name. @@ -55,7 +50,7 @@ pub fn ovmf_variant_for_version(version: &str) -> Result { /// /// The optional `.SUFFIX` is permitted to be non-numeric (pre-release tag, /// build label, etc.) and is dropped from the returned slice — only the -/// numeric `X.Y.Z` is needed to pick the OVMF variant. +/// numeric `X.Y.Z` is needed to validate the image version. /// /// Returns `None` when the segment after the last `-` is not at least a valid /// `X.Y.Z` triple of non-empty numeric components. @@ -77,7 +72,7 @@ pub fn extract_version_from_image_name(image: &str) -> Option<&str> { Some(&tail[..core_len]) } -/// Pick the OVMF variant from an image name like `dstack-0.5.10`. +/// Return the supported OVMF variant from an image name like `dstack-0.5.10`. /// /// Falls back to `OvmfVariant::default()` (= `Pre202505`) when the image name is /// missing or doesn't carry a parseable version suffix. Use this only as a @@ -94,8 +89,11 @@ mod ovmf_variant_tests { use super::*; #[test] - fn pre_202505_for_old_versions() { - for v in ["0.4.99", "0.5.7", "0.5.8", "0.5.9", "0.6.0"] { + fn pre_202505_for_all_versions() { + for v in [ + "0.4.99", "0.5.7", "0.5.8", "0.5.9", "0.5.10", "0.5.99", "0.6.0", "0.6.1", "0.6.2", + "0.7.0", "1.0.0", + ] { assert_eq!( ovmf_variant_for_version(v).unwrap(), OvmfVariant::Pre202505, @@ -104,17 +102,6 @@ mod ovmf_variant_tests { } } - #[test] - fn stable_202505_for_new_versions() { - for v in ["0.5.10", "0.5.99", "0.6.1", "0.6.2", "0.7.0", "1.0.0"] { - assert_eq!( - ovmf_variant_for_version(v).unwrap(), - OvmfVariant::Stable202505, - "{v}" - ); - } - } - #[test] fn rejects_malformed_version() { assert!(ovmf_variant_for_version("0.5").is_err()); @@ -177,11 +164,11 @@ mod ovmf_variant_tests { ); assert_eq!( ovmf_variant_for_image(Some("dstack-0.5.10")), - OvmfVariant::Stable202505 + OvmfVariant::Pre202505 ); assert_eq!( ovmf_variant_for_image(Some("dstack-nvidia-dev-0.6.1")), - OvmfVariant::Stable202505 + OvmfVariant::Pre202505 ); } @@ -192,12 +179,8 @@ mod ovmf_variant_tests { "\"pre202505\"" ); assert_eq!( - serde_json::to_string(&OvmfVariant::Stable202505).unwrap(), - "\"stable202505\"" - ); - assert_eq!( - serde_json::from_str::("\"stable202505\"").unwrap(), - OvmfVariant::Stable202505 + serde_json::from_str::("\"pre202505\"").unwrap(), + OvmfVariant::Pre202505 ); } } diff --git a/dstack-mr/src/machine.rs b/dstack-mr/src/machine.rs index 756a21ee4..823470d23 100644 --- a/dstack-mr/src/machine.rs +++ b/dstack-mr/src/machine.rs @@ -33,7 +33,7 @@ pub struct Machine<'a> { #[builder(default)] pub host_share_mode: String, /// Selects which OVMF measurement event layout to expect. - /// Defaults to the pre-edk2-stable202505 layout for backwards compatibility. + /// Defaults to the supported pre-202505 layout. #[builder(default)] pub ovmf_variant: OvmfVariant, } diff --git a/dstack-mr/src/main.rs b/dstack-mr/src/main.rs index 2dca7574f..a6ace663f 100644 --- a/dstack-mr/src/main.rs +++ b/dstack-mr/src/main.rs @@ -4,17 +4,51 @@ //! `dstack-mr` CLI. //! -//! Currently exposes the AMD SEV-SNP `os_image_hash` computation used by the -//! image build to emit `digest.sev.txt`. +//! Exposes build-time OS-image measurement material/hash computations. use anyhow::{bail, Context, Result}; +use dstack_types::OsImageMeasurementDocument; +use serde_json::Value; use std::path::Path; -const USAGE: &str = "usage: dstack-mr sev-os-image-hash "; +const USAGE: &str = "\ +usage: + dstack-mr measure-os + dstack-mr inspect-measurement + dstack-mr sev-os-image-hash + dstack-mr tdx-os-image-measurement + dstack-mr tdx-os-image-hash + +features: + cbor-measurement-v2"; fn main() -> Result<()> { let mut args = std::env::args().skip(1); match args.next().as_deref() { + Some("measure-os") => { + let image_dir = args.next().context(USAGE)?; + let document = dstack_mr::measurement::os_image_measurement_document_for_image_dir( + Path::new(&image_dir), + ) + .context("failed to compute os image measurement document")?; + println!( + "{}", + serde_json::to_string(&document) + .context("failed to serialize os image measurement document")? + ); + Ok(()) + } + Some("inspect-measurement") => { + let measurement_json = args.next().context(USAGE)?; + let document = inspect_measurement(Path::new(&measurement_json)) + .context("failed to inspect os image measurement document")?; + println!( + "{}", + serde_json::to_string_pretty(&document) + .context("failed to serialize decoded measurement document")? + ); + Ok(()) + } Some("sev-os-image-hash") => { let image_dir = args.next().context(USAGE)?; let hash = dstack_mr::sev::sev_os_image_hash_for_image_dir(Path::new(&image_dir)) @@ -22,6 +56,26 @@ fn main() -> Result<()> { println!("{}", hex::encode(hash)); Ok(()) } + Some("tdx-os-image-measurement") => { + let image_dir = args.next().context(USAGE)?; + let document = dstack_mr::tdx::tdx_os_image_measurement_document_for_image_dir( + Path::new(&image_dir), + ) + .context("failed to compute tdx os image measurement material")?; + println!( + "{}", + serde_json::to_string(&document) + .context("failed to serialize tdx measurement material")? + ); + Ok(()) + } + Some("tdx-os-image-hash") => { + let image_dir = args.next().context(USAGE)?; + let hash = dstack_mr::tdx::tdx_os_image_hash_for_image_dir(Path::new(&image_dir)) + .context("failed to compute tdx os_image_hash")?; + println!("{}", hex::encode(hash)); + Ok(()) + } Some("-h") | Some("--help") => { println!("{USAGE}"); Ok(()) @@ -30,3 +84,41 @@ fn main() -> Result<()> { None => bail!("{USAGE}"), } } + +fn inspect_measurement(path: &Path) -> Result { + let document_text = fs_err::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let document: OsImageMeasurementDocument = serde_json::from_str(&document_text) + .with_context(|| format!("failed to parse {}", path.display()))?; + let mut out: Value = serde_json::from_str(&document_text) + .with_context(|| format!("failed to parse {}", path.display()))?; + + if let (Some(tdx), Some(tdx_value)) = (&document.tdx, out.get_mut("tdx")) { + replace_measurement_field( + tdx_value, + tdx.decode_measurement_value() + .map_err(anyhow::Error::msg) + .context("failed to decode tdx measurement CBOR")?, + ); + } + if let (Some(snp), Some(snp_value)) = (&document.snp, out.get_mut("snp")) { + replace_measurement_field( + snp_value, + snp.decode_measurement_value() + .map_err(anyhow::Error::msg) + .context("failed to decode snp measurement CBOR")?, + ); + } + Ok(out) +} + +fn replace_measurement_field(section: &mut Value, decoded_measurement: Value) { + let Some(section) = section.as_object_mut() else { + return; + }; + if section.contains_key("measurement") { + section.insert("measurement".to_string(), decoded_measurement); + } else if section.contains_key("m") { + section.insert("m".to_string(), decoded_measurement); + } +} diff --git a/dstack-mr/src/measurement.rs b/dstack-mr/src/measurement.rs new file mode 100644 index 000000000..602afee60 --- /dev/null +++ b/dstack-mr/src/measurement.rs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Unified build-time OS-image measurement document. + +use anyhow::{Context, Result}; +use dstack_types::{ + OsImageMeasurementDocument, SevOsImageMeasurementDocument, TdxOsImageMeasurementDocument, +}; +use fs_err as fs; +use serde::Deserialize; +use std::path::Path; + +#[derive(Debug, Deserialize)] +struct ImageMetadata { + #[serde(default, rename = "bios-sev")] + bios_sev: Option, +} + +/// Generate `measurement.json` for an image directory. +/// +/// TDX material is mandatory for the normal dstack image. SNP material is +/// included when metadata declares a dedicated `bios-sev` firmware. +pub fn os_image_measurement_document_for_image_dir( + image_dir: &Path, +) -> Result { + let meta_path = image_dir.join("metadata.json"); + let meta_str = fs::read_to_string(&meta_path) + .with_context(|| format!("cannot read {}", meta_path.display()))?; + let meta: ImageMetadata = + serde_json::from_str(&meta_str).context("failed to parse image metadata.json")?; + + let tdx = TdxOsImageMeasurementDocument::new( + crate::tdx::tdx_os_image_measurement_for_image_dir(image_dir) + .context("failed to build TDX measurement document")?, + ); + + let snp = if meta.bios_sev.is_some() { + Some(SevOsImageMeasurementDocument::new( + crate::sev::sev_os_image_measurement_for_image_dir(image_dir) + .context("failed to build SNP measurement document")?, + )) + } else { + None + }; + + Ok(OsImageMeasurementDocument::new(Some(tdx), snp)) +} diff --git a/dstack-mr/src/sev.rs b/dstack-mr/src/sev.rs index 1d97d2c2f..59e96a1de 100644 --- a/dstack-mr/src/sev.rs +++ b/dstack-mr/src/sev.rs @@ -50,7 +50,7 @@ pub struct OvmfSectionParam { #[serde(deny_unknown_fields)] pub struct MeasurementInput { /// Original image kernel cmdline used for SNP measured launch. - pub base_cmdline: Option, + pub base_cmdline: String, /// 48-byte OVMF GCTX launch digest seed supplied by the VMM. pub ovmf_hash: String, /// 32-byte kernel SHA-256 hash. @@ -116,7 +116,7 @@ pub fn validate_measurement_input(input: &MeasurementInput) -> Result<()> { bail!("guest_features must be non-zero"); } - rootfs_hash_from_cmdline(input.base_cmdline.as_deref())?; + rootfs_hash_from_cmdline(Some(&input.base_cmdline))?; decode_required_hex("kernel_hash", &input.kernel_hash, 32)?; decode_optional_hex("initrd_hash", &input.initrd_hash, 32)?; if input.vcpus == 0 { @@ -321,6 +321,24 @@ fn build_sev_hashes_page( Ok(page) } +fn measured_kernel_cmdline(input: &str) -> String { + input.trim().to_string() +} + +fn kernel_cmdline_sha256(input: &str) -> Vec { + let cmdline = measured_kernel_cmdline(input); + let mut cmdline_bytes = cmdline.as_bytes().to_vec(); + cmdline_bytes.push(0); + Sha256::digest(&cmdline_bytes).to_vec() +} + +fn effective_initrd_hash_from_hex(value: &str) -> Result> { + if value.is_empty() { + return Ok(Sha256::digest(b"").to_vec()); + } + decode_required_hex("initrd_hash", value, 32) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SectionType { SnpSecMemory = 1, @@ -664,10 +682,7 @@ pub fn compute_expected_measurement(input: &MeasurementInput) -> Result<[u8; 48] .as_deref() .ok_or_else(|| anyhow::anyhow!("vcpu_type is required"))?; - let cmdline = match input.base_cmdline.as_deref() { - Some(base) if !base.trim().is_empty() => base.trim().to_string(), - _ => "console=ttyS0 loglevel=7".to_string(), - }; + let cmdline = measured_kernel_cmdline(&input.base_cmdline); let resolved_sections = input .ovmf_sections .iter() @@ -737,12 +752,15 @@ pub fn compute_expected_measurement(input: &MeasurementInput) -> Result<[u8; 48] fn sev_os_image_measurement( input: &MeasurementInput, ) -> Result { + // Validate that the measured command line commits the rootfs identity. The + // compact image projection does not carry a separate rootfs_hash because it + // is already committed by `kernel_cmdline_sha256`. + rootfs_hash_from_cmdline(Some(&input.base_cmdline))?; Ok(dstack_types::SevOsImageMeasurement { - rootfs_hash: rootfs_hash_from_cmdline(input.base_cmdline.as_deref())?, - base_cmdline: input.base_cmdline.clone(), - ovmf_hash: input.ovmf_hash.clone(), - kernel_hash: input.kernel_hash.clone(), - initrd_hash: input.initrd_hash.clone(), + kernel_cmdline_sha256: kernel_cmdline_sha256(&input.base_cmdline), + ovmf_hash: decode_required_hex("ovmf_hash", &input.ovmf_hash, 48)?, + kernel_hash: decode_required_hex("kernel_hash", &input.kernel_hash, 32)?, + initrd_hash: effective_initrd_hash_from_hex(&input.initrd_hash)?, sev_hashes_table_gpa: input.sev_hashes_table_gpa, sev_es_reset_eip: input.sev_es_reset_eip, ovmf_sections: input @@ -821,9 +839,9 @@ struct ImageMetadata { bios_sev: Option, } -fn file_sha256_hex(path: &Path) -> Result { +fn file_sha256(path: &Path) -> Result> { let data = fs::read(path).with_context(|| format!("cannot read {}", path.display()))?; - Ok(hex::encode(Sha256::digest(data))) + Ok(Sha256::digest(data).to_vec()) } pub fn rootfs_hash_from_cmdline(cmdline: Option<&str>) -> Result { @@ -840,14 +858,12 @@ pub fn rootfs_hash_from_cmdline(cmdline: Option<&str>) -> Result { )?)) } -/// Compute the AMD SEV-SNP `os_image_hash` from an OS image directory containing -/// `metadata.json` plus the SEV firmware, kernel and initrd. -/// -/// This is the canonical producer of `digest.sev.txt`. The value equals the -/// `os_image_hash` the KMS and verifier derive from a hardware-verified launch -/// measurement, because both go through [`snp_measurement_os_image_hash`] / -/// `dstack_types::SevOsImageMeasurement`. -pub fn sev_os_image_hash_for_image_dir(image_dir: &Path) -> Result<[u8; 32]> { +/// Compute the AMD SEV-SNP image-invariant measurement projection from an OS +/// image directory containing `metadata.json` plus the SEV firmware, kernel and +/// initrd. +pub fn sev_os_image_measurement_for_image_dir( + image_dir: &Path, +) -> Result { let meta_path = image_dir.join("metadata.json"); let meta_str = fs::read_to_string(&meta_path) .with_context(|| format!("cannot read {}", meta_path.display()))?; @@ -862,13 +878,20 @@ pub fn sev_os_image_hash_for_image_dir(image_dir: &Path) -> Result<[u8; 32]> { .or(meta.bios.as_deref()) .context("bios-sev/bios is required for amd sev-snp os_image_hash")?; let ovmf = ovmf_measurement_info(&image_dir.join(bios))?; + // Validate that the measured command line commits the rootfs identity. The + // compact image projection does not carry a separate rootfs_hash because it + // is already committed by `kernel_cmdline_sha256`. + rootfs_hash_from_cmdline(meta.cmdline.as_deref())?; - let measurement = dstack_types::SevOsImageMeasurement { - rootfs_hash: rootfs_hash_from_cmdline(meta.cmdline.as_deref())?, - base_cmdline: meta.cmdline.as_deref().map(|c| c.trim().to_string()), - ovmf_hash: ovmf.ovmf_hash, - kernel_hash: file_sha256_hex(&image_dir.join(&meta.kernel))?, - initrd_hash: file_sha256_hex(&image_dir.join(&meta.initrd))?, + Ok(dstack_types::SevOsImageMeasurement { + kernel_cmdline_sha256: kernel_cmdline_sha256( + meta.cmdline + .as_deref() + .context("metadata.json cmdline is required for amd sev-snp os_image_hash")?, + ), + ovmf_hash: decode_required_hex("ovmf_hash", &ovmf.ovmf_hash, 48)?, + kernel_hash: file_sha256(&image_dir.join(&meta.kernel))?, + initrd_hash: file_sha256(&image_dir.join(&meta.initrd))?, sev_hashes_table_gpa: ovmf.sev_hashes_table_gpa, sev_es_reset_eip: ovmf.sev_es_reset_eip, ovmf_sections: ovmf @@ -880,8 +903,27 @@ pub fn sev_os_image_hash_for_image_dir(image_dir: &Path) -> Result<[u8; 32]> { section_type: s.section_type, }) .collect(), - }; - Ok(measurement.os_image_hash()) + }) +} + +/// Compute the AMD SEV-SNP `os_image_hash` from an OS image directory. +/// +/// This is the canonical legacy producer of `digest.sev.txt`. New images carry +/// the same value in `measurement.json.snp.os_image_hash`. The value equals the +/// `os_image_hash` the KMS and verifier derive from a hardware-verified launch +/// measurement, because both go through [`snp_measurement_os_image_hash`] / +/// `dstack_types::SevOsImageMeasurement`. +pub fn sev_os_image_hash_for_image_dir(image_dir: &Path) -> Result<[u8; 32]> { + Ok(sev_os_image_measurement_for_image_dir(image_dir)?.os_image_hash()) +} + +/// Build the SNP section of `measurement.json`. +pub fn sev_os_image_measurement_document_for_image_dir( + image_dir: &Path, +) -> Result { + Ok(dstack_types::SevOsImageMeasurementDocument::new( + sev_os_image_measurement_for_image_dir(image_dir)?, + )) } /// `sha256(MEASUREMENT || HOST_DATA)` — the SNP aggregated identity digest. @@ -1120,7 +1162,7 @@ mod tests { fn valid_input() -> MeasurementInput { let rootfs_hash = hex_of(0x33, 32); MeasurementInput { - base_cmdline: Some(format!("console=ttyS0 dstack.rootfs_hash={rootfs_hash}")), + base_cmdline: format!("console=ttyS0 dstack.rootfs_hash={rootfs_hash}"), ovmf_hash: hex_of(0x44, 48), kernel_hash: hex_of(0x55, 32), initrd_hash: hex_of(0x66, 32), @@ -1158,6 +1200,30 @@ mod tests { serde_json::to_string(input).expect("measurement input should serialize") } + #[test] + fn measurement_input_requires_base_cmdline() { + let mut value = serde_json::to_value(valid_input()).expect("serialize measurement input"); + value + .as_object_mut() + .expect("measurement input is an object") + .remove("base_cmdline"); + let err = serde_json::from_value::(value) + .expect_err("missing base_cmdline must reject"); + assert!( + err.to_string().contains("missing field `base_cmdline`"), + "unexpected error: {err:?}" + ); + + let mut input = valid_input(); + input.base_cmdline = " ".to_string(); + let err = + validate_measurement_input(&input).expect_err("empty measured cmdline must reject"); + assert!( + err.to_string().contains("dstack.rootfs_hash is required"), + "unexpected error: {err:?}" + ); + } + #[test] fn measurement_input_does_not_carry_standalone_rootfs_hash() { let value = serde_json::to_value(valid_input()).expect("serialize measurement input"); @@ -1187,16 +1253,13 @@ mod tests { // Image-determined fields MUST change the os_image_hash. let image_cases: Vec<(&str, fn(&mut MeasurementInput))> = vec![ ("base_cmdline.rootfs_hash", |i| { - i.base_cmdline = Some(format!( - "console=ttyS0 dstack.rootfs_hash={}", - hex_of(0x34, 32) - )) + i.base_cmdline = format!("console=ttyS0 dstack.rootfs_hash={}", hex_of(0x34, 32)) }), ("base_cmdline", |i| { - i.base_cmdline = Some(format!( + i.base_cmdline = format!( "console=ttyS0 loglevel=8 dstack.rootfs_hash={}", hex_of(0x33, 32) - )) + ) }), ("ovmf_hash", |i| i.ovmf_hash = hex_of(0x45, 48)), ("kernel_hash", |i| i.kernel_hash = hex_of(0x56, 32)), @@ -1313,13 +1376,13 @@ mod tests { "7f51e17f72a04d5422cb2c00998166536019a217376f3aa45a630e59c805a599847ff250dbffcd07e1ba639771d6f05d", ); - // os_image_hash derived from the same document must match the value the - // CVM advertised in its vm_config (and digest.sev.txt). + // os_image_hash derived from the same document must match the current + // measurement.json projection for these launch inputs. let os_image_hash = snp_measurement_os_image_hash(REAL_MEASUREMENT_DOC).expect("derive os_image_hash"); assert_eq!( hex::encode(os_image_hash), - "32b4767373ad7fa0f9c418925006194d5c3f5619529f309fe81156789fecd8bc", + "b6e8403b8f6167bcef4e39aa1039d8728fe624532ca6cedf2625a87fac2e5fda", ); } @@ -1407,10 +1470,10 @@ mod tests { let (input, mr_config, measurement, host_data, _vm_config) = honest_case(); let cases: Vec<(&str, fn(&mut MeasurementInput))> = vec![ ("base_cmdline", |i| { - i.base_cmdline = Some(format!( + i.base_cmdline = format!( "console=ttyS0 evil=1 dstack.rootfs_hash={}", hex_of(0x33, 32) - )) + ) }), ("ovmf_hash", |i| i.ovmf_hash = hex_of(0x99, 48)), ("kernel_hash", |i| i.kernel_hash = hex_of(0x99, 32)), @@ -1453,10 +1516,7 @@ mod tests { .expect("honest launch verifies"); let mut tampered = input.clone(); - tampered.base_cmdline = Some(format!( - "console=ttyS0 dstack.rootfs_hash={}", - hex_of(0x99, 32) - )); + tampered.base_cmdline = format!("console=ttyS0 dstack.rootfs_hash={}", hex_of(0x99, 32)); let tampered_vm = synthetic_vm_config(&tampered, &mr_config); let err = verify_sev_launch(&measurement, &host_data, &tampered_vm) .expect_err("tampered rootfs hash in cmdline must not verify"); diff --git a/dstack-mr/src/tdvf.rs b/dstack-mr/src/tdvf.rs index f3791e8fc..3b6b7d3af 100644 --- a/dstack-mr/src/tdvf.rs +++ b/dstack-mr/src/tdvf.rs @@ -9,35 +9,11 @@ use sha2::{Digest, Sha384}; use crate::acpi::Tables; use crate::num::read_le; -use crate::uefi_var::{ - boot_option_bytes, boot_order_bytes, fv_file_node, fv_node, END_OF_DEVICE_PATH, -}; use crate::{measure_log, measure_sha384, utf16_encode, Machine, OvmfVariant, RtmrLog}; const PAGE_SIZE: u64 = 0x1000; const MR_EXTEND_GRANULARITY: usize = 0x100; -// OVMF firmware-volume identifiers used by edk2-stable202505. These are baked -// into the OVMF binary at build time; if the firmware is regenerated against a -// different EDK2 source these constants may need refreshing. -// -// Each GUID is stored in the on-the-wire little-endian byte form OVMF puts in -// the EFI_DEVICE_PATH MEDIA_FV / MEDIA_FV_FILE nodes — the first three GUID -// fields are byte-swapped relative to the canonical string form. -// -// canonical: 7cb8bdc9-f8eb-4f34-aaea-3ee4af6516a1 -const OVMF_FV_GUID_LE: [u8; 16] = [ - 0xc9, 0xbd, 0xb8, 0x7c, 0xeb, 0xf8, 0x34, 0x4f, 0xaa, 0xea, 0x3e, 0xe4, 0xaf, 0x65, 0x16, 0xa1, -]; -// canonical: eec25bdc-67f2-4d95-b1d5-f81b2039d11d (MdeModulePkg UiApp) -const OVMF_UIAPP_FILE_GUID_LE: [u8; 16] = [ - 0xdc, 0x5b, 0xc2, 0xee, 0xf2, 0x67, 0x95, 0x4d, 0xb1, 0xd5, 0xf8, 0x1b, 0x20, 0x39, 0xd1, 0x1d, -]; -// canonical: 462caa21-7614-4503-836e-8ab6f4662331 (MdeModulePkg BootMaintenance / FrontPage) -const OVMF_FRONTPAGE_FILE_GUID_LE: [u8; 16] = [ - 0x21, 0xaa, 0x2c, 0x46, 0x14, 0x76, 0x03, 0x45, 0x83, 0x6e, 0x8a, 0xb6, 0xf4, 0x66, 0x23, 0x31, -]; - const ATTRIBUTE_MR_EXTEND: u32 = 0x00000001; const ATTRIBUTE_PAGE_AUG: u32 = 0x00000002; @@ -49,6 +25,53 @@ pub enum PageAddOrder { SinglePass, } +#[derive(Debug, Clone)] +pub(crate) struct AcpiTableHashes { + pub loader: Vec, + pub rsdp: Vec, + pub tables: Vec, +} + +pub(crate) fn rtmr0_log_from_td_hob_hash_with_acpi_hashes( + td_hob_hash: Vec, + ovmf_variant: OvmfVariant, + acpi_hashes: &AcpiTableHashes, +) -> Result { + let cfv_image_hash = hex!("344BC51C980BA621AAA00DA3ED7436F7D6E549197DFE699515DFA2C6583D95E6412AF21C097D473155875FFD561D6790"); + + let secureboot_hash = + measure_tdx_efi_variable("8BE4DF61-93CA-11D2-AA0D-00E098032B8C", "SecureBoot")?; + let pk_hash = measure_tdx_efi_variable("8BE4DF61-93CA-11D2-AA0D-00E098032B8C", "PK")?; + let kek_hash = measure_tdx_efi_variable("8BE4DF61-93CA-11D2-AA0D-00E098032B8C", "KEK")?; + let db_hash = measure_tdx_efi_variable("D719B2CB-3D3A-4596-A3BC-DAD00E67656F", "db")?; + let dbx_hash = measure_tdx_efi_variable("D719B2CB-3D3A-4596-A3BC-DAD00E67656F", "dbx")?; + let separator_hash = measure_sha384(&[0x00, 0x00, 0x00, 0x00]); + + let log = match ovmf_variant { + OvmfVariant::Pre202505 => { + // Boot0000 = OVMF UiApp (fixed digest for pre-202505 firmware). + let boot000_hash = hex!("23ADA07F5261F12F34A0BD8E46760962D6B4D576A416F1FEA1C64BC656B1D28EACF7047AE6E967C58FD2A98BFA74C298"); + vec![ + td_hob_hash, + cfv_image_hash.to_vec(), + secureboot_hash, + pk_hash, + kek_hash, + db_hash, + dbx_hash, + separator_hash, + acpi_hashes.loader.clone(), + acpi_hashes.rsdp.clone(), + acpi_hashes.tables.clone(), + measure_sha384(&[0x00, 0x00]), // BootOrder (raw 2 bytes in legacy OVMF) + boot000_hash.to_vec(), + ] + } + }; + + Ok(log) +} + /// Helper to decode little-endian integers from byte slice using scale codec fn decode_le(data: &[u8], context: &str) -> Result { T::decode(&mut &data[..]) @@ -279,6 +302,14 @@ impl<'a> Tdvf<'a> { Ok(h.finalize().to_vec()) } + pub(crate) fn mrtd_single_pass(&self) -> Result> { + self.compute_mrtd(PageAddOrder::SinglePass) + } + + pub(crate) fn mrtd_two_pass(&self) -> Result> { + self.compute_mrtd(PageAddOrder::TwoPass) + } + pub fn mrtd(&self, machine: &Machine) -> Result> { let opts = machine .versioned_options() @@ -290,6 +321,89 @@ impl<'a> Tdvf<'a> { }) } + /// Build the compact TdHobWitnessV1 byte string for this TDVF. + /// + /// The witness contains only the accepted TD HOB/TEMP_MEM ranges needed to + /// reconstruct the TD HOB for any VM memory size. All addresses/sizes are + /// represented in 4 KiB pages using unsigned LEB128 varints: + /// + /// varuint base_page + /// varuint td_hob_page_delta + /// varuint range_count + /// repeated range_count: + /// varuint start_page_delta + /// varuint page_count + /// + /// `base_page` is the minimum accepted range start page. Deltas are relative + /// to it. Ranges are sorted by start page and intentionally not merged; the + /// TD HOB measurement code emits adjacent accepted ranges as separate HOB + /// resources when TDVF metadata describes them separately. + pub(crate) fn td_hob_witness_v1(&self) -> Result> { + fn put_varuint(mut value: u64, out: &mut Vec) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + out.push(byte); + if value == 0 { + break; + } + } + } + + let mut ranges = Vec::<(u64, u64)>::new(); + let mut td_hob_page = None; + + for s in &self.sections { + if matches!(s.sec_type, TDVF_SECTION_TD_HOB | TDVF_SECTION_TEMP_MEM) { + let start_page = s.memory_address / PAGE_SIZE; + let page_count = s.memory_data_size / PAGE_SIZE; + if page_count == 0 { + bail!("TD HOB witness range must not be empty"); + } + ranges.push((start_page, page_count)); + } + if s.sec_type == TDVF_SECTION_TD_HOB + && td_hob_page.replace(s.memory_address / PAGE_SIZE).is_some() + { + bail!("TDVF metadata contains more than one TD_HOB section"); + } + } + + if ranges.is_empty() { + bail!("TDVF metadata has no TD_HOB/TEMP_MEM sections"); + } + let td_hob_page = td_hob_page.context("TDVF metadata is missing TD_HOB section")?; + + ranges.sort_by_key(|&(start_page, _)| start_page); + let mut prev_end = None; + for &(start_page, page_count) in &ranges { + if let Some(end) = prev_end { + if start_page < end { + bail!("TD HOB witness ranges must not overlap"); + } + } + prev_end = Some(start_page + page_count); + } + + let base_page = ranges[0].0; + if td_hob_page < base_page { + bail!("TD_HOB page is below TD HOB witness base page"); + } + + let mut out = Vec::with_capacity(4 + ranges.len() * 2); + put_varuint(base_page, &mut out); + put_varuint(td_hob_page - base_page, &mut out); + put_varuint(ranges.len() as u64, &mut out); + for (start_page, page_count) in ranges { + put_varuint(start_page - base_page, &mut out); + put_varuint(page_count, &mut out); + } + Ok(out) + } + #[allow(dead_code)] pub fn rtmr0(&self, machine: &Machine) -> Result> { let (rtmr0_log, _) = self.rtmr0_log(machine)?; @@ -297,135 +411,30 @@ impl<'a> Tdvf<'a> { } pub fn rtmr0_log(&self, machine: &Machine) -> Result<(RtmrLog, Tables)> { - let td_hob_hash = self.measure_td_hob(machine.memory_size)?; - let cfv_image_hash = hex!("344BC51C980BA621AAA00DA3ED7436F7D6E549197DFE699515DFA2C6583D95E6412AF21C097D473155875FFD561D6790"); - let tables = machine.build_tables()?; - let acpi_tables_hash = measure_sha384(&tables.tables); - let acpi_rsdp_hash = measure_sha384(&tables.rsdp); - let acpi_loader_hash = measure_sha384(&tables.loader); - - let secureboot_hash = - measure_tdx_efi_variable("8BE4DF61-93CA-11D2-AA0D-00E098032B8C", "SecureBoot")?; - let pk_hash = measure_tdx_efi_variable("8BE4DF61-93CA-11D2-AA0D-00E098032B8C", "PK")?; - let kek_hash = measure_tdx_efi_variable("8BE4DF61-93CA-11D2-AA0D-00E098032B8C", "KEK")?; - let db_hash = measure_tdx_efi_variable("D719B2CB-3D3A-4596-A3BC-DAD00E67656F", "db")?; - let dbx_hash = measure_tdx_efi_variable("D719B2CB-3D3A-4596-A3BC-DAD00E67656F", "dbx")?; - let separator_hash = measure_sha384(&[0x00, 0x00, 0x00, 0x00]); - - let log = match machine.ovmf_variant { - OvmfVariant::Pre202505 => { - // Boot0000 = OVMF UiApp (fixed digest for pre-202505 firmware). - let boot000_hash = hex!("23ADA07F5261F12F34A0BD8E46760962D6B4D576A416F1FEA1C64BC656B1D28EACF7047AE6E967C58FD2A98BFA74C298"); - vec![ - td_hob_hash, - cfv_image_hash.to_vec(), - secureboot_hash, - pk_hash, - kek_hash, - db_hash, - dbx_hash, - separator_hash, - acpi_loader_hash, - acpi_rsdp_hash, - acpi_tables_hash, - measure_sha384(&[0x00, 0x00]), // BootOrder (raw 2 bytes in legacy OVMF) - boot000_hash.to_vec(), - ] - } - OvmfVariant::Stable202505 => { - // edk2-stable202505 emits 17 RTMR[0] events instead of 13. The - // boot-option set is fully derivable from OVMF-internal - // constants (FV and file GUIDs, descriptions, attributes); the - // remaining two — the bootorder fw_cfg measurement and - // EV_EFI_VARIABLE_AUTHORITY — stay as captured digests because - // their content depends on QEMU's emitted device list and on - // OVMF-internal logic that's not worth shadowing here. - - // fw_cfg `BootMenu` is a u16; dstack doesn't pass `-boot - // menu=on`, so it defaults to 0x0000. - let bootmenu_fwcfg_hash = measure_sha384(&[0x00, 0x00]); - - // fw_cfg `bootorder` is the NUL-separated list of QEMU device - // paths whose backing devices have `bootindex` set. For - // `-kernel` boot, QEMU (hw/i386/x86.c::x86_load_linux) injects - // a single option ROM with `bootindex = 0`: - // * `linuxboot_dma.bin` if fw_cfg DMA is enabled (q35 default) - // * `linuxboot.bin` otherwise - // dstack-vmm always uses q35 → DMA is on → the bootorder file - // contains just the single path below (31 bytes, trailing - // NUL). No other dstack device gets an implicit bootindex. - // - // Verified end-to-end: gdb-attached the live QEMU and called - // get_boot_devices_list() — returned exactly these 31 bytes. - let bootorder_fwcfg_hash = measure_sha384(b"/rom@genroms/linuxboot_dma.bin\0"); - - // EV_EFI_VARIABLE_AUTHORITY: OVMF emits this once during BDS - // even when Secure Boot is disabled. The 32-byte event blob in - // the log is a sentinel; the actual measured payload is - // OVMF-internal. Captured digest is a constant for the - // edk2-stable202505 build dstack ships. - let variable_authority_hash = - hex!("FB66919801F1DFC9C4C273B6A739380790CB0FD3CB706A42F6AC050510EBC8618E7FBA53A1564522F5C6F0DC9E1F41A6"); - - // BootOrder UEFI variable holds [0x0000, 0x0001] — the two - // boot options OVMF's BDS publishes (UiApp and FrontPage). - // The TCG digest for `EV_EFI_VARIABLE_BOOT2` is over the raw - // variable data, NOT a UEFI_VARIABLE_DATA wrapper. - let boot_order_var_hash = measure_sha384(&boot_order_bytes(&[0x0000, 0x0001])); - - // Boot0000 = OVMF's BootManagerMenuApp; Boot0001 = "EFI - // Firmware Setup" (FrontPage). Both live in the OVMF FV and - // are baked into the firmware at build time. The attribute - // bits and descriptions come from MdeModulePkg's - // BdsBootManagerLib in edk2-stable202505. - // 0x101 = LOAD_OPTION_ACTIVE | LOAD_OPTION_CATEGORY_APP - // 0x109 = + LOAD_OPTION_HIDDEN - let boot0000_hash = measure_sha384(&boot_option_bytes( - 0x0000_0109, - "BootManagerMenuApp", - &[ - fv_node(&OVMF_FV_GUID_LE), - fv_file_node(&OVMF_UIAPP_FILE_GUID_LE), - END_OF_DEVICE_PATH, - ], - &[], - )); - let boot0001_hash = measure_sha384(&boot_option_bytes( - 0x0000_0101, - "EFI Firmware Setup", - &[ - fv_node(&OVMF_FV_GUID_LE), - fv_file_node(&OVMF_FRONTPAGE_FILE_GUID_LE), - END_OF_DEVICE_PATH, - ], - &[], - )); - vec![ - td_hob_hash, - cfv_image_hash.to_vec(), - bootmenu_fwcfg_hash, - bootorder_fwcfg_hash.to_vec(), - secureboot_hash, - pk_hash, - kek_hash, - db_hash, - dbx_hash, - separator_hash, - acpi_loader_hash, - acpi_rsdp_hash, - acpi_tables_hash, - variable_authority_hash.to_vec(), - boot_order_var_hash, - boot0000_hash, - boot0001_hash, - ] - } + let acpi_hashes = AcpiTableHashes { + tables: measure_sha384(&tables.tables), + rsdp: measure_sha384(&tables.rsdp), + loader: measure_sha384(&tables.loader), }; - + let log = self.rtmr0_log_with_acpi_hashes( + machine.memory_size, + machine.ovmf_variant, + &acpi_hashes, + )?; Ok((log, tables)) } + pub(crate) fn rtmr0_log_with_acpi_hashes( + &self, + memory_size: u64, + ovmf_variant: OvmfVariant, + acpi_hashes: &AcpiTableHashes, + ) -> Result { + let td_hob_hash = self.measure_td_hob(memory_size)?; + rtmr0_log_from_td_hob_hash_with_acpi_hashes(td_hob_hash, ovmf_variant, acpi_hashes) + } + fn measure_td_hob(&self, memory_size: u64) -> Result> { let mut memory_acceptor = MemoryAcceptor::new(0, memory_size); let mut td_hob = Vec::new(); @@ -533,3 +542,55 @@ impl MemoryAcceptor { self.ranges = new_ranges; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn td_hob_witness_v1_encodes_current_dstack_ranges_compactly() -> Result<()> { + let tdvf = Tdvf { + fw: &[], + sections: vec![ + TdvfSection { + data_offset: 0, + raw_data_size: 0, + memory_address: 0x810000, + memory_data_size: 0x10000, + sec_type: TDVF_SECTION_TEMP_MEM, + attributes: 0, + }, + TdvfSection { + data_offset: 0, + raw_data_size: 0, + memory_address: 0x80b000, + memory_data_size: 0x2000, + sec_type: TDVF_SECTION_TEMP_MEM, + attributes: 0, + }, + TdvfSection { + data_offset: 0, + raw_data_size: 0, + memory_address: 0x809000, + memory_data_size: 0x2000, + sec_type: TDVF_SECTION_TD_HOB, + attributes: 0, + }, + TdvfSection { + data_offset: 0, + raw_data_size: 0, + memory_address: 0x800000, + memory_data_size: 0x6000, + sec_type: TDVF_SECTION_TEMP_MEM, + attributes: 0, + }, + ], + }; + + assert_eq!( + hex::encode(tdvf.td_hob_witness_v1()?), + "80100904000609020b021010" + ); + Ok(()) + } +} diff --git a/dstack-mr/src/tdx.rs b/dstack-mr/src/tdx.rs new file mode 100644 index 000000000..f604bb0b5 --- /dev/null +++ b/dstack-mr/src/tdx.rs @@ -0,0 +1,625 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Build-time TDX OS-image static measurement material. +//! +//! The current verifier path recomputes TDX MRs from a downloaded image. This +//! module emits the image-static material needed by the no-image-download path: +//! MRTD candidates, compact TD HOB witness, command line, kernel/initrd digests +//! and sizes. VM-specific inputs (RAM size, vCPU count, QEMU topology knobs) are +//! intentionally excluded and must come from `VmConfig`. + +use crate::kernel::{ + patched_kernel_authenticode_sha384, tdx_kernel_hash_uses_precomputed_high_mem, + TDX_KERNEL_HASH_COMPAT_2G_MEMORY, TDX_KERNEL_HASH_STABLE_MIN_MEMORY, +}; +use crate::tdvf::{rtmr0_log_from_td_hob_hash_with_acpi_hashes, AcpiTableHashes, Tdvf}; +use crate::util::{measure_log, measure_sha384}; +use anyhow::{bail, Context, Result}; +use dstack_types::{ + OvmfVariant, TdxImageMeasurement, TdxMrtdCandidates, TdxOsImageMeasurement, + TdxOsImageMeasurementDocument, TdxTdvfMeasurement, VmConfig, +}; +use fs_err as fs; +use serde::Deserialize; +use std::path::Path; + +#[derive(Debug, Deserialize)] +struct ImageMetadata { + #[serde(default)] + cmdline: Option, + kernel: String, + initrd: String, + bios: String, + #[serde(default)] + version: String, + #[serde(default)] + ovmf_variant: Option, +} + +#[derive(Debug, Clone)] +pub struct TdxRtmr0AcpiHashes { + pub loader: Vec, + pub rsdp: Vec, + pub tables: Vec, +} + +#[derive(Debug, Clone)] +pub struct TdxMeasurementsWithoutRtmr0 { + pub mrtd: Vec, + pub rtmr1: Vec, + pub rtmr2: Vec, +} + +fn validate_bytes_field(value: &[u8], field: &str, expected_len: usize) -> Result> { + if value.len() != expected_len { + bail!( + "{field} has invalid length {}, expected {expected_len}", + value.len() + ); + } + Ok(value.to_vec()) +} + +fn select_mrtd(measurement: &TdxOsImageMeasurement, vm_config: &VmConfig) -> Result> { + let machine = crate::Machine::builder() + .cpu_count(vm_config.cpu_count) + .memory_size(vm_config.memory_size) + .firmware("") + .kernel("") + .initrd("") + .kernel_cmdline("") + .root_verity(true) + .hotplug_off(vm_config.hotplug_off) + .maybe_two_pass_add_pages(vm_config.qemu_single_pass_add_pages) + .maybe_pic(vm_config.pic) + .maybe_qemu_version(vm_config.qemu_version.clone()) + .maybe_pci_hole64_size(if vm_config.pci_hole64_size > 0 { + Some(vm_config.pci_hole64_size) + } else { + None + }) + .hugepages(vm_config.hugepages) + .num_gpus(vm_config.num_gpus) + .num_nvswitches(vm_config.num_nvswitches) + .host_share_mode(vm_config.host_share_mode.clone()) + .ovmf_variant(measurement.tdvf.ovmf_variant) + .build(); + let opts = machine + .versioned_options() + .context("failed to resolve QEMU measurement options")?; + let mrtd = if opts.two_pass_add_pages { + &measurement.tdvf.mrtd.two_pass + } else { + &measurement.tdvf.mrtd.single_pass + }; + validate_bytes_field(mrtd, "tdx.measurement.tdvf.mrtd", 48) +} + +fn read_varuint(input: &mut &[u8]) -> Result { + let mut value = 0u64; + let mut shift = 0u32; + loop { + let (&byte, rest) = input + .split_first() + .context("truncated TD HOB witness varuint")?; + *input = rest; + value |= ((byte & 0x7f) as u64) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + shift += 7; + if shift >= 64 { + bail!("TD HOB witness varuint is too large"); + } + } +} + +fn measure_td_hob_from_witness_data(data: &[u8], memory_size: u64) -> Result> { + let mut input = data; + let base_page = read_varuint(&mut input)?; + let td_hob_page_delta = read_varuint(&mut input)?; + let range_count = read_varuint(&mut input)?; + let td_hob_base_addr = (base_page + td_hob_page_delta) + .checked_mul(0x1000) + .context("TD HOB base address overflow")?; + + let mut memory_acceptor = MemoryAcceptor::new(0, memory_size); + for _ in 0..range_count { + let start_page_delta = read_varuint(&mut input)?; + let page_count = read_varuint(&mut input)?; + let start = (base_page + start_page_delta) + .checked_mul(0x1000) + .context("TD HOB range start overflow")?; + let len = page_count + .checked_mul(0x1000) + .context("TD HOB range length overflow")?; + memory_acceptor.accept(start, start + len); + } + if !input.is_empty() { + bail!("TD HOB witness has trailing bytes"); + } + + let mut td_hob = Vec::new(); + td_hob.extend_from_slice(&[0x01, 0x00]); // HobType + td_hob.extend_from_slice(&56u16.to_le_bytes()); // HobLength + td_hob.extend_from_slice(&[0u8; 4]); // Reserved + td_hob.extend_from_slice(&9u32.to_le_bytes()); // Version + td_hob.extend_from_slice(&[0u8; 4]); // BootMode + td_hob.extend_from_slice(&[0u8; 8]); // EfiMemoryTop + td_hob.extend_from_slice(&[0u8; 8]); // EfiMemoryBottom + td_hob.extend_from_slice(&[0u8; 8]); // EfiFreeMemoryTop + td_hob.extend_from_slice(&[0u8; 8]); // EfiFreeMemoryBottom + td_hob.extend_from_slice(&[0u8; 8]); // EfiEndOfHobList (placeholder) + + let mut add_memory_resource_hob = |resource_type: u8, start: u64, length: u64| { + td_hob.extend_from_slice(&[0x03, 0x00]); // HobType + td_hob.extend_from_slice(&48u16.to_le_bytes()); // HobLength + td_hob.extend_from_slice(&[0u8; 4]); // Reserved + td_hob.extend_from_slice(&[0u8; 16]); // Owner + td_hob.extend_from_slice(&resource_type.to_le_bytes()); + td_hob.extend_from_slice(&[0u8; 3]); // Padding for resource type + td_hob.extend_from_slice(&7u32.to_le_bytes()); // ResourceAttribute + td_hob.extend_from_slice(&start.to_le_bytes()); + td_hob.extend_from_slice(&length.to_le_bytes()); + }; + + let (_, last_start, last_end) = memory_acceptor.ranges.pop().context("No ranges")?; + + for (accepted, start, end) in memory_acceptor.ranges { + if end < start { + bail!("Invalid memory range: end < start"); + } + let size = end - start; + if accepted { + add_memory_resource_hob(0x00, start, size); + } else { + add_memory_resource_hob(0x07, start, size); + } + } + + if last_end < last_start { + bail!("Invalid last memory range: end < start"); + } + if memory_size >= TDX_KERNEL_HASH_STABLE_MIN_MEMORY { + if last_start < 0x80000000u64 { + add_memory_resource_hob(0x07, last_start, 0x80000000u64 - last_start); + } + if last_end > 0x80000000u64 { + add_memory_resource_hob(0x07, 0x100000000, last_end - 0x80000000u64); + } + } else { + add_memory_resource_hob(0x07, last_start, last_end - last_start); + } + + let end_of_hob_list = td_hob_base_addr + td_hob.len() as u64 + 8; + td_hob[48..56].copy_from_slice(&end_of_hob_list.to_le_bytes()); + + Ok(measure_sha384(&td_hob)) +} + +struct MemoryAcceptor { + ranges: Vec<(bool, u64, u64)>, +} + +impl MemoryAcceptor { + fn new(start: u64, size: u64) -> Self { + Self { + ranges: vec![(false, start, start + size)], + } + } + + fn accept(&mut self, start: u64, end: u64) { + if start >= end { + return; + } + + let mut new_ranges = Vec::new(); + + for &(is_accepted, range_start, range_end) in &self.ranges { + if is_accepted || range_end <= start || range_start >= end { + new_ranges.push((is_accepted, range_start, range_end)); + } else { + if range_start < start { + new_ranges.push((false, range_start, start)); + } + if range_end > end { + new_ranges.push((false, end, range_end)); + } + } + } + new_ranges.push((true, start, end)); + new_ranges.sort_by_key(|&(_, start, _)| start); + self.ranges = new_ranges; + } +} + +fn rtmr1_log_from_kernel_hash(kernel_hash: Vec) -> Vec> { + vec![ + kernel_hash, + measure_sha384(b"Calling EFI Application from Boot Option"), + measure_sha384(&[0x00, 0x00, 0x00, 0x00]), // Separator + measure_sha384(b"Exit Boot Services Invocation"), + measure_sha384(b"Exit Boot Services Returned with Success"), + ] +} + +/// Return the measured TDX kernel command line for a metadata cmdline. +/// +/// This mirrors the existing dstack TDX measurement replay path, which measures +/// the image-provided cmdline plus OVMF/QEMU's `initrd=initrd` suffix. +pub fn measured_kernel_cmdline(base_cmdline: &str) -> String { + format!("{base_cmdline} initrd=initrd") +} + +/// Generate the image-static TDX measurement material from an image directory. +pub fn tdx_os_image_measurement_for_image_dir(image_dir: &Path) -> Result { + let meta_path = image_dir.join("metadata.json"); + let meta_str = fs::read_to_string(&meta_path) + .with_context(|| format!("cannot read {}", meta_path.display()))?; + let meta: ImageMetadata = + serde_json::from_str(&meta_str).context("failed to parse image metadata.json")?; + + let base_cmdline = meta + .cmdline + .filter(|s| !s.trim().is_empty()) + .context("metadata.json cmdline is required for TDX os_image_hash")? + .to_string(); + + // Validate that the image identity carried by the measured cmdline is + // well-formed. The normalized rootfs hash is not stored separately to keep + // the TDX projection compact; it is already committed by the measured + // kernel command line digest. + crate::sev::rootfs_hash_from_cmdline(Some(&base_cmdline)) + .context("failed to parse dstack.rootfs_hash from TDX cmdline")?; + + let ovmf_variant = meta + .ovmf_variant + .or_else(|| { + if meta.version.is_empty() { + None + } else { + crate::ovmf_variant_for_version(&meta.version).ok() + } + }) + .unwrap_or_default(); + + let fw_data = fs::read(image_dir.join(&meta.bios)) + .with_context(|| format!("cannot read {}", image_dir.join(&meta.bios).display()))?; + let tdvf = Tdvf::parse(&fw_data).context("failed to parse TDX TDVF metadata")?; + + let initrd_path = image_dir.join(&meta.initrd); + let initrd = + fs::read(&initrd_path).with_context(|| format!("cannot read {}", initrd_path.display()))?; + let kernel_path = image_dir.join(&meta.kernel); + let kernel = + fs::read(&kernel_path).with_context(|| format!("cannot read {}", kernel_path.display()))?; + let kernel_authenticode = patched_kernel_authenticode_sha384( + &kernel, + initrd.len() as u32, + TDX_KERNEL_HASH_STABLE_MIN_MEMORY, + 0x28000, + ) + .context("failed to compute high-memory QEMU-patched kernel hash")?; + + Ok(TdxOsImageMeasurement { + image: TdxImageMeasurement { + kernel_cmdline_sha384: crate::kernel::measure_cmdline(&measured_kernel_cmdline( + &base_cmdline, + )), + kernel_authenticode, + initrd_sha384: measure_sha384(&initrd), + }, + tdvf: TdxTdvfMeasurement { + ovmf_variant, + mrtd: TdxMrtdCandidates { + single_pass: tdvf.mrtd_single_pass()?, + two_pass: tdvf.mrtd_two_pass()?, + }, + td_hob_witness: tdvf.td_hob_witness_v1()?, + }, + }) +} + +/// Generate the self-contained TDX measurement document for an image directory. +/// +/// The document contains both the hash projection and the resulting +/// `os_image_hash`, avoiding a separate `digest.tdx.txt` artifact. +pub fn tdx_os_image_measurement_document_for_image_dir( + image_dir: &Path, +) -> Result { + Ok(TdxOsImageMeasurementDocument::new( + tdx_os_image_measurement_for_image_dir(image_dir)?, + )) +} + +/// Compute the TDX static-material OS image hash for an image directory. +pub fn tdx_os_image_hash_for_image_dir(image_dir: &Path) -> Result<[u8; 32]> { + Ok(tdx_os_image_measurement_for_image_dir(image_dir)?.os_image_hash()) +} + +/// Compute expected TDX measurements from the self-contained `measurement.json` +/// TDX document and the three ACPI table digests captured in RTMR[0]. +/// +/// This path intentionally does not download or read the OS image. Because +/// QEMU's patched kernel Authenticode hash depends on exact guest RAM below +/// `TDX_KERNEL_HASH_STABLE_MIN_MEMORY`, the no-image-download path supports +/// CVMs at or above that threshold plus the exact 2 GiB placement, which QEMU +/// patches to the same kernel bytes as the high-memory case. +pub fn tdx_measurements_from_measurement_document( + document: &TdxOsImageMeasurementDocument, + vm_config: &VmConfig, + acpi_hashes: &TdxRtmr0AcpiHashes, +) -> Result { + if document.version != TdxOsImageMeasurementDocument::VERSION { + bail!( + "unsupported TDX measurement document version {}", + document.version + ); + } + if !tdx_kernel_hash_uses_precomputed_high_mem(vm_config.memory_size) { + bail!( + "TDX lite attestation without image download requires memory_size == {} bytes ({} MiB) or >= {} bytes ({} MiB); got {} bytes", + TDX_KERNEL_HASH_COMPAT_2G_MEMORY, + TDX_KERNEL_HASH_COMPAT_2G_MEMORY / 1024 / 1024, + TDX_KERNEL_HASH_STABLE_MIN_MEMORY, + TDX_KERNEL_HASH_STABLE_MIN_MEMORY / 1024 / 1024, + vm_config.memory_size + ); + } + + let measurement = document + .decode_measurement() + .map_err(anyhow::Error::msg) + .context("failed to decode TDX measurement CBOR")?; + let mrtd = select_mrtd(&measurement, vm_config)?; + + let td_hob_hash = + measure_td_hob_from_witness_data(&measurement.tdvf.td_hob_witness, vm_config.memory_size) + .context("failed to measure TD HOB from witness")?; + let rtmr0_log = rtmr0_log_from_td_hob_hash_with_acpi_hashes( + td_hob_hash, + measurement.tdvf.ovmf_variant, + &AcpiTableHashes { + loader: acpi_hashes.loader.clone(), + rsdp: acpi_hashes.rsdp.clone(), + tables: acpi_hashes.tables.clone(), + }, + ) + .context("failed to compute RTMR0 from measurement document")?; + let rtmr0 = measure_log(&rtmr0_log); + + let kernel_hash = validate_bytes_field( + &measurement.image.kernel_authenticode, + "tdx.measurement.image.kernel_authenticode", + 48, + )?; + let rtmr1 = measure_log(&rtmr1_log_from_kernel_hash(kernel_hash)); + + let initrd_hash = validate_bytes_field( + &measurement.image.initrd_sha384, + "tdx.measurement.image.initrd_sha384", + 48, + )?; + let kernel_cmdline_hash = validate_bytes_field( + &measurement.image.kernel_cmdline_sha384, + "tdx.measurement.image.kernel_cmdline_sha384", + 48, + )?; + let rtmr2 = measure_log(&[kernel_cmdline_hash, initrd_hash]); + + Ok(crate::TdxMeasurements { + mrtd, + rtmr0, + rtmr1, + rtmr2, + }) +} + +/// Compute image-critical TDX measurements without RTMR[0]. +/// +/// RTMR[0] contains QEMU-generated ACPI blobs and other launch-environment +/// material. This helper verifies the OS-image binding pieces that do not need +/// QEMU: MRTD (TDVF firmware), RTMR[1] (QEMU-patched kernel image), and RTMR[2] +/// (kernel command line + initrd). +pub fn tdx_measurements_for_image_dir_without_rtmr0( + image_dir: &Path, + vm_config: &VmConfig, +) -> Result { + let meta_path = image_dir.join("metadata.json"); + let meta_str = fs::read_to_string(&meta_path) + .with_context(|| format!("cannot read {}", meta_path.display()))?; + let meta: ImageMetadata = + serde_json::from_str(&meta_str).context("failed to parse image metadata.json")?; + + let base_cmdline = meta + .cmdline + .filter(|s| !s.trim().is_empty()) + .context("metadata.json cmdline is required for TDX measurement")? + .to_string(); + let kernel_cmdline = measured_kernel_cmdline(&base_cmdline); + + let firmware_path = image_dir.join(&meta.bios); + let kernel_path = image_dir.join(&meta.kernel); + let initrd_path = image_dir.join(&meta.initrd); + + let fw_data = fs::read(&firmware_path) + .with_context(|| format!("cannot read {}", firmware_path.display()))?; + let kernel_data = + fs::read(&kernel_path).with_context(|| format!("cannot read {}", kernel_path.display()))?; + let initrd_data = + fs::read(&initrd_path).with_context(|| format!("cannot read {}", initrd_path.display()))?; + + let ovmf_variant = vm_config + .ovmf_variant + .or(meta.ovmf_variant) + .or_else(|| { + if meta.version.is_empty() { + None + } else { + crate::ovmf_variant_for_version(&meta.version).ok() + } + }) + .unwrap_or_else(|| crate::ovmf_variant_for_image(vm_config.image.as_deref())); + + let firmware = firmware_path.display().to_string(); + let kernel = kernel_path.display().to_string(); + let initrd = initrd_path.display().to_string(); + let machine = crate::Machine::builder() + .cpu_count(vm_config.cpu_count) + .memory_size(vm_config.memory_size) + .firmware(&firmware) + .kernel(&kernel) + .initrd(&initrd) + .kernel_cmdline(&kernel_cmdline) + .root_verity(true) + .hotplug_off(vm_config.hotplug_off) + .maybe_two_pass_add_pages(vm_config.qemu_single_pass_add_pages) + .maybe_pic(vm_config.pic) + .maybe_qemu_version(vm_config.qemu_version.clone()) + .maybe_pci_hole64_size(if vm_config.pci_hole64_size > 0 { + Some(vm_config.pci_hole64_size) + } else { + None + }) + .hugepages(vm_config.hugepages) + .num_gpus(vm_config.num_gpus) + .num_nvswitches(vm_config.num_nvswitches) + .host_share_mode(vm_config.host_share_mode.clone()) + .ovmf_variant(ovmf_variant) + .build(); + + let tdvf = Tdvf::parse(&fw_data).context("failed to parse TDX TDVF metadata")?; + let mrtd = tdvf.mrtd(&machine).context("failed to compute MRTD")?; + + let rtmr1_log = crate::kernel::rtmr1_log( + &kernel_data, + initrd_data.len() as u32, + vm_config.memory_size, + 0x28000, + ) + .context("failed to compute RTMR1")?; + let rtmr1 = measure_log(&rtmr1_log); + + let rtmr2_log = vec![ + crate::kernel::measure_cmdline(&kernel_cmdline), + measure_sha384(&initrd_data), + ]; + let rtmr2 = measure_log(&rtmr2_log); + + Ok(TdxMeasurementsWithoutRtmr0 { mrtd, rtmr1, rtmr2 }) +} + +/// Compute TDX measurements without invoking QEMU-derived helper binaries. +/// +/// RTMR[0] includes ACPI blobs generated by QEMU at launch time. The caller +/// supplies the already-measured ACPI event digests from the hardware-bound +/// event log; this function recomputes the rest of the TDX image measurement +/// from image files and VM configuration. +pub fn tdx_measurements_for_image_dir_with_acpi_hashes( + image_dir: &Path, + vm_config: &VmConfig, + acpi_hashes: &TdxRtmr0AcpiHashes, +) -> Result { + let meta_path = image_dir.join("metadata.json"); + let meta_str = fs::read_to_string(&meta_path) + .with_context(|| format!("cannot read {}", meta_path.display()))?; + let meta: ImageMetadata = + serde_json::from_str(&meta_str).context("failed to parse image metadata.json")?; + + let base_cmdline = meta + .cmdline + .filter(|s| !s.trim().is_empty()) + .context("metadata.json cmdline is required for TDX measurement")? + .to_string(); + let kernel_cmdline = measured_kernel_cmdline(&base_cmdline); + + let firmware_path = image_dir.join(&meta.bios); + let kernel_path = image_dir.join(&meta.kernel); + let initrd_path = image_dir.join(&meta.initrd); + + let fw_data = fs::read(&firmware_path) + .with_context(|| format!("cannot read {}", firmware_path.display()))?; + let kernel_data = + fs::read(&kernel_path).with_context(|| format!("cannot read {}", kernel_path.display()))?; + let initrd_data = + fs::read(&initrd_path).with_context(|| format!("cannot read {}", initrd_path.display()))?; + + let ovmf_variant = vm_config + .ovmf_variant + .or(meta.ovmf_variant) + .or_else(|| { + if meta.version.is_empty() { + None + } else { + crate::ovmf_variant_for_version(&meta.version).ok() + } + }) + .unwrap_or_else(|| crate::ovmf_variant_for_image(vm_config.image.as_deref())); + + let firmware = firmware_path.display().to_string(); + let kernel = kernel_path.display().to_string(); + let initrd = initrd_path.display().to_string(); + let machine = crate::Machine::builder() + .cpu_count(vm_config.cpu_count) + .memory_size(vm_config.memory_size) + .firmware(&firmware) + .kernel(&kernel) + .initrd(&initrd) + .kernel_cmdline(&kernel_cmdline) + .root_verity(true) + .hotplug_off(vm_config.hotplug_off) + .maybe_two_pass_add_pages(vm_config.qemu_single_pass_add_pages) + .maybe_pic(vm_config.pic) + .maybe_qemu_version(vm_config.qemu_version.clone()) + .maybe_pci_hole64_size(if vm_config.pci_hole64_size > 0 { + Some(vm_config.pci_hole64_size) + } else { + None + }) + .hugepages(vm_config.hugepages) + .num_gpus(vm_config.num_gpus) + .num_nvswitches(vm_config.num_nvswitches) + .host_share_mode(vm_config.host_share_mode.clone()) + .ovmf_variant(ovmf_variant) + .build(); + + let tdvf = Tdvf::parse(&fw_data).context("failed to parse TDX TDVF metadata")?; + let mrtd = tdvf.mrtd(&machine).context("failed to compute MRTD")?; + + let rtmr0_log = tdvf + .rtmr0_log_with_acpi_hashes( + vm_config.memory_size, + ovmf_variant, + &AcpiTableHashes { + loader: acpi_hashes.loader.clone(), + rsdp: acpi_hashes.rsdp.clone(), + tables: acpi_hashes.tables.clone(), + }, + ) + .context("failed to compute RTMR0 without ACPI table generation")?; + let rtmr0 = measure_log(&rtmr0_log); + + let rtmr1_log = crate::kernel::rtmr1_log( + &kernel_data, + initrd_data.len() as u32, + vm_config.memory_size, + 0x28000, + ) + .context("failed to compute RTMR1")?; + let rtmr1 = measure_log(&rtmr1_log); + + let rtmr2_log = vec![ + crate::kernel::measure_cmdline(&kernel_cmdline), + measure_sha384(&initrd_data), + ]; + let rtmr2 = measure_log(&rtmr2_log); + + Ok(crate::TdxMeasurements { + mrtd, + rtmr0, + rtmr1, + rtmr2, + }) +} diff --git a/dstack-mr/src/uefi_var.rs b/dstack-mr/src/uefi_var.rs deleted file mode 100644 index b3d0c6040..000000000 --- a/dstack-mr/src/uefi_var.rs +++ /dev/null @@ -1,135 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -//! Helpers for synthesising the UEFI variable byte blobs that OVMF measures -//! into RTMR[0] as `EV_EFI_VARIABLE_BOOT2` events. -//! -//! For the BootOrder / Boot#### variables the TCG PFP spec digest is taken -//! over the *variable data* portion only (not the full `UEFI_VARIABLE_DATA` -//! struct), so we just build the on-the-wire variable contents here. - -use crate::utf16_encode; - -/// Build the raw bytes of a `BootOrder` UEFI variable from a sequence of boot -/// option numbers — each entry is a little-endian `u16` referring to a -/// `Boot####` variable. -pub fn boot_order_bytes(entries: &[u16]) -> Vec { - let mut out = Vec::with_capacity(entries.len() * 2); - for &entry in entries { - out.extend_from_slice(&entry.to_le_bytes()); - } - out -} - -/// An `EFI_DEVICE_PATH_PROTOCOL` node. -#[derive(Clone, Copy)] -pub struct DevicePathNode<'a> { - pub r#type: u8, - pub subtype: u8, - pub data: &'a [u8], -} - -impl DevicePathNode<'_> { - fn write_to(self, buf: &mut Vec) { - let len = 4 + self.data.len(); - buf.push(self.r#type); - buf.push(self.subtype); - buf.extend_from_slice(&(len as u16).to_le_bytes()); - buf.extend_from_slice(self.data); - } -} - -/// `END_ENTIRE_DEVICE_PATH` terminator. -pub const END_OF_DEVICE_PATH: DevicePathNode<'static> = DevicePathNode { - r#type: 0x7f, - subtype: 0xff, - data: &[], -}; - -/// `MEDIA_DEVICE_PATH / Firmware Volume` node (`type=4, subtype=7`). -pub fn fv_node(guid_le: &[u8; 16]) -> DevicePathNode<'_> { - DevicePathNode { - r#type: 0x04, - subtype: 0x07, - data: guid_le, - } -} - -/// `MEDIA_DEVICE_PATH / Firmware File` node (`type=4, subtype=6`). -pub fn fv_file_node(guid_le: &[u8; 16]) -> DevicePathNode<'_> { - DevicePathNode { - r#type: 0x04, - subtype: 0x06, - data: guid_le, - } -} - -/// Build the raw bytes of a `Boot####` UEFI variable — the on-the-wire form of -/// `EFI_LOAD_OPTION { Attributes, FilePathListLength, Description, FilePathList, -/// OptionalData }`. -/// -/// The description is automatically NUL-terminated in UTF-16LE. -pub fn boot_option_bytes( - attributes: u32, - description: &str, - file_path_nodes: &[DevicePathNode<'_>], - optional_data: &[u8], -) -> Vec { - // Serialise the device-path list first so we know its length. - let mut file_path = Vec::new(); - for node in file_path_nodes { - node.write_to(&mut file_path); - } - - let mut desc = utf16_encode(description); - desc.extend_from_slice(&[0x00, 0x00]); // NUL terminator - - let mut out = Vec::with_capacity(4 + 2 + desc.len() + file_path.len() + optional_data.len()); - out.extend_from_slice(&attributes.to_le_bytes()); - out.extend_from_slice(&(file_path.len() as u16).to_le_bytes()); - out.extend_from_slice(&desc); - out.extend_from_slice(&file_path); - out.extend_from_slice(optional_data); - out -} - -#[cfg(test)] -mod tests { - use super::*; - use sha2::{Digest, Sha384}; - - fn sha384(bytes: &[u8]) -> String { - hex::encode(Sha384::new_with_prefix(bytes).finalize()) - } - - #[test] - fn boot_option_round_trip_sample() { - // Trivial sanity check: a load option with one MEDIA_FV_FILE node and - // an empty description should serialise to a 4 (Attrs) + 2 (FpLen) + - // 2 (NUL) + (4 + 16) (FV_FILE) + 4 (END) = 32 byte blob, and - // round-tripping the descriptive string survives. - let blob = boot_option_bytes(1, "", &[fv_file_node(&[0; 16]), END_OF_DEVICE_PATH], &[]); - assert_eq!(blob.len(), 4 + 2 + 2 + 20 + 4); - assert_eq!(&blob[0..4], &[0x01, 0x00, 0x00, 0x00]); - assert_eq!(&blob[4..6], &[0x18, 0x00]); // FilePathListLength = 24 - // Description is just a NUL terminator (two bytes of 0). - assert_eq!(&blob[6..8], &[0x00, 0x00]); - } - - #[test] - fn boot_order_encodes_u16_le_entries() { - assert_eq!( - boot_order_bytes(&[0x0000, 0x0001]), - vec![0x00, 0x00, 0x01, 0x00] - ); - assert_eq!( - boot_order_bytes(&[0x1234, 0xabcd]), - vec![0x34, 0x12, 0xcd, 0xab] - ); - assert_eq!( - sha384(&boot_order_bytes(&[0x0000, 0x0001])), - "52b9a02de946b947364b57d8210c63113b9058996e2a3ba7cead54af11ae0873b085d1e52bc01e4febe57ca05ca1332b" - ); - } -} diff --git a/dstack-types/Cargo.toml b/dstack-types/Cargo.toml index 1bea45ec5..526d5192b 100644 --- a/dstack-types/Cargo.toml +++ b/dstack-types/Cargo.toml @@ -10,6 +10,8 @@ edition.workspace = true license.workspace = true [dependencies] +ciborium.workspace = true +hex = { workspace = true, features = ["std"] } or-panic.workspace = true scale = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] } diff --git a/dstack-types/src/lib.rs b/dstack-types/src/lib.rs index d891eee93..ab0b5a229 100644 --- a/dstack-types/src/lib.rs +++ b/dstack-types/src/lib.rs @@ -2,9 +2,8 @@ // // SPDX-License-Identifier: Apache-2.0 -use std::path::Path; +use std::{io::Cursor, path::Path}; -use or_panic::ResultOrPanic; use scale::{Decode, Encode}; use serde::{Deserialize, Serialize}; use serde_human_bytes as hex_bytes; @@ -12,26 +11,57 @@ use size_parser::human_size; /// Identifies which OVMF flavour the guest image was built with. /// -/// The firmware switch happened in meta-dstack commit f9f11f3 (upgrade from an -/// untagged 2024-09 snapshot to edk2-stable202505): 0.5.7 and earlier shipped -/// `Pre202505`, 0.5.9 onwards ships `Stable202505`. The newer firmware emits -/// more boot-time events into RTMR[0], so quote replay needs a different -/// expected event list for the two flavours. -/// -/// When the variant isn't carried explicitly in `VmConfig`, the runtime cutoff -/// rule in `dstack_mr::ovmf_variant_for_version` draws the line at OS version -/// `0.5.10` (and again at `0.6.1`) — a deliberate policy decision that doesn't -/// follow the firmware-flip date exactly. See that function's docs for the -/// authoritative selection rule. +/// Only the pre-202505 OVMF measurement layout is supported. #[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum OvmfVariant { - /// Pre-edk2-stable202505 OVMF (13 RTMR[0] events). + /// Pre-202505 OVMF (13 RTMR[0] events). #[default] Pre202505, - /// edk2-stable202505+ OVMF (17 RTMR[0] events: new fw_cfg, VARIABLE_AUTHORITY - /// and BootXXXX entries). - Stable202505, +} + +impl OvmfVariant { + pub fn to_u8(self) -> u8 { + match self { + Self::Pre202505 => 0, + } + } + + pub fn from_u8(value: u8) -> Option { + match value { + 0 => Some(Self::Pre202505), + _ => None, + } + } +} + +/// Selects how a TDX attestation should bind the OS image. +/// +/// `Legacy` preserves the existing verifier behavior: `vm_config.os_image_hash` +/// is the content digest (`digest.txt`) and the verifier recomputes the full +/// TDX launch measurement using the legacy image/QEMU-derived path. +/// +/// `Lite` opts into the no-QEMU verifier path: `vm_config.os_image_hash` +/// is `measurement.json.tdx.os_image_hash`, `vm_config.tdx_measurement` carries +/// the self-contained measurement material, and KMS/verifier select the new +/// logic from this vm_config flag while the attestation quote remains the +/// existing `DstackTdx`. +#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum TdxAttestationVariant { + #[default] + Legacy, + Lite, +} + +impl TdxAttestationVariant { + pub fn is_legacy(&self) -> bool { + matches!(self, Self::Legacy) + } + + pub fn is_lite(&self) -> bool { + matches!(self, Self::Lite) + } } #[derive(Deserialize, Serialize, Debug, Clone)] @@ -259,6 +289,14 @@ pub struct VmConfig { /// (e.g. parsing the OS version out of `image`). #[serde(default, skip_serializing_if = "Option::is_none")] pub ovmf_variant: Option, + /// TDX-only attestation/hash scheme selector. Defaults to `legacy` and is + /// omitted from legacy configs to keep old behavior and wire shape stable. + #[serde(default, skip_serializing_if = "TdxAttestationVariant::is_legacy")] + pub tdx_attestation_variant: TdxAttestationVariant, + /// TDX-only no-image-download measurement material. Present only when + /// `tdx_attestation_variant = "lite"` and omitted for legacy TDX. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tdx_measurement: Option, } /// One OVMF SEV metadata section (gpa/size/type) that affects the SEV-SNP @@ -270,34 +308,422 @@ pub struct OvmfSection { pub section_type: u32, } -/// Image-invariant projection that determines the AMD SEV-SNP OS image identity. -/// -/// `os_image_hash` is the SHA-256 of this projection, canonically serialized -/// (JCS). It is shared by the VMM/KMS (which derive it from a verified launch -/// measurement) and the image build (which precomputes `digest.sev.txt`), so -/// both sides agree. It deliberately EXCLUDES per-deployment values (vcpus, -/// vcpu_type, guest_features, app_id, compose_hash): the same OS image must hash +fn cbor_to_vec(value: &T, context: &str) -> Vec { + let mut out = Vec::new(); + ciborium::ser::into_writer(value, &mut out) + .unwrap_or_else(|e| panic!("{context}: failed to encode CBOR: {e}")); + out +} + +fn cbor_from_slice( + bytes: &[u8], + context: &str, +) -> Result { + ciborium::de::from_reader(Cursor::new(bytes)) + .map_err(|e| format!("{context}: failed to decode CBOR: {e}")) +} + +fn sha256(bytes: &[u8]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes).into() +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(sha256(bytes)) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CborOvmfSection { + gpa: u64, + size: u64, + #[serde(rename = "type")] + section_type: u32, +} + +impl From<&OvmfSection> for CborOvmfSection { + fn from(section: &OvmfSection) -> Self { + Self { + gpa: section.gpa, + size: section.size, + section_type: section.section_type, + } + } +} + +impl From for OvmfSection { + fn from(section: CborOvmfSection) -> Self { + Self { + gpa: section.gpa, + size: section.size, + section_type: section.section_type, + } + } +} + +/// Image-invariant projection that determines the AMD SEV-SNP OS image +/// identity. It deliberately excludes per-deployment values (vcpus, vcpu_type, +/// guest_features, app_id, compose_hash): the same OS image must hash /// identically regardless of how it is launched. +/// +/// `os_image_hash` is SHA-256 over the CBOR representation of this projection, +/// not over the outer measurement.json field names. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SevOsImageMeasurement { - pub rootfs_hash: String, - pub base_cmdline: Option, - pub ovmf_hash: String, - pub kernel_hash: String, - pub initrd_hash: String, + /// SHA-256 of the kernel command line bytes as measured in the SEV-SNP hash + /// table (trimmed command line plus trailing NUL byte). This avoids carrying + /// the full plaintext command line in image metadata while preserving the + /// exact measured value used by OVMF/QEMU. + #[serde(with = "hex_bytes")] + pub kernel_cmdline_sha256: Vec, + #[serde(with = "hex_bytes")] + pub ovmf_hash: Vec, + #[serde(with = "hex_bytes")] + pub kernel_hash: Vec, + #[serde(with = "hex_bytes")] + pub initrd_hash: Vec, pub sev_hashes_table_gpa: u64, pub sev_es_reset_eip: u32, pub ovmf_sections: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CborSevOsImageMeasurement { + /// Measured kernel cmdline SHA-256. + #[serde(rename = "cmdline_sha256", with = "hex_bytes")] + kernel_cmdline_sha256: Vec, + /// OVMF launch digest. + #[serde(with = "hex_bytes")] + ovmf_hash: Vec, + /// Kernel SHA-256. + #[serde(with = "hex_bytes")] + kernel_hash: Vec, + /// Initrd SHA-256. + #[serde(with = "hex_bytes")] + initrd_hash: Vec, + /// SEV hash table GPA. + hashes_table_gpa: u64, + /// SEV-ES AP reset EIP. + reset_eip: u32, + /// OVMF metadata sections. + ovmf_sections: Vec, +} + +impl From<&SevOsImageMeasurement> for CborSevOsImageMeasurement { + fn from(measurement: &SevOsImageMeasurement) -> Self { + Self { + kernel_cmdline_sha256: measurement.kernel_cmdline_sha256.clone(), + ovmf_hash: measurement.ovmf_hash.clone(), + kernel_hash: measurement.kernel_hash.clone(), + initrd_hash: measurement.initrd_hash.clone(), + hashes_table_gpa: measurement.sev_hashes_table_gpa, + reset_eip: measurement.sev_es_reset_eip, + ovmf_sections: measurement.ovmf_sections.iter().map(Into::into).collect(), + } + } +} + +impl From for SevOsImageMeasurement { + fn from(measurement: CborSevOsImageMeasurement) -> Self { + Self { + kernel_cmdline_sha256: measurement.kernel_cmdline_sha256, + ovmf_hash: measurement.ovmf_hash, + kernel_hash: measurement.kernel_hash, + initrd_hash: measurement.initrd_hash, + sev_hashes_table_gpa: measurement.hashes_table_gpa, + sev_es_reset_eip: measurement.reset_eip, + ovmf_sections: measurement + .ovmf_sections + .into_iter() + .map(Into::into) + .collect(), + } + } +} + impl SevOsImageMeasurement { - /// SHA-256 over the canonical (JCS) serialization of this projection. + /// CBOR representation used as the `os_image_hash` input. + pub fn to_cbor_vec(&self) -> Vec { + cbor_to_vec( + &CborSevOsImageMeasurement::from(self), + "SevOsImageMeasurement", + ) + } + + pub fn from_cbor_slice(bytes: &[u8]) -> Result { + cbor_from_slice::(bytes, "SevOsImageMeasurement").map(Into::into) + } + + pub fn cbor_json_value_from_slice(bytes: &[u8]) -> Result { + let cbor = cbor_from_slice::(bytes, "SevOsImageMeasurement")?; + serde_json::to_value(cbor) + .map_err(|e| format!("SevOsImageMeasurement: failed to convert CBOR to JSON: {e}")) + } + + /// SHA-256 over the CBOR representation of this projection. pub fn os_image_hash(&self) -> [u8; 32] { - use sha2::{Digest, Sha256}; - // JCS serialization of this plain owned struct (strings/ints/array) - // cannot fail; panic loudly if that invariant is ever broken. - let canonical = serde_jcs::to_vec(self).or_panic("SevOsImageMeasurement JCS serialization"); - Sha256::digest(canonical).into() + sha256(&self.to_cbor_vec()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SevOsImageMeasurementDocument { + /// Document schema version. + #[serde(alias = "v")] + pub version: u32, + /// SHA-256 over the CBOR `measurement` bytes. This field is not included in + /// its own hash input. + #[serde(alias = "h")] + pub os_image_hash: String, + /// CBOR bytes for `SevOsImageMeasurement`. + #[serde(alias = "m", with = "hex_bytes")] + pub measurement: Vec, +} + +impl SevOsImageMeasurementDocument { + pub const VERSION: u32 = 2; + + pub fn new(measurement: SevOsImageMeasurement) -> Self { + let measurement = measurement.to_cbor_vec(); + let os_image_hash = sha256_hex(&measurement); + Self { + version: Self::VERSION, + os_image_hash, + measurement, + } + } + + pub fn decode_measurement(&self) -> Result { + SevOsImageMeasurement::from_cbor_slice(&self.measurement) + } + + pub fn decode_measurement_value(&self) -> Result { + SevOsImageMeasurement::cbor_json_value_from_slice(&self.measurement) + } + + pub fn measurement_os_image_hash(&self) -> [u8; 32] { + sha256(&self.measurement) + } +} + +/// Image-invariant projection that determines the TDX OS image identity. +/// +/// This is the build-time, image-static material for the verifier-side +/// no-image-download TDX path. Dynamic VM parameters (vCPU count, RAM size, +/// QEMU PCI topology, GPU count, etc.) are deliberately excluded and must be +/// supplied by `VmConfig` when replaying RTMRs. +/// +/// `os_image_hash` is SHA-256 over the CBOR representation of this projection, +/// not over the outer measurement.json field names. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TdxOsImageMeasurement { + pub image: TdxImageMeasurement, + pub tdvf: TdxTdvfMeasurement, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TdxImageMeasurement { + /// SHA-384 of the exact kernel command line event measured into RTMR[2]. + /// + /// The measured value is the image-provided command line plus OVMF/QEMU's + /// `initrd=initrd` suffix, encoded as UTF-16LE with a trailing NUL. + #[serde(with = "hex_bytes")] + pub kernel_cmdline_sha384: Vec, + /// Authenticode SHA-384 digest of the QEMU-patched kernel image when the + /// guest memory is at or above QEMU's high-memory TDX initrd placement + /// threshold. Below that threshold the patched kernel header depends on the + /// exact guest memory size, so the no-image-download verifier rejects it. + #[serde(with = "hex_bytes")] + pub kernel_authenticode: Vec, + /// SHA-384 of the initrd file bytes. This is the second RTMR[2] event. + #[serde(with = "hex_bytes")] + pub initrd_sha384: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TdxTdvfMeasurement { + /// OVMF RTMR[0] event layout. + pub ovmf_variant: OvmfVariant, + pub mrtd: TdxMrtdCandidates, + /// Compact TdHobWitnessV1 byte string. + #[serde(with = "hex_bytes")] + pub td_hob_witness: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TdxMrtdCandidates { + /// Candidate MRTD for QEMU's single-pass MEM.PAGE.ADD/MR.EXTEND order. + #[serde(with = "hex_bytes")] + pub single_pass: Vec, + /// Candidate MRTD for QEMU's two-pass MEM.PAGE.ADD then MR.EXTEND order. + #[serde(with = "hex_bytes")] + pub two_pass: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CborTdxImageMeasurement { + /// Measured kernel cmdline SHA-384. + #[serde(rename = "cmdline_sha384", with = "hex_bytes")] + kernel_cmdline_sha384: Vec, + /// QEMU-patched kernel Authenticode SHA-384. + #[serde(with = "hex_bytes")] + kernel_authenticode: Vec, + /// Initrd SHA-384. + #[serde(with = "hex_bytes")] + initrd_sha384: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CborTdxMrtdCandidates { + #[serde(with = "hex_bytes")] + single_pass: Vec, + #[serde(with = "hex_bytes")] + two_pass: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CborTdxTdvfMeasurement { + #[serde(rename = "ovmf")] + ovmf_variant: OvmfVariant, + mrtd: CborTdxMrtdCandidates, + #[serde(rename = "td_hob", with = "hex_bytes")] + td_hob_witness: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CborTdxOsImageMeasurement { + image: CborTdxImageMeasurement, + tdvf: CborTdxTdvfMeasurement, +} + +impl From<&TdxOsImageMeasurement> for CborTdxOsImageMeasurement { + fn from(measurement: &TdxOsImageMeasurement) -> Self { + Self { + image: CborTdxImageMeasurement { + kernel_cmdline_sha384: measurement.image.kernel_cmdline_sha384.clone(), + kernel_authenticode: measurement.image.kernel_authenticode.clone(), + initrd_sha384: measurement.image.initrd_sha384.clone(), + }, + tdvf: CborTdxTdvfMeasurement { + ovmf_variant: measurement.tdvf.ovmf_variant, + mrtd: CborTdxMrtdCandidates { + single_pass: measurement.tdvf.mrtd.single_pass.clone(), + two_pass: measurement.tdvf.mrtd.two_pass.clone(), + }, + td_hob_witness: measurement.tdvf.td_hob_witness.clone(), + }, + } + } +} + +impl From for TdxOsImageMeasurement { + fn from(measurement: CborTdxOsImageMeasurement) -> Self { + Self { + image: TdxImageMeasurement { + kernel_cmdline_sha384: measurement.image.kernel_cmdline_sha384, + kernel_authenticode: measurement.image.kernel_authenticode, + initrd_sha384: measurement.image.initrd_sha384, + }, + tdvf: TdxTdvfMeasurement { + ovmf_variant: measurement.tdvf.ovmf_variant, + mrtd: TdxMrtdCandidates { + single_pass: measurement.tdvf.mrtd.single_pass, + two_pass: measurement.tdvf.mrtd.two_pass, + }, + td_hob_witness: measurement.tdvf.td_hob_witness, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TdxOsImageMeasurementDocument { + /// Document schema version. + #[serde(alias = "v")] + pub version: u32, + /// SHA-256 over the CBOR `measurement` bytes. This field is not included in + /// its own hash input. + #[serde(alias = "h")] + pub os_image_hash: String, + /// CBOR bytes for `TdxOsImageMeasurement`. + #[serde(alias = "m", with = "hex_bytes")] + pub measurement: Vec, +} + +impl TdxOsImageMeasurement { + /// CBOR representation used as the `os_image_hash` input. + pub fn to_cbor_vec(&self) -> Vec { + cbor_to_vec( + &CborTdxOsImageMeasurement::from(self), + "TdxOsImageMeasurement", + ) + } + + pub fn from_cbor_slice(bytes: &[u8]) -> Result { + let cbor = cbor_from_slice::(bytes, "TdxOsImageMeasurement")?; + Ok(cbor.into()) + } + + pub fn cbor_json_value_from_slice(bytes: &[u8]) -> Result { + let cbor = cbor_from_slice::(bytes, "TdxOsImageMeasurement")?; + serde_json::to_value(cbor) + .map_err(|e| format!("TdxOsImageMeasurement: failed to convert CBOR to JSON: {e}")) + } + + /// SHA-256 over the CBOR representation of this projection. + pub fn os_image_hash(&self) -> [u8; 32] { + sha256(&self.to_cbor_vec()) + } +} + +impl TdxOsImageMeasurementDocument { + pub const VERSION: u32 = 2; + + pub fn new(measurement: TdxOsImageMeasurement) -> Self { + let measurement = measurement.to_cbor_vec(); + let os_image_hash = sha256_hex(&measurement); + Self { + version: Self::VERSION, + os_image_hash, + measurement, + } + } + + pub fn decode_measurement(&self) -> Result { + TdxOsImageMeasurement::from_cbor_slice(&self.measurement) + } + + pub fn decode_measurement_value(&self) -> Result { + TdxOsImageMeasurement::cbor_json_value_from_slice(&self.measurement) + } + + pub fn measurement_os_image_hash(&self) -> [u8; 32] { + sha256(&self.measurement) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OsImageMeasurementDocument { + /// Document schema version. + #[serde(alias = "v")] + pub version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tdx: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snp: Option, +} + +impl OsImageMeasurementDocument { + pub const VERSION: u32 = 2; + + pub fn new( + tdx: Option, + snp: Option, + ) -> Self { + Self { + version: Self::VERSION, + tdx, + snp, + } } } diff --git a/gateway/src/config.rs b/gateway/src/config.rs index 68db41c84..e43bf54bd 100644 --- a/gateway/src/config.rs +++ b/gateway/src/config.rs @@ -9,6 +9,7 @@ use load_config::load_config; use rocket::figment::Figment; use serde::{Deserialize, Serialize}; use std::net::Ipv4Addr; +use std::path::PathBuf; use std::time::Duration; use tracing::info; @@ -113,6 +114,12 @@ pub struct ProxyConfig { pub connect_top_n: usize, pub localhost_enabled: bool, pub workers: usize, + #[serde(default)] + pub base_domain: Option, + #[serde(default)] + pub cert_chain: Option, + #[serde(default)] + pub cert_key: Option, pub app_address_ns_prefix: String, pub app_address_ns_compat: bool, /// Maximum concurrent connections per app. 0 means unlimited. diff --git a/gateway/src/main_service.rs b/gateway/src/main_service.rs index 74b640a2d..14b0f93dd 100644 --- a/gateway/src/main_service.rs +++ b/gateway/src/main_service.rs @@ -39,8 +39,8 @@ use crate::{ cert_store::{CertResolver, CertStoreBuilder}, config::{Config, TlsConfig}, kv::{ - fetch_peers_from_bootnode, AppIdValidator, HttpsClientConfig, InstanceData, KvStore, - NodeData, NodeStatus, PortPolicy, WaveKvSyncService, + fetch_peers_from_bootnode, AppIdValidator, CertData, HttpsClientConfig, InstanceData, + KvStore, NodeData, NodeStatus, PortPolicy, WaveKvSyncService, }, models::{InstanceInfo, PortPolicyView, WgConf}, proxy::{create_acceptor_with_cert_resolver, AddressGroup, AddressInfo}, @@ -267,6 +267,32 @@ impl ProxyInner { all_cert_data.len() ); } + if let (Some(base_domain), Some(cert_chain), Some(cert_key)) = ( + &config.proxy.base_domain, + &config.proxy.cert_chain, + &config.proxy.cert_key, + ) { + let cert_pem = std::fs::read_to_string(cert_chain).with_context(|| { + format!("failed to read proxy cert_chain {}", cert_chain.display()) + })?; + let key_pem = std::fs::read_to_string(cert_key) + .with_context(|| format!("failed to read proxy cert_key {}", cert_key.display()))?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let cert_data = CertData { + cert_pem, + key_pem, + not_after: now + 14 * 24 * 60 * 60, + issued_by: config.sync.node_id, + issued_at: now, + }; + cert_resolver + .update_cert(base_domain, &cert_data) + .with_context(|| format!("failed to load static proxy cert for {base_domain}"))?; + info!("CertStore: loaded static proxy certificate for *.{base_domain}"); + } // Create multi-domain certbot (uses KvStore configs for DNS credentials and domains) let certbot = Arc::new(DistributedCertBot::new( diff --git a/guest-agent/rpc/build.rs b/guest-agent/rpc/build.rs index fe19530a5..bc584fdbe 100644 --- a/guest-agent/rpc/build.rs +++ b/guest-agent/rpc/build.rs @@ -11,6 +11,10 @@ fn main() { .build_scale_ext(false) .disable_package_emission() .enable_serde_extension() + .field_attribute( + ".dstack_guest.GetQuoteResponse.attestation", + "#[serde(skip_serializing_if = \"::prost::alloc::vec::Vec::is_empty\")]", + ) .disable_service_name_emission() .compile_dir("./proto") .expect("failed to compile proto files"); diff --git a/guest-agent/rpc/proto/agent_rpc.proto b/guest-agent/rpc/proto/agent_rpc.proto index 3226d2ef3..3b74289a7 100644 --- a/guest-agent/rpc/proto/agent_rpc.proto +++ b/guest-agent/rpc/proto/agent_rpc.proto @@ -200,9 +200,8 @@ message GetQuoteResponse { // Hw config string vm_config = 4; // Platform-adaptive versioned attestation (SCALE/msgpack encoded). Populated - // for every TEE platform (TDX, AMD SEV-SNP, ...) and is the verifier-ready - // payload to send to dstack-verifier's `/verify` `attestation` field. Use - // this instead of `quote`/`event_log` for platform-agnostic verification. + // on non-TDX TEE platforms (AMD SEV-SNP, ...). TDX uses `quote` + `event_log` + // above to keep this response compact. bytes attestation = 5; } diff --git a/guest-agent/src/backend.rs b/guest-agent/src/backend.rs index a8e06cd8e..2e5f32dc3 100644 --- a/guest-agent/src/backend.rs +++ b/guest-agent/src/backend.rs @@ -36,13 +36,17 @@ impl PlatformBackend for RealPlatform { fn quote_response(&self, report_data: [u8; 64], vm_config: &str) -> Result { let attestation = Attestation::quote(&report_data).context("Failed to get quote")?; let tdx_quote = attestation.get_tdx_quote_bytes(); - let tdx_event_log = attestation.get_tdx_event_log_string(); - // Always carry the platform-adaptive versioned attestation so callers on - // non-TDX platforms (AMD SEV-SNP) still get a verifier-ready payload. - let versioned = attestation - .into_versioned() - .to_bytes() - .context("Failed to encode versioned attestation")?; + let tdx_event_log = attestation.get_tdx_event_log_string_for_config(vm_config); + // TDX callers already have quote + event_log. Only non-TDX platforms + // need the platform-adaptive versioned attestation payload. + let versioned = if tdx_quote.is_some() { + Vec::new() + } else { + attestation + .into_versioned() + .to_bytes() + .context("Failed to encode versioned attestation")? + }; Ok(GetQuoteResponse { quote: tdx_quote.unwrap_or_default(), event_log: tdx_event_log.unwrap_or_default(), diff --git a/guest-agent/src/rpc_service.rs b/guest-agent/src/rpc_service.rs index 984da23b7..fd0324f6e 100644 --- a/guest-agent/src/rpc_service.rs +++ b/guest-agent/src/rpc_service.rs @@ -839,10 +839,6 @@ pNs85uhOZE8z2jr8Pg== let Some(quote) = attestation.platform.tdx_quote().map(ToOwned::to_owned) else { return Err(anyhow::anyhow!("Quote not found")); }; - let versioned = VersionedAttestation::V1 { - attestation: attestation.clone(), - } - .to_bytes()?; Ok(GetQuoteResponse { quote, event_log: serde_json::to_string( @@ -851,7 +847,7 @@ pNs85uhOZE8z2jr8Pg== .unwrap_or_default(), report_data: report_data.to_vec(), vm_config: vm_config.to_string(), - attestation: versioned, + attestation: Vec::new(), }) } @@ -1092,6 +1088,7 @@ pNs85uhOZE8z2jr8Pg== const EXPECTED_REPORT_DATA: &str = "dip1::ed25519-pk:5Pbre1Amf1hrp2V2bbfKlIfxpQb2pJAmrgmhxgVoG9s\0\0\0\0"; assert_eq!(EXPECTED_REPORT_DATA.as_bytes(), response.report_data); + assert!(response.attestation.is_empty()); } #[tokio::test] @@ -1107,6 +1104,7 @@ pNs85uhOZE8z2jr8Pg== const EXPECTED_REPORT_DATA: &str = "dip1::secp256k1c-pk:A6t_JdVkVdMAocH3f1f20WGT6JzdntxcXimUtEax8zc9"; assert_eq!(EXPECTED_REPORT_DATA.as_bytes(), response.report_data); + assert!(response.attestation.is_empty()); } #[tokio::test] diff --git a/kms/src/main_service.rs b/kms/src/main_service.rs index 17b6f5948..ca40541d3 100644 --- a/kms/src/main_service.rs +++ b/kms/src/main_service.rs @@ -566,7 +566,7 @@ mod tests { fn valid_snp_measurement_input() -> MeasurementInput { let rootfs_hash = hex_of(0x33, 32); MeasurementInput { - base_cmdline: Some(format!("console=ttyS0 dstack.rootfs_hash={rootfs_hash}")), + base_cmdline: format!("console=ttyS0 dstack.rootfs_hash={rootfs_hash}"), ovmf_hash: hex_of(0x44, 48), kernel_hash: hex_of(0x55, 32), initrd_hash: hex_of(0x66, 32), diff --git a/kms/src/main_service/amd_attest.rs b/kms/src/main_service/amd_attest.rs index 48688c6a9..da6831170 100644 --- a/kms/src/main_service/amd_attest.rs +++ b/kms/src/main_service/amd_attest.rs @@ -212,7 +212,7 @@ mod tests { fn valid_input() -> MeasurementInput { let rootfs_hash = hex_of(0x33, 32); MeasurementInput { - base_cmdline: Some(format!("console=ttyS0 dstack.rootfs_hash={rootfs_hash}")), + base_cmdline: format!("console=ttyS0 dstack.rootfs_hash={rootfs_hash}"), ovmf_hash: hex_of(0x44, 48), kernel_hash: hex_of(0x55, 32), initrd_hash: hex_of(0x66, 32), @@ -681,10 +681,7 @@ mod tests { #[test] fn rejects_empty_or_malformed_binding_hashes() { let mut input = valid_input(); - input.base_cmdline = Some(format!( - "console=ttyS0 dstack.rootfs_hash={}", - hex_of(0x33, 31) - )); + input.base_cmdline = format!("console=ttyS0 dstack.rootfs_hash={}", hex_of(0x33, 31)); assert_rejects(input, "dstack.rootfs_hash must be 32 bytes"); let mut input = valid_input(); diff --git a/kms/src/onboard_service.rs b/kms/src/onboard_service.rs index 79a8d1085..272e5dc56 100644 --- a/kms/src/onboard_service.rs +++ b/kms/src/onboard_service.rs @@ -205,7 +205,7 @@ mod tests { fn valid_snp_measurement_input() -> MeasurementInput { let rootfs_hash = hex_of(0x33, 32); MeasurementInput { - base_cmdline: Some(format!("console=ttyS0 dstack.rootfs_hash={rootfs_hash}")), + base_cmdline: format!("console=ttyS0 dstack.rootfs_hash={rootfs_hash}"), ovmf_hash: hex_of(0x44, 48), kernel_hash: hex_of(0x55, 32), initrd_hash: hex_of(0x66, 32), diff --git a/verifier/fixtures/tdx-lite-attestation.json b/verifier/fixtures/tdx-lite-attestation.json new file mode 100644 index 000000000..e5fefc398 --- /dev/null +++ b/verifier/fixtures/tdx-lite-attestation.json @@ -0,0 +1,4 @@ +{ + "attestation": "0000394e040002008100000000000000939a7233f79c4ca9940a0db3957f06071026ff2bbebac59cc1ef911279d9481b000000000c010400000000000000000000000000d0d80c085166ba78ccc69af268e5753cf0f3394523cb4ff7c50b08d9265c82489c099c377be6a400e4d2b57da924012c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000e702060000000000fd685522ce791dfef67414614eb07d03fc07a32c5a66f36288b329dab92b724b1564c73d436ffb9ea84488c51ac5a1c50186b0e55f2fa8e4fb69d890f14f54d5612707646e2573d54e0d2ddaaade77caa9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f8438db36b96f85d8752ff7f24a89ec05c79ec9eda2ba732c897fb970ca429365b7471b1c054cb84f17b1c2b23ba66402023546e7f3b9d1228e274f70c44d481162540f8452544520a796a52f06879709b81a824a26792a7822327504b0d2aee4c1b739ed451a637b0f82642e48a5ea83925d23633c72e7385c8e9aca4175e133ed1625b7d92eb39edf509c27ff392dc6f24c170d0fd63fc2b1b53202eea47b013978437fa6982cf5e0438ff95c208994aaa0f4ebab2e3a66824b5b56869137e646970313a3a736563703235366b31632d706b3a41353570576d74654a494a4f6a385f7049372d707a654478793147327131384744763838484e526442586b51cc1000008bca152d0454bdfd5adab1bc3a527884f77ea7993d32ee0e4426b2ae0fe42bf3f5642d6abd763b4f4c6042133e2ed79cce743f2c54ff4c7ea5d712dc1172ec244fe5b32ac6ffeb104614bcb8894c7aaafbbbe6f6bfd852f5dcd6cf400557ee764e62850d955975d93eff63b17e6e13e329a7bb13926706c0430017d543ab01920600461000000404191b04ff0006000000000000000000000000000000000000000000000000000000000000000000000000000000001500000000000000e700000000000000e5a3a7b5d830c2953b98534c6c59a3a34fdc34e933f7f5898f0a85cf08846bca0000000000000000000000000000000000000000000000000000000000000000dc9e2a7c6f948f17474e34a7fc43ed030f7c1563f1babddf6340c82e0e54a8c500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c1140324365c08f021a721dbe9175cb89dcd2235e2bd00bfb235b2a66b8c783600000000000000000000000000000000000000000000000000000000000000002af8cd12d44e0d22f904b15c02968b57b668e7f2487ba308e1d9a269ea125e48b243f7d32bb8551e1e3c2c09bd2162d36941eeb47be50b9b55a766a14d0cfe302000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f05005e0e00002d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d49494538444343424a6167417749424167495556706163774c766c316d476155506b384b4375504141334769465177436759494b6f5a497a6a3045417749770a634445694d434147413155454177775a535735305a577767553064594946424453794251624746305a6d397962534244515445614d42674741315545436777520a535735305a577767513239796347397959585270623234784644415342674e564241634d43314e68626e526849454e7359584a684d51737743515944565151490a44414a445154454c4d416b474131554542684d4356564d774868634e4d6a59774e4445314d4441314d4455345768634e4d7a4d774e4445314d4441314d4455340a576a42774d534977494159445651514444426c4a626e526c624342545231676755454e4c49454e6c636e52705a6d6c6a5958526c4d526f77474159445651514b0a4442464a626e526c6243424462334a7762334a6864476c76626a45554d424947413155454277774c553246756447456751327868636d4578437a414a42674e560a4241674d416b4e424d517377435159445651514745774a56557a425a4d424d4742797147534d34394167454743437147534d343941774548413049414245586a0a53374265726c3262726b65543677707878436a556536564775577268586e51767a41395862524768356b68637671766b566b427874715935475759544f6551340a5948496a636b7974734c6c5531774b594a74576a67674d4d4d4949444344416642674e5648534d4547444157674253566231334e765276683655424a796454300a4d383442567776655644427242674e56485238455a4442694d47436758714263686c706f64485277637a6f764c32467761533530636e567a6447566b633256790a646d6c6a5a584d75615735305a577775593239744c334e6e6543396a5a584a3061575a7059324630615739754c3359304c33426a61324e796244396a595431770a624746305a6d397962535a6c626d4e765a476c755a7a316b5a584977485159445652304f42425945464362386b6b73714d364c384f6765734c713943337339440a7a5333504d41344741315564447745422f775145417749477744414d42674e5648524d4241663845416a41414d4949434f51594a4b6f5a496876684e415130420a424949434b6a4343416959774867594b4b6f5a496876684e4151304241515151514e367178312b487a7758704c373859496b716c646a434341574d47436971470a534962345451454e41514977676746544d42414743797147534962345451454e41514942416745454d42414743797147534962345451454e41514943416745450a4d42414743797147534962345451454e41514944416745434d42414743797147534962345451454e41514945416745434d42414743797147534962345451454e0a41514946416745454d42414743797147534962345451454e41514947416745424d42414743797147534962345451454e41514948416745414d424147437971470a534962345451454e41514949416745464d42414743797147534962345451454e4151494a416745414d42414743797147534962345451454e4151494b416745410a4d42414743797147534962345451454e4151494c416745414d42414743797147534962345451454e4151494d416745414d42414743797147534962345451454e0a4151494e416745414d42414743797147534962345451454e4151494f416745414d42414743797147534962345451454e41514950416745414d424147437971470a534962345451454e41514951416745414d42414743797147534962345451454e415149524167454e4d42384743797147534962345451454e41514953424241450a42414943424145414251414141414141414141414d42414743697147534962345451454e41514d45416741414d42514743697147534962345451454e415151450a42704441627741414144415042676f71686b69472b45304244514546436745424d42344743697147534962345451454e4151594545464a37386f7137314543670a6c7536335265417a675430775241594b4b6f5a496876684e41513042427a41324d42414743797147534962345451454e415163424151482f4d424147437971470a534962345451454e41516343415145414d42414743797147534962345451454e415163444151482f4d416f4743437147534d343942414d43413067414d4555430a494778676472434e7a344753716d32647a4c45533874757663717230444d692b427537533771537133325343416945417439454f6377584f6a31484a4c4462750a6d473357414549577962624f61635959612b7253384366526c514d3d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d4949436c6a4343416a32674177494241674956414a567658633239472b487051456e4a3150517a7a674658433935554d416f4743437147534d343942414d430a4d476778476a415942674e5642414d4d45556c756447567349464e48574342536232393049454e424d526f77474159445651514b4442464a626e526c624342440a62334a7762334a6864476c76626a45554d424947413155454277774c553246756447456751327868636d4578437a414a42674e564241674d416b4e424d5173770a435159445651514745774a56557a4165467730784f4441314d6a45784d4455774d5442614677307a4d7a41314d6a45784d4455774d5442614d484178496a41670a42674e5642414d4d47556c756447567349464e4857434251513073675547786864475a76636d306751304578476a415942674e5642416f4d45556c75644756730a49454e76636e4276636d4630615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b474131554543417743513045780a437a414a42674e5642415954416c56544d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a304441516344516741454e53422f377432316c58534f0a3243757a7078773734654a423732457944476757357258437478327456544c7136684b6b367a2b5569525a436e71523770734f766771466553786c6d546c4a6c0a65546d693257597a33714f42757a43427544416642674e5648534d4547444157674251695a517a575770303069664f44744a5653763141624f536347724442530a42674e5648523845537a424a4d45656752614244686b466f64485277637a6f764c324e6c636e52705a6d6c6a5958526c63793530636e567a6447566b633256790a646d6c6a5a584d75615735305a577775593239744c306c756447567355306459556d397664454e424c6d526c636a416442674e5648513445466751556c5739640a7a62306234656c4153636e553944504f4156634c336c517744675944565230504151482f42415144416745474d42494741315564457745422f7751494d4159420a4166384341514177436759494b6f5a497a6a30454177494452774177524149675873566b6930772b6936565947573355462f32327561586530594a446a3155650a6e412b546a44316169356343494359623153416d4435786b66545670766f34556f79695359787244574c6d5552344349394e4b7966504e2b0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d4949436a7a4343416a53674177494241674955496d554d316c71644e496e7a6737535655723951477a6b6e42717777436759494b6f5a497a6a3045417749770a614445614d4267474131554541777752535735305a5777675530645949464a766233516751304578476a415942674e5642416f4d45556c756447567349454e760a636e4276636d4630615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b47413155454341774351304578437a414a0a42674e5642415954416c56544d423458445445344d4455794d5445774e4455784d466f58445451354d54497a4d54497a4e546b314f566f77614445614d4267470a4131554541777752535735305a5777675530645949464a766233516751304578476a415942674e5642416f4d45556c756447567349454e76636e4276636d46300a615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b47413155454341774351304578437a414a42674e56424159540a416c56544d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a3044415163445167414543366e45774d4449595a4f6a2f69505773437a61454b69370a314f694f534c52466857476a626e42564a66566e6b59347533496a6b4459594c304d784f346d717379596a6c42616c54565978465032734a424b357a6c4b4f420a757a43427544416642674e5648534d4547444157674251695a517a575770303069664f44744a5653763141624f5363477244425342674e5648523845537a424a0a4d45656752614244686b466f64485277637a6f764c324e6c636e52705a6d6c6a5958526c63793530636e567a6447566b63325679646d6c6a5a584d75615735300a5a577775593239744c306c756447567355306459556d397664454e424c6d526c636a416442674e564851344546675155496d554d316c71644e496e7a673753560a55723951477a6b6e4271777744675944565230504151482f42415144416745474d42494741315564457745422f7751494d4159424166384341514577436759490a4b6f5a497a6a3045417749445351417752674968414f572f35516b522b533943695344634e6f6f774c7550524c735747662f59693747535839344267775477670a41694541344a306c72486f4d732b586f356f2f7358364f39515778485241765a55474f6452513763767152586171493d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000000a000000c0095d04cf26fe03aef6e3561fa24c1aa1cea93f4aeaf563b1f9f7616184c53454875925759434769cec2490acb563a3370024414350492044415441000000000a000000c08d9a4d4777a1bc77ecd9d8d37a4628129a80052a510320159a20a923bd07a0e90d8d1f2e1ebf088992b25f0d0fa672ef0024414350492044415441000000000a000000c03070721e169bc41884724cb0e6b3082e1baf249083d8b389181ba50b9afa951057876c380b8870e8c2facf2eff67a2b600244143504920444154410300000001000008004073797374656d2d707265706172696e6700030000000100000800186170702d69645086b0e55f2fa8e4fb69d890f14f54d5612707646e03000000010000080030636f6d706f73652d686173688086b0e55f2fa8e4fb69d890f14f54d5612707646e2573d54e0d2ddaaade77caa90300000001000008002c696e7374616e63652d696450050bf89570575fe8fab4cb8f0a62a9e64efe8ead03000000010000080030626f6f742d6d722d646f6e6500030000000100000800346f732d696d6167652d686173688007a2388c7a6a1b6a646d443f1517990a4ec294471d63146cda9d56972765051d030000000100000800306b65792d70726f766964657231037b226e616d65223a226b6d73222c226964223a223330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030343266373165323334643733333961316365616361303963336333393165623831366335333366393830616461616233346631366561643039336666306163313030643963303332353361333035366636643237373335313235343333313830623365363163353461373866336664313333333738363965303035316465653036227d0300000001000008002873746f726167652d66730c7a66730300000001000008003073797374656d2d726561647900244073797374656d2d707265706172696e6700186170702d69645086b0e55f2fa8e4fb69d890f14f54d5612707646e30636f6d706f73652d686173688086b0e55f2fa8e4fb69d890f14f54d5612707646e2573d54e0d2ddaaade77caa92c696e7374616e63652d696450050bf89570575fe8fab4cb8f0a62a9e64efe8ead30626f6f742d6d722d646f6e6500346f732d696d6167652d686173688007a2388c7a6a1b6a646d443f1517990a4ec294471d63146cda9d56972765051d306b65792d70726f766964657231037b226e616d65223a226b6d73222c226964223a223330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030343266373165323334643733333961316365616361303963336333393165623831366335333366393830616461616233346631366561643039336666306163313030643963303332353361333035366636643237373335313235343333313830623365363163353461373866336664313333333738363965303035316465653036227d2873746f726167652d66730c7a66733073797374656d2d726561647900646970313a3a736563703235366b31632d706b3a41353570576d74654a494a4f6a385f7049372d707a654478793147327131384744763838484e526442586b5165137b226f735f696d6167655f68617368223a2230376132333838633761366131623661363436643434336631353137393930613465633239343437316436333134366364613964353639373237363530353164222c226370755f636f756e74223a322c226d656d6f72795f73697a65223a323134373438333634382c2271656d755f76657273696f6e223a22382e322e32222c227063695f686f6c6536345f73697a65223a302c22687567657061676573223a66616c73652c226e756d5f67707573223a302c226e756d5f6e767377697463686573223a302c22686f74706c75675f6f6666223a66616c73652c22696d616765223a2264737461636b2d302e362e30222c22686f73745f73686172655f6d6f6465223a223970222c226f766d665f76617269616e74223a22707265323032353035222c227464785f6174746573746174696f6e5f76617269616e74223a226c697465222c227464785f6d6561737572656d656e74223a7b2276657273696f6e223a322c226f735f696d6167655f68617368223a2230376132333838633761366131623661363436643434336631353137393930613465633239343437316436333134366364613964353639373237363530353164222c226d6561737572656d656e74223a22613236353639366436313637363561333665363336643634366336393665363535663733363836313333333833343538333037383632383038343262373336343238376133613730643936663765333039323532383537626562343566623166393133313461326561383633646230616463303463383433316563626632396139363634303536303436333161356161623837333662363537323665363536633566363137353734363836353665373436393633366636343635353833306163376536333264636635636432613166653563316634316634643962383231393537306536346564336336313033386664626632353430346536663534326666643537663237366263353037363330376566616638383265366436343137373664363936653639373437323634356637333638363133333338333435383330346665346637373130313334613631643764656633353761646436616335306264626665656535303332613463313030333735653230373231366666653432613362643538323262323465363739663931353031666666373935623831353231363437343634373636366133363436663736366436363639373037323635333233303332333533303335363436643732373436346132366237333639366536373663363535663730363137333733353833306136663261633934353138313036383661346462323539666538666135343338646334613538626461396664326635623166623039323833333537303535303064323961313563393233383734313661326635326464646365393963383366383638373437373666356637303631373337333538333066643638353532326365373931646665663637343134363134656230376430336663303761333263356136366633363238386233323964616239326237323462313536346337336434333666666239656138343438386335316163356131633536363734363435663638366636323463383031303039303430303036303930323062303231303130227d2c22737065635f76657273696f6e223a317d", + "vm_config": "{\"os_image_hash\":\"07a2388c7a6a1b6a646d443f1517990a4ec294471d63146cda9d56972765051d\",\"cpu_count\":2,\"memory_size\":2147483648,\"qemu_version\":\"8.2.2\",\"pci_hole64_size\":0,\"hugepages\":false,\"num_gpus\":0,\"num_nvswitches\":0,\"hotplug_off\":false,\"image\":\"dstack-0.6.0\",\"host_share_mode\":\"9p\",\"ovmf_variant\":\"pre202505\",\"tdx_attestation_variant\":\"lite\",\"tdx_measurement\":{\"version\":2,\"os_image_hash\":\"07a2388c7a6a1b6a646d443f1517990a4ec294471d63146cda9d56972765051d\",\"measurement\":\"a265696d616765a36e636d646c696e655f7368613338345830786280842b7364287a3a70d96f7e309252857beb45fb1f91314a2ea863db0adc04c8431ecbf29a966405604631a5aab8736b65726e656c5f61757468656e7469636f64655830ac7e632dcf5cd2a1fe5c1f41f4d9b8219570e64ed3c61038fdbf25404e6f542ffd57f276bc5076307efaf882e6d641776d696e697472645f73686133383458304fe4f7710134a61d7def357add6ac50bdbfeee5032a4c100375e207216ffe42a3bd5822b24e679f91501fff795b815216474647666a3646f766d6669707265323032353035646d727464a26b73696e676c655f706173735830a6f2ac9451810686a4db259fe8fa5438dc4a58bda9fd2f5b1fb0928335705500d29a15c92387416a2f52dddce99c83f86874776f5f706173735830fd685522ce791dfef67414614eb07d03fc07a32c5a66f36288b329dab92b724b1564c73d436ffb9ea84488c51ac5a1c56674645f686f624c80100904000609020b021010\"},\"spec_version\":1}" +} diff --git a/verifier/fixtures/tdx-lite-getquote.json b/verifier/fixtures/tdx-lite-getquote.json new file mode 100644 index 000000000..bd92d9429 --- /dev/null +++ b/verifier/fixtures/tdx-lite-getquote.json @@ -0,0 +1,6 @@ +{ + "quote": "040002008100000000000000939a7233f79c4ca9940a0db3957f06071026ff2bbebac59cc1ef911279d9481b000000000c010400000000000000000000000000d0d80c085166ba78ccc69af268e5753cf0f3394523cb4ff7c50b08d9265c82489c099c377be6a400e4d2b57da924012c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000e702060000000000fd685522ce791dfef67414614eb07d03fc07a32c5a66f36288b329dab92b724b1564c73d436ffb9ea84488c51ac5a1c50186b0e55f2fa8e4fb69d890f14f54d5612707646e2573d54e0d2ddaaade77caa9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f8438db36b96f85d8752ff7f24a89ec05c79ec9eda2ba732c897fb970ca429365b7471b1c054cb84f17b1c2b23ba66402023546e7f3b9d1228e274f70c44d481162540f8452544520a796a52f06879709b81a824a26792a7822327504b0d2aee4c1b739ed451a637b0f82642e48a5ea83925d23633c72e7385c8e9aca4175e133ed1625b7d92eb39edf509c27ff392dc6f24c170d0fd63fc2b1b53202eea47b013978437fa6982cf5e0438ff95c208994aaa0f4ebab2e3a66824b5b56869137e646970313a3a736563703235366b31632d706b3a41353570576d74654a494a4f6a385f7049372d707a654478793147327131384744763838484e526442586b51cc1000008bca152d0454bdfd5adab1bc3a527884f77ea7993d32ee0e4426b2ae0fe42bf3f5642d6abd763b4f4c6042133e2ed79cce743f2c54ff4c7ea5d712dc1172ec244fe5b32ac6ffeb104614bcb8894c7aaafbbbe6f6bfd852f5dcd6cf400557ee764e62850d955975d93eff63b17e6e13e329a7bb13926706c0430017d543ab01920600461000000404191b04ff0006000000000000000000000000000000000000000000000000000000000000000000000000000000001500000000000000e700000000000000e5a3a7b5d830c2953b98534c6c59a3a34fdc34e933f7f5898f0a85cf08846bca0000000000000000000000000000000000000000000000000000000000000000dc9e2a7c6f948f17474e34a7fc43ed030f7c1563f1babddf6340c82e0e54a8c500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c1140324365c08f021a721dbe9175cb89dcd2235e2bd00bfb235b2a66b8c783600000000000000000000000000000000000000000000000000000000000000002af8cd12d44e0d22f904b15c02968b57b668e7f2487ba308e1d9a269ea125e48b243f7d32bb8551e1e3c2c09bd2162d36941eeb47be50b9b55a766a14d0cfe302000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f05005e0e00002d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d49494538444343424a6167417749424167495556706163774c766c316d476155506b384b4375504141334769465177436759494b6f5a497a6a3045417749770a634445694d434147413155454177775a535735305a577767553064594946424453794251624746305a6d397962534244515445614d42674741315545436777520a535735305a577767513239796347397959585270623234784644415342674e564241634d43314e68626e526849454e7359584a684d51737743515944565151490a44414a445154454c4d416b474131554542684d4356564d774868634e4d6a59774e4445314d4441314d4455345768634e4d7a4d774e4445314d4441314d4455340a576a42774d534977494159445651514444426c4a626e526c624342545231676755454e4c49454e6c636e52705a6d6c6a5958526c4d526f77474159445651514b0a4442464a626e526c6243424462334a7762334a6864476c76626a45554d424947413155454277774c553246756447456751327868636d4578437a414a42674e560a4241674d416b4e424d517377435159445651514745774a56557a425a4d424d4742797147534d34394167454743437147534d343941774548413049414245586a0a53374265726c3262726b65543677707878436a556536564775577268586e51767a41395862524768356b68637671766b566b427874715935475759544f6551340a5948496a636b7974734c6c5531774b594a74576a67674d4d4d4949444344416642674e5648534d4547444157674253566231334e765276683655424a796454300a4d383442567776655644427242674e56485238455a4442694d47436758714263686c706f64485277637a6f764c32467761533530636e567a6447566b633256790a646d6c6a5a584d75615735305a577775593239744c334e6e6543396a5a584a3061575a7059324630615739754c3359304c33426a61324e796244396a595431770a624746305a6d397962535a6c626d4e765a476c755a7a316b5a584977485159445652304f42425945464362386b6b73714d364c384f6765734c713943337339440a7a5333504d41344741315564447745422f775145417749477744414d42674e5648524d4241663845416a41414d4949434f51594a4b6f5a496876684e415130420a424949434b6a4343416959774867594b4b6f5a496876684e4151304241515151514e367178312b487a7758704c373859496b716c646a434341574d47436971470a534962345451454e41514977676746544d42414743797147534962345451454e41514942416745454d42414743797147534962345451454e41514943416745450a4d42414743797147534962345451454e41514944416745434d42414743797147534962345451454e41514945416745434d42414743797147534962345451454e0a41514946416745454d42414743797147534962345451454e41514947416745424d42414743797147534962345451454e41514948416745414d424147437971470a534962345451454e41514949416745464d42414743797147534962345451454e4151494a416745414d42414743797147534962345451454e4151494b416745410a4d42414743797147534962345451454e4151494c416745414d42414743797147534962345451454e4151494d416745414d42414743797147534962345451454e0a4151494e416745414d42414743797147534962345451454e4151494f416745414d42414743797147534962345451454e41514950416745414d424147437971470a534962345451454e41514951416745414d42414743797147534962345451454e415149524167454e4d42384743797147534962345451454e41514953424241450a42414943424145414251414141414141414141414d42414743697147534962345451454e41514d45416741414d42514743697147534962345451454e415151450a42704441627741414144415042676f71686b69472b45304244514546436745424d42344743697147534962345451454e4151594545464a37386f7137314543670a6c7536335265417a675430775241594b4b6f5a496876684e41513042427a41324d42414743797147534962345451454e415163424151482f4d424147437971470a534962345451454e41516343415145414d42414743797147534962345451454e415163444151482f4d416f4743437147534d343942414d43413067414d4555430a494778676472434e7a344753716d32647a4c45533874757663717230444d692b427537533771537133325343416945417439454f6377584f6a31484a4c4462750a6d473357414549577962624f61635959612b7253384366526c514d3d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d4949436c6a4343416a32674177494241674956414a567658633239472b487051456e4a3150517a7a674658433935554d416f4743437147534d343942414d430a4d476778476a415942674e5642414d4d45556c756447567349464e48574342536232393049454e424d526f77474159445651514b4442464a626e526c624342440a62334a7762334a6864476c76626a45554d424947413155454277774c553246756447456751327868636d4578437a414a42674e564241674d416b4e424d5173770a435159445651514745774a56557a4165467730784f4441314d6a45784d4455774d5442614677307a4d7a41314d6a45784d4455774d5442614d484178496a41670a42674e5642414d4d47556c756447567349464e4857434251513073675547786864475a76636d306751304578476a415942674e5642416f4d45556c75644756730a49454e76636e4276636d4630615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b474131554543417743513045780a437a414a42674e5642415954416c56544d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a304441516344516741454e53422f377432316c58534f0a3243757a7078773734654a423732457944476757357258437478327456544c7136684b6b367a2b5569525a436e71523770734f766771466553786c6d546c4a6c0a65546d693257597a33714f42757a43427544416642674e5648534d4547444157674251695a517a575770303069664f44744a5653763141624f536347724442530a42674e5648523845537a424a4d45656752614244686b466f64485277637a6f764c324e6c636e52705a6d6c6a5958526c63793530636e567a6447566b633256790a646d6c6a5a584d75615735305a577775593239744c306c756447567355306459556d397664454e424c6d526c636a416442674e5648513445466751556c5739640a7a62306234656c4153636e553944504f4156634c336c517744675944565230504151482f42415144416745474d42494741315564457745422f7751494d4159420a4166384341514177436759494b6f5a497a6a30454177494452774177524149675873566b6930772b6936565947573355462f32327561586530594a446a3155650a6e412b546a44316169356343494359623153416d4435786b66545670766f34556f79695359787244574c6d5552344349394e4b7966504e2b0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d4949436a7a4343416a53674177494241674955496d554d316c71644e496e7a6737535655723951477a6b6e42717777436759494b6f5a497a6a3045417749770a614445614d4267474131554541777752535735305a5777675530645949464a766233516751304578476a415942674e5642416f4d45556c756447567349454e760a636e4276636d4630615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b47413155454341774351304578437a414a0a42674e5642415954416c56544d423458445445344d4455794d5445774e4455784d466f58445451354d54497a4d54497a4e546b314f566f77614445614d4267470a4131554541777752535735305a5777675530645949464a766233516751304578476a415942674e5642416f4d45556c756447567349454e76636e4276636d46300a615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b47413155454341774351304578437a414a42674e56424159540a416c56544d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a3044415163445167414543366e45774d4449595a4f6a2f69505773437a61454b69370a314f694f534c52466857476a626e42564a66566e6b59347533496a6b4459594c304d784f346d717379596a6c42616c54565978465032734a424b357a6c4b4f420a757a43427544416642674e5648534d4547444157674251695a517a575770303069664f44744a5653763141624f5363477244425342674e5648523845537a424a0a4d45656752614244686b466f64485277637a6f764c324e6c636e52705a6d6c6a5958526c63793530636e567a6447566b63325679646d6c6a5a584d75615735300a5a577775593239744c306c756447567355306459556d397664454e424c6d526c636a416442674e564851344546675155496d554d316c71644e496e7a673753560a55723951477a6b6e4271777744675944565230504151482f42415144416745474d42494741315564457745422f7751494d4159424166384341514577436759490a4b6f5a497a6a3045417749445351417752674968414f572f35516b522b533943695344634e6f6f774c7550524c735747662f59693747535839344267775477670a41694541344a306c72486f4d732b586f356f2f7358364f39515778485241765a55474f6452513763767152586171493d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "event_log": "[{\"imr\":0,\"event_type\":2147483659,\"digest\":\"0b8772e5b0b41b83e6044a68397e02f49fb47066b4fbe4917ea2c45c64f323fdacbb37948f821ebaf8bc9c938ba8a749\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":0,\"event_type\":2147483658,\"digest\":\"344bc51c980ba621aaa00da3ed7436f7d6e549197dfe699515dfa2c6583d95e6412af21c097d473155875ffd561d6790\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":0,\"event_type\":2147483649,\"digest\":\"9dc3a1f80bcec915391dcda5ffbb15e7419f77eab462bbf72b42166fb70d50325e37b36f93537a863769bcf9bedae6fb\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":0,\"event_type\":2147483649,\"digest\":\"6f2e3cbc14f9def86980f5f66fd85e99d63e69a73014ed8a5633ce56eca5b64b692108c56110e22acadcef58c3250f1b\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":0,\"event_type\":2147483649,\"digest\":\"d607c0efb41c0d757d69bca0615c3a9ac0b1db06c557d992e906c6b7dee40e0e031640c7bfd7bcd35844ef9edeadc6f9\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":0,\"event_type\":2147483649,\"digest\":\"08a74f8963b337acb6c93682f934496373679dd26af1089cb4eaf0c30cf260a12e814856385ab8843e56a9acea19e127\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":0,\"event_type\":2147483649,\"digest\":\"18cc6e01f0c6ea99aa23f8a280423e94ad81d96d0aeb5180504fc0f7a40cb3619dd39bd6a95ec1680a86ed6ab0f9828d\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":0,\"event_type\":4,\"digest\":\"394341b7182cd227c5c6b07ef8000cdfd86136c4292b8e576573ad7ed9ae41019f5818b4b971c9effc60e1ad9f1289f0\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":0,\"event_type\":10,\"digest\":\"095d04cf26fe03aef6e3561fa24c1aa1cea93f4aeaf563b1f9f7616184c53454875925759434769cec2490acb563a337\",\"event\":\"\",\"event_payload\":\"414350492044415441\"},{\"imr\":0,\"event_type\":10,\"digest\":\"8d9a4d4777a1bc77ecd9d8d37a4628129a80052a510320159a20a923bd07a0e90d8d1f2e1ebf088992b25f0d0fa672ef\",\"event\":\"\",\"event_payload\":\"414350492044415441\"},{\"imr\":0,\"event_type\":10,\"digest\":\"3070721e169bc41884724cb0e6b3082e1baf249083d8b389181ba50b9afa951057876c380b8870e8c2facf2eff67a2b6\",\"event\":\"\",\"event_payload\":\"414350492044415441\"},{\"imr\":1,\"event_type\":2147483651,\"digest\":\"ac7e632dcf5cd2a1fe5c1f41f4d9b8219570e64ed3c61038fdbf25404e6f542ffd57f276bc5076307efaf882e6d64177\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":0,\"event_type\":2147483650,\"digest\":\"1dd6f7b457ad880d840d41c961283bab688e94e4b59359ea45686581e90feccea3c624b1226113f824f315eb60ae0a7c\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":0,\"event_type\":2147483650,\"digest\":\"23ada07f5261f12f34a0bd8e46760962d6b4d576a416f1fea1c64bc656b1d28eacf7047ae6e967c58fd2a98bfa74c298\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":1,\"event_type\":2147483655,\"digest\":\"77a0dab2312b4e1e57a84d865a21e5b2ee8d677a21012ada819d0a98988078d3d740f6346bfe0abaa938ca20439a8d71\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":1,\"event_type\":4,\"digest\":\"394341b7182cd227c5c6b07ef8000cdfd86136c4292b8e576573ad7ed9ae41019f5818b4b971c9effc60e1ad9f1289f0\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":2,\"event_type\":6,\"digest\":\"786280842b7364287a3a70d96f7e309252857beb45fb1f91314a2ea863db0adc04c8431ecbf29a966405604631a5aab8\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":2,\"event_type\":6,\"digest\":\"4fe4f7710134a61d7def357add6ac50bdbfeee5032a4c100375e207216ffe42a3bd5822b24e679f91501fff795b81521\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":1,\"event_type\":2147483655,\"digest\":\"214b0bef1379756011344877743fdc2a5382bac6e70362d624ccf3f654407c1b4badf7d8f9295dd3dabdef65b27677e0\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":1,\"event_type\":2147483655,\"digest\":\"0a2e01c85deae718a530ad8c6d20a84009babe6c8989269e950d8cf440c6e997695e64d455c4174a652cd080f6230b74\",\"event\":\"\",\"event_payload\":\"\"},{\"imr\":3,\"event_type\":134217729,\"digest\":\"\",\"event\":\"system-preparing\",\"event_payload\":\"\"},{\"imr\":3,\"event_type\":134217729,\"digest\":\"\",\"event\":\"app-id\",\"event_payload\":\"86b0e55f2fa8e4fb69d890f14f54d5612707646e\"},{\"imr\":3,\"event_type\":134217729,\"digest\":\"\",\"event\":\"compose-hash\",\"event_payload\":\"86b0e55f2fa8e4fb69d890f14f54d5612707646e2573d54e0d2ddaaade77caa9\"},{\"imr\":3,\"event_type\":134217729,\"digest\":\"\",\"event\":\"instance-id\",\"event_payload\":\"050bf89570575fe8fab4cb8f0a62a9e64efe8ead\"},{\"imr\":3,\"event_type\":134217729,\"digest\":\"\",\"event\":\"boot-mr-done\",\"event_payload\":\"\"},{\"imr\":3,\"event_type\":134217729,\"digest\":\"\",\"event\":\"os-image-hash\",\"event_payload\":\"07a2388c7a6a1b6a646d443f1517990a4ec294471d63146cda9d56972765051d\"},{\"imr\":3,\"event_type\":134217729,\"digest\":\"\",\"event\":\"key-provider\",\"event_payload\":\"7b226e616d65223a226b6d73222c226964223a223330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030343266373165323334643733333961316365616361303963336333393165623831366335333366393830616461616233346631366561643039336666306163313030643963303332353361333035366636643237373335313235343333313830623365363163353461373866336664313333333738363965303035316465653036227d\"},{\"imr\":3,\"event_type\":134217729,\"digest\":\"\",\"event\":\"storage-fs\",\"event_payload\":\"7a6673\"},{\"imr\":3,\"event_type\":134217729,\"digest\":\"\",\"event\":\"system-ready\",\"event_payload\":\"\"}]", + "report_data": "646970313a3a736563703235366b31632d706b3a41353570576d74654a494a4f6a385f7049372d707a654478793147327131384744763838484e526442586b51", + "vm_config": "{\"os_image_hash\":\"07a2388c7a6a1b6a646d443f1517990a4ec294471d63146cda9d56972765051d\",\"cpu_count\":2,\"memory_size\":2147483648,\"qemu_version\":\"8.2.2\",\"pci_hole64_size\":0,\"hugepages\":false,\"num_gpus\":0,\"num_nvswitches\":0,\"hotplug_off\":false,\"image\":\"dstack-0.6.0\",\"host_share_mode\":\"9p\",\"ovmf_variant\":\"pre202505\",\"tdx_attestation_variant\":\"lite\",\"tdx_measurement\":{\"version\":2,\"os_image_hash\":\"07a2388c7a6a1b6a646d443f1517990a4ec294471d63146cda9d56972765051d\",\"measurement\":\"a265696d616765a36e636d646c696e655f7368613338345830786280842b7364287a3a70d96f7e309252857beb45fb1f91314a2ea863db0adc04c8431ecbf29a966405604631a5aab8736b65726e656c5f61757468656e7469636f64655830ac7e632dcf5cd2a1fe5c1f41f4d9b8219570e64ed3c61038fdbf25404e6f542ffd57f276bc5076307efaf882e6d641776d696e697472645f73686133383458304fe4f7710134a61d7def357add6ac50bdbfeee5032a4c100375e207216ffe42a3bd5822b24e679f91501fff795b815216474647666a3646f766d6669707265323032353035646d727464a26b73696e676c655f706173735830a6f2ac9451810686a4db259fe8fa5438dc4a58bda9fd2f5b1fb0928335705500d29a15c92387416a2f52dddce99c83f86874776f5f706173735830fd685522ce791dfef67414614eb07d03fc07a32c5a66f36288b329dab92b724b1564c73d436ffb9ea84488c51ac5a1c56674645f686f624c80100904000609020b021010\"},\"spec_version\":1}" +} diff --git a/verifier/fixtures/tdx-lite.README.md b/verifier/fixtures/tdx-lite.README.md new file mode 100644 index 000000000..95b25f731 --- /dev/null +++ b/verifier/fixtures/tdx-lite.README.md @@ -0,0 +1,58 @@ +# TDX lite attestation fixture + +This fixture was captured from the local meta-dstack e2e stack using TDX +`tdx_attestation_variant = "lite"`. It covers the KMS/verifier path that +verifies the OS image from `vm_config.tdx_measurement`, without downloading the +image and without running the QEMU ACPI table helper. + +Files: + +- `tdx-lite-attestation.json`: verifier input that mimics the KMS + `GetAppKey` flow. It contains a stripped `attestation` plus the explicit + `vm_config` carrying `tdx_measurement`. +- `tdx-lite-getquote.json`: raw guest-agent `GetQuoteResponse` captured + via `GetAttestationForAppKey`, including quote, event log, and vm_config. + TDX `GetQuoteResponse` intentionally omits the `attestation` field to keep + the response compact. + +Captured with: + +```bash +E2E_APP_TIMEOUT=900 ./e2e/run.sh up \ + --image-dir images \ + --image dstack-0.6.0 \ + --apps 1 \ + --force \ + --kms-image-verify \ + --kms-no-qemu +``` + +Important fixture properties: + +- `vm_config.tdx_attestation_variant = "lite"` +- `vm_config.memory_size = 2147483648` (2 GiB) +- `vm_config.os_image_hash = 07a2388c7a6a1b6a646d443f1517990a4ec294471d63146cda9d56972765051d` +- The top-level `event_log` and stripped attestation keep the three RTMR0 + `ACPI DATA` digests and marker payloads needed by the lite verifier, plus + RTMR3 runtime events. + +To verify without image download, use a config whose download URL is unreachable; +the lite verifier should still pass: + +```toml +address = "127.0.0.1" +port = 0 +image_cache_dir = "/tmp/dstack-verifier-tdx-lite-fixture-cache" +image_download_url = "http://127.0.0.1:9/should-not-download/{OS_IMAGE_HASH}.tar.gz" +image_download_timeout_secs = 1 +``` + +Then run: + +```bash +dstack-verifier --config verifier-no-download.toml \ + --verify verifier/fixtures/tdx-lite-attestation.json +``` + +Expected result: `Valid: true`, with quote, event log, and OS image hash all +verified. diff --git a/verifier/src/verification.rs b/verifier/src/verification.rs index 49326d30c..ecddf017e 100644 --- a/verifier/src/verification.rs +++ b/verifier/src/verification.rs @@ -10,7 +10,9 @@ use std::{ use anyhow::{anyhow, bail, Context, Result}; use cc_eventlog::TdxEvent; -use dstack_mr::{RtmrLog, TdxMeasurementDetails, TdxMeasurements}; +use dstack_mr::{ + tdx::TdxRtmr0AcpiHashes, RtmrLog, RtmrLogs, TdxMeasurementDetails, TdxMeasurements, +}; use dstack_types::VmConfig; use hex_literal::hex; use ra_tls::attestation::{ @@ -138,9 +140,8 @@ fn collect_rtmr_mismatch( } // Bump whenever expected RTMR computation changes so stale entries get ignored. -// v2: edk2-stable202505 OVMF RTMR[0] layout (added 4 events, reshaped BootOrder -// and Boot0000); the legacy 13-event log no longer matches any in-field image. -const MEASUREMENT_CACHE_VERSION: u32 = 2; +// v3: all supported OVMF measurements use the Pre202505 RTMR[0] layout. +const MEASUREMENT_CACHE_VERSION: u32 = 3; #[derive(Clone, Serialize, Deserialize)] struct CachedMeasurement { @@ -149,6 +150,7 @@ struct CachedMeasurement { } struct ImagePaths { + image_dir: PathBuf, fw_path: PathBuf, kernel_path: PathBuf, initrd_path: PathBuf, @@ -359,6 +361,83 @@ impl CvmVerifier { Ok(measurements) } + fn image_content_digest(image_dir: &Path) -> Result>> { + let sha256sum_path = image_dir.join("sha256sum.txt"); + if !sha256sum_path.exists() { + return Ok(None); + } + let files_doc = + fs_err::read_to_string(&sha256sum_path).context("Failed to read sha256sum.txt")?; + Ok(Some( + Sha256::new_with_prefix(files_doc.as_bytes()) + .finalize() + .to_vec(), + )) + } + + fn image_hash_matches_legacy_digest(image_dir: &Path, expected: &[u8]) -> Result { + Ok(Self::image_content_digest(image_dir)? + .as_deref() + .is_some_and(|digest| digest == expected)) + } + + fn tdx_acpi_digest_candidates_from_event_log(event_log: &[TdxEvent]) -> Result>> { + const TDX_ACPI_DATA_EVENT_TYPE: u32 = 10; + const TDX_ACPI_DATA_EVENT_PAYLOAD: &[u8] = b"ACPI DATA"; + + let rtmr0_events = event_log + .iter() + .filter(|event| event.imr == 0) + .collect::>(); + let candidates = rtmr0_events + .iter() + .filter(|event| { + event.event_type == TDX_ACPI_DATA_EVENT_TYPE + && event.event_payload == TDX_ACPI_DATA_EVENT_PAYLOAD + }) + .map(|event| event.digest()) + .collect::>(); + if candidates.len() != 3 { + bail!( + "TDX lite attestation requires exactly 3 RTMR0 ACPI DATA digests; found {} candidates and {} RTMR0 events", + candidates.len(), + rtmr0_events.len() + ); + } + for (idx, digest) in candidates.iter().enumerate() { + if digest.len() != 48 { + bail!( + "TDX RTMR0 ACPI DATA digest {idx} has invalid length {}, expected 48", + digest.len() + ); + } + } + Ok(candidates) + } + + fn tdx_acpi_hash_permutations(digests: &[Vec]) -> Vec { + debug_assert_eq!(digests.len(), 3); + let mut permutations = Vec::with_capacity(6); + for loader_idx in 0..3 { + for rsdp_idx in 0..3 { + if rsdp_idx == loader_idx { + continue; + } + for tables_idx in 0..3 { + if tables_idx == loader_idx || tables_idx == rsdp_idx { + continue; + } + permutations.push(TdxRtmr0AcpiHashes { + loader: digests[loader_idx].clone(), + rsdp: digests[rsdp_idx].clone(), + tables: digests[tables_idx].clone(), + }); + } + } + } + permutations + } + /// Helper method to ensure image is downloaded and return image paths async fn ensure_image_downloaded(&self, vm_config: &VmConfig) -> Result { let hex_os_image_hash = hex::encode(&vm_config.os_image_hash); @@ -391,6 +470,7 @@ impl CvmVerifier { let kernel_cmdline = image_info.cmdline + " initrd=initrd"; Ok(ImagePaths { + image_dir, fw_path, kernel_path, initrd_path, @@ -526,8 +606,23 @@ impl CvmVerifier { .await?; } AttestationQuote::DstackTdx(_) => { - self.verify_os_image_hash_for_dstack_tdx(&vm_config, attestation, debug, details) + if vm_config.tdx_attestation_variant.is_lite() { + self.verify_os_image_hash_for_dstack_tdx_lite( + &vm_config, + attestation, + debug, + details, + ) .await?; + } else { + self.verify_os_image_hash_for_dstack_tdx( + &vm_config, + attestation, + debug, + details, + ) + .await?; + } } AttestationQuote::DstackNitroEnclave(_) => { let DstackVerifiedReport::DstackNitroEnclave(report) = &attestation.report else { @@ -596,13 +691,11 @@ impl CvmVerifier { bail!("No TDX quote"); }; let event_log = &tdx_quote.event_log; - // Get boot info from attestation let report = report .report .as_td10() .context("Failed to decode TD report")?; - // Extract the verified MRs from the report let verified_mrs = Mrs { mrtd: report.mr_td.to_vec(), rtmr0: report.rt_mr0.to_vec(), @@ -610,16 +703,21 @@ impl CvmVerifier { rtmr2: report.rt_mr2.to_vec(), }; - // one download serves both measurement computation and the dev/version flags + // Legacy TDX attestation keeps the original KMS verifier semantics: + // os_image_hash must be the image content digest, and expected MRs are + // recomputed through the existing full-image path. let image_paths = self.ensure_image_downloaded(vm_config).await?; + if !Self::image_hash_matches_legacy_digest(&image_paths.image_dir, &vm_config.os_image_hash) + .context("Failed to check legacy image digest")? + { + bail!("legacy TDX attestation requires the digest.txt os_image_hash"); + } 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 TdxMeasurementDetails { measurements, rtmr_logs, @@ -642,7 +740,6 @@ impl CvmVerifier { (measurements, Some(rtmr_logs)) } else { - // For non-debug mode, use the cached-measurement path. ( self.load_or_compute_measurements( vm_config, @@ -656,13 +753,140 @@ impl CvmVerifier { ) }; - let expected_mrs = Mrs { - mrtd: mrs.mrtd.clone(), - rtmr0: mrs.rtmr0.clone(), - rtmr1: mrs.rtmr1.clone(), - rtmr2: mrs.rtmr2.clone(), + self.compare_tdx_mrs( + Mrs { + mrtd: mrs.mrtd, + rtmr0: mrs.rtmr0, + rtmr1: mrs.rtmr1, + rtmr2: mrs.rtmr2, + }, + verified_mrs, + expected_logs.as_ref(), + event_log, + debug, + details, + ) + } + + async fn verify_os_image_hash_for_dstack_tdx_lite( + &self, + vm_config: &VmConfig, + attestation: &VerifiedAttestation, + debug: bool, + _details: &mut VerificationDetails, + ) -> Result<()> { + let Some(report) = &attestation.report.tdx_report() else { + bail!("No TDX report"); + }; + let Some(tdx_quote) = attestation.tdx_quote() else { + bail!("No TDX quote"); + }; + let event_log = &tdx_quote.event_log; + // Get boot info from attestation + let report = report + .report + .as_td10() + .context("Failed to decode TD report")?; + + // Extract the verified MRs from the report + let verified_mrs = Mrs { + mrtd: report.mr_td.to_vec(), + rtmr0: report.rt_mr0.to_vec(), + rtmr1: report.rt_mr1.to_vec(), + rtmr2: report.rt_mr2.to_vec(), }; + let document = vm_config + .tdx_measurement + .as_ref() + .context("tdx lite attestation requires vm_config.tdx_measurement")?; + let document_hash = hex::decode(&document.os_image_hash) + .context("vm_config.tdx_measurement.os_image_hash is not valid hex")?; + if document_hash != vm_config.os_image_hash { + bail!( + "tdx measurement os_image_hash mismatch: vm_config={}, document={}", + hex::encode(&vm_config.os_image_hash), + document.os_image_hash + ); + } + let computed_hash = document.measurement_os_image_hash(); + if computed_hash.as_slice() != vm_config.os_image_hash { + bail!( + "tdx measurement document hash mismatch: vm_config={}, computed={}", + hex::encode(&vm_config.os_image_hash), + hex::encode(computed_hash) + ); + } + let measurement = document + .decode_measurement() + .map_err(anyhow::Error::msg) + .context("failed to decode vm_config.tdx_measurement CBOR")?; + if let Some(config_ovmf_variant) = vm_config.ovmf_variant { + if config_ovmf_variant != measurement.tdvf.ovmf_variant { + bail!( + "tdx measurement ovmf_variant mismatch: vm_config={:?}, document={:?}", + config_ovmf_variant, + measurement.tdvf.ovmf_variant + ); + } + } + + // Compute expected measurements. New TDX images advertise the + // measurement.json-derived TDX os_image_hash; verify those without + // downloading the image or running QEMU-derived ACPI table generators. + // The event log supplies only the three hardware-bound RTMR0 ACPI DATA + // digests. Their payloads do not distinguish loader/RSDP/tables, so try + // all assignments and accept the one that replays to the quote RTMRs. + // This avoids hard-coding OVMF-version-specific RTMR0 indexes. + let acpi_digests = Self::tdx_acpi_digest_candidates_from_event_log(event_log) + .context("TDX lite attestation is missing RTMR0 ACPI DATA digests")?; + let mut last_error = None; + for acpi_hashes in Self::tdx_acpi_hash_permutations(&acpi_digests) { + let mrs = match dstack_mr::tdx::tdx_measurements_from_measurement_document( + document, + vm_config, + &acpi_hashes, + ) + .context("Failed to compute TDX expected measurements without image download") + { + Ok(mrs) => mrs, + Err(e) => { + last_error = Some(e); + continue; + } + }; + + let expected_mrs = Mrs { + mrtd: mrs.mrtd.clone(), + rtmr0: mrs.rtmr0.clone(), + rtmr1: mrs.rtmr1.clone(), + rtmr2: mrs.rtmr2.clone(), + }; + match expected_mrs.assert_eq(&verified_mrs) { + Ok(()) => return Ok(()), + Err(e) => last_error = Some(e), + } + } + + let result = Err(last_error.unwrap_or_else(|| { + anyhow!("MRs do not match for any RTMR0 ACPI DATA digest assignment") + })) + .context("MRs do not match"); + if !debug { + return result; + } + result + } + + fn compare_tdx_mrs( + &self, + expected_mrs: Mrs, + verified_mrs: Mrs, + expected_logs: Option<&RtmrLogs>, + event_log: &[TdxEvent], + debug: bool, + details: &mut VerificationDetails, + ) -> Result<()> { match expected_mrs.assert_eq(&verified_mrs) { Ok(()) => Ok(()), Err(e) => { @@ -670,7 +894,7 @@ impl CvmVerifier { if !debug { return result; } - let Some(expected_logs) = expected_logs.as_ref() else { + let Some(expected_logs) = expected_logs else { return result; }; let mut rtmr_debug = Vec::new(); @@ -894,10 +1118,24 @@ impl CvmVerifier { } } - // os_image_hash should eq to sha256sum of the sha256sum.txt - let os_image_hash = Sha256::new_with_prefix(files_doc.as_bytes()).finalize(); - if hex::encode(os_image_hash) != hex_os_image_hash { - bail!("os_image_hash does not match sha256sum of the sha256sum.txt"); + // Legacy images use sha256(sha256sum.txt) as os_image_hash. Newer + // TDX/SNP images may instead be addressed by measurement.json-derived + // hashes, so accept those too after recomputing them from extracted + // image files. + let legacy_os_image_hash = Sha256::new_with_prefix(files_doc.as_bytes()).finalize(); + let mut image_hash_matches = hex::encode(legacy_os_image_hash) == hex_os_image_hash; + if !image_hash_matches { + image_hash_matches = dstack_mr::tdx::tdx_os_image_hash_for_image_dir(&extracted_dir) + .map(|hash| hex::encode(hash) == hex_os_image_hash) + .unwrap_or(false) + || dstack_mr::sev::sev_os_image_hash_for_image_dir(&extracted_dir) + .map(|hash| hex::encode(hash) == hex_os_image_hash) + .unwrap_or(false); + } + if !image_hash_matches { + bail!( + "os_image_hash matches neither sha256sum.txt nor measurement.json-derived hashes" + ); } // Move the extracted files to the destination directory diff --git a/vmm/src/app.rs b/vmm/src/app.rs index fa21297a0..56893a674 100644 --- a/vmm/src/app.rs +++ b/vmm/src/app.rs @@ -1324,8 +1324,11 @@ fn amd_sev_snp_ovmf_measurement_info(image: &Image) -> Result) -> Option { - base_cmdline.map(|cmdline| cmdline.trim().to_string()) +fn amd_sev_snp_measurement_base_cmdline(base_cmdline: Option<&str>) -> Result { + match base_cmdline.map(str::trim) { + Some(cmdline) if !cmdline.is_empty() => Ok(cmdline.to_string()), + _ => anyhow::bail!("metadata.json cmdline is required for amd sev-snp measurement"), + } } fn sha256_file(path: impl AsRef) -> Result<[u8; 32]> { @@ -1344,17 +1347,30 @@ fn make_vm_config( ) -> Result { let is_amd_sev_snp = cfg.cvm.resolved_platform() == crate::config::TeePlatform::AmdSevSnp && !manifest.no_tee; + let is_tdx = cfg.cvm.resolved_platform() == crate::config::TeePlatform::Tdx && !manifest.no_tee; + let tdx_attestation_variant = if is_tdx { + cfg.cvm.tdx_attestation_variant + } else { + dstack_types::TdxAttestationVariant::Legacy + }; // AMD SEV-SNP binds the OS image through the launch-measurement-derived - // os_image_hash, computed at image build time by `dstack-mr sev-os-image-hash` - // and shipped as `digest.sev.txt` (the same value KMS/verifier derive from a - // verified launch measurement). The VMM reads it from the image rather than - // recomputing it; TDX still uses the generic content digest. + // os_image_hash, computed at image build time and shipped in + // `measurement.json.snp.os_image_hash` (legacy images used `digest.sev.txt`). TDX keeps + // using the generic content digest unless the + // operator explicitly opts into the lite attestation variant. let os_image_hash = if is_amd_sev_snp { let digest = image.sev_digest.as_deref().context( - "amd sev-snp image is missing digest.sev.txt; \ - rebuild the image so `dstack-mr sev-os-image-hash` emits it", + "amd sev-snp image is missing measurement.json SNP hash; \ + rebuild the image so `dstack-mr os-image-measurement` emits it", )?; - hex::decode(digest).context("digest.sev.txt is not valid hex")? + hex::decode(digest).context("SNP os_image_hash is not valid hex")? + } else if tdx_attestation_variant.is_lite() { + let digest = image.tdx_digest.as_deref().context( + "tdx lite attestation requested but image is missing \ + measurement.json TDX hash; rebuild the image so \ + `dstack-mr os-image-measurement` emits it", + )?; + hex::decode(digest).context("TDX os_image_hash is not valid hex")? } else { image .digest @@ -1362,6 +1378,14 @@ fn make_vm_config( .and_then(|d| hex::decode(d).ok()) .unwrap_or_default() }; + let tdx_measurement = if tdx_attestation_variant.is_lite() { + Some(image.tdx_measurement.clone().context( + "tdx lite attestation requested but image is missing \ + measurement.json TDX measurement material", + )?) + } else { + None + }; let gpus = if cfg.cvm.gpu.enabled { manifest.gpus.clone().unwrap_or_default() } else { @@ -1383,6 +1407,8 @@ fn make_vm_config( hotplug_off: cfg.cvm.qemu_hotplug_off, image: Some(manifest.image.clone()), ovmf_variant: image.info.ovmf_variant, + tdx_attestation_variant, + tdx_measurement, })?; // For backward compatibility config["spec_version"] = serde_json::Value::from(1); @@ -1396,7 +1422,7 @@ fn make_vm_config( } let ovmf = amd_sev_snp_ovmf_measurement_info(image)?; let measurement = json!({ - "base_cmdline": amd_sev_snp_measurement_base_cmdline(image.info.cmdline.as_deref()), + "base_cmdline": amd_sev_snp_measurement_base_cmdline(image.info.cmdline.as_deref())?, "ovmf_hash": ovmf.ovmf_hash, "kernel_hash": file_sha256_hex(&image.kernel)?, "initrd_hash": file_sha256_hex(&image.initrd)?, @@ -1516,9 +1542,11 @@ mod tests { #[test] fn amd_sev_snp_measurement_base_cmdline_trims_image_cmdline() { assert_eq!( - amd_sev_snp_measurement_base_cmdline(Some(" console=ttyS0 loglevel=7 ")), - Some("console=ttyS0 loglevel=7".to_string()) + amd_sev_snp_measurement_base_cmdline(Some(" console=ttyS0 loglevel=7 ")).unwrap(), + "console=ttyS0 loglevel=7" ); + assert!(amd_sev_snp_measurement_base_cmdline(None).is_err()); + assert!(amd_sev_snp_measurement_base_cmdline(Some(" ")).is_err()); } #[test] @@ -1580,11 +1608,19 @@ mod tests { ) .to_canonical_json(); - // digest.sev.txt is produced at build time by the `dstack-mr - // sev-os-image-hash` command; the VMM reads it instead of recomputing. + // measurement.json is produced at build time by the `dstack-mr + // os-image-measurement` command; the VMM reads it instead of recomputing. // Emit it here so the deploy path (make_vm_config) can read it back. - let build_hash = dstack_mr::sev::sev_os_image_hash_for_image_dir(&image_dir)?; - fs::write(image_dir.join("digest.sev.txt"), hex::encode(build_hash))?; + let snp_document = + dstack_mr::sev::sev_os_image_measurement_document_for_image_dir(&image_dir)?; + let build_hash = + hex::decode(&snp_document.os_image_hash).context("snp os_image_hash must be hex")?; + let measurement_document = + dstack_types::OsImageMeasurementDocument::new(None, Some(snp_document)); + fs::write( + image_dir.join("measurement.json"), + serde_json::to_string(&measurement_document)?, + )?; let sys_config_document = make_sys_config(&config, &manifest, &compose_hash, Some(mr_config))?; @@ -1607,13 +1643,13 @@ mod tests { assert_eq!(parsed_mr_config.compose_hash, vec![0x22; 32]); assert_eq!(vm_config["mr_config"], sys_config["mr_config"]); // The deploy path must surface the os_image_hash straight from - // digest.sev.txt (not recompute it). + // measurement.json (not recompute it). assert_eq!( vm_config["os_image_hash"] .as_str() .context("os_image_hash must be a string")?, - hex::encode(build_hash), - "vm_config os_image_hash must come from digest.sev.txt" + hex::encode(&build_hash), + "vm_config os_image_hash must come from measurement.json" ); assert!(measurement.get("app_id").is_none()); assert!(measurement.get("compose_hash").is_none()); @@ -1650,18 +1686,24 @@ mod tests { 4 ); - // The build-time os_image_hash (dstack-mr sev-os-image-hash -> - // digest.sev.txt) must equal the os_image_hash a verifier derives from + // The build-time os_image_hash (measurement.json.snp.os_image_hash) must + // equal the os_image_hash a verifier derives from // the launch measurement document, i.e. the image-invariant projection. - let as_str = |v: &serde_json::Value| v.as_str().unwrap().to_string(); - let rootfs_hash = - dstack_mr::sev::rootfs_hash_from_cmdline(measurement["base_cmdline"].as_str())?; + let as_bytes = |v: &serde_json::Value| hex::decode(v.as_str().unwrap()).unwrap(); + dstack_mr::sev::rootfs_hash_from_cmdline(measurement["base_cmdline"].as_str())?; let projected = dstack_types::SevOsImageMeasurement { - rootfs_hash, - base_cmdline: measurement["base_cmdline"].as_str().map(str::to_string), - ovmf_hash: as_str(&measurement["ovmf_hash"]), - kernel_hash: as_str(&measurement["kernel_hash"]), - initrd_hash: as_str(&measurement["initrd_hash"]), + kernel_cmdline_sha256: { + let mut cmdline = measurement["base_cmdline"] + .as_str() + .unwrap() + .as_bytes() + .to_vec(); + cmdline.push(0); + Sha256::digest(&cmdline).to_vec() + }, + ovmf_hash: as_bytes(&measurement["ovmf_hash"]), + kernel_hash: as_bytes(&measurement["kernel_hash"]), + initrd_hash: as_bytes(&measurement["initrd_hash"]), sev_hashes_table_gpa: measurement["sev_hashes_table_gpa"].as_u64().unwrap(), sev_es_reset_eip: measurement["sev_es_reset_eip"].as_u64().unwrap() as u32, ovmf_sections: measurement["ovmf_sections"] @@ -1677,8 +1719,8 @@ mod tests { }; assert_eq!( build_hash, - projected.os_image_hash(), - "digest.sev.txt must match the os_image_hash derived from the launch measurement" + projected.os_image_hash().to_vec(), + "measurement.json SNP hash must match the os_image_hash derived from the launch measurement" ); Ok(()) } diff --git a/vmm/src/app/image.rs b/vmm/src/app/image.rs index c8e7d255d..f7bdb2e7f 100644 --- a/vmm/src/app/image.rs +++ b/vmm/src/app/image.rs @@ -7,6 +7,7 @@ use path_absolutize::Absolutize; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; +use dstack_types::{OsImageMeasurementDocument, TdxOsImageMeasurementDocument}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] @@ -71,9 +72,12 @@ pub struct Image { pub bios: Option, pub bios_sev: Option, pub digest: Option, - /// AMD SEV-SNP os_image_hash, read from `digest.sev.txt` (produced at image - /// build time by `dstack-mr sev-os-image-hash`). The VMM does not recompute - /// it; the deploy path reads this value directly. + /// TDX os_image_hash, read from `measurement.json.tdx.os_image_hash`. + pub tdx_digest: Option, + /// TDX no-image-download measurement material, read from `measurement.json.tdx`. + pub tdx_measurement: Option, + /// AMD SEV-SNP os_image_hash, read from `measurement.json.snp.os_image_hash` + /// for new images, falling back to legacy `digest.sev.txt`. pub sev_digest: Option, } @@ -103,10 +107,31 @@ impl Image { let digest = fs::read_to_string(base_path.join("digest.txt")) .ok() .map(|s| s.trim().to_string()); - let sev_digest = fs::read_to_string(base_path.join("digest.sev.txt")) + let measurement_path = base_path.join("measurement.json"); + let measurement = if measurement_path.exists() { + let file = fs::File::open(&measurement_path) + .with_context(|| format!("failed to open {}", measurement_path.display()))?; + Some( + serde_json::from_reader::<_, OsImageMeasurementDocument>(file) + .with_context(|| format!("failed to parse {}", measurement_path.display()))?, + ) + } else { + None + }; + let legacy_sev_digest = fs::read_to_string(base_path.join("digest.sev.txt")) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); + let sev_digest = measurement + .as_ref() + .and_then(|m| m.snp.as_ref()) + .map(|snp| snp.os_image_hash.clone()) + .or(legacy_sev_digest); + let tdx_digest = measurement + .as_ref() + .and_then(|m| m.tdx.as_ref()) + .map(|tdx| tdx.os_image_hash.clone()); + let tdx_measurement = measurement.as_ref().and_then(|m| m.tdx.clone()); if info.version.is_empty() { // Older images does not have version field. Fallback to the version of the image folder name info.version = guess_version(&base_path).unwrap_or_default(); @@ -120,6 +145,8 @@ impl Image { bios, bios_sev, digest, + tdx_digest, + tdx_measurement, sev_digest, } .ensure_exists() diff --git a/vmm/src/config.rs b/vmm/src/config.rs index b0b234a29..788865d49 100644 --- a/vmm/src/config.rs +++ b/vmm/src/config.rs @@ -10,6 +10,7 @@ use path_absolutize::Absolutize; use rocket::figment::Figment; use serde::{Deserialize, Serialize}; +use dstack_types::TdxAttestationVariant; use lspci::{lspci_filtered, Device}; use tracing::{info, warn}; @@ -260,6 +261,12 @@ pub struct CvmConfig { /// QEMU hotplug_off pub qemu_hotplug_off: bool, + /// TDX attestation/hash scheme. `legacy` keeps the existing digest.txt + + /// dstack-acpi-tables verifier path; `lite` opts into the + /// measurement.json + no-QEMU verifier path. + #[serde(default)] + pub tdx_attestation_variant: TdxAttestationVariant, + /// Networking configuration pub networking: Networking, diff --git a/vmm/vmm.toml b/vmm/vmm.toml index 73d8c124a..cde99b7a7 100644 --- a/vmm/vmm.toml +++ b/vmm/vmm.toml @@ -45,6 +45,9 @@ use_mrconfigid = true #qemu_version = "" qemu_pci_hole64_size = 0 qemu_hotplug_off = false +# TDX attestation/hash scheme: "legacy" (digest.txt + legacy verifier) or +# "lite" (measurement.json.tdx.os_image_hash + no-QEMU verifier). +tdx_attestation_variant = "legacy" host_share_mode = "9p"