diff --git a/Cargo.lock b/Cargo.lock index 4e3503c96..e5c644d87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2729,6 +2729,8 @@ dependencies = [ name = "dstack-types" version = "0.5.11" dependencies = [ + "ciborium", + "hex", "or-panic", "parity-scale-codec", "serde", diff --git a/cc-eventlog/src/tdx.rs b/cc-eventlog/src/tdx.rs index bf7d677c0..ae3e62c5d 100644 --- a/cc-eventlog/src/tdx.rs +++ b/cc-eventlog/src/tdx.rs @@ -11,6 +11,17 @@ use crate::{ tcg::TcgEventLog, }; +pub const TDX_ACPI_DATA_EVENT_TYPE: u32 = 10; +pub const TDX_ACPI_DATA_EVENT_PAYLOAD: &[u8] = b"ACPI DATA"; +pub const TDX_ACPI_LOADER_EVENT: &str = "acpi-loader"; +pub const TDX_ACPI_RSDP_EVENT: &str = "acpi-rsdp"; +pub const TDX_ACPI_TABLES_EVENT: &str = "acpi-tables"; +pub const TDX_ACPI_DATA_EVENT_NAMES: [&str; 3] = [ + TDX_ACPI_LOADER_EVENT, + TDX_ACPI_RSDP_EVENT, + TDX_ACPI_TABLES_EVENT, +]; + /// This is the TDX event log format that is used to store the event log in the TDX guest. /// It is a simplified version of the TCG event log format, containing only a single digest /// and the raw event data. The IMR index is zero-based, unlike the TCG event log format @@ -97,9 +108,68 @@ impl From for TdxEvent { } } +pub 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 +} + +/// Give dstack's three Pre202505 OVMF ACPI DATA RTMR0 events stable semantic +/// names. The firmware event payload is the same "ACPI DATA" marker for all +/// three entries, so the guest labels them before exposing the event log. +pub fn label_tdx_acpi_data_events(event_logs: &mut [TdxEvent]) { + for (acpi_idx, event) in event_logs + .iter_mut() + .filter(|event| is_tdx_acpi_data_event(event)) + .enumerate() + { + if let Some(name) = TDX_ACPI_DATA_EVENT_NAMES.get(acpi_idx) { + event.event = (*name).to_string(); + } + } +} + /// Read both boottime and runtime event logs. pub fn read_event_log() -> Result> { let mut event_logs = TcgEventLog::decode_from_ccel_file()?.to_cc_event_log()?; + label_tdx_acpi_data_events(&mut event_logs); event_logs.extend(RuntimeEvent::read_all()?.into_iter().map(Into::into)); Ok(event_logs) } + +#[cfg(test)] +mod tests { + use super::*; + + fn acpi_data_event(digest_byte: u8) -> TdxEvent { + TdxEvent { + imr: 0, + event_type: TDX_ACPI_DATA_EVENT_TYPE, + digest: vec![digest_byte; 48], + event: String::new(), + event_payload: TDX_ACPI_DATA_EVENT_PAYLOAD.to_vec(), + } + } + + #[test] + fn labels_pre202505_acpi_data_events_in_order() { + let mut events = vec![ + TdxEvent::new(0, 4, String::new(), vec![0]), + acpi_data_event(1), + acpi_data_event(2), + acpi_data_event(3), + TdxEvent::new(3, DSTACK_RUNTIME_EVENT_TYPE, "app-id".into(), vec![4]), + ]; + + label_tdx_acpi_data_events(&mut events); + + let names = events + .iter() + .filter(|event| is_tdx_acpi_data_event(event)) + .map(|event| event.event.as_str()) + .collect::>(); + assert_eq!(names, TDX_ACPI_DATA_EVENT_NAMES); + assert_eq!(events[0].event, ""); + assert_eq!(events[4].event, "app-id"); + } +} diff --git a/docs/amd-sev-snp-review-readiness.md b/docs/amd-sev-snp-review-readiness.md index eda547f13..99c685731 100644 --- a/docs/amd-sev-snp-review-readiness.md +++ b/docs/amd-sev-snp-review-readiness.md @@ -119,8 +119,8 @@ After those fixes, the manual smoke progressed through full dstack-managed SNP g - Configfs TSM report collection falls back to the SEV-SNP extended-report ioctl when configfs does not carry certificate collateral. - If verifier-side evidence still lacks ASK/VCEK collateral, the verifier can fetch AMD KDS ARK/ASK/VCEK using the report `chip_id` and reported TCB, then verify the signed report fail-closed. - KMS measurement recomputation now uses the image's original kernel cmdline for SNP launch measurement, while app identity is bound by MrConfigV3/HOST_DATA instead of appended cmdline fields. -- VMM now extracts the image OVMF SEV metadata and OVMF launch digest seed, includes them in the `sev_snp_measurement` document string, and passes that through the guest to KMS; KMS no longer needs a single locally configured `ovmf_path`, so different image/OVMF versions can be verified by their self-contained launch inputs. -- SNP `BootInfo.os_image_hash` is the canonical image-invariant projection of the verified launch inputs: rootfs identity is derived from the measured `dstack.rootfs_hash=...` cmdline parameter, and the hash covers the cmdline, kernel/initrd hashes, and OVMF hash/sections while excluding per-deployment values like vCPU count/model and guest features. +- VMM now passes the image's split `measurement.snp.cbor` material plus per-launch SNP fields through the guest to KMS; KMS no longer needs a single locally configured `ovmf_path`, so different image/OVMF versions can be verified by their self-contained launch inputs. +- SNP `BootInfo.os_image_hash` is the unified image digest (`sha256(sha256sum.txt)`). The `measurement.snp.cbor` entry in `sha256sum.txt` commits to the cmdline, kernel/initrd hashes, and OVMF hash/sections while excluding per-deployment values like vCPU count/model and guest features. Latest sanitized remote smoke result with PR-built host binaries and a coherent `MACHINE = "sev-snp"` guest image: diff --git a/docs/security/security-model.md b/docs/security/security-model.md index 5c3bdf3fe..4025ddbfc 100644 --- a/docs/security/security-model.md +++ b/docs/security/security-model.md @@ -92,6 +92,47 @@ dstack implements layered verification from hardware to application. Each layer **Key management layer.** The KMS root CA public key hash is recorded in RTMR3 as the key-provider event. This binds your workload to a specific KMS instance. The KMS itself runs in a TEE with its own attestation quote, so you can verify the KMS the same way you verify any workload. +### How `os_image_hash` becomes trusted + +The `os_image_hash` carried in `vm_config` is not trusted just because the guest +or host reports it. The verifier first validates the hardware-signed quote, then +uses the quoted measurements to bind `os_image_hash` to the software that +actually booted. + +For the full-image TDX path, the verifier obtains the OS image identified by +`os_image_hash`, checks the image checksum manifest, recomputes the expected +MRTD and RTMR0-2 from the image and VM configuration, and requires those values +to match the measurements in the quote. If the host substitutes either the image +hash or the VM configuration, the recomputed measurements no longer match the +quote. + +For the no-image-download TDX lite path and the AMD SEV-SNP path, +`os_image_hash` is the unified image identity: `sha256(sha256sum.txt)`. The +`sha256sum.txt` file is the image checksum manifest generated at image build +time. It is a text file whose lines contain a SHA-256 digest and relative file +name for each manifest entry, such as `metadata.json`, the kernel, initrd, +firmware, and the split measurement file. Some launch-critical artifacts are +represented indirectly instead of as direct manifest entries: for example, the +rootfs is committed by the measured `dstack.rootfs_hash` kernel command-line +parameter, and the SEV firmware is committed by `measurement.snp.cbor`. The exact +`sha256sum.txt` bytes are hashed, so the manifest contents, file names, ordering, +and line endings are all part of the image identity. + +The attestation carries a copy of the image's `sha256sum.txt` plus the platform +specific measurement material (`measurement.tdx.cbor` or +`measurement.snp.cbor`). The verifier checks that: + +1. `sha256(checksum_file) == os_image_hash`; +2. the checksum file contains the expected `measurement.*.cbor` entry and that + entry hashes to the supplied measurement material; +3. the supplied measurement material replays to the hardware-signed TDX + MRTD/RTMR values or SEV-SNP launch `MEASUREMENT`/`HOST_DATA`. + +Only after these checks pass does the verifier treat the returned +`os_image_hash` as the measured OS image identity. Downstream authorization +systems can then compare that trusted value against an allowlist or governance +contract. + ## Verification Checklist Use this checklist to verify a workload running in a dstack CVM. @@ -124,6 +165,31 @@ This is also reflected at the source: the event log shipped alongside an attesta The reason boot-time event log entries (RTMR0-2) are dropped is that **nothing downstream consumes them**. Verification recomputes the OS-layer measurements directly from the signed `rt_mr0/1/2` values and compares them to independently reproduced expected measurements, so the corresponding boot event log would be redundant. Keeping it would only bloat the RA-TLS certificate and expose extra detail without adding any verification capability. RTMR3, by contrast, is runtime-extended (compose-hash, key-provider, instance-id, and application-emitted events), so its event log is the only one with a real consumer — the replay that proves what was extended into RTMR3. +### Why TDX lite mode does not validate ACPI table contents + +TDX lite mode verifies the OS image without downloading the image and without +running QEMU to regenerate ACPI tables. It still uses the three RTMR0 `ACPI +DATA` digests from the attestation event log as measurement inputs. The guest +labels those three events as `acpi-loader`, `acpi-rsdp`, and `acpi-tables` +before exposing the event log, and the verifier checks that the recomputed RTMR +values match the hardware-signed quote. What it does not do is reconstruct and +byte-compare the full ACPI table contents. + +This is safe for dstack's threat model because ACPI tables are treated as +untrusted host-provided platform description, not as trusted guest code. The +dangerous executable part of ACPI is AML (ACPI Machine Language): malicious AML +can try to use `SystemMemory` operation regions through the Linux ACPICA +interpreter to read or write guest physical memory. dstack kernels include the +BadAML sandbox patch (`0002-acpi-sandbox-block-aml-systemmemory-ram-access.patch`), +which hooks the ACPI `SystemMemory` region handler, walks the guest page tables, +and denies AML access to encrypted/private guest RAM. AML can only access +unencrypted/shared mappings. + +Therefore, an infrastructure operator can still provide bad ACPI data and cause +misconfiguration or denial of service, but unvalidated ACPI/AML cannot tamper +with confidential private memory or extract secrets. That residual availability +risk is already outside dstack's confidentiality/integrity guarantees. + ### TCB status is surfaced, not gated, during verification dstack's `validate_tcb` does not reject a quote based on its TCB status string (`UpToDate`, `OutOfDate`, `ConfigurationNeeded`, `SWHardeningNeeded`, ...). It only enforces hard invariants: debug mode must be off, and the SEAM/service-TD measurements must be well-formed. The verified report carries the `status` field through to the caller. diff --git a/dstack-attest/src/attestation.rs b/dstack-attest/src/attestation.rs index 5c325c7fd..44ff30a06 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::{AmdKdsClient, 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 } } @@ -1009,17 +1020,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, } } } @@ -1148,8 +1158,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() }) } @@ -1691,6 +1722,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")? @@ -1739,9 +1778,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() @@ -2054,6 +2091,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..3fa28952a 100644 --- a/dstack-attest/src/v1.rs +++ b/dstack-attest/src/v1.rs @@ -3,13 +3,66 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::{anyhow, bail, Context, Result}; -use cc_eventlog::{RuntimeEvent, TdxEvent}; +use cc_eventlog::{ + tdx::{self, TDX_ACPI_DATA_EVENT_PAYLOAD}, + RuntimeEvent, TdxEvent, +}; use dstack_types::mr_config::MrConfigV3; use serde::{Deserialize, Serialize}; use tpm_types::TpmQuote; pub const ATTESTATION_VERSION: u64 = 1; +pub(crate) fn is_tdx_acpi_data_event(event: &TdxEvent) -> bool { + tdx::is_tdx_acpi_data_event(event) +} + +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 +145,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 +160,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 +291,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, } } @@ -320,6 +370,7 @@ impl Attestation { #[cfg(test)] mod tests { use super::*; + use cc_eventlog::tdx::TDX_ACPI_DATA_EVENT_TYPE; fn test_mr_config_document() -> String { MrConfigV3::new( @@ -414,6 +465,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..8fa4c3317 100644 --- a/dstack-attest/tests/sev_snp_verify.rs +++ b/dstack-attest/tests/sev_snp_verify.rs @@ -10,9 +10,10 @@ //! one built into `sev-snp-qvl`. use dstack_attest::attestation::{AttestationQuote, VersionedAttestation}; -use dstack_mr::sev::verify_sev_launch; +use dstack_mr::sev::{sev_os_image_measurement_from_input, verify_sev_launch, MeasurementInput}; use dstack_types::{mr_config::MrConfigV3, KeyProviderKind}; use sev_snp_qvl::{verify_amd_snp_attestation, AmdSnpAttestationInput, VerifiedAmdSnpReport}; +use sha2::{Digest, Sha256}; /// Real SEV-SNP attestation captured from a dstack CVM (VersionedAttestation, SCALE V0). const SEV_ATTESTATION_BIN: &[u8] = include_bytes!("sev_snp_attestation.bin"); @@ -83,20 +84,21 @@ fn verify_sev_snp_attestation_bin() { // does after the hardware report verifies. Recompute the launch measurement // from the self-contained `sev_snp_measurement` document embedded in the // attestation config, require it to equal the hardware MEASUREMENT, require - // HOST_DATA to bind the MrConfigV3 document, and derive the os_image_hash. - let binding = dstack_mr::sev::verify_sev_launch( - &verified.measurement, - &verified.host_data, - &attestation.config, - ) - .expect("recompute SEV launch + derive os_image_hash from the attestation config"); - - // The os_image_hash matches the value advertised in the CVM config and the - // image build's digest.sev.txt. + // HOST_DATA to bind the MrConfigV3 document, and verify the unified + // os_image_hash against sha256sum.txt + measurement.snp.cbor. + let config = upgrade_snp_config_for_split_measurement(&attestation.config); + let binding = + dstack_mr::sev::verify_sev_launch(&verified.measurement, &verified.host_data, &config) + .expect("recompute SEV launch + verify os_image_hash from the attestation config"); + + // The os_image_hash matches the value advertised in the CVM config. + let config_value: serde_json::Value = serde_json::from_str(&config).expect("config json"); assert_eq!( hex::encode(&binding.os_image_hash), - "32b4767373ad7fa0f9c418925006194d5c3f5619529f309fe81156789fecd8bc", - "derived os_image_hash" + config_value["os_image_hash"] + .as_str() + .expect("os_image_hash"), + "verified os_image_hash" ); // The HOST_DATA-bound app identity is recovered from the mr_config document. assert_eq!( @@ -111,8 +113,6 @@ fn verify_sev_snp_attestation_bin() { // Forged / tampered quote coverage (all offline, using the real fixture). // --------------------------------------------------------------------------- -const OS_IMAGE_HASH: &str = "32b4767373ad7fa0f9c418925006194d5c3f5619529f309fe81156789fecd8bc"; - fn decoded_attestation() -> dstack_attest::attestation::Attestation { let versioned = VersionedAttestation::from_scale(SEV_ATTESTATION_BIN).expect("decode VersionedAttestation"); @@ -130,8 +130,48 @@ fn fixture_report() -> Vec { quote.report.clone() } +fn upgrade_snp_config_for_split_measurement(config: &str) -> String { + let mut value: serde_json::Value = serde_json::from_str(config).expect("config json"); + let measurement_doc = value["sev_snp_measurement"] + .as_str() + .expect("sev_snp_measurement string") + .to_string(); + let measurement_value: serde_json::Value = + serde_json::from_str(&measurement_doc).expect("measurement json"); + if measurement_value.get("measurement").is_some() + && measurement_value.get("checksum_file").is_some() + { + return config.to_string(); + } + + let input: MeasurementInput = + serde_json::from_value(measurement_value).expect("legacy SNP measurement input"); + let measurement = sev_os_image_measurement_from_input(&input) + .expect("image measurement") + .to_cbor_vec(); + let sha256sum = format!( + "{} {}\n", + hex::encode(Sha256::digest(&measurement)), + dstack_types::SNP_MEASUREMENT_FILENAME + ) + .into_bytes(); + let document = dstack_mr::sev::SnpMeasurementDocument { + checksum_file: sha256sum, + measurement, + vcpus: input.vcpus, + vcpu_type: input.vcpu_type, + guest_features: input.guest_features, + }; + value["os_image_hash"] = serde_json::Value::String(hex::encode( + dstack_types::image_hash_from_sha256sum(&document.checksum_file), + )); + value["sev_snp_measurement"] = + serde_json::Value::String(serde_json::to_string(&document).expect("serialize document")); + value.to_string() +} + fn fixture_config() -> String { - decoded_attestation().config + upgrade_snp_config_for_split_measurement(&decoded_attestation().config) } fn verified_fixture_report() -> VerifiedAmdSnpReport { @@ -144,18 +184,33 @@ fn verified_fixture_report() -> VerifiedAmdSnpReport { .expect("verify SEV-SNP attestation offline") } -/// Rewrite one field inside the embedded `sev_snp_measurement` document. -fn with_measurement_field(config: &str, f: impl FnOnce(&mut serde_json::Value)) -> String { +/// Rewrite the image CBOR inside the embedded `sev_snp_measurement` document. +fn with_image_measurement( + config: &str, + f: impl FnOnce(&mut dstack_types::SevOsImageMeasurement), +) -> String { let mut value: serde_json::Value = serde_json::from_str(config).expect("config json"); let measurement_doc = value["sev_snp_measurement"] .as_str() .expect("sev_snp_measurement string") .to_string(); - let mut measurement: serde_json::Value = + let mut document: dstack_mr::sev::SnpMeasurementDocument = serde_json::from_str(&measurement_doc).expect("measurement json"); - f(&mut measurement); + let mut image = dstack_types::SevOsImageMeasurement::from_cbor_slice(&document.measurement) + .expect("decode measurement.snp.cbor"); + f(&mut image); + document.measurement = image.to_cbor_vec(); + document.checksum_file = format!( + "{} {}\n", + hex::encode(Sha256::digest(&document.measurement)), + dstack_types::SNP_MEASUREMENT_FILENAME + ) + .into_bytes(); + value["os_image_hash"] = serde_json::Value::String(hex::encode( + dstack_types::image_hash_from_sha256sum(&document.checksum_file), + )); value["sev_snp_measurement"] = - serde_json::Value::String(serde_json::to_string(&measurement).expect("reserialize")); + serde_json::Value::String(serde_json::to_string(&document).expect("reserialize")); value.to_string() } @@ -278,8 +333,8 @@ fn tampered_launch_inputs_break_os_image_binding() { // recomputed measurement no longer equals the hardware MEASUREMENT, so the // forged (allow-listed-looking) os_image_hash is never trusted. let verified = verified_fixture_report(); - let tampered = with_measurement_field(&fixture_config(), |m| { - m["kernel_hash"] = serde_json::Value::String("00".repeat(32)); + let tampered = with_image_measurement(&fixture_config(), |m| { + m.kernel_hash = vec![0; 32]; }); let err = verify_sev_launch(&verified.measurement, &verified.host_data, &tampered) .expect_err("tampered launch inputs must reject"); @@ -311,20 +366,20 @@ fn substituted_mr_config_breaks_host_data_binding() { } #[test] -fn advertised_os_image_hash_is_ignored() { - // A forged top-level os_image_hash is ignored; the authoritative value is - // derived from the measurement-bound launch inputs. +fn advertised_os_image_hash_must_match_sha256sum() { + // A forged top-level os_image_hash is rejected because it must equal + // sha256(sha256sum.txt) for the supplied measurement material. let verified = verified_fixture_report(); let mut value: serde_json::Value = serde_json::from_str(&fixture_config()).expect("config json"); value["os_image_hash"] = serde_json::Value::String("de".repeat(32)); let tampered = value.to_string(); - let binding = verify_sev_launch(&verified.measurement, &verified.host_data, &tampered) - .expect("a bogus advertised os_image_hash is ignored, not fatal"); - assert_eq!( - hex::encode(&binding.os_image_hash), - OS_IMAGE_HASH, - "derived os_image_hash must win over the advertised one" + let err = verify_sev_launch(&verified.measurement, &verified.host_data, &tampered) + .expect_err("a bogus advertised os_image_hash must reject"); + assert!( + err.to_string() + .contains("amd sev-snp measurement material does not match os_image_hash"), + "unexpected error: {err:?}" ); } 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..18be3b728 100644 --- a/dstack-mr/src/main.rs +++ b/dstack-mr/src/main.rs @@ -4,21 +4,88 @@ //! `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 serde_json::Value; +use std::io::Write; 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 [tdx|snp] + dstack-mr tdx-measurement-cbor + dstack-mr snp-measurement-cbor + dstack-mr tdx-measurement-hash + dstack-mr snp-measurement-hash + +features: + split-cbor-measurement-v3"; fn main() -> Result<()> { let mut args = std::env::args().skip(1); match args.next().as_deref() { - Some("sev-os-image-hash") => { + 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 first = args.next().context(USAGE)?; + let second = args.next(); + let (kind, measurement_cbor) = match second { + Some(path) => (first, path), + None => (infer_measurement_kind(&first)?, first), + }; + let document = inspect_measurement(&kind, Path::new(&measurement_cbor)) + .context("failed to inspect os image measurement document")?; + println!( + "{}", + serde_json::to_string_pretty(&document) + .context("failed to serialize decoded measurement document")? + ); + Ok(()) + } + Some("snp-measurement-cbor") => { + let image_dir = args.next().context(USAGE)?; + let cbor = + dstack_mr::sev::sev_os_image_measurement_cbor_for_image_dir(Path::new(&image_dir)) + .context("failed to compute amd sev-snp measurement CBOR")?; + std::io::stdout() + .write_all(&cbor) + .context("failed to write amd sev-snp measurement CBOR")?; + Ok(()) + } + Some("tdx-measurement-cbor") => { + let image_dir = args.next().context(USAGE)?; + let cbor = + dstack_mr::tdx::tdx_os_image_measurement_cbor_for_image_dir(Path::new(&image_dir)) + .context("failed to compute tdx measurement CBOR")?; + std::io::stdout() + .write_all(&cbor) + .context("failed to write tdx measurement CBOR")?; + Ok(()) + } + Some("snp-measurement-hash") | Some("sev-measurement-hash") => { + let image_dir = args.next().context(USAGE)?; + let hash = dstack_mr::sev::sev_measurement_hash_for_image_dir(Path::new(&image_dir)) + .context("failed to compute amd sev-snp measurement hash")?; + println!("{}", hex::encode(hash)); + Ok(()) + } + Some("tdx-measurement-hash") => { let image_dir = args.next().context(USAGE)?; - let hash = dstack_mr::sev::sev_os_image_hash_for_image_dir(Path::new(&image_dir)) - .context("failed to compute amd sev-snp os_image_hash")?; + let hash = dstack_mr::tdx::tdx_measurement_hash_for_image_dir(Path::new(&image_dir)) + .context("failed to compute tdx measurement hash")?; println!("{}", hex::encode(hash)); Ok(()) } @@ -30,3 +97,28 @@ fn main() -> Result<()> { None => bail!("{USAGE}"), } } + +fn inspect_measurement(kind: &str, path: &Path) -> Result { + let cbor = fs_err::read(path).with_context(|| format!("failed to read {}", path.display()))?; + match kind { + "tdx" => dstack_types::TdxOsImageMeasurement::cbor_json_value_from_slice(&cbor) + .map_err(anyhow::Error::msg), + "snp" | "sev" => dstack_types::SevOsImageMeasurement::cbor_json_value_from_slice(&cbor) + .map_err(anyhow::Error::msg), + other => bail!("unknown measurement kind {other:?}; expected tdx or snp"), + } +} + +fn infer_measurement_kind(path: &str) -> Result { + let filename = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path); + if filename.contains(".tdx.") || filename.contains("tdx") { + Ok("tdx".to_string()) + } else if filename.contains(".snp.") || filename.contains("snp") || filename.contains("sev") { + Ok("snp".to_string()) + } else { + bail!("cannot infer measurement kind from {filename:?}; pass tdx or snp explicitly") + } +} diff --git a/dstack-mr/src/measurement.rs b/dstack-mr/src/measurement.rs new file mode 100644 index 000000000..5351be395 --- /dev/null +++ b/dstack-mr/src/measurement.rs @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Compatibility helpers for build-time OS-image measurement documents. + +use anyhow::{Context, Result}; +use dstack_types::{ + OsImageMeasurementDocument, SevOsImageMeasurementDocument, TdxOsImageMeasurementDocument, + SNP_MEASUREMENT_FILENAME, TDX_MEASUREMENT_FILENAME, +}; +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 a compatibility `measurement.json` for an image directory that has +/// already produced `sha256sum.txt` plus split measurement CBOR files. +/// +/// New image builds should ship `measurement.tdx.cbor` / `measurement.snp.cbor` +/// directly instead of this combined JSON document. +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 sha256sum_path = image_dir.join("sha256sum.txt"); + let sha256sum = fs::read(&sha256sum_path) + .with_context(|| format!("cannot read {}", sha256sum_path.display()))?; + + let tdx_path = image_dir.join(TDX_MEASUREMENT_FILENAME); + let tdx = if tdx_path.exists() { + Some(TdxOsImageMeasurementDocument::new( + sha256sum.clone(), + fs::read(&tdx_path).with_context(|| format!("cannot read {}", tdx_path.display()))?, + )) + } else { + None + }; + + let snp = if meta.bios_sev.is_some() { + let snp_path = image_dir.join(SNP_MEASUREMENT_FILENAME); + Some(SevOsImageMeasurementDocument::new( + sha256sum, + fs::read(&snp_path).with_context(|| format!("cannot read {}", snp_path.display()))?, + )) + } else { + None + }; + + Ok(OsImageMeasurementDocument::new(tdx, snp)) +} diff --git a/dstack-mr/src/sev.rs b/dstack-mr/src/sev.rs index 1d97d2c2f..157d083bf 100644 --- a/dstack-mr/src/sev.rs +++ b/dstack-mr/src/sev.rs @@ -2,13 +2,12 @@ // // SPDX-License-Identifier: Apache-2.0 -//! AMD SEV-SNP launch-measurement recomputation and `os_image_hash` derivation. +//! AMD SEV-SNP launch-measurement recomputation. //! //! This is the single source of truth shared by `dstack-kms` (key release) and //! `dstack-verifier` (attestation verification). It recomputes the expected SNP //! launch `MEASUREMENT` from self-contained launch inputs (the -//! `sev_snp_measurement` document a VMM embeds in `vm_config`) and derives the -//! image-invariant `os_image_hash`. +//! `sev_snp_measurement` document a VMM embeds in `vm_config`). //! //! It deals only in primitive, hardware-verified values (`measurement`, //! `host_data`) so it can stay free of attestation/RA-TLS types and be reused by @@ -50,7 +49,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 +115,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 +320,17 @@ fn build_sev_hashes_page( Ok(page) } +fn measured_kernel_cmdline(input: &str) -> String { + input.trim().to_string() +} + +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 +674,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 +744,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(), + base_cmdline: measured_kernel_cmdline(&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 @@ -757,20 +767,10 @@ fn sev_os_image_measurement( }) } -/// Derive the OS image hash from a self-contained SNP measurement document. -/// -/// os_image_hash identifies the OS image only, so it covers exactly the -/// image-determined measurement inputs and EXCLUDES per-deployment values -/// (`vcpus`, `vcpu_type`, `guest_features`). Hashing the full -/// `MeasurementInput` made the same image hash differently per vCPU count, -/// which broke per-image on-chain allow-listing. App/config identity is bound -/// separately by MrConfigV3/HOST_DATA. The canonical hashing lives in -/// `dstack_types::SevOsImageMeasurement` so the image build can reproduce the -/// same value as `digest.sev.txt`. -pub fn snp_measurement_os_image_hash(measurement_document: &str) -> Result> { - let input: MeasurementInput = serde_json::from_str(measurement_document) - .context("failed to parse sev-snp measurement document for os_image_hash")?; - Ok(sev_os_image_measurement(&input)?.os_image_hash().to_vec()) +pub fn sev_os_image_measurement_from_input( + input: &MeasurementInput, +) -> Result { + sev_os_image_measurement(input) } /// OVMF launch-measurement metadata: the GCTX launch digest of the firmware @@ -821,9 +821,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 +840,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 +860,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 { + base_cmdline: measured_kernel_cmdline( + meta.cmdline + .as_deref() + .context("metadata.json cmdline is required for amd sev-snp measurement")?, + ), + 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 +885,17 @@ 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 measurement-material hash from an OS image directory. +pub fn sev_measurement_hash_for_image_dir(image_dir: &Path) -> Result<[u8; 32]> { + Ok(sev_os_image_measurement_for_image_dir(image_dir)?.measurement_hash()) +} + +/// Generate the raw `measurement.snp.cbor` bytes for an image directory. +pub fn sev_os_image_measurement_cbor_for_image_dir(image_dir: &Path) -> Result> { + Ok(sev_os_image_measurement_for_image_dir(image_dir)?.to_cbor_vec()) } /// `sha256(MEASUREMENT || HOST_DATA)` — the SNP aggregated identity digest. @@ -930,21 +944,65 @@ pub fn validate_snp_mr_config_binding( #[derive(Debug, serde::Deserialize)] struct SevSnpMeasurementVmConfig { + #[serde(with = "serde_human_bytes", default)] + os_image_hash: Vec, sev_snp_measurement: Option, mr_config: Option, } +#[derive(Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub struct SnpMeasurementDocument { + #[serde(with = "serde_human_bytes::base64")] + pub checksum_file: Vec, + #[serde(with = "serde_human_bytes::base64")] + pub measurement: Vec, + pub vcpus: u32, + pub vcpu_type: Option, + pub guest_features: u64, +} + +pub fn measurement_input_from_snp_document( + document: &SnpMeasurementDocument, +) -> Result { + let image = dstack_types::SevOsImageMeasurement::from_cbor_slice(&document.measurement) + .map_err(anyhow::Error::msg) + .context("invalid measurement.snp.cbor")?; + Ok(MeasurementInput { + base_cmdline: image.base_cmdline, + ovmf_hash: hex::encode(image.ovmf_hash), + kernel_hash: hex::encode(image.kernel_hash), + initrd_hash: hex::encode(image.initrd_hash), + sev_hashes_table_gpa: image.sev_hashes_table_gpa, + sev_es_reset_eip: image.sev_es_reset_eip, + vcpus: document.vcpus, + vcpu_type: document.vcpu_type.clone(), + guest_features: document.guest_features, + ovmf_sections: image + .ovmf_sections + .into_iter() + .map(|s| OvmfSectionParam { + gpa: s.gpa, + size: s.size, + section_type: s.section_type, + }) + .collect(), + }) +} + /// Launch inputs extracted from a VMM-produced `vm_config` string. pub struct SnpLaunchInputs { pub input: MeasurementInput, - /// Raw `sev_snp_measurement` document used for os_image_hash derivation. + /// Raw `sev_snp_measurement` document carried by vm_config. pub measurement_document: String, + /// Unified OS image hash from vm_config: `sha256(sha256sum.txt)`. + pub os_image_hash: Vec, /// Raw MrConfigV3 document bound by HOST_DATA. pub mr_config_document: String, } /// Parse the SNP launch-measurement inputs (`sev_snp_measurement`) and the -/// `mr_config` document out of a VMM `vm_config` JSON string. +/// `mr_config` document out of a VMM `vm_config` string. /// /// The fields are intentionally explicit so missing SNP launch inputs fail /// closed instead of falling back to TDX event-log decoding. Both the top-level @@ -970,8 +1028,26 @@ pub fn parse_snp_inputs_from_vm_config(vm_config: &str) -> Result Result Result, /// App/config identity bound by HOST_DATA. pub mr_config: MrConfigV3, @@ -1004,7 +1081,8 @@ pub struct SevImageBinding { /// 2. recomputes the launch measurement and checks it equals `measurement` /// (this is what makes the otherwise-untrusted launch inputs trustworthy), /// 3. checks `HOST_DATA` binds the `mr_config` document, and -/// 4. derives the image-invariant `os_image_hash`. +/// 4. returns the unified `os_image_hash` after checking it commits to the +/// supplied `sha256sum.txt` and `measurement.snp.cbor`. pub fn verify_sev_launch( verified_measurement: &[u8; 48], verified_host_data: &[u8; 32], @@ -1017,9 +1095,8 @@ pub fn verify_sev_launch( bail!("amd sev-snp measurement mismatch"); } let mr_config = validate_snp_mr_config_binding(verified_host_data, &inputs.mr_config_document)?; - let os_image_hash = snp_measurement_os_image_hash(&inputs.measurement_document)?; Ok(SevImageBinding { - os_image_hash, + os_image_hash: inputs.os_image_hash, mr_config, }) } @@ -1120,7 +1197,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), @@ -1154,8 +1231,77 @@ mod tests { } } + fn snp_document(input: &MeasurementInput) -> SnpMeasurementDocument { + let measurement = sev_os_image_measurement_from_input(input) + .expect("image measurement") + .to_cbor_vec(); + let sha256sum = format!( + "{} {}\n", + hex::encode(Sha256::digest(&measurement)), + dstack_types::SNP_MEASUREMENT_FILENAME + ) + .into_bytes(); + SnpMeasurementDocument { + checksum_file: sha256sum, + measurement, + vcpus: input.vcpus, + vcpu_type: input.vcpu_type.clone(), + guest_features: input.guest_features, + } + } + fn measurement_document(input: &MeasurementInput) -> String { - serde_json::to_string(input).expect("measurement input should serialize") + serde_json::to_string(&snp_document(input)).expect("measurement document serializes") + } + + #[test] + fn measurement_document_serializes_bytes_as_base64() { + let document = measurement_document(&valid_input()); + let value: serde_json::Value = + serde_json::from_str(&document).expect("measurement document json"); + let checksum_file = value["checksum_file"] + .as_str() + .expect("checksum_file string"); + let measurement = value["measurement"].as_str().expect("measurement string"); + assert!( + checksum_file.contains(|c: char| !c.is_ascii_hexdigit()), + "checksum_file should use base64, got {checksum_file}" + ); + assert!( + measurement.contains(|c: char| !c.is_ascii_hexdigit()), + "measurement should use base64, got {measurement}" + ); + let parsed: SnpMeasurementDocument = + serde_json::from_str(&document).expect("base64 document parses"); + assert_eq!(parsed, snp_document(&valid_input())); + } + + fn os_image_hash(input: &MeasurementInput) -> Vec { + dstack_types::image_hash_from_sha256sum(&snp_document(input).checksum_file).to_vec() + } + + #[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] @@ -1178,25 +1324,20 @@ mod tests { } #[test] - fn snp_os_image_hash_covers_image_fields_only() { + fn unified_os_image_hash_covers_sha256sum_entries() { let input = valid_input(); - let os_image_hash = - |i: &MeasurementInput| snp_measurement_os_image_hash(&measurement_document(i)).unwrap(); let baseline = os_image_hash(&input); // 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)), @@ -1219,8 +1360,8 @@ mod tests { ); } - // Per-deployment fields MUST NOT change the os_image_hash (the same OS - // image must hash identically regardless of vCPU count, CPU model, etc.). + // Per-deployment fields MUST NOT change the os_image_hash because they + // are outside measurement.snp.cbor and sha256sum.txt. let deployment_cases: Vec<(&str, fn(&mut MeasurementInput))> = vec![ ("vcpus", |i| i.vcpus = 3), ("vcpu_type", |i| { @@ -1300,7 +1441,7 @@ mod tests { const REAL_MEASUREMENT_DOC: &str = r#"{"base_cmdline":"console=ttyS0 init=/init panic=1 net.ifnames=0 biosdevname=0 mce=off oops=panic pci=noearly pci=nommconf random.trust_cpu=y random.trust_bootloader=n tsc=reliable no-kvmclock dstack.rootfs_hash=ca5adaef0ac3a36108035925763b48a5818f634e700fbaab561d419fd30d7121 dstack.rootfs_size=490713088","ovmf_hash":"ffb57e393469a497c0e3b07bd1c97d8611e555f464d14491837665893ac642b263a71f9507ff100a847897fe0c3f8c6f","kernel_hash":"dd9ea274ce9a07090b22e8284b0c841b65c021c2d15ca57d0f16731089dd226c","initrd_hash":"5f844c4a2ca5a3d0711b3db38293b21ba929bb8e0b3c5bc1a779a57f69221c19","sev_hashes_table_gpa":8457216,"sev_es_reset_eip":8433668,"vcpus":2,"vcpu_type":"EPYC-v4","guest_features":1,"ovmf_sections":[{"gpa":8388608,"size":36864,"section_type":1},{"gpa":8429568,"size":12288,"section_type":1},{"gpa":8441856,"size":4096,"section_type":2},{"gpa":8445952,"size":4096,"section_type":3},{"gpa":8450048,"size":4096,"section_type":4},{"gpa":8458240,"size":61440,"section_type":1},{"gpa":8454144,"size":4096,"section_type":16}]}"#; #[test] - fn real_fixture_recomputes_measurement_and_os_image_hash() { + fn real_fixture_recomputes_measurement() { let input: MeasurementInput = serde_json::from_str(REAL_MEASUREMENT_DOC).expect("real measurement doc parses"); validate_measurement_input(&input).expect("real measurement input is valid"); @@ -1313,14 +1454,14 @@ 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). - 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", - ); + let document = snp_document(&input); + let image_hash = dstack_types::image_hash_from_sha256sum(&document.checksum_file); + dstack_types::SevOsImageMeasurementDocument::new( + document.checksum_file, + document.measurement, + ) + .verify(&image_hash) + .expect("fixture measurement material verifies against sha256sum.txt"); } // ---- Forged-quote / tampered-input coverage for `verify_sev_launch` ---- @@ -1343,7 +1484,8 @@ mod tests { fn synthetic_vm_config(input: &MeasurementInput, mr_config: &MrConfigV3) -> String { serde_json::json!({ - "sev_snp_measurement": serde_json::to_string(input).expect("serialize input"), + "os_image_hash": hex::encode(os_image_hash(input)), + "sev_snp_measurement": measurement_document(input), "mr_config": mr_config.to_canonical_json(), }) .to_string() @@ -1365,10 +1507,7 @@ mod tests { let (input, mr_config, measurement, host_data, vm_config) = honest_case(); let binding = verify_sev_launch(&measurement, &host_data, &vm_config) .expect("honest launch verifies"); - assert_eq!( - binding.os_image_hash, - snp_measurement_os_image_hash(&serde_json::to_string(&input).unwrap()).unwrap() - ); + assert_eq!(binding.os_image_hash, os_image_hash(&input)); assert_eq!(binding.mr_config.app_id, mr_config.app_id); } @@ -1407,10 +1546,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 +1592,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"); @@ -1464,8 +1600,7 @@ mod tests { err.to_string().contains("amd sev-snp measurement mismatch"), "unexpected error: {err:?}" ); - let tampered_hash = - snp_measurement_os_image_hash(&serde_json::to_string(&tampered).unwrap()).unwrap(); + let tampered_hash = os_image_hash(&tampered); assert_ne!( honest.os_image_hash, tampered_hash, "a tampered rootfs hash must change the derived os_image_hash" @@ -1512,23 +1647,24 @@ mod tests { } #[test] - fn verify_sev_launch_ignores_advertised_os_image_hash() { - // The os_image_hash is derived from the measurement-bound inputs; a - // top-level attacker-advertised os_image_hash is ignored entirely. + fn verify_sev_launch_rejects_bad_advertised_os_image_hash() { + // The advertised os_image_hash must equal sha256(sha256sum.txt), and + // sha256sum.txt must commit to measurement.snp.cbor. let (input, mr_config, measurement, host_data, _vm) = honest_case(); let bogus = vec![0xde; 32]; let vm_config = serde_json::json!({ "os_image_hash": hex::encode(&bogus), - "sev_snp_measurement": serde_json::to_string(&input).unwrap(), + "sev_snp_measurement": measurement_document(&input), "mr_config": mr_config.to_canonical_json(), }) .to_string(); - let binding = verify_sev_launch(&measurement, &host_data, &vm_config) - .expect("bogus advertised os_image_hash is ignored, not fatal"); - let expected = - snp_measurement_os_image_hash(&serde_json::to_string(&input).unwrap()).unwrap(); - assert_eq!(binding.os_image_hash, expected); - assert_ne!(binding.os_image_hash, bogus); + let err = verify_sev_launch(&measurement, &host_data, &vm_config) + .expect_err("bogus advertised os_image_hash must reject"); + assert!( + err.to_string() + .contains("amd sev-snp measurement material does not match os_image_hash"), + "unexpected error: {err:?}" + ); } #[test] @@ -1537,14 +1673,12 @@ mod tests { // image's inputs: the booted image's MEASUREMENT differs from the // advertised inputs' recomputed measurement. let honest = valid_input(); - let honest_hash = - snp_measurement_os_image_hash(&serde_json::to_string(&honest).unwrap()).unwrap(); + let honest_hash = os_image_hash(&honest); let mut malicious = honest.clone(); malicious.kernel_hash = hex_of(0xab, 32); // different kernel == different image let malicious_measurement = compute_expected_measurement(&malicious).unwrap(); - let malicious_hash = - snp_measurement_os_image_hash(&serde_json::to_string(&malicious).unwrap()).unwrap(); + let malicious_hash = os_image_hash(&malicious); assert_ne!( honest_hash, malicious_hash, "different image must hash differently" @@ -1576,9 +1710,11 @@ mod tests { "unexpected error: {err:?}" ); - let no_mr_config = - serde_json::json!({ "sev_snp_measurement": serde_json::to_string(&input).unwrap() }) - .to_string(); + let no_mr_config = serde_json::json!({ + "os_image_hash": hex::encode(os_image_hash(&input)), + "sev_snp_measurement": measurement_document(&input) + }) + .to_string(); let err = verify_sev_launch(&measurement, &host_data, &no_mr_config) .expect_err("missing mr_config must fail closed"); assert!( 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..087556567 --- /dev/null +++ b/dstack-mr/src/tdx.rs @@ -0,0 +1,612 @@ +// 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 measurement")? + .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 raw `measurement.tdx.cbor` bytes for an image directory. +pub fn tdx_os_image_measurement_cbor_for_image_dir(image_dir: &Path) -> Result> { + Ok(tdx_os_image_measurement_for_image_dir(image_dir)?.to_cbor_vec()) +} + +/// Compute the TDX static measurement-material hash for an image directory. +pub fn tdx_measurement_hash_for_image_dir(image_dir: &Path) -> Result<[u8; 32]> { + Ok(tdx_os_image_measurement_for_image_dir(image_dir)?.measurement_hash()) +} + +/// Compute expected TDX measurements from self-contained TDX measurement +/// material 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 !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..90b0f6f55 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,58 @@ 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 image digest (`digest.txt`, i.e. `sha256(sha256sum.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` +/// remains the unified image digest (`sha256(sha256sum.txt)`), +/// `vm_config.tdx_measurement` carries `sha256sum.txt` plus the TDX measurement +/// CBOR file, 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 +290,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 +309,507 @@ 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 -/// identically regardless of how it is launched. +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() +} + +pub const TDX_MEASUREMENT_FILENAME: &str = "measurement.tdx.cbor"; +pub const SNP_MEASUREMENT_FILENAME: &str = "measurement.snp.cbor"; + +pub fn image_hash_from_sha256sum(checksum_file: &[u8]) -> [u8; 32] { + sha256(checksum_file) +} + +pub fn sha256sum_entry_hash(checksum_file: &[u8], filename: &str) -> Result<[u8; 32], String> { + let text = std::str::from_utf8(checksum_file) + .map_err(|e| format!("sha256sum.txt is not valid UTF-8: {e}"))?; + let mut found = None; + for (line_no, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let mut parts = line.split_whitespace(); + let Some(hash_hex) = parts.next() else { + continue; + }; + let Some(path) = parts.next() else { + return Err(format!( + "sha256sum.txt line {} is missing filename", + line_no + 1 + )); + }; + if path != filename { + continue; + } + if found.is_some() { + return Err(format!( + "sha256sum.txt contains duplicate {filename} entries" + )); + } + let hash = hex::decode(hash_hex) + .map_err(|e| format!("sha256sum.txt {filename} hash is not valid hex: {e}"))?; + let hash: [u8; 32] = hash.try_into().map_err(|hash: Vec| { + format!( + "sha256sum.txt {filename} hash has invalid length {}, expected 32", + hash.len() + ) + })?; + found = Some(hash); + } + found.ok_or_else(|| format!("sha256sum.txt is missing {filename}")) +} + +pub fn verify_measurement_material( + os_image_hash: &[u8], + checksum_file: &[u8], + measurement: &[u8], + filename: &str, +) -> Result<(), String> { + if image_hash_from_sha256sum(checksum_file).as_slice() != os_image_hash { + return Err(format!( + "os_image_hash mismatch: expected sha256(sha256sum.txt)={}, actual={}", + hex::encode(os_image_hash), + hex::encode(image_hash_from_sha256sum(checksum_file)) + )); + } + let expected_measurement_hash = sha256sum_entry_hash(checksum_file, filename)?; + let actual_measurement_hash = sha256(measurement); + if expected_measurement_hash != actual_measurement_hash { + return Err(format!( + "{filename} hash mismatch: sha256sum.txt={}, actual={}", + hex::encode(expected_measurement_hash), + hex::encode(actual_measurement_hash) + )); + } + Ok(()) +} + +#[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 AMD SEV-SNP measurement material. It deliberately excludes +/// per-deployment values (vcpus, vcpu_type, guest_features, app_id, +/// compose_hash): the same OS image carries identical SNP material regardless of +/// how it is launched. The OS image identity itself is always +/// `sha256(sha256sum.txt)`; this material is bound to that identity by the +/// `measurement.snp.cbor` entry in `sha256sum.txt`. #[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, + /// Original image kernel cmdline used for SNP measured launch. + pub base_cmdline: String, + #[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 { + version: u32, + /// Original image kernel cmdline used for SNP measured launch. + #[serde(rename = "cmdline")] + base_cmdline: String, + /// 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 { + version: SevOsImageMeasurement::VERSION, + base_cmdline: measurement.base_cmdline.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 { + base_cmdline: measurement.base_cmdline, + 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. - 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() + pub const VERSION: u32 = 3; + + /// CBOR representation stored as `measurement.snp.cbor`. + pub fn to_cbor_vec(&self) -> Vec { + cbor_to_vec( + &CborSevOsImageMeasurement::from(self), + "SevOsImageMeasurement", + ) + } + + pub fn from_cbor_slice(bytes: &[u8]) -> Result { + let cbor = cbor_from_slice::(bytes, "SevOsImageMeasurement")?; + if cbor.version != Self::VERSION { + return Err(format!( + "SevOsImageMeasurement: unsupported version {}, expected {}", + cbor.version, + Self::VERSION + )); + } + Ok(cbor.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 measurement material. + pub fn measurement_hash(&self) -> [u8; 32] { + sha256(&self.to_cbor_vec()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SevOsImageMeasurementDocument { + /// Raw checksum file bytes (`sha256sum.txt`). `sha256(checksum_file)` is + /// the unified `os_image_hash`. + #[serde(with = "serde_human_bytes::base64")] + pub checksum_file: Vec, + /// Raw bytes of `measurement.snp.cbor`. + #[serde(with = "serde_human_bytes::base64")] + pub measurement: Vec, +} + +impl SevOsImageMeasurementDocument { + pub fn new(checksum_file: Vec, measurement: Vec) -> Self { + Self { + checksum_file, + measurement, + } + } + + pub fn from_measurement(checksum_file: Vec, measurement: SevOsImageMeasurement) -> Self { + Self::new(checksum_file, measurement.to_cbor_vec()) + } + + 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 verify(&self, os_image_hash: &[u8]) -> Result<(), String> { + verify_measurement_material( + os_image_hash, + &self.checksum_file, + &self.measurement, + SNP_MEASUREMENT_FILENAME, + ) + } +} + +/// Image-invariant TDX measurement 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. The OS image identity itself is +/// always `sha256(sha256sum.txt)`; this material is bound to that identity by +/// the `measurement.tdx.cbor` entry in `sha256sum.txt`. +#[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 { + version: u32, + image: CborTdxImageMeasurement, + tdvf: CborTdxTdvfMeasurement, +} + +impl From<&TdxOsImageMeasurement> for CborTdxOsImageMeasurement { + fn from(measurement: &TdxOsImageMeasurement) -> Self { + Self { + version: TdxOsImageMeasurement::VERSION, + 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 { + /// Raw checksum file bytes (`sha256sum.txt`). `sha256(checksum_file)` is + /// the unified `os_image_hash`. + #[serde(with = "serde_human_bytes::base64")] + pub checksum_file: Vec, + /// Raw bytes of `measurement.tdx.cbor`. + #[serde(with = "serde_human_bytes::base64")] + pub measurement: Vec, +} + +impl TdxOsImageMeasurement { + pub const VERSION: u32 = 3; + + /// CBOR representation stored as `measurement.tdx.cbor`. + 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")?; + if cbor.version != Self::VERSION { + return Err(format!( + "TdxOsImageMeasurement: unsupported version {}, expected {}", + cbor.version, + Self::VERSION + )); + } + 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 measurement material. + pub fn measurement_hash(&self) -> [u8; 32] { + sha256(&self.to_cbor_vec()) + } +} + +impl TdxOsImageMeasurementDocument { + pub fn new(checksum_file: Vec, measurement: Vec) -> Self { + Self { + checksum_file, + measurement, + } + } + + pub fn from_measurement(checksum_file: Vec, measurement: TdxOsImageMeasurement) -> Self { + Self::new(checksum_file, measurement.to_cbor_vec()) + } + + 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 verify(&self, os_image_hash: &[u8]) -> Result<(), String> { + verify_measurement_material( + os_image_hash, + &self.checksum_file, + &self.measurement, + TDX_MEASUREMENT_FILENAME, + ) + } +} + +#[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 = 3; + + 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 85aa533b6..c44a19e9a 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, AppAddressResolver}, @@ -270,6 +270,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..8ee247d89 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), @@ -645,17 +645,47 @@ mod tests { } } + fn snp_measurement_document( + input: &MeasurementInput, + ) -> dstack_mr::sev::SnpMeasurementDocument { + let measurement = dstack_mr::sev::sev_os_image_measurement_from_input(input) + .unwrap() + .to_cbor_vec(); + let sha256sum = format!( + "{} {}\n", + hex::encode(sha2::Sha256::digest(&measurement)), + dstack_types::SNP_MEASUREMENT_FILENAME + ) + .into_bytes(); + dstack_mr::sev::SnpMeasurementDocument { + checksum_file: sha256sum, + measurement, + vcpus: input.vcpus, + vcpu_type: input.vcpu_type.clone(), + guest_features: input.guest_features, + } + } + + fn snp_vm_config( + input: &MeasurementInput, + mr_config: &dstack_types::mr_config::MrConfigV3, + ) -> String { + let document = snp_measurement_document(input); + serde_json::json!({ + "os_image_hash": hex::encode(dstack_types::image_hash_from_sha256sum(&document.checksum_file)), + "sev_snp_measurement": serde_json::to_string(&document).unwrap(), + "mr_config": mr_config.to_canonical_json(), + }) + .to_string() + } + #[test] fn build_boot_info_for_attestation_accepts_snp_vm_config_path() { let input = valid_snp_measurement_input(); let measurement = compute_expected_measurement(&input).unwrap(); let mr_config = valid_snp_mr_config(); let attestation = verified_snp_attestation(measurement, [0xab; 64]); - let vm_config = serde_json::json!({ - "sev_snp_measurement": serde_json::to_string(&input).unwrap(), - "mr_config": mr_config.to_canonical_json(), - }) - .to_string(); + let vm_config = snp_vm_config(&input, &mr_config); let boot_info = build_boot_info_for_attestation(&attestation, false, &vm_config) .expect("snp attestation should build boot info through vm_config path"); @@ -671,11 +701,7 @@ mod tests { let input = valid_snp_measurement_input(); let measurement = compute_expected_measurement(&input).unwrap(); let mr_config = valid_snp_mr_config(); - let embedded_config = serde_json::json!({ - "sev_snp_measurement": serde_json::to_string(&input).unwrap(), - "mr_config": mr_config.to_canonical_json(), - }) - .to_string(); + let embedded_config = snp_vm_config(&input, &mr_config); let attestation = verified_snp_attestation_with_config( measurement, [0xab; 64], @@ -697,11 +723,7 @@ mod tests { let measurement = compute_expected_measurement(&input).unwrap(); let mr_config = valid_snp_mr_config(); let attestation = verified_snp_attestation(measurement, [0xab; 64]); - let vm_config = serde_json::json!({ - "sev_snp_measurement": serde_json::to_string(&input).unwrap(), - "mr_config": mr_config.to_canonical_json(), - }) - .to_string(); + let vm_config = snp_vm_config(&input, &mr_config); let boot_info = build_boot_info_for_attestation(&attestation, false, &vm_config) .expect("self-contained SNP vm_config should not require KMS-local sev_snp config"); @@ -714,11 +736,7 @@ mod tests { let measurement = compute_expected_measurement(&input).unwrap(); let mr_config = valid_snp_mr_config(); let attestation = verified_snp_attestation(measurement, [0xab; 64]); - let vm_config = serde_json::json!({ - "sev_snp_measurement": serde_json::to_string(&input).unwrap(), - "mr_config": mr_config.to_canonical_json(), - }) - .to_string(); + let vm_config = snp_vm_config(&input, &mr_config); build_boot_info_for_attestation(&attestation, false, &vm_config).unwrap() } diff --git a/kms/src/main_service/amd_attest.rs b/kms/src/main_service/amd_attest.rs index 48688c6a9..bcbbdbf4d 100644 --- a/kms/src/main_service/amd_attest.rs +++ b/kms/src/main_service/amd_attest.rs @@ -36,10 +36,9 @@ use super::upgrade_authority::BootInfo; // working. `allow(unused_imports)` because some are consumed only by tests. #[allow(unused_imports)] pub(crate) use dstack_mr::sev::{ - compute_expected_measurement, parse_snp_inputs_from_vm_config, snp_measurement_os_image_hash, - snp_mr_aggregated_digest, validate_measurement_input, validate_snp_mr_config_binding, - MeasurementInput, OvmfSectionParam, SnpLaunchInputs, MAX_OVMF_METADATA_PAGES, - MAX_OVMF_SECTIONS, MAX_VCPUS, + compute_expected_measurement, parse_snp_inputs_from_vm_config, snp_mr_aggregated_digest, + validate_measurement_input, validate_snp_mr_config_binding, MeasurementInput, OvmfSectionParam, + SnpLaunchInputs, MAX_OVMF_METADATA_PAGES, MAX_OVMF_SECTIONS, MAX_VCPUS, }; pub(crate) fn validate_amd_snp_measurement_binding( @@ -77,8 +76,7 @@ pub(crate) fn build_amd_snp_boot_info( ) -> Result { let mr_config = test_mr_config(vec![0x11; 20], vec![0x22; 32]); let mr_config_document = mr_config.to_canonical_json(); - let measurement_document = serde_json::to_string(input) - .context("failed to serialize amd sev-snp measurement input")?; + let os_image_hash = test_os_image_hash(input)?; let host_data = MrConfigV3::snp_host_data_from_document(&mr_config_document); build_amd_snp_boot_info_with_tcb_status( verified_measurement, @@ -87,7 +85,7 @@ pub(crate) fn build_amd_snp_boot_info( "UpToDate", &[], input, - &measurement_document, + &os_image_hash, &mr_config_document, ) } @@ -100,13 +98,12 @@ fn build_amd_snp_boot_info_with_tcb_status( tcb_status: &str, advisory_ids: &[String], input: &MeasurementInput, - measurement_document: &str, + os_image_hash: &[u8], mr_config_document: &str, ) -> Result { validate_amd_snp_measurement_binding(verified_measurement, input)?; let mr_config = validate_snp_mr_config_binding(verified_host_data, mr_config_document)?; - let os_image_hash = snp_measurement_os_image_hash(measurement_document)?; let mr_system = Sha256::digest(verified_measurement).to_vec(); let mr_aggregated = snp_mr_aggregated_digest(verified_measurement, verified_host_data); let key_provider_info = mr_config_key_provider_info(&mr_config)?; @@ -114,7 +111,7 @@ fn build_amd_snp_boot_info_with_tcb_status( Ok(BootInfo { attestation_mode: AttestationMode::DstackAmdSevSnp, mr_aggregated, - os_image_hash, + os_image_hash: os_image_hash.to_vec(), mr_system, app_id: mr_config.app_id.clone(), compose_hash: mr_config.compose_hash.clone(), @@ -136,8 +133,8 @@ fn build_amd_snp_boot_info_with_tcb_status( pub(crate) fn build_amd_snp_boot_info_from_verified_attestation( attestation: &VerifiedAttestation, input: &MeasurementInput, - measurement_document: &str, mr_config_document: &str, + os_image_hash: &[u8], ) -> Result { let verified = attestation .report @@ -150,7 +147,7 @@ pub(crate) fn build_amd_snp_boot_info_from_verified_attestation( verified.tcb_info.tcb_status(), &verified.advisory_ids, input, - measurement_document, + os_image_hash, mr_config_document, ) } @@ -166,14 +163,15 @@ pub(crate) fn build_amd_snp_boot_info_from_verified_attestation_and_vm_config( ) -> Result { let SnpLaunchInputs { input, - measurement_document, + os_image_hash, mr_config_document, + .. } = parse_snp_inputs_from_vm_config(vm_config)?; build_amd_snp_boot_info_from_verified_attestation( attestation, &input, - &measurement_document, &mr_config_document, + &os_image_hash, ) } @@ -201,6 +199,51 @@ fn test_mr_config(app_id: Vec, compose_hash: Vec) -> MrConfigV3 { ) } +#[cfg(test)] +fn test_snp_measurement_document( + input: &MeasurementInput, +) -> Result { + let measurement = dstack_mr::sev::sev_os_image_measurement_from_input(input)?.to_cbor_vec(); + let measurement_hash = Sha256::digest(&measurement); + let sha256sum = format!( + "{} {}\n", + hex::encode(measurement_hash), + dstack_types::SNP_MEASUREMENT_FILENAME + ) + .into_bytes(); + Ok(dstack_mr::sev::SnpMeasurementDocument { + checksum_file: sha256sum, + measurement, + vcpus: input.vcpus, + vcpu_type: input.vcpu_type.clone(), + guest_features: input.guest_features, + }) +} + +#[cfg(test)] +fn test_os_image_hash(input: &MeasurementInput) -> Result> { + Ok(dstack_types::image_hash_from_sha256sum( + &test_snp_measurement_document(input)?.checksum_file, + ) + .to_vec()) +} + +#[cfg(test)] +fn test_snp_measurement_document_json(input: &MeasurementInput) -> Result { + serde_json::to_string(&test_snp_measurement_document(input)?) + .context("failed to serialize test SNP measurement document") +} + +#[cfg(test)] +fn test_vm_config(input: &MeasurementInput, mr_config: &MrConfigV3) -> Result { + Ok(serde_json::json!({ + "os_image_hash": hex::encode(test_os_image_hash(input)?), + "sev_snp_measurement": test_snp_measurement_document_json(input)?, + "mr_config": mr_config.to_canonical_json(), + }) + .to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -212,7 +255,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), @@ -251,7 +294,7 @@ mod tests { } fn measurement_document(input: &MeasurementInput) -> String { - serde_json::to_string(input).expect("measurement input should serialize") + test_snp_measurement_document_json(input).expect("measurement input should serialize") } fn verified_snp_attestation( @@ -327,10 +370,7 @@ mod tests { assert_eq!(boot_info.device_id, chip_id.to_vec()); assert_eq!(boot_info.app_id, vec![0x11; 20]); assert_eq!(boot_info.compose_hash, vec![0x22; 32]); - assert_eq!( - boot_info.os_image_hash, - snp_measurement_os_image_hash(&measurement_document(&input)).unwrap() - ); + assert_eq!(boot_info.os_image_hash, test_os_image_hash(&input).unwrap()); assert_eq!(boot_info.mr_system.len(), 32); assert!(!boot_info.key_provider_info.is_empty()); assert_eq!(boot_info.instance_id.len(), 20); @@ -357,8 +397,8 @@ mod tests { let boot_info = build_amd_snp_boot_info_from_verified_attestation( &attestation, &input, - &measurement_document(&input), &mr_config_document, + &test_os_image_hash(&input)?, ) .expect("verified snp attestation should feed boot info helper"); @@ -418,8 +458,8 @@ mod tests { let boot_info = build_amd_snp_boot_info_from_verified_attestation( &attestation, &input, - &measurement_document(&input), &mr_config_document, + &test_os_image_hash(&input)?, ) .expect("verified snp attestation should feed boot info helper"); @@ -436,11 +476,7 @@ mod tests { let chip_id = [0xab; 64]; let mr_config = valid_mr_config(&input)?; let attestation = verified_snp_attestation(verified, chip_id, &mr_config); - let vm_config = serde_json::json!({ - "sev_snp_measurement": measurement_document(&input), - "mr_config": mr_config.to_canonical_json(), - }) - .to_string(); + let vm_config = test_vm_config(&input, &mr_config)?; let boot_info = build_amd_snp_boot_info_from_verified_attestation_and_vm_config( &attestation, @@ -463,7 +499,7 @@ mod tests { let err = build_amd_snp_boot_info_from_verified_attestation_and_vm_config( &attestation, - r#"{"os_image_hash":"0x00"}"#, + &serde_json::json!({ "os_image_hash": hex::encode([0u8; 32]) }).to_string(), ) .expect_err("missing sev_snp_measurement must fail closed"); assert!( @@ -475,10 +511,15 @@ mod tests { #[test] fn vm_config_measurement_parser_rejects_unknown_measurement_fields() { - let mut measurement = serde_json::to_value(valid_input()).unwrap(); + let input = valid_input(); + let mr_config = valid_mr_config(&input).unwrap(); + let mut measurement = + serde_json::to_value(test_snp_measurement_document(&input).unwrap()).unwrap(); measurement["unexpected"] = serde_json::json!(true); let vm_config = serde_json::json!({ + "os_image_hash": hex::encode(test_os_image_hash(&input).unwrap()), "sev_snp_measurement": measurement.to_string(), + "mr_config": mr_config.to_canonical_json(), }) .to_string(); @@ -492,20 +533,34 @@ mod tests { #[test] fn vm_config_measurement_parser_bounds_ovmf_sections_during_deserialization() { - let mut measurement = serde_json::to_value(valid_input()).unwrap(); - measurement["ovmf_sections"] = serde_json::Value::Array( - (0..=MAX_OVMF_SECTIONS) - .map(|_| { - serde_json::json!({ - "gpa": 0x100000u64, - "size": 0x1000u64, - "section_type": 1u32, - }) - }) - .collect(), - ); + let input = valid_input(); + let mr_config = valid_mr_config(&input).unwrap(); + let mut image = dstack_mr::sev::sev_os_image_measurement_from_input(&input).unwrap(); + image.ovmf_sections = (0..=MAX_OVMF_SECTIONS) + .map(|_| dstack_types::OvmfSection { + gpa: 0x100000, + size: 0x1000, + section_type: 1, + }) + .collect(); + let measurement_cbor = image.to_cbor_vec(); + let sha256sum = format!( + "{} {}\n", + hex::encode(Sha256::digest(&measurement_cbor)), + dstack_types::SNP_MEASUREMENT_FILENAME + ) + .into_bytes(); + let document = dstack_mr::sev::SnpMeasurementDocument { + checksum_file: sha256sum, + measurement: measurement_cbor, + vcpus: input.vcpus, + vcpu_type: input.vcpu_type.clone(), + guest_features: input.guest_features, + }; let vm_config = serde_json::json!({ - "sev_snp_measurement": measurement.to_string(), + "os_image_hash": hex::encode(dstack_types::image_hash_from_sha256sum(&document.checksum_file)), + "sev_snp_measurement": serde_json::to_string(&document).unwrap(), + "mr_config": mr_config.to_canonical_json(), }) .to_string(); @@ -548,8 +603,8 @@ mod tests { let err = build_amd_snp_boot_info_from_verified_attestation( &attestation, &input, - &measurement_document(&input), &mr_config_document, + &test_os_image_hash(&input)?, ) .expect_err("non-snp verified attestation must reject"); assert!( @@ -567,7 +622,7 @@ mod tests { let chip_id = [0xcd; 64]; let mr_config = test_mr_config(vec![0x11; 20], vec![0x22; 32]); let mr_config_document = mr_config.to_canonical_json(); - let measurement_doc = measurement_document(&input); + let os_image_hash = test_os_image_hash(&input)?; let host_data = MrConfigV3::snp_host_data_from_document(&mr_config_document); let boot_info = build_amd_snp_boot_info_with_tcb_status( &verified, @@ -576,7 +631,7 @@ mod tests { "UpToDate", &[], &input, - &measurement_doc, + &os_image_hash, &mr_config_document, )?; @@ -596,7 +651,7 @@ mod tests { "UpToDate", &[], &input, - &measurement_doc, + &os_image_hash, &changed_mr_config_document, )?; @@ -681,10 +736,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..691926155 100644 --- a/kms/src/onboard_service.rs +++ b/kms/src/onboard_service.rs @@ -193,8 +193,7 @@ fn build_attestation_info_response( mod tests { use super::*; use crate::main_service::amd_attest::{ - compute_expected_measurement, snp_measurement_os_image_hash, MeasurementInput, - OvmfSectionParam, + compute_expected_measurement, MeasurementInput, OvmfSectionParam, }; use sha2::Digest; @@ -205,7 +204,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), @@ -275,14 +274,38 @@ mod tests { } } + fn snp_measurement_document( + input: &MeasurementInput, + ) -> dstack_mr::sev::SnpMeasurementDocument { + let measurement = dstack_mr::sev::sev_os_image_measurement_from_input(input) + .unwrap() + .to_cbor_vec(); + let sha256sum = format!( + "{} {}\n", + hex::encode(sha2::Sha256::digest(&measurement)), + dstack_types::SNP_MEASUREMENT_FILENAME + ) + .into_bytes(); + dstack_mr::sev::SnpMeasurementDocument { + checksum_file: sha256sum, + measurement, + vcpus: input.vcpus, + vcpu_type: input.vcpu_type.clone(), + guest_features: input.guest_features, + } + } + #[test] fn attestation_info_response_uses_snp_boot_info_and_chip_id() { let input = valid_snp_measurement_input(); let measurement = compute_expected_measurement(&input).unwrap(); let mr_config = valid_snp_mr_config(); let attestation = verified_snp_attestation(measurement, [0xab; 64]); + let snp_document = snp_measurement_document(&input); + let os_image_hash = dstack_types::image_hash_from_sha256sum(&snp_document.checksum_file); let vm_config = serde_json::json!({ - "sev_snp_measurement": serde_json::to_string(&input).unwrap(), + "os_image_hash": hex::encode(os_image_hash), + "sev_snp_measurement": serde_json::to_string(&snp_document).unwrap(), "mr_config": mr_config.to_canonical_json(), }) .to_string(); @@ -303,10 +326,7 @@ mod tests { ); assert_eq!(response.ppid, vec![0xab; 64]); assert_eq!(response.mr_aggregated.len(), 32); - assert_eq!( - response.os_image_hash, - snp_measurement_os_image_hash(&serde_json::to_string(&input).unwrap()).unwrap() - ); + assert_eq!(response.os_image_hash, os_image_hash.to_vec()); assert_eq!(response.attestation_mode, "dstack-amd-sev-snp"); assert_eq!(response.site_name, "test-site"); assert_eq!(response.eth_rpc_url, "https://rpc.example"); diff --git a/verifier/README.md b/verifier/README.md index e70fd7c77..ad8501dbb 100644 --- a/verifier/README.md +++ b/verifier/README.md @@ -184,9 +184,9 @@ The verifier performs three main verification steps: 1. **Quote Verification**: Validates the TDX quote using dcap-qvl, checking the quote signature and TCB status 2. **Event Log Verification**: Replays event logs to ensure RTMR values match and extracts app information. For RTMR3 (runtime measurements), both the digest and payload integrity are verified. For RTMR 0-2 (boot-time measurements), only the digests are verified; the payload content is not validated as dstack does not define semantics for these payloads 3. **OS Image Hash Verification**: - - Automatically downloads OS images if not cached locally - - Uses dstack-mr to compute expected measurements - - Compares against the verified measurements from the quote + - Treats `vm_config` and any attached measurement material as untrusted inputs until they are bound to the hardware quote + - For the full-image TDX path, downloads or loads the image identified by `os_image_hash`, checks the image checksum manifest, uses dstack-mr to compute expected MRTD/RTMR0-2, and compares them against the verified measurements from the quote + - For TDX lite and AMD SEV-SNP, verifies that `os_image_hash = sha256(sha256sum.txt)`, where `sha256sum.txt` is the image build's checksum manifest (` ` lines for image files), that the manifest entry for `measurement.tdx.cbor` or `measurement.snp.cbor` matches the supplied measurement material, and that the measurement material replays to the quote's hardware-signed measurements All three steps must pass for the verification to be considered valid. diff --git a/verifier/fixtures/sev-snp-attestation.json b/verifier/fixtures/sev-snp-attestation.json new file mode 100644 index 000000000..ceaccd00a --- /dev/null +++ b/verifier/fixtures/sev-snp-attestation.json @@ -0,0 +1,3 @@ +{ + "attestation": "00038112030000000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000100000004000000000018d5250000000000000000000000000000006174746573742d746573742d666978747572652d32303236000000000000000000000000000000000000000000000000000000000000000000000000000000007f51e17f72a04d5422cb2c00998166536019a217376f3aa45a630e59c805a599847ff250dbffcd07e1ba639771d6f05d783f0057820acb99249af56cc3b07b4e8d80f65183167cba9cf437bb680f742f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ab942f03af5e6389b3c0fb8401af5d2fa4010f87e86d424989a57303ceabab12ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff04000000000017d519010100000000000000000000000000000000000000000038d174589d2dff97a6d40cb9f9d90b9507c027491219083cef3ce73ed18f7289142d941ad61eabecd27d25f268c1095d665f6001358e98a4769c82734a6bb87704000000000018d51d3701001d37010004000000000018d5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000506d88f9db02fc2005feded4a20d281b33337b70a2968eb346742cc73fe1f8fbfed9cc4b819357b980c0c8502a6166ad000000000000000000000000000000000000000000000000ef68f81d09b43b55cc3b929268c02865d5b4b0331d47f59642ac9e9f4f431b5afb6165856fce3b4d64eb74928d8905ea00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000855242d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d49494769544343424469674177494241674944415141424d455947435371475349623344514542436a41356f4138774451594a59495a4941575544424149430a425143684844416142676b71686b694739773042415167774451594a59495a49415755444241494342514369417749424d4b4d44416745424d487378464441530a42674e564241734d433056755a326c755a5756796157356e4d517377435159445651514745774a56557a45554d424947413155454277774c55324675644745670a51327868636d4578437a414a42674e564241674d416b4e424d523877485159445651514b44425a425a485a68626d4e6c5a43424e61574e79627942455a585a700a5932567a4d524977454159445651514444416c42556b737454576c73595734774868634e4d6a41784d4449794d5467794e4449775768634e4e4455784d4449790a4d5467794e444977576a42374d525177456759445651514c44417446626d6470626d566c636d6c755a7a454c4d416b474131554542684d4356564d78464441530a42674e564241634d43314e68626e526849454e7359584a684d517377435159445651514944414a44515445664d4230474131554543677757515752325957356a0a5a57516754576c6a636d38675247563261574e6c637a45534d424147413155454177774a553056574c553170624746754d494943496a414e42676b71686b69470a397730424151454641414f43416738414d49494343674b43416745416e55326472724e546662684e51496c6c662b5732792b524f4362537a496431614b5a66740a3254397a6a5a514f7a6a4763636c313769316d494b576c374e5463423056595874334a785a537a4f5a6a736a4c4e5641454e324d476a39546965644c2b5165770a4b5a58304a6d514575596a6d2b574b6b734c747867644c70394537455a4e774e447156317230715250357442384f576b79516249644c65753461437a376a2f530a6c31466b427974657639736246477a743763776e6a7a69396d376e6f71736b2b7552564270332b496e3335515064636a3859666c456d6e48424e767555444a680a4c434a4d57384b4f6a50362b2b5068627333694369744a63414e4574573471544e466f4b573343486c626353436a544d384b734e625578334138656b3545564c0a6a5a57483170743945335466705236587966514b6e59366b6c356145495077645733654659617143465072496f39705154365775445350344a43594a625a6e650a4b4b49625a6a7a586b4a74334e5147333245756b59496d42623953436b6d392b6653354c5a4667396f6a7a75624d58332b4e6b426f535849374f50766e484d780a6a7570396d773573653651555637477170434132544e79706f6c6d75512b6341617856374a71484538646c397057662b59336172622b3969694643774674346c0a416c4a77354430435452544331593559574644424372412f76476e6d546e714738432b6a6a55415337636a6a523871344f506879446d4a52506e61432f5a47350a7550304b307a36476f4f2f3375656e397771736843754865674c54704f6548454a524b725146723450564977564f42302b65624f3546676f794f7734336e79460a4435554b424478454234424b6f2f307541694b484c527676674c624f526255384b4152497331456f71456a6d46385574726d5157563268556a777a71777648460a6569387250784d434177454141614f426f7a43426f44416442674e5648513445466751554f385a75474372442f5431695a456962343764484c4c5438762f67770a487759445652306a42426777466f41556861776130555033794b7856314d5564515569723158684b31464d7745675944565230544151482f42416777426745420a2f7749424144414f42674e56485138424166384542414d43415151774f6759445652306642444d774d5441766f4332674b3459706148523063484d364c7939720a5a484e70626e526d4c6d46745a43356a62323076646d4e6c617939324d53394e615778686269396a636d77775267594a4b6f5a496876634e4151454b4d446d670a447a414e42676c67686b67425a514d4541674946414b45634d426f474353714753496233445145424344414e42676c67686b67425a514d4541674946414b49440a416745776f774d43415145446767494241496765555153634166336c4459716757553156746c44626d494e3853326443356b6d517a735a2f4874416a516e4c450a5049316a6833674a624c784c366766334b386a7863747a4f576e6b59636264664d4f4f7232384b54333549614152323072656b4b52467074544868652b4446720a3341467a5a4c44443763574b32392f4770506974504a444b43764937413455673036726b374a307a426531667a2f71653469322f4631327276667743475968630a5278507937514633713866523647434a644231555135536c77436a4678443475657a55527a74496c49416a4d6b74374446764b52682b327a4b2b35706c5647470a46736a444a744d7a32756439793070764f45346a336448354957396a47786153475374714e7261626e6e7046323336455472312f613433623846464b4c35514e0a6d7438567239786e5852707a6e71435276716a722b6b56726236646c6675546c6c69586551544d6c426f5257464a4f524c384163424a78475a344b326d5866740a6c316a5535544c6568354b584c394e5737612f71414f4955733246694f687172747a41684a526739496a38516b5139506b2b634b477a7736456c3354336b46720a4567367a6b786d764d7561625a4f73644b66526b57666848325a4b63546c44666d483148307a7130513262473375766156646943744659314c6c57794233384a0a5332664e73522f50793674356272454a43464e767a61446b79364b654334696f6e2f635667556169377a7a5333624751577a4b444b55333553714e5532576b500a493878435a3030577449694b4b466e5857555178766c4b6d6d675a424959506530317a44304e38617446786d5769536e664a6c3639304239724a704e522f66490a616a7843573353656977733672315a6d2b74437556624d694e7470533954686a4e58347576653574687966453244676f78524676593143736f46354d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a7d1d2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494946517a43434176656741774942416749424144424242676b71686b69473977304241516f774e4b41504d413047435743475341466c41775143416755410a6f5277774767594a4b6f5a496876634e415145494d413047435743475341466c41775143416755416f674d4341544177657a45554d424947413155454377774c0a5257356e6157356c5a584a70626d6378437a414a42674e5642415954416c56544d5251774567594456515148444174545957353059534244624746795954454c0a4d416b47413155454341774351304578487a416442674e5642416f4d466b466b646d46755932566b4945317059334a764945526c646d6c6a5a584d78456a41510a42674e5642414d4d43564e465669314e61577868626a4165467730794e6a41324d5463774d5441314d4452614677307a4d7a41324d5463774d5441314d4452610a4d486f784644415342674e564241734d433056755a326c755a5756796157356e4d517377435159445651514745774a56557a45554d424947413155454277774c0a553246756447456751327868636d4578437a414a42674e564241674d416b4e424d523877485159445651514b44425a425a485a68626d4e6c5a43424e61574e790a627942455a585a705932567a4d52457744775944565151444441685452565974566b4e46537a42324d42414742797147534d34394167454742537542424141690a4132494142456a4a38647772704166506d697461586552553646335235392f304955342b376b766a6b536d5a3937307665325543566f6457536357744c34724d0a54344e76482f472f3632436f684841534e75357947436a7247564b656e70556b30647643677349646275456c3675356f6e426d2b74444942637261526b5267440a69545538384b4f4341526377676745544d42414743537347415151426e48674241515144416745414d42634743537347415151426e4867424167514b4667684e0a61577868626931434d44415242676f7242674545415a783441514d4242414d43415151774551594b4b775942424147636541454441675144416745414d4245470a43697347415151426e48674241775145417749424144415242676f7242674545415a783441514d4642414d43415141774551594b4b77594242414763654145440a42675144416745414d42454743697347415151426e48674241776345417749424144415242676f7242674545415a783441514d4442414d43415263774567594b0a4b775942424147636541454443415145416749413154424e42676b7242674545415a78344151514551446a52644669644c662b587074514d75666e5a433555480a7743644a45686b49504f3838357a37526a334b4a4643325547745965712b7a5366535879614d454a58575a66594145316a70696b6470794363307072754863770a5151594a4b6f5a496876634e4151454b4d445367447a414e42676c67686b67425a514d4541674946414b45634d426f474353714753496233445145424344414e0a42676c67686b67425a514d4541674946414b49444167457741344943415142766153364952394a795378575076426a43697841526561437a70533334526637710a6e563148495545584b373248365850794554387a6a5a6759686b477a72393942356a4f662b625a6a3258716554367438764147382b5677725a456e78527a31340a7765704930563152494d75396d62316846516c4b714b72725679396a5241394e6432736a746176387a793478762b6e6541562b36486a57483357325269536e650a534c624f77556b594b764c5a305a6d6c787646557a345a3045356f306f66514458662f5852596e544d4a5449336e6b4e47433035495259303273654b46524b700a66363439636d70793873586a2b47533446714f6a65796d5739574267787378657956392b44684a3075364e3754782b5148484a7563344147536e5671324b4a790a6e64726b6e7036626b2f7949535931334475556b65463731512f46476b337351653550734b376b534c63736f47614455527541337772727374704f2f6f6f49610a4f6e7971425736414b4c36766c75777a506343754d74784a3869562f4e6458497353556f6c506e6938685a51374d5968343734626c36384e6e6c442b763843500a7943366367486576516d4b467457417457586c7861707a55556c42496c4d495a414b55652b48656c364d68554638764c5955667245526f436e61636c5a6f6b700a42595676676a34514c7175677a595679484273526c6e7965704d755554364b785a6630314c573252717655774d4630307852376d715156445a6f6964417746320a30525a342b476f4c38794b5352366e6c4342544366494f6c446f4251505244475933524d4757426a68636331566e7a787847542b2b2f7561754b4b5269554b450a36347161565170306559544431435a477134493659592f5362386232553553533279766f4639474a4e3837337771476e785869704e785a415754492b704237370a7341732f3344645172413d3d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0ab1067b226170705f6964223a2238366535393632356265393332303762633233353163346431626261323030333763656338653136222c22636f6d706f73655f68617368223a2238366535393632356265393332303762633233353163346431626261323030333763656338653136386461366231386635353961663561663635376237613233222c22696e7374616e63655f6964223a2264333738633030653635313132633432333562666562343663366130663938666430323134356133222c226b65795f70726f7669646572223a226b6d73222c226b65795f70726f76696465725f6964223a223330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030343139623234353764643962386161363434366439383066313336666666373831326563643663373737343065656230653238623130643536633063303030323861356236653539646365613330376435383362643166373037363965396331313664663262636662313735386139356438363133653764653163383438326330222c2276657273696f6e223a337d244073797374656d2d707265706172696e6700186170702d69645086e59625be93207bc2351c4d1bba20037cec8e1630636f6d706f73652d686173688086e59625be93207bc2351c4d1bba20037cec8e168da6b18f559af5af657b7a232c696e7374616e63652d696450d378c00e65112c4235bfeb46c6a0f98fd02145a330626f6f742d6d722d646f6e6500346f732d696d6167652d686173688032b4767373ad7fa0f9c418925006194d5c3f5619529f309fe81156789fecd8bc306b65792d70726f766964657231037b226e616d65223a226b6d73222c226964223a223330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030343139623234353764643962386161363434366439383066313336666666373831326563643663373737343065656230653238623130643536633063303030323861356236653539646365613330376435383362643166373037363965396331313664663262636662313735386139356438363133653764653163383438326330227d2873746f726167652d66730c7a66733073797374656d2d7265616479006174746573742d746573742d666978747572652d3230323600000000000000000000000000000000000000000000000000000000000000000000000000000000911e7b226f735f696d6167655f68617368223a2266363963386535303331656332623236396262633038383339636530326362393865653235356436393536323666393431663462333762393539343664333532222c226370755f636f756e74223a322c226d656d6f72795f73697a65223a343239343936373239362c2271656d755f76657273696f6e223a2231302e302e32222c227063695f686f6c6536345f73697a65223a302c22687567657061676573223a66616c73652c226e756d5f67707573223a302c226e756d5f6e767377697463686573223a302c22686f74706c75675f6f6666223a66616c73652c22696d616765223a2264737461636b2d6e76696469612d302e362e302e6132222c22686f73745f73686172655f6d6f6465223a22766864222c226f766d665f76617269616e74223a22707265323032353035222c22737065635f76657273696f6e223a312c226d725f636f6e666967223a227b5c226170705f69645c223a5c22383665353936323562653933323037626332333531633464316262613230303337636563386531365c222c5c22636f6d706f73655f686173685c223a5c22383665353936323562653933323037626332333531633464316262613230303337636563386531363864613662313866353539616635616636353762376132335c222c5c22696e7374616e63655f69645c223a5c22643337386330306536353131326334323335626665623436633661306639386664303231343561335c222c5c226b65795f70726f76696465725c223a5c226b6d735c222c5c226b65795f70726f76696465725f69645c223a5c2233303539333031333036303732613836343863653364303230313036303832613836343863653364303330313037303334323030303431396232343537646439623861613634343664393830663133366666663738313265636436633737373430656562306532386231306435366330633030303238613562366535396463656133303764353833626431663730373639653963313136646632626366623137353861393564383631336537646531633834383263305c222c5c2276657273696f6e5c223a337d222c227365765f736e705f6d6561737572656d656e74223a227b5c22636865636b73756d5f66696c655c223a5c224d44646a4d6a6c6b4e6d55334e7a4e694e3252684d7a42695a44646c4e5756695a4463784e575a694d6d5a6a4d44426d5a6d45344d474932596a63785a4749794f5749795a4455344e6d597a4f4464684d5746684d79416762575668633356795a57316c626e5175633235774c6d4e696233494b5c222c5c226d6561737572656d656e745c223a5c22714764325a584a7a615739754132646a625752736157356c6551456659323975633239735a54313064486c544d434270626d6c3050533970626d6c3049484268626d6c6a50544567626d56304c6d6c6d626d46745a584d394d4342696157397a5a475632626d46745a5430774947316a5a5431765a6d596762323977637a3177595735705979427759326b39626d396c59584a736553427759326b39626d397462574e76626d5967636d46755a4739744c6e527964584e3058324e776454313549484a68626d527662533530636e567a6446396962323930624739685a4756795057346764484e6a50584a6c62476c68596d786c494735764c57743262574e7362324e724947527a6447466a61793579623239305a6e4e666147467a6144316a595456685a47466c5a6a4268597a4e684d7a59784d4467774d7a55354d6a55334e6a4e694e4468684e5467784f4759324d7a526c4e7a41775a6d4a68595749314e6a466b4e4445355a6d517a4d4751334d5449784947527a6447466a61793579623239305a6e4e6663326c365a5430304f5441334d544d774f44687062335a745a6c396f59584e6f5744442f745834354e476d6b6c38446a73487652795832474565565639475452524a4744646d574a4f735a43736d4f6e483555482f78414b684869582f67772f6a47397261325679626d56735832686863326859494e32656f6e544f6d67634a43794c6f4b45734d6842746c774348433056796c665138576378434a33534a7361326c75615852795a46396f59584e6f574342666845784b4c4b576a3048456250624f436b37496271536d376a6773385738476e6561562f615349634758426f59584e6f5a584e6664474669624756665a3342684767434244414270636d567a5a5852665a576c77476743417341527462335a745a6c397a5a574e30615739756334656a5932647759526f41674141415a484e70656d555a6b41426b64486c775a51476a5932647759526f41674b41415a484e70656d555a4d41426b64486c775a51476a5932647759526f41674e41415a484e70656d555a4541426b64486c775a514b6a5932647759526f41674f41415a484e70656d555a4541426b64486c775a514f6a5932647759526f41675041415a484e70656d555a4541426b64486c775a51536a5932647759526f41675241415a484e70656d555a3841426b64486c775a51476a5932647759526f41675141415a484e70656d555a4541426b64486c775a52413d5c222c5c2276637075735c223a322c5c22766370755f747970655c223a5c22455059432d76345c222c5c2267756573745f66656174757265735c223a317d227d" +} diff --git a/verifier/fixtures/sev-snp.README.md b/verifier/fixtures/sev-snp.README.md new file mode 100644 index 000000000..999884287 --- /dev/null +++ b/verifier/fixtures/sev-snp.README.md @@ -0,0 +1,37 @@ +# SEV-SNP verifier fixture + +This fixture exercises the dstack-verifier AMD SEV-SNP path with the current +split image-measurement material format. + +Files: + +- `sev-snp-attestation.json`: verifier input containing a self-contained + `attestation`. The embedded `vm_config` carries `sev_snp_measurement` with + `checksum_file` and `measurement` as JSON base64 byte strings. + +The fixture is derived from the real SEV-SNP attestation under +`dstack-attest/tests/`: + +- the signed 1184-byte SNP report and MrConfigV3 document are unchanged; +- the embedded config is normalized to the current + `sha256sum.txt + measurement.snp.cbor` schema; +- the pinned ASK/VCEK PEMs are embedded in the attestation `cert_chain`, so the + verifier test is fully offline and does not call AMD KDS. + +To verify manually without image download: + +```toml +address = "127.0.0.1" +port = 0 +image_cache_dir = "/tmp/dstack-verifier-sev-snp-fixture-cache" +image_download_url = "http://127.0.0.1:9/should-not-download/{OS_IMAGE_HASH}.tar.gz" +image_download_timeout_secs = 1 +``` + +```bash +dstack-verifier --config verifier-no-download.toml \ + --verify verifier/fixtures/sev-snp-attestation.json +``` + +Expected result: `Valid: true`, with quote, event log/app info, and OS image +hash all verified. The cache directory should not be created. diff --git a/verifier/fixtures/tdx-lite-attestation.json b/verifier/fixtures/tdx-lite-attestation.json new file mode 100644 index 000000000..93f4af0ab --- /dev/null +++ b/verifier/fixtures/tdx-lite-attestation.json @@ -0,0 +1,3 @@ +{ + "attestation": "0000394e040002008100000000000000939a7233f79c4ca9940a0db3957f06071026ff2bbebac59cc1ef911279d9481b000000000c010400000000000000000000000000d0d80c085166ba78ccc69af268e5753cf0f3394523cb4ff7c50b08d9265c82489c099c377be6a400e4d2b57da924012c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000e702060000000000fd685522ce791dfef67414614eb07d03fc07a32c5a66f36288b329dab92b724b1564c73d436ffb9ea84488c51ac5a1c50186b0e55f2fa8e4fb69d890f14f54d5612707646e2573d54e0d2ddaaade77caa9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f8438db36b96f85d8752ff7f24a89ec05c79ec9eda2ba732c897fb970ca429365b7471b1c054cb84f17b1c2b23ba66402023546e7f3b9d1228e274f70c44d481162540f8452544520a796a52f06879709b81a824a26792a7822327504b0d2aee4c1b739ed451a637b0f82642e48a5ea83925d23633c72e7385c8e9aca4175e133ed1625b7d92eb39edf509c27ff392dc6f24c170d0fd63fc2b1b53202eea47b013978437fa6982cf5e0438ff95c208994aaa0f4ebab2e3a66824b5b56869137e646970313a3a736563703235366b31632d706b3a41353570576d74654a494a4f6a385f7049372d707a654478793147327131384744763838484e526442586b51cc1000008bca152d0454bdfd5adab1bc3a527884f77ea7993d32ee0e4426b2ae0fe42bf3f5642d6abd763b4f4c6042133e2ed79cce743f2c54ff4c7ea5d712dc1172ec244fe5b32ac6ffeb104614bcb8894c7aaafbbbe6f6bfd852f5dcd6cf400557ee764e62850d955975d93eff63b17e6e13e329a7bb13926706c0430017d543ab01920600461000000404191b04ff0006000000000000000000000000000000000000000000000000000000000000000000000000000000001500000000000000e700000000000000e5a3a7b5d830c2953b98534c6c59a3a34fdc34e933f7f5898f0a85cf08846bca0000000000000000000000000000000000000000000000000000000000000000dc9e2a7c6f948f17474e34a7fc43ed030f7c1563f1babddf6340c82e0e54a8c500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c1140324365c08f021a721dbe9175cb89dcd2235e2bd00bfb235b2a66b8c783600000000000000000000000000000000000000000000000000000000000000002af8cd12d44e0d22f904b15c02968b57b668e7f2487ba308e1d9a269ea125e48b243f7d32bb8551e1e3c2c09bd2162d36941eeb47be50b9b55a766a14d0cfe302000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f05005e0e00002d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d49494538444343424a6167417749424167495556706163774c766c316d476155506b384b4375504141334769465177436759494b6f5a497a6a3045417749770a634445694d434147413155454177775a535735305a577767553064594946424453794251624746305a6d397962534244515445614d42674741315545436777520a535735305a577767513239796347397959585270623234784644415342674e564241634d43314e68626e526849454e7359584a684d51737743515944565151490a44414a445154454c4d416b474131554542684d4356564d774868634e4d6a59774e4445314d4441314d4455345768634e4d7a4d774e4445314d4441314d4455340a576a42774d534977494159445651514444426c4a626e526c624342545231676755454e4c49454e6c636e52705a6d6c6a5958526c4d526f77474159445651514b0a4442464a626e526c6243424462334a7762334a6864476c76626a45554d424947413155454277774c553246756447456751327868636d4578437a414a42674e560a4241674d416b4e424d517377435159445651514745774a56557a425a4d424d4742797147534d34394167454743437147534d343941774548413049414245586a0a53374265726c3262726b65543677707878436a556536564775577268586e51767a41395862524768356b68637671766b566b427874715935475759544f6551340a5948496a636b7974734c6c5531774b594a74576a67674d4d4d4949444344416642674e5648534d4547444157674253566231334e765276683655424a796454300a4d383442567776655644427242674e56485238455a4442694d47436758714263686c706f64485277637a6f764c32467761533530636e567a6447566b633256790a646d6c6a5a584d75615735305a577775593239744c334e6e6543396a5a584a3061575a7059324630615739754c3359304c33426a61324e796244396a595431770a624746305a6d397962535a6c626d4e765a476c755a7a316b5a584977485159445652304f42425945464362386b6b73714d364c384f6765734c713943337339440a7a5333504d41344741315564447745422f775145417749477744414d42674e5648524d4241663845416a41414d4949434f51594a4b6f5a496876684e415130420a424949434b6a4343416959774867594b4b6f5a496876684e4151304241515151514e367178312b487a7758704c373859496b716c646a434341574d47436971470a534962345451454e41514977676746544d42414743797147534962345451454e41514942416745454d42414743797147534962345451454e41514943416745450a4d42414743797147534962345451454e41514944416745434d42414743797147534962345451454e41514945416745434d42414743797147534962345451454e0a41514946416745454d42414743797147534962345451454e41514947416745424d42414743797147534962345451454e41514948416745414d424147437971470a534962345451454e41514949416745464d42414743797147534962345451454e4151494a416745414d42414743797147534962345451454e4151494b416745410a4d42414743797147534962345451454e4151494c416745414d42414743797147534962345451454e4151494d416745414d42414743797147534962345451454e0a4151494e416745414d42414743797147534962345451454e4151494f416745414d42414743797147534962345451454e41514950416745414d424147437971470a534962345451454e41514951416745414d42414743797147534962345451454e415149524167454e4d42384743797147534962345451454e41514953424241450a42414943424145414251414141414141414141414d42414743697147534962345451454e41514d45416741414d42514743697147534962345451454e415151450a42704441627741414144415042676f71686b69472b45304244514546436745424d42344743697147534962345451454e4151594545464a37386f7137314543670a6c7536335265417a675430775241594b4b6f5a496876684e41513042427a41324d42414743797147534962345451454e415163424151482f4d424147437971470a534962345451454e41516343415145414d42414743797147534962345451454e415163444151482f4d416f4743437147534d343942414d43413067414d4555430a494778676472434e7a344753716d32647a4c45533874757663717230444d692b427537533771537133325343416945417439454f6377584f6a31484a4c4462750a6d473357414549577962624f61635959612b7253384366526c514d3d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d4949436c6a4343416a32674177494241674956414a567658633239472b487051456e4a3150517a7a674658433935554d416f4743437147534d343942414d430a4d476778476a415942674e5642414d4d45556c756447567349464e48574342536232393049454e424d526f77474159445651514b4442464a626e526c624342440a62334a7762334a6864476c76626a45554d424947413155454277774c553246756447456751327868636d4578437a414a42674e564241674d416b4e424d5173770a435159445651514745774a56557a4165467730784f4441314d6a45784d4455774d5442614677307a4d7a41314d6a45784d4455774d5442614d484178496a41670a42674e5642414d4d47556c756447567349464e4857434251513073675547786864475a76636d306751304578476a415942674e5642416f4d45556c75644756730a49454e76636e4276636d4630615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b474131554543417743513045780a437a414a42674e5642415954416c56544d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a304441516344516741454e53422f377432316c58534f0a3243757a7078773734654a423732457944476757357258437478327456544c7136684b6b367a2b5569525a436e71523770734f766771466553786c6d546c4a6c0a65546d693257597a33714f42757a43427544416642674e5648534d4547444157674251695a517a575770303069664f44744a5653763141624f536347724442530a42674e5648523845537a424a4d45656752614244686b466f64485277637a6f764c324e6c636e52705a6d6c6a5958526c63793530636e567a6447566b633256790a646d6c6a5a584d75615735305a577775593239744c306c756447567355306459556d397664454e424c6d526c636a416442674e5648513445466751556c5739640a7a62306234656c4153636e553944504f4156634c336c517744675944565230504151482f42415144416745474d42494741315564457745422f7751494d4159420a4166384341514177436759494b6f5a497a6a30454177494452774177524149675873566b6930772b6936565947573355462f32327561586530594a446a3155650a6e412b546a44316169356343494359623153416d4435786b66545670766f34556f79695359787244574c6d5552344349394e4b7966504e2b0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d4949436a7a4343416a53674177494241674955496d554d316c71644e496e7a6737535655723951477a6b6e42717777436759494b6f5a497a6a3045417749770a614445614d4267474131554541777752535735305a5777675530645949464a766233516751304578476a415942674e5642416f4d45556c756447567349454e760a636e4276636d4630615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b47413155454341774351304578437a414a0a42674e5642415954416c56544d423458445445344d4455794d5445774e4455784d466f58445451354d54497a4d54497a4e546b314f566f77614445614d4267470a4131554541777752535735305a5777675530645949464a766233516751304578476a415942674e5642416f4d45556c756447567349454e76636e4276636d46300a615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b47413155454341774351304578437a414a42674e56424159540a416c56544d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a3044415163445167414543366e45774d4449595a4f6a2f69505773437a61454b69370a314f694f534c52466857476a626e42564a66566e6b59347533496a6b4459594c304d784f346d717379596a6c42616c54565978465032734a424b357a6c4b4f420a757a43427544416642674e5648534d4547444157674251695a517a575770303069664f44744a5653763141624f5363477244425342674e5648523845537a424a0a4d45656752614244686b466f64485277637a6f764c324e6c636e52705a6d6c6a5958526c63793530636e567a6447566b63325679646d6c6a5a584d75615735300a5a577775593239744c306c756447567355306459556d397664454e424c6d526c636a416442674e564851344546675155496d554d316c71644e496e7a673753560a55723951477a6b6e4271777744675944565230504151482f42415144416745474d42494741315564457745422f7751494d4159424166384341514577436759490a4b6f5a497a6a3045417749445351417752674968414f572f35516b522b533943695344634e6f6f774c7550524c735747662f59693747535839344267775477670a41694541344a306c72486f4d732b586f356f2f7358364f39515778485241765a55474f6452513763767152586171493d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000000a000000c0095d04cf26fe03aef6e3561fa24c1aa1cea93f4aeaf563b1f9f7616184c53454875925759434769cec2490acb563a3372c616370692d6c6f6164657224414350492044415441000000000a000000c08d9a4d4777a1bc77ecd9d8d37a4628129a80052a510320159a20a923bd07a0e90d8d1f2e1ebf088992b25f0d0fa672ef24616370692d7273647024414350492044415441000000000a000000c03070721e169bc41884724cb0e6b3082e1baf249083d8b389181ba50b9afa951057876c380b8870e8c2facf2eff67a2b62c616370692d7461626c6573244143504920444154410300000001000008004073797374656d2d707265706172696e6700030000000100000800186170702d69645086b0e55f2fa8e4fb69d890f14f54d5612707646e03000000010000080030636f6d706f73652d686173688086b0e55f2fa8e4fb69d890f14f54d5612707646e2573d54e0d2ddaaade77caa90300000001000008002c696e7374616e63652d696450050bf89570575fe8fab4cb8f0a62a9e64efe8ead03000000010000080030626f6f742d6d722d646f6e6500030000000100000800346f732d696d6167652d686173688007a2388c7a6a1b6a646d443f1517990a4ec294471d63146cda9d56972765051d030000000100000800306b65792d70726f766964657231037b226e616d65223a226b6d73222c226964223a223330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030343266373165323334643733333961316365616361303963336333393165623831366335333366393830616461616233346631366561643039336666306163313030643963303332353361333035366636643237373335313235343333313830623365363163353461373866336664313333333738363965303035316465653036227d0300000001000008002873746f726167652d66730c7a66730300000001000008003073797374656d2d726561647900244073797374656d2d707265706172696e6700186170702d69645086b0e55f2fa8e4fb69d890f14f54d5612707646e30636f6d706f73652d686173688086b0e55f2fa8e4fb69d890f14f54d5612707646e2573d54e0d2ddaaade77caa92c696e7374616e63652d696450050bf89570575fe8fab4cb8f0a62a9e64efe8ead30626f6f742d6d722d646f6e6500346f732d696d6167652d686173688007a2388c7a6a1b6a646d443f1517990a4ec294471d63146cda9d56972765051d306b65792d70726f766964657231037b226e616d65223a226b6d73222c226964223a223330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030343266373165323334643733333961316365616361303963336333393165623831366335333366393830616461616233346631366561643039336666306163313030643963303332353361333035366636643237373335313235343333313830623365363163353461373866336664313333333738363965303035316465653036227d2873746f726167652d66730c7a66733073797374656d2d726561647900646970313a3a736563703235366b31632d706b3a41353570576d74654a494a4f6a385f7049372d707a654478793147327131384744763838484e526442586b514d107b226f735f696d6167655f68617368223a2265366635636665633230633032653762393762616132313364306637313830323062353565303430313732643930636362636239343664353663386230396462222c226370755f636f756e74223a322c226d656d6f72795f73697a65223a323134373438333634382c2271656d755f76657273696f6e223a22382e322e32222c227063695f686f6c6536345f73697a65223a302c22687567657061676573223a66616c73652c226e756d5f67707573223a302c226e756d5f6e767377697463686573223a302c22686f74706c75675f6f6666223a66616c73652c22696d616765223a2264737461636b2d302e362e30222c22686f73745f73686172655f6d6f6465223a223970222c226f766d665f76617269616e74223a22707265323032353035222c227464785f6174746573746174696f6e5f76617269616e74223a226c697465222c227464785f6d6561737572656d656e74223a7b226d6561737572656d656e74223a226f3264325a584a7a61573975413256706257466e5a614e755932316b62476c755a56397a6147457a4f4452594d486869674951726332516f656a70773257392b4d4a4a5368587672526673666b54464b4c71686a32777263424d6844487376796d705a6b425742474d61577175484e725a584a755a577866595856306147567564476c6a6232526c57444373666d4d747a317a536f66356348304830326267686c58446d5474504745446a3976795641546d39554c2f3158386e6138554859776676723467756257515864746157357064484a6b58334e6f59544d344e466777542b54336351453070683139377a5636335772464339762b376c4179704d45414e3134676368622f35436f37315949724a4f5a352b5255422f2f6556754255685a48526b646d616a5a47393262575a7063484a6c4d6a41794e5441315a4731796447536961334e70626d64735a56397759584e7a5744436d3871795555594547687154624a5a2f6f2b6c51343345705976616e394c317366734a4b444e584256414e4b6146636b6a683046714c314c64334f6d63672f686f644864765833426863334e594d50316f56534c4f6552332b396e5155595536776651503842364d73576d627a596f697a4b6471354b334a4c4657544850554e762b35366f52496a464773576878575a305a46396f62324a4d6742414a424141474351494c41684151222c22636865636b73756d5f66696c65223a224f474d355a546b314e575977596a55334e324e6a4f446b304e5745344f5446684d7a526a59325a684e5749345a544134596d49315a54566b4d6a51354f546b794e6a417a5a5459305a4751784e4467334d47557a5979416762575668633356795a57316c626e5175644752344c6d4e696233494b227d2c22737065635f76657273696f6e223a317d" +} diff --git a/verifier/fixtures/tdx-lite-getquote.json b/verifier/fixtures/tdx-lite-getquote.json new file mode 100644 index 000000000..57a30c837 --- /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\":\"acpi-loader\",\"event_payload\":\"414350492044415441\"},{\"imr\":0,\"event_type\":10,\"digest\":\"8d9a4d4777a1bc77ecd9d8d37a4628129a80052a510320159a20a923bd07a0e90d8d1f2e1ebf088992b25f0d0fa672ef\",\"event\":\"acpi-rsdp\",\"event_payload\":\"414350492044415441\"},{\"imr\":0,\"event_type\":10,\"digest\":\"3070721e169bc41884724cb0e6b3082e1baf249083d8b389181ba50b9afa951057876c380b8870e8c2facf2eff67a2b6\",\"event\":\"acpi-tables\",\"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\":\"e6f5cfec20c02e7b97baa213d0f718020b55e040172d90ccbcb946d56c8b09db\",\"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\":{\"measurement\":\"o2d2ZXJzaW9uA2VpbWFnZaNuY21kbGluZV9zaGEzODRYMHhigIQrc2Qoejpw2W9+MJJShXvrRfsfkTFKLqhj2wrcBMhDHsvympZkBWBGMaWquHNrZXJuZWxfYXV0aGVudGljb2RlWDCsfmMtz1zSof5cH0H02bghlXDmTtPGEDj9vyVATm9UL/1X8na8UHYwfvr4gubWQXdtaW5pdHJkX3NoYTM4NFgwT+T3cQE0ph197zV63WrFC9v+7lAypMEAN14gchb/5Co71YIrJOZ5+RUB//eVuBUhZHRkdmajZG92bWZpcHJlMjAyNTA1ZG1ydGSia3NpbmdsZV9wYXNzWDCm8qyUUYEGhqTbJZ/o+lQ43EpYvan9L1sfsJKDNXBVANKaFckjh0FqL1Ld3Omcg/hodHdvX3Bhc3NYMP1oVSLOeR3+9nQUYU6wfQP8B6MsWmbzYoizKdq5K3JLFWTHPUNv+56oRIjFGsWhxWZ0ZF9ob2JMgBAJBAAGCQILAhAQ\",\"checksum_file\":\"OGM5ZTk1NWYwYjU3N2NjODk0NWE4OTFhMzRjY2ZhNWI4ZTA4YmI1ZTVkMjQ5OTkyNjAzZTY0ZGQxNDg3MGUzYyAgbWVhc3VyZW1lbnQudGR4LmNib3IK\"},\"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..1b2160f81 --- /dev/null +++ b/verifier/fixtures/tdx-lite.README.md @@ -0,0 +1,65 @@ +# 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` (`sha256sum.txt` bytes +plus `measurement.tdx.cbor` bytes), 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` whose embedded + `vm_config` carries `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 = e6f5cfec20c02e7b97baa213d0f718020b55e040172d90ccbcb946d56c8b09db` +- `vm_config.tdx_measurement.{checksum_file,measurement}` are JSON base64 byte + strings. +- The raw top-level `event_log` and stripped attestation keep the three named + RTMR0 `ACPI DATA` digests (`acpi-loader`, `acpi-rsdp`, `acpi-tables`) and + marker payloads needed by the lite verifier, plus RTMR3 runtime events. +- When `attestation` is present, dstack-verifier ignores top-level + `quote`/`event_log`/`vm_config`; the attestation's embedded config is the + single source of truth. The raw quote path should omit `attestation` and pass + `quote` + `event_log` + `vm_config` instead. + +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 a2ab747d6..6b432a713 100644 --- a/verifier/src/verification.rs +++ b/verifier/src/verification.rs @@ -10,9 +10,17 @@ use std::{ }; use anyhow::{anyhow, bail, Context, Result}; -use cc_eventlog::TdxEvent; +use cc_eventlog::{ + tdx::{ + TDX_ACPI_DATA_EVENT_PAYLOAD, TDX_ACPI_DATA_EVENT_TYPE, TDX_ACPI_LOADER_EVENT, + TDX_ACPI_RSDP_EVENT, TDX_ACPI_TABLES_EVENT, + }, + TdxEvent, +}; use dstack_attest::amd_sev_snp::AmdKdsClient; -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::{ @@ -140,9 +148,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 { @@ -151,6 +158,7 @@ struct CachedMeasurement { } struct ImagePaths { + image_dir: PathBuf, fw_path: PathBuf, kernel_path: PathBuf, initrd_path: PathBuf, @@ -373,6 +381,75 @@ 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_hashes_from_event_log(event_log: &[TdxEvent]) -> Result { + let rtmr0_events = event_log + .iter() + .filter(|event| event.imr == 0) + .collect::>(); + let acpi_events = rtmr0_events + .iter() + .filter(|event| { + event.event_type == TDX_ACPI_DATA_EVENT_TYPE + && event.event_payload == TDX_ACPI_DATA_EVENT_PAYLOAD + }) + .collect::>(); + if acpi_events.len() != 3 { + bail!( + "TDX lite attestation requires exactly 3 RTMR0 ACPI DATA events; found {} candidates and {} RTMR0 events", + acpi_events.len(), + rtmr0_events.len() + ); + } + + let digest_for = |name: &str| -> Result> { + let matches = acpi_events + .iter() + .copied() + .filter(|event| event.event == name) + .collect::>(); + if matches.len() != 1 { + bail!( + "TDX lite attestation requires exactly one RTMR0 ACPI DATA event named {name}; found {}", + matches.len() + ); + } + let digest = matches[0].digest(); + if digest.len() != 48 { + bail!( + "TDX RTMR0 ACPI DATA event {name} has invalid digest length {}, expected 48", + digest.len() + ); + } + Ok(digest) + }; + + Ok(TdxRtmr0AcpiHashes { + loader: digest_for(TDX_ACPI_LOADER_EVENT)?, + rsdp: digest_for(TDX_ACPI_RSDP_EVENT)?, + tables: digest_for(TDX_ACPI_TABLES_EVENT)?, + }) + } + /// 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); @@ -405,6 +482,7 @@ impl CvmVerifier { let kernel_cmdline = image_info.cmdline + " initrd=initrd"; Ok(ImagePaths { + image_dir, fw_path, kernel_path, initrd_path, @@ -435,6 +513,26 @@ impl CvmVerifier { } pub async fn verify(&self, request: VerificationRequest) -> Result { + // Keep the two verifier input modes disjoint: + // - `attestation` is self-contained and its embedded config is used. + // - raw TDX input uses top-level `quote` + `event_log` + `vm_config`. + // Never mix top-level config with an attestation; otherwise an + // untrusted, separately supplied config could influence verification. + let has_attestation = request.attestation.is_some(); + if has_attestation + && (request.quote.is_some() + || request.event_log.is_some() + || request.vm_config.is_some()) + { + warn!( + "attestation is present; ignoring top-level quote/event_log/vm_config to avoid mixed verification inputs" + ); + } + let request_vm_config = if has_attestation { + String::new() + } else { + request.vm_config.clone().unwrap_or_default() + }; let attestation = if let Some(attestation) = &request.attestation { VersionedAttestation::from_bytes(attestation).context("Failed to decode attestaion")? } else if let Some(tdx_quote) = request.quote { @@ -483,7 +581,7 @@ impl CvmVerifier { // Step 3: Verify os-image-hash matches using dstack-mr let verified = self .verify_os_image_hash( - request.vm_config.clone().unwrap_or_default(), + request_vm_config.clone(), &verified_attestation, debug, &mut details, @@ -500,7 +598,7 @@ impl CvmVerifier { } }; details.os_image_hash_verified = true; - match verified_attestation.decode_app_info(false) { + match verified_attestation.decode_app_info_ex(false, &request_vm_config) { Ok(mut info) => { info.os_image_hash = vm_config.os_image_hash; details.event_log_verified = true; @@ -547,8 +645,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 { @@ -576,8 +689,8 @@ impl CvmVerifier { /// document in its `vm_config`; we recompute the launch measurement from /// those inputs and require it to equal the hardware-signed `MEASUREMENT` /// (which is what makes the otherwise-untrusted inputs trustworthy), require - /// `HOST_DATA` to bind the MrConfigV3 document, and then derive the - /// image-invariant `os_image_hash`. The shared recomputation in + /// `HOST_DATA` to bind the MrConfigV3 document, and then verify/return the + /// unified `os_image_hash` (`sha256(sha256sum.txt)`). The shared recomputation in /// `dstack_mr::sev` is the same code path the KMS uses for key release, so a /// quote that the KMS would release keys for verifies here too. fn verify_os_image_hash_for_dstack_sev( @@ -594,9 +707,8 @@ impl CvmVerifier { let binding = dstack_mr::sev::verify_sev_launch(&report.measurement, &report.host_data, raw_config) .context("amd sev-snp launch verification failed")?; - // The os_image_hash derived from the measurement-bound launch inputs is - // the authoritative one; surface it (overriding any guest-advertised - // value, which is not independently trusted). + // verify_sev_launch has checked that vm_config.os_image_hash commits to + // the supplied sha256sum.txt and measurement.snp.cbor material. vm_config.os_image_hash = binding.os_image_hash; details.tcb_status = Some(report.tcb_info.tcb_status().to_string()); details.advisory_ids = report.advisory_ids.clone(); @@ -617,13 +729,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(), @@ -631,16 +741,22 @@ 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 digest (digest.txt = + // sha256(sha256sum.txt)), 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 os_image_hash = sha256(sha256sum.txt)"); + } 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, @@ -663,7 +779,6 @@ impl CvmVerifier { (measurements, Some(rtmr_logs)) } else { - // For non-debug mode, use the cached-measurement path. ( self.load_or_compute_measurements( vm_config, @@ -677,13 +792,106 @@ impl CvmVerifier { ) }; + 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")?; + document + .verify(&vm_config.os_image_hash) + .map_err(anyhow::Error::msg) + .context("tdx lite measurement material does not match os_image_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. TDX lite keeps the unified image hash + // and carries split measurement material; verify it without + // downloading the image or running QEMU-derived ACPI table generators. + // The guest labels the three RTMR0 ACPI DATA events as acpi-loader, + // acpi-rsdp, and acpi-tables before exposing the event log, so the + // verifier does not guess based on event order. + let acpi_hashes = Self::tdx_acpi_hashes_from_event_log(event_log) + .context("TDX lite attestation is missing named RTMR0 ACPI DATA digests")?; + let mrs = dstack_mr::tdx::tdx_measurements_from_measurement_document( + document, + vm_config, + &acpi_hashes, + ) + .context("Failed to compute TDX expected measurements without image download")?; + let expected_mrs = Mrs { mrtd: mrs.mrtd.clone(), rtmr0: mrs.rtmr0.clone(), rtmr1: mrs.rtmr1.clone(), rtmr2: mrs.rtmr2.clone(), }; + expected_mrs + .assert_eq(&verified_mrs) + .context("MRs do not match") + } + 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) => { @@ -691,7 +899,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(); @@ -915,10 +1123,12 @@ 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"); + // All image modes are addressed by sha256(sha256sum.txt). Extra + // measurement CBOR files are ordinary sha256sum.txt entries and do not + // define alternate image hashes. + let legacy_os_image_hash = Sha256::new_with_prefix(files_doc.as_bytes()).finalize(); + if hex::encode(legacy_os_image_hash) != hex_os_image_hash { + bail!("os_image_hash does not match sha256(sha256sum.txt)"); } // Move the extracted files to the destination directory @@ -985,6 +1195,43 @@ impl Mrs { mod tests { use super::*; + fn acpi_event(name: &str, digest_byte: u8) -> TdxEvent { + TdxEvent { + imr: 0, + event_type: TDX_ACPI_DATA_EVENT_TYPE, + digest: vec![digest_byte; 48], + event: name.to_string(), + event_payload: TDX_ACPI_DATA_EVENT_PAYLOAD.to_vec(), + } + } + + #[test] + fn tdx_lite_acpi_hashes_are_selected_by_event_name() { + let event_log = vec![ + acpi_event(TDX_ACPI_RSDP_EVENT, 2), + acpi_event(TDX_ACPI_TABLES_EVENT, 3), + acpi_event(TDX_ACPI_LOADER_EVENT, 1), + ]; + + let hashes = + CvmVerifier::tdx_acpi_hashes_from_event_log(&event_log).expect("named ACPI hashes"); + + assert_eq!(hashes.loader, vec![1u8; 48]); + assert_eq!(hashes.rsdp, vec![2u8; 48]); + assert_eq!(hashes.tables, vec![3u8; 48]); + } + + #[test] + fn tdx_lite_acpi_hashes_reject_unlabeled_events() { + let event_log = vec![ + acpi_event("", 1), + acpi_event(TDX_ACPI_RSDP_EVENT, 2), + acpi_event(TDX_ACPI_TABLES_EVENT, 3), + ]; + + assert!(CvmVerifier::tdx_acpi_hashes_from_event_log(&event_log).is_err()); + } + #[test] fn decode_key_provider_info_parses_json_and_tolerates_garbage() { let info = @@ -996,4 +1243,51 @@ mod tests { assert!(decode_key_provider_info(b"").is_none()); assert!(decode_key_provider_info(b"not json").is_none()); } + + #[tokio::test] + async fn verifies_sev_snp_attestation_fixture_without_image_download() { + let request: VerificationRequest = + serde_json::from_str(include_str!("../fixtures/sev-snp-attestation.json")) + .expect("SNP verifier fixture parses"); + let cache = tempfile::tempdir().expect("temp cache dir"); + let image_cache_dir = cache.path().join("cache"); + let verifier = CvmVerifier::new( + image_cache_dir.display().to_string(), + "http://127.0.0.1:9/should-not-download/{OS_IMAGE_HASH}.tar.gz".to_string(), + Duration::from_secs(1), + None, + ); + + let response = verifier.verify(request).await.expect("verifier runs"); + assert!(response.is_valid, "{:?}", response.reason); + assert!(response.details.quote_verified); + assert!(response.details.event_log_verified); + assert!(response.details.os_image_hash_verified); + assert_eq!(response.details.tee_platform.as_deref(), Some("sev-snp")); + assert!( + !image_cache_dir.exists(), + "SNP verification must not download or cache OS images" + ); + } + + #[tokio::test] + async fn attestation_fixture_ignores_conflicting_top_level_inputs() { + let mut request: VerificationRequest = + serde_json::from_str(include_str!("../fixtures/sev-snp-attestation.json")) + .expect("SNP verifier fixture parses"); + request.quote = Some(vec![0]); + request.event_log = Some("[]".to_string()); + request.vm_config = Some("not-json".to_string()); + let cache = tempfile::tempdir().expect("temp cache dir"); + let verifier = CvmVerifier::new( + cache.path().join("cache").display().to_string(), + "http://127.0.0.1:9/should-not-download/{OS_IMAGE_HASH}.tar.gz".to_string(), + Duration::from_secs(1), + None, + ); + + let response = verifier.verify(request).await.expect("verifier runs"); + assert!(response.is_valid, "{:?}", response.reason); + assert_eq!(response.details.tee_platform.as_deref(), Some("sev-snp")); + } } diff --git a/vmm/src/app.rs b/vmm/src/app.rs index fa21297a0..e926253f1 100644 --- a/vmm/src/app.rs +++ b/vmm/src/app.rs @@ -1304,30 +1304,6 @@ fn mr_config_from_vm_config(sys_config: &serde_json::Value) -> Result Result { - Ok(hex::encode(sha256_file(path)?)) -} - -fn amd_sev_snp_ovmf_measurement_info(image: &Image) -> Result { - // Measure the same firmware the guest launches with: the SEV firmware - // (bios-sev) when present, falling back to the generic bios. The OVMF - // parsing/GCTX logic is shared with `dstack-mr sev-os-image-hash`. - let bios = image - .firmware(true) - .map(|p| p.as_path()) - .ok_or_else(|| anyhow::anyhow!("bios/OVMF is required for amd sev-snp measurement"))?; - dstack_mr::sev::ovmf_measurement_info(bios).with_context(|| { - format!( - "failed to extract amd sev-snp OVMF measurement metadata from {}", - bios.display() - ) - }) -} - -fn amd_sev_snp_measurement_base_cmdline(base_cmdline: Option<&str>) -> Option { - base_cmdline.map(|cmdline| cmdline.trim().to_string()) -} - fn sha256_file(path: impl AsRef) -> Result<[u8; 32]> { let data = fs::read(path).context("Failed to read file for sha256")?; let mut out = [0u8; 32]; @@ -1335,6 +1311,14 @@ fn sha256_file(path: impl AsRef) -> Result<[u8; 32]> { Ok(out) } +fn image_supports_tdx_lite(image: &Image) -> bool { + image + .digest + .as_deref() + .is_some_and(|d| !d.trim().is_empty()) + && image.tdx_measurement.is_some() +} + fn make_vm_config( cfg: &Config, manifest: &Manifest, @@ -1342,25 +1326,32 @@ fn make_vm_config( _compose_hash: &str, mr_config: Option, ) -> Result { - let is_amd_sev_snp = - cfg.cvm.resolved_platform() == crate::config::TeePlatform::AmdSevSnp && !manifest.no_tee; - // 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. - 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", - )?; - hex::decode(digest).context("digest.sev.txt is not valid hex")? + let platform = cfg.cvm.resolved_platform(); + let is_amd_sev_snp = platform == crate::config::TeePlatform::AmdSevSnp && !manifest.no_tee; + let is_tdx = platform == crate::config::TeePlatform::Tdx && !manifest.no_tee; + let tdx_attestation_variant = if is_tdx { + cfg.cvm + .tdx_attestation_variant + .resolve(manifest.memory, image_supports_tdx_lite(image)) } else { - image - .digest - .as_ref() - .and_then(|d| hex::decode(d).ok()) - .unwrap_or_default() + dstack_types::TdxAttestationVariant::Legacy + }; + // All dstack OS-image verification modes use the same public image + // identity: digest.txt = sha256(sha256sum.txt). Lite TDX/SNP carry extra + // split CBOR measurement material, but that material is committed by + // sha256sum.txt instead of defining a second image hash. + let os_image_hash = image + .digest + .as_ref() + .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.tdx.cbor/sha256sum.txt measurement material", + )?) + } else { + None }; let gpus = if cfg.cvm.gpu.enabled { manifest.gpus.clone().unwrap_or_default() @@ -1383,30 +1374,26 @@ 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); if is_amd_sev_snp { - // The rootfs identity is part of the measured kernel cmdline; do not - // carry it as a standalone, unmeasured launch-input field. - dstack_mr::sev::rootfs_hash_from_cmdline(image.info.cmdline.as_deref())?; if let Some(mr_config) = mr_config { MrConfigV3::from_document(&mr_config).context("Invalid mr_config document")?; config["mr_config"] = serde_json::Value::String(mr_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()), - "ovmf_hash": ovmf.ovmf_hash, - "kernel_hash": file_sha256_hex(&image.kernel)?, - "initrd_hash": file_sha256_hex(&image.initrd)?, - "sev_hashes_table_gpa": ovmf.sev_hashes_table_gpa, - "sev_es_reset_eip": ovmf.sev_es_reset_eip, - "vcpus": effective_vcpus, - "vcpu_type": "EPYC-v4", - "guest_features": 1, - "ovmf_sections": ovmf.sections, - }); + let image_measurement = image.sev_measurement.as_ref().context( + "amd sev-snp image is missing measurement.snp.cbor/sha256sum.txt measurement material", + )?; + let measurement = dstack_mr::sev::SnpMeasurementDocument { + checksum_file: image_measurement.checksum_file.clone(), + measurement: image_measurement.measurement.clone(), + vcpus: effective_vcpus, + vcpu_type: Some("EPYC-v4".to_string()), + guest_features: 1, + }; config["sev_snp_measurement"] = serde_json::Value::String( serde_json::to_string(&measurement) .context("Failed to serialize amd sev-snp measurement input")?, @@ -1418,7 +1405,11 @@ fn make_vm_config( #[cfg(test)] mod tests { use super::*; - use crate::config::{load_config_figment, TeePlatform}; + use crate::config::{load_config_figment, TdxAttestationVariantConfig, TeePlatform}; + use dstack_types::{ + TdxImageMeasurement, TdxMrtdCandidates, TdxOsImageMeasurement, + TdxOsImageMeasurementDocument, TdxTdvfMeasurement, + }; use rocket::figment::Figment; use std::time::UNIX_EPOCH; @@ -1499,6 +1490,89 @@ mod tests { ovmf } + fn test_manifest(memory: u32) -> Manifest { + Manifest { + id: "tdx-test".to_string(), + name: "tdx-test".to_string(), + app_id: hex_of(0x11, 20), + vcpu: 2, + memory, + disk_size: 1024, + image: "dstack-test".to_string(), + port_map: vec![], + created_at_ms: 0, + hugepages: false, + pin_numa: false, + gpus: None, + kms_urls: vec![], + gateway_urls: vec![], + no_tee: false, + networking: None, + } + } + + fn dummy_tdx_measurement_document() -> TdxOsImageMeasurementDocument { + let measurement = TdxOsImageMeasurement { + image: TdxImageMeasurement { + kernel_cmdline_sha384: vec![0x10; 48], + kernel_authenticode: vec![0x20; 48], + initrd_sha384: vec![0x30; 48], + }, + tdvf: TdxTdvfMeasurement { + ovmf_variant: Default::default(), + mrtd: TdxMrtdCandidates { + single_pass: vec![0x40; 48], + two_pass: vec![0x50; 48], + }, + td_hob_witness: vec![0x60; 16], + }, + }; + let measurement = measurement.to_cbor_vec(); + let sha256sum = format!( + "{} {}\n", + hex::encode(Sha256::digest(&measurement)), + dstack_types::TDX_MEASUREMENT_FILENAME + ) + .into_bytes(); + TdxOsImageMeasurementDocument::new(sha256sum, measurement) + } + + fn test_tdx_image(supports_lite: bool) -> Image { + let tdx_measurement = supports_lite.then(dummy_tdx_measurement_document); + Image { + info: ImageInfo { + cmdline: None, + kernel: "kernel".to_string(), + initrd: "initrd".to_string(), + hda: None, + rootfs: None, + bios: None, + bios_sev: None, + rootfs_hash: None, + shared_ro: false, + version: "0.6.0".to_string(), + is_dev: false, + ovmf_variant: None, + }, + initrd: PathBuf::from("initrd"), + kernel: PathBuf::from("kernel"), + hda: None, + rootfs: None, + bios: None, + bios_sev: None, + digest: Some(hex_of(0xaa, 32)), + tdx_measurement, + sev_measurement: None, + } + } + + fn test_tdx_config() -> Result { + let mut config: Config = Figment::from(load_config_figment(None)).extract()?; + config.cvm.platform = Some(TeePlatform::Tdx); + config.cvm.tdx_attestation_variant = TdxAttestationVariantConfig::Auto; + Ok(config) + } + #[test] fn effective_vcpu_count_clamps_zero_to_one() { assert_eq!(effective_vcpu_count(0, None), 1); @@ -1514,11 +1588,57 @@ mod tests { } #[test] - fn amd_sev_snp_measurement_base_cmdline_trims_image_cmdline() { + fn tdx_auto_variant_uses_legacy_for_low_non_2g_memory() -> Result<()> { + let config = test_tdx_config()?; + let manifest = test_manifest(1024); + let image = test_tdx_image(true); + let vm_config = make_vm_config(&config, &manifest, &image, &hex_of(0x22, 32), None)?; + + assert!(vm_config.get("tdx_attestation_variant").is_none()); + assert!(vm_config.get("tdx_measurement").is_none()); assert_eq!( - amd_sev_snp_measurement_base_cmdline(Some(" console=ttyS0 loglevel=7 ")), - Some("console=ttyS0 loglevel=7".to_string()) + vm_config["os_image_hash"] + .as_str() + .context("os_image_hash must be a string")?, + hex_of(0xaa, 32) ); + Ok(()) + } + + #[test] + fn tdx_auto_variant_uses_lite_for_2g_supported_image() -> Result<()> { + let config = test_tdx_config()?; + let manifest = test_manifest(2048); + let image = test_tdx_image(true); + let vm_config = make_vm_config(&config, &manifest, &image, &hex_of(0x22, 32), None)?; + + assert_eq!(vm_config["tdx_attestation_variant"], "lite"); + assert!(vm_config.get("tdx_measurement").is_some()); + assert_eq!( + vm_config["os_image_hash"] + .as_str() + .context("os_image_hash must be a string")?, + hex_of(0xaa, 32) + ); + Ok(()) + } + + #[test] + fn tdx_auto_variant_falls_back_to_legacy_when_image_lacks_lite_support() -> Result<()> { + let config = test_tdx_config()?; + let manifest = test_manifest(3072); + let image = test_tdx_image(false); + let vm_config = make_vm_config(&config, &manifest, &image, &hex_of(0x22, 32), None)?; + + assert!(vm_config.get("tdx_attestation_variant").is_none()); + assert!(vm_config.get("tdx_measurement").is_none()); + assert_eq!( + vm_config["os_image_hash"] + .as_str() + .context("os_image_hash must be a string")?, + hex_of(0xaa, 32) + ); + Ok(()) } #[test] @@ -1580,11 +1700,30 @@ 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. - // 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))?; + // The image build emits split SNP measurement CBOR, includes it in + // sha256sum.txt, and keeps digest.txt as sha256(sha256sum.txt). + let snp_cbor = dstack_mr::sev::sev_os_image_measurement_cbor_for_image_dir(&image_dir)?; + fs::write( + image_dir.join(dstack_types::SNP_MEASUREMENT_FILENAME), + &snp_cbor, + )?; + let mut sha256sum = String::new(); + for name in [ + "ovmf.fd", + "kernel", + "initrd", + "metadata.json", + dstack_types::SNP_MEASUREMENT_FILENAME, + ] { + sha256sum.push_str(&format!( + "{} {}\n", + hex::encode(Sha256::digest(fs::read(image_dir.join(name))?)), + name + )); + } + fs::write(image_dir.join("sha256sum.txt"), &sha256sum)?; + let build_hash = Sha256::digest(sha256sum.as_bytes()).to_vec(); + fs::write(image_dir.join("digest.txt"), hex::encode(&build_hash))?; let sys_config_document = make_sys_config(&config, &manifest, &compose_hash, Some(mr_config))?; @@ -1597,7 +1736,11 @@ mod tests { let measurement_document = vm_config["sev_snp_measurement"] .as_str() .context("sev_snp_measurement must be a string")?; - let measurement: serde_json::Value = serde_json::from_str(measurement_document)?; + let measurement: dstack_mr::sev::SnpMeasurementDocument = + serde_json::from_str(measurement_document)?; + let image_measurement = + dstack_types::SevOsImageMeasurement::from_cbor_slice(&measurement.measurement) + .map_err(anyhow::Error::msg)?; let mr_config_document = sys_config["mr_config"] .as_str() .context("mr_config must be a string")?; @@ -1606,80 +1749,42 @@ mod tests { assert_eq!(parsed_mr_config.app_id, vec![0x11; 20]); 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). 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 digest.txt" ); - assert!(measurement.get("app_id").is_none()); - assert!(measurement.get("compose_hash").is_none()); - assert!(measurement.get("rootfs_hash").is_none()); assert_eq!( - measurement["base_cmdline"], + image_measurement.base_cmdline, format!("console=ttyS0 dstack.rootfs_hash={}", hex_of(0x33, 32)) ); assert_eq!( - measurement["kernel_hash"], - hex::encode(Sha256::digest(b"snp-test-kernel")) + image_measurement.kernel_hash, + Sha256::digest(b"snp-test-kernel").to_vec() ); assert_eq!( - measurement["initrd_hash"], - hex::encode(Sha256::digest(b"snp-test-initrd")) + image_measurement.initrd_hash, + Sha256::digest(b"snp-test-initrd").to_vec() ); - assert_eq!(measurement["vcpus"], 2); - assert_eq!(measurement["vcpu_type"], "EPYC-v4"); - assert_eq!(measurement["guest_features"], 1); + assert_eq!(measurement.vcpus, 2); + assert_eq!(measurement.vcpu_type.as_deref(), Some("EPYC-v4")); + assert_eq!(measurement.guest_features, 1); assert_eq!( - measurement["ovmf_hash"] - .as_str() - .context("ovmf_hash must be a string")? - .len(), - 96 - ); - assert_eq!(measurement["sev_hashes_table_gpa"], 0x4000); - assert_eq!(measurement["sev_es_reset_eip"], 0xffff_fff0u32); - assert_eq!( - measurement["ovmf_sections"] - .as_array() - .context("ovmf_sections must be an array")? - .len(), - 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 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 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"]), - 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"] - .as_array() - .unwrap() - .iter() - .map(|s| dstack_types::OvmfSection { - gpa: s["gpa"].as_u64().unwrap(), - size: s["size"].as_u64().unwrap(), - section_type: s["section_type"].as_u64().unwrap() as u32, - }) - .collect(), - }; - assert_eq!( - build_hash, - projected.os_image_hash(), - "digest.sev.txt must match the os_image_hash derived from the launch measurement" + image_measurement.ovmf_hash.len(), + 48, + "ovmf_hash must be 48 bytes" ); + assert_eq!(image_measurement.sev_hashes_table_gpa, 0x4000); + assert_eq!(image_measurement.sev_es_reset_eip, 0xffff_fff0u32); + assert_eq!(image_measurement.ovmf_sections.len(), 4); + dstack_types::SevOsImageMeasurementDocument::new( + measurement.checksum_file, + measurement.measurement, + ) + .verify(&build_hash) + .map_err(anyhow::Error::msg)?; Ok(()) } } diff --git a/vmm/src/app/image.rs b/vmm/src/app/image.rs index c8e7d255d..40f7df4fd 100644 --- a/vmm/src/app/image.rs +++ b/vmm/src/app/image.rs @@ -7,6 +7,10 @@ use path_absolutize::Absolutize; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; +use dstack_types::{ + SevOsImageMeasurementDocument, TdxOsImageMeasurementDocument, SNP_MEASUREMENT_FILENAME, + TDX_MEASUREMENT_FILENAME, +}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] @@ -71,10 +75,10 @@ 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. - pub sev_digest: Option, + /// TDX no-image-download measurement material. + pub tdx_measurement: Option, + /// AMD SEV-SNP no-image-download measurement material. + pub sev_measurement: Option, } impl Image { @@ -103,10 +107,47 @@ 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")) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); + let sha256sum_path = base_path.join("sha256sum.txt"); + let sha256sum = if sha256sum_path.exists() { + Some( + fs::read(&sha256sum_path) + .with_context(|| format!("failed to read {}", sha256sum_path.display()))?, + ) + } else { + None + }; + let tdx_path = base_path.join(TDX_MEASUREMENT_FILENAME); + let tdx_cbor = if tdx_path.exists() { + Some( + fs::read(&tdx_path) + .with_context(|| format!("failed to read {}", tdx_path.display()))?, + ) + } else { + None + }; + let tdx_measurement = match (&sha256sum, tdx_cbor) { + (Some(sha256sum), Some(measurement)) => Some(TdxOsImageMeasurementDocument::new( + sha256sum.clone(), + measurement, + )), + _ => None, + }; + let snp_path = base_path.join(SNP_MEASUREMENT_FILENAME); + let snp_cbor = if snp_path.exists() { + Some( + fs::read(&snp_path) + .with_context(|| format!("failed to read {}", snp_path.display()))?, + ) + } else { + None + }; + let sev_measurement = match (&sha256sum, snp_cbor) { + (Some(sha256sum), Some(measurement)) => Some(SevOsImageMeasurementDocument::new( + sha256sum.clone(), + measurement, + )), + _ => None, + }; 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,7 +161,8 @@ impl Image { bios, bios_sev, digest, - sev_digest, + tdx_measurement, + sev_measurement, } .ensure_exists() } diff --git a/vmm/src/config.rs b/vmm/src/config.rs index b0b234a29..330a2e440 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}; @@ -208,6 +209,41 @@ impl CvmConfig { } } +/// VMM-side policy for selecting the TDX attestation/hash scheme. +/// +/// This is intentionally separate from `dstack_types::TdxAttestationVariant`: +/// the VM config shared with KMS/verifier must contain the resolved runtime +/// variant (`legacy` or `lite`), never the VMM-only `auto` policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TdxAttestationVariantConfig { + Legacy, + Lite, + #[default] + Auto, +} + +impl TdxAttestationVariantConfig { + const TWO_GIB_MIB: u32 = 2 * 1024; + const THREE_GIB_MIB: u32 = 3 * 1024; + + pub fn resolve(self, memory_mib: u32, image_supports_lite: bool) -> TdxAttestationVariant { + match self { + Self::Legacy => TdxAttestationVariant::Legacy, + Self::Lite => TdxAttestationVariant::Lite, + Self::Auto => { + if memory_mib < Self::THREE_GIB_MIB && memory_mib != Self::TWO_GIB_MIB { + TdxAttestationVariant::Legacy + } else if image_supports_lite { + TdxAttestationVariant::Lite + } else { + TdxAttestationVariant::Legacy + } + } + } + } +} + #[derive(Debug, Clone, Deserialize)] pub struct CvmConfig { /// TEE platform to use when launching CVMs. Omit (or set `auto`) to detect @@ -260,6 +296,14 @@ pub struct CvmConfig { /// QEMU hotplug_off pub qemu_hotplug_off: bool, + /// TDX attestation/hash scheme policy. `legacy` keeps the existing + /// digest.txt + dstack-acpi-tables verifier path; `lite` opts into the + /// split measurement CBOR + no-QEMU verifier path; `auto` selects `legacy` for + /// CVMs below 3 GiB except exactly 2 GiB, otherwise uses `lite` when the + /// image carries TDX measurement material and falls back to `legacy`. + #[serde(default)] + pub tdx_attestation_variant: TdxAttestationVariantConfig, + /// Networking configuration pub networking: Networking, @@ -703,6 +747,41 @@ mod tests { ); } + #[test] + fn tdx_attestation_variant_config_accepts_auto_and_resolves() { + let parse = |s: &str| serde_json::from_str::(s).unwrap(); + assert_eq!(parse(r#""legacy""#), TdxAttestationVariantConfig::Legacy); + assert_eq!(parse(r#""lite""#), TdxAttestationVariantConfig::Lite); + assert_eq!(parse(r#""auto""#), TdxAttestationVariantConfig::Auto); + + use dstack_types::TdxAttestationVariant::{Legacy, Lite}; + + // Explicit settings bypass auto heuristics. + assert_eq!( + TdxAttestationVariantConfig::Legacy.resolve(2048, true), + Legacy + ); + assert_eq!(TdxAttestationVariantConfig::Lite.resolve(1024, false), Lite); + + // Auto avoids lite for sub-3 GiB memory sizes except exactly 2 GiB. + assert_eq!( + TdxAttestationVariantConfig::Auto.resolve(1024, true), + Legacy + ); + assert_eq!( + TdxAttestationVariantConfig::Auto.resolve(2816, true), + Legacy + ); + assert_eq!(TdxAttestationVariantConfig::Auto.resolve(2048, true), Lite); + + // At 3 GiB and above, auto follows image support. + assert_eq!(TdxAttestationVariantConfig::Auto.resolve(3072, true), Lite); + assert_eq!( + TdxAttestationVariantConfig::Auto.resolve(3072, false), + Legacy + ); + } + #[test] fn tee_platform_auto_detects_amd_sev_snp_from_flag() { let cpuinfo = "flags : fpu svm sev sev_es sev_snp debug_swap"; diff --git a/vmm/vmm.toml b/vmm/vmm.toml index 73d8c124a..42ac81079 100644 --- a/vmm/vmm.toml +++ b/vmm/vmm.toml @@ -45,6 +45,12 @@ use_mrconfigid = true #qemu_version = "" qemu_pci_hole64_size = 0 qemu_hotplug_off = false +# TDX attestation/hash scheme policy: +# - "legacy": digest.txt + legacy verifier +# - "lite": digest.txt + measurement.tdx.cbor + no-QEMU verifier +# - "auto": legacy for CVM memory below 3 GiB except exactly 2 GiB; otherwise +# lite when the image supports it, legacy when it does not. +tdx_attestation_variant = "auto" host_share_mode = "9p"