From b953c660fbc790f0d5dc91d62fc2ab4fcd89de4e Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 21 Jul 2026 22:46:01 -0700 Subject: [PATCH 1/7] feat: add canonical JSON event log v2 --- .gitignore | 2 +- dstack/Cargo.lock | 4 + dstack/cc-eventlog/Cargo.toml | 3 + dstack/cc-eventlog/src/lib.rs | 62 +++- dstack/cc-eventlog/src/runtime_events.rs | 285 +++++++++++++- dstack/cc-eventlog/src/tcg.rs | 57 ++- dstack/cc-eventlog/src/tdx.rs | 222 +++++++---- dstack/dstack-attest/src/attestation.rs | 348 ++++++------------ dstack/dstack-attest/src/lib.rs | 30 +- dstack/dstack-attest/src/v1.rs | 20 +- dstack/dstack-types/src/lib.rs | 43 +++ dstack/dstack-util/src/main.rs | 18 +- dstack/dstack-util/src/system_setup.rs | 90 +++-- dstack/gateway/src/distributed_certbot.rs | 18 +- dstack/gateway/src/gen_debug_key.rs | 1 + dstack/guest-agent-simulator/Cargo.toml | 1 + dstack/guest-agent-simulator/src/main.rs | 41 ++- dstack/guest-agent-simulator/src/simulator.rs | 21 +- dstack/guest-agent/rpc/proto/agent_rpc.proto | 41 ++- dstack/guest-agent/src/backend.rs | 54 ++- dstack/guest-agent/src/rpc_service.rs | 51 ++- .../kms/src/main_service/upgrade_authority.rs | 7 +- dstack/vmm/src/vmm-cli.py | 9 + .../vmm/ui/src/components/CreateVmDialog.ts | 10 + dstack/vmm/ui/src/composables/useVmManager.ts | 8 + sdk/go/dstack/client.go | 15 +- sdk/js/src/index.ts | 2 +- sdk/python/src/dstack_sdk/dstack_client.py | 5 +- sdk/rust/types/src/dstack.rs | 11 +- 29 files changed, 1052 insertions(+), 427 deletions(-) diff --git a/.gitignore b/.gitignore index b40b898a3..3c5562374 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,5 @@ node_modules/ __pycache__ /.ruff_cache/ .planning/ -/dstack/vmm/src/console_v1.html +/vmm/src/console_v1.html .claude/worktrees/ diff --git a/dstack/Cargo.lock b/dstack/Cargo.lock index 7e940d40e..2e5387f1f 100644 --- a/dstack/Cargo.lock +++ b/dstack/Cargo.lock @@ -767,13 +767,16 @@ version = "0.6.0" dependencies = [ "anyhow", "digest 0.10.7", + "dstack-types", "ez-hash", "fs-err", "hex", "insta", + "or-panic", "parity-scale-codec", "serde", "serde-human-bytes", + "serde_jcs", "serde_json", "sha2 0.10.9", ] @@ -1999,6 +2002,7 @@ dependencies = [ "clap", "dstack-guest-agent", "dstack-guest-agent-rpc", + "dstack-types", "ra-rpc", "ra-tls", "rocket", diff --git a/dstack/cc-eventlog/Cargo.toml b/dstack/cc-eventlog/Cargo.toml index 2863760f7..5fa010cf2 100644 --- a/dstack/cc-eventlog/Cargo.toml +++ b/dstack/cc-eventlog/Cargo.toml @@ -13,12 +13,15 @@ license.workspace = true [dependencies] anyhow.workspace = true digest = "0.10.7" +dstack-types.workspace = true ez-hash.workspace = true fs-err.workspace = true hex.workspace = true +or-panic.workspace = true scale.workspace = true serde.workspace = true serde-human-bytes.workspace = true +serde_jcs = "0.2.0" serde_json = { workspace = true, features = ["alloc"] } sha2.workspace = true diff --git a/dstack/cc-eventlog/src/lib.rs b/dstack/cc-eventlog/src/lib.rs index 93bbc77ff..a9ff449c8 100644 --- a/dstack/cc-eventlog/src/lib.rs +++ b/dstack/cc-eventlog/src/lib.rs @@ -2,18 +2,24 @@ // // SPDX-License-Identifier: Apache-2.0 -pub use runtime_events::{replay_events, RuntimeEvent}; +pub use dstack_types::EventLogVersion; +pub use runtime_events::{ + canonical_event_json_v2, replay_events, RuntimeEvent, DSTACK_RUNTIME_EVENT_TYPE, +}; pub use tdx::TdxEvent; mod codecs; mod runtime_events; -mod tcg; +pub mod tcg; pub mod tdx; pub mod tpm; #[cfg(test)] mod tests { use super::*; + use dstack_types::EventLogVersion; + use ez_hash::{Hasher, Sha384}; + use tdx::TdxEvent; #[test] fn parse_ccel() { @@ -24,4 +30,56 @@ mod tests { let json = serde_json::to_string_pretty(&tdx_event_logs).unwrap(); insta::assert_snapshot!(json); } + + #[test] + fn encode_runtime_events_roundtrip() { + // Synthesize a CCEL: sample boot-time bytes + encoded runtime events. + // Decode with the same TcgEventLog parser and verify: + // 1. all boot-time events are preserved + // 2. runtime events appear with the expected pcrIndex / event_type / digest + // 3. sha384(event_data) == digest (the property we document) + let boot_raw = include_bytes!("../samples/ccel.bin"); + let valid = tcg::ccel_content_len(boot_raw).unwrap(); + + let runtime_v1: TdxEvent = RuntimeEvent::new( + "app-id".to_string(), + vec![0xaa, 0xbb, 0xcc], + EventLogVersion::V1, + ) + .into(); + let runtime_v2: TdxEvent = RuntimeEvent::new( + "compose-hash".to_string(), + vec![0xde, 0xad, 0xbe, 0xef], + EventLogVersion::V2, + ) + .into(); + let runtime_events = vec![runtime_v1.clone(), runtime_v2.clone()]; + + let mut merged = boot_raw[..valid].to_vec(); + merged.extend_from_slice(&tcg::encode_runtime_events_as_tcg(&runtime_events)); + merged.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); + + let parsed = tcg::TcgEventLog::decode(&mut merged.as_slice()).unwrap(); + let boot_count = tcg::TcgEventLog::decode(&mut boot_raw.as_slice()) + .unwrap() + .event_logs + .len(); + assert_eq!(parsed.event_logs.len(), boot_count + 2); + + // Convert and verify runtime tail matches what we put in. + let converted = parsed.to_cc_event_log().unwrap(); + let tail = &converted[converted.len() - 2..]; + for (orig, got) in runtime_events.iter().zip(tail.iter()) { + assert_eq!(got.imr, orig.imr); + assert_eq!(got.event_type, orig.event_type); + assert_eq!(got.digest, orig.digest()); + // The event_payload carried in TdxEvent after TCG round-trip is the + // hash_input bytes we stored as TCG event data. + let runtime = orig.to_runtime_event().unwrap(); + assert_eq!(got.event_payload, runtime.hash_input()); + // Property: sha384(event_data) == digest + let h = Sha384::hash([got.event_payload.as_slice()]); + assert_eq!(h.as_slice(), got.digest.as_slice()); + } + } } diff --git a/dstack/cc-eventlog/src/runtime_events.rs b/dstack/cc-eventlog/src/runtime_events.rs index fa948f36c..c6ba8cedf 100644 --- a/dstack/cc-eventlog/src/runtime_events.rs +++ b/dstack/cc-eventlog/src/runtime_events.rs @@ -3,7 +3,9 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::{Context, Result}; +use dstack_types::EventLogVersion; use fs_err as fs; +use or_panic::ResultOrPanic; use scale::{Decode, Encode}; use serde::{Deserialize, Serialize}; use serde_human_bytes::base64; @@ -14,6 +16,10 @@ use ez_hash::{Hasher, Sha256, Sha384}; /// The event type for dstack runtime events. /// This code is not defined in the TCG specification. /// See https://trustedcomputinggroup.org/wp-content/uploads/PC-ClientSpecific_Platform_Profile_for_TPM_2p0_Systems_v51.pdf +/// +/// V1 and V2 use the same event type; the digest format is distinguished by +/// `EventLogVersion` (carried on `RuntimeEvent`/`TdxEvent` or inferred from +/// the v2 canonical JSON content). pub const DSTACK_RUNTIME_EVENT_TYPE: u32 = 0x08000001; /// The path to the userspace TDX event log file. pub const RUNTIME_EVENT_LOG_FILE: &str = "/run/log/dstack/runtime_events.log"; @@ -26,11 +32,19 @@ pub struct RuntimeEvent { /// Event payload #[serde(with = "base64")] pub payload: Vec, + /// Event log version + #[serde(default)] + #[codec(skip)] + pub version: EventLogVersion, } impl RuntimeEvent { - pub fn new(event: String, payload: Vec) -> Self { - Self { event, payload } + pub fn new(event: String, payload: Vec, version: EventLogVersion) -> Self { + Self { + event, + payload, + version, + } } pub fn read_all() -> Result> { @@ -97,21 +111,56 @@ impl RuntimeEvent { } /// Compute the digest of the event. + /// + /// - V1: `SHA(event_type_le || ":" || event_name || ":" || payload)` + /// - V2: `SHA(canonical_json({"name":"...","type":134217729,"payload":"hex..."}))` pub fn digest(&self) -> H::Output { - H::hash([ - &DSTACK_RUNTIME_EVENT_TYPE.to_ne_bytes()[..], - b":", - self.event.as_bytes(), - b":", - &self.payload, - ]) + H::hash([self.hash_input().as_slice()]) + } + + /// The exact byte sequence that gets hashed to produce the digest. + /// + /// Useful for relying parties that want to verify the digest computation + /// or inspect event content without knowing the dstack schema. + /// + /// - V1: binary concatenation `event_type_le || ":" || name || ":" || payload` + /// - V2: UTF-8 bytes of the JCS canonical JSON + pub fn hash_input(&self) -> Vec { + match self.version { + EventLogVersion::V1 => { + let mut buf = Vec::with_capacity(4 + 1 + self.event.len() + 1 + self.payload.len()); + buf.extend_from_slice(&DSTACK_RUNTIME_EVENT_TYPE.to_le_bytes()); + buf.push(b':'); + buf.extend_from_slice(self.event.as_bytes()); + buf.push(b':'); + buf.extend_from_slice(&self.payload); + buf + } + EventLogVersion::V2 => canonical_event_json_v2(&self.event, &self.payload).into_bytes(), + } } + /// The event type used when extending RTMR. Always `DSTACK_RUNTIME_EVENT_TYPE`. + /// Version is distinguished via `EventLogVersion`, not the event type. pub fn cc_event_type(&self) -> u32 { DSTACK_RUNTIME_EVENT_TYPE } } +/// Construct the JCS (RFC 8785) canonical JSON used as the v2 digest input. +/// +/// Keys and number/string formatting are handled by `serde_jcs` per RFC 8785. +/// Version is carried out-of-band via `RuntimeEvent::version`, not in the +/// hashed content. +pub fn canonical_event_json_v2(event: &str, payload: &[u8]) -> String { + let obj = serde_json::json!({ + "name": event, + "type": DSTACK_RUNTIME_EVENT_TYPE, + "payload": hex::encode(payload), + }); + serde_jcs::to_string(&obj).or_panic("canonical JSON serialization failed") +} + /// Replay event logs pub fn replay_events(eventlog: &[RuntimeEvent], to_event: Option<&str>) -> H::Output { let mut mr = H::zeros(); @@ -125,3 +174,221 @@ pub fn replay_events(eventlog: &[RuntimeEvent], to_event: Option<&str } mr } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v1_digest_unchanged() { + let event = RuntimeEvent::new( + "app-id".to_string(), + vec![0xde, 0xad, 0xbe, 0xef], + EventLogVersion::V1, + ); + let digest = event.digest::(); + let expected = Sha384::hash([ + &DSTACK_RUNTIME_EVENT_TYPE.to_le_bytes()[..], + b":", + b"app-id", + b":", + &[0xde, 0xad, 0xbe, 0xef], + ]); + assert_eq!(digest, expected, "v1 digest must be backward compatible"); + } + + #[test] + fn v2_digest_is_canonical_json_hash() { + let event = RuntimeEvent::new( + "compose-hash".to_string(), + vec![0xab, 0xcd], + EventLogVersion::V2, + ); + let canonical = canonical_event_json_v2(&event.event, &event.payload); + assert_eq!( + canonical, + r#"{"name":"compose-hash","payload":"abcd","type":134217729}"# + ); + let digest = event.digest::(); + let expected = Sha384::hash([canonical.as_bytes()]); + assert_eq!(digest, expected); + } + + #[test] + fn v2_digest_differs_from_v1() { + let v1 = RuntimeEvent::new("test".to_string(), vec![1, 2, 3], EventLogVersion::V1); + let v2 = RuntimeEvent::new("test".to_string(), vec![1, 2, 3], EventLogVersion::V2); + assert_ne!( + v1.digest::(), + v2.digest::(), + "v1 and v2 digests must differ" + ); + } + + #[test] + fn v1_event_type() { + let event = RuntimeEvent::new("test".to_string(), vec![], EventLogVersion::V1); + assert_eq!(event.cc_event_type(), DSTACK_RUNTIME_EVENT_TYPE); + } + + #[test] + fn v2_event_type() { + // v2 uses the same event_type as v1 — version is carried separately + let event = RuntimeEvent::new("test".to_string(), vec![], EventLogVersion::V2); + assert_eq!(event.cc_event_type(), DSTACK_RUNTIME_EVENT_TYPE); + } + + #[test] + fn deserialize_v1_without_version_field() { + let json = r#"{"event":"app-id","payload":"AQID"}"#; + let event: RuntimeEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.version, EventLogVersion::V1); + assert_eq!(event.cc_event_type(), DSTACK_RUNTIME_EVENT_TYPE); + } + + #[test] + fn serde_roundtrip_preserves_version() { + let v2 = RuntimeEvent::new("test".to_string(), vec![1], EventLogVersion::V2); + let json = serde_json::to_string(&v2).unwrap(); + assert!(json.contains(r#""version":2"#), "v2 must serialize version"); + let decoded: RuntimeEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.version, EventLogVersion::V2); + } + + #[test] + fn deserialize_without_version_defaults_to_v1() { + let json = r#"{"event":"test","payload":"AQ=="}"#; + let decoded: RuntimeEvent = serde_json::from_str(json).unwrap(); + assert_eq!(decoded.version, EventLogVersion::V1); + } + + #[test] + fn canonical_json_escapes_special_chars() { + let canonical = canonical_event_json_v2("event\"with\\special\nchars", &[0xff]); + // Exact bytewise output — JCS must be deterministic + assert_eq!( + canonical, + r#"{"name":"event\"with\\special\nchars","payload":"ff","type":134217729}"# + ); + } + + #[test] + fn canonical_json_keys_are_sorted_alphabetically() { + // JCS RFC 8785 requires keys sorted by UTF-16 code unit order. + // For ASCII keys, this is alphabetical. + let canonical = canonical_event_json_v2("test", &[0x01]); + let name_pos = canonical.find(r#""name":"#).unwrap(); + let payload_pos = canonical.find(r#""payload":"#).unwrap(); + let type_pos = canonical.find(r#""type":"#).unwrap(); + assert!(name_pos < payload_pos); + assert!(payload_pos < type_pos); + } + + #[test] + fn canonical_json_empty_event_and_payload() { + let canonical = canonical_event_json_v2("", &[]); + assert_eq!(canonical, r#"{"name":"","payload":"","type":134217729}"#); + } + + #[test] + fn canonical_json_idempotent() { + // Same input must always produce bytewise-identical output. + // HashMap randomization internally shouldn't affect output. + let reference = canonical_event_json_v2("compose-hash", &[0xde, 0xad, 0xbe, 0xef]); + for _ in 0..100 { + assert_eq!( + canonical_event_json_v2("compose-hash", &[0xde, 0xad, 0xbe, 0xef]), + reference + ); + } + } + + #[test] + fn canonical_json_non_ascii_unicode() { + // JCS requires UTF-8 output; non-ASCII characters that don't need + // escaping (i.e., not control chars, not " or \) must be emitted as-is. + let canonical = canonical_event_json_v2("测试-emoji-🦀", &[]); + // Event name should appear verbatim in the JSON (no \uXXXX escaping) + assert!(canonical.contains("测试-emoji-🦀"), "got: {canonical}"); + // Must still be parseable and roundtrip + let parsed: serde_json::Value = serde_json::from_str(&canonical).unwrap(); + assert_eq!(parsed["name"].as_str().unwrap(), "测试-emoji-🦀"); + } + + #[test] + fn canonical_json_control_character_escaping() { + // JCS (via RFC 8259) uses short escapes for \b \f \n \r \t and \uXXXX for other controls. + let canonical = canonical_event_json_v2("\x08\x0c\n\r\t\x01", &[]); + assert!( + canonical.contains(r#""name":"\b\f\n\r\t\u0001""#), + "got: {canonical}" + ); + } + + #[test] + fn canonical_json_payload_lowercase_hex() { + // Payload must be hex-encoded lowercase for determinism. + let canonical = canonical_event_json_v2("test", &[0xAB, 0xCD, 0xEF]); + assert!( + canonical.contains(r#""payload":"abcdef""#), + "got: {canonical}" + ); + } + + #[test] + fn canonical_json_is_valid_rfc8785_structure() { + // No whitespace, no trailing commas, proper JSON + let canonical = canonical_event_json_v2("x", &[0xff]); + assert!(!canonical.contains(' ')); + assert!(!canonical.contains('\n')); + assert!(!canonical.contains('\t')); + assert!(canonical.starts_with('{')); + assert!(canonical.ends_with('}')); + // Must parse back + let _: serde_json::Value = serde_json::from_str(&canonical).unwrap(); + } + + #[test] + fn mixed_v1_v2_replay() { + let events = vec![ + RuntimeEvent::new("app-id".to_string(), vec![1, 2], EventLogVersion::V1), + RuntimeEvent::new("compose-hash".to_string(), vec![3, 4], EventLogVersion::V2), + RuntimeEvent::new("instance-id".to_string(), vec![5, 6], EventLogVersion::V1), + ]; + let mr = replay_events::(&events, None); + // Replay manually to verify + let mut expected = Sha384::zeros(); + expected = Sha384::hash((expected, events[0].digest::())); + expected = Sha384::hash((expected, events[1].digest::())); + expected = Sha384::hash((expected, events[2].digest::())); + assert_eq!(mr, expected, "mixed v1/v2 replay must work correctly"); + } + + #[test] + fn scale_roundtrip_preserves_event_data() { + use scale::{Decode, Encode}; + // V1 event + let v1 = RuntimeEvent::new("test".to_string(), vec![1, 2, 3], EventLogVersion::V1); + let encoded = v1.encode(); + let decoded = RuntimeEvent::decode(&mut &encoded[..]).unwrap(); + assert_eq!(decoded.event, v1.event); + assert_eq!(decoded.payload, v1.payload); + // version is #[codec(skip)] so it defaults to V1 on decode + assert_eq!(decoded.version, EventLogVersion::V1); + } + + #[test] + fn scale_decode_old_format_without_version() { + use scale::{Decode, Encode}; + // Encode a current RuntimeEvent (version is skipped by codec), + // then decode — simulates reading data from before version was added + let original = + RuntimeEvent::new("app-id".to_string(), vec![0xaa, 0xbb], EventLogVersion::V2); + let encoded = original.encode(); + let decoded = RuntimeEvent::decode(&mut &encoded[..]).unwrap(); + assert_eq!(decoded.event, "app-id"); + assert_eq!(decoded.payload, vec![0xaa, 0xbb]); + // version is #[codec(skip)] so always decodes as default (V1) + assert_eq!(decoded.version, EventLogVersion::V1); + } +} diff --git a/dstack/cc-eventlog/src/tcg.rs b/dstack/cc-eventlog/src/tcg.rs index c6280abd6..b2756db1c 100644 --- a/dstack/cc-eventlog/src/tcg.rs +++ b/dstack/cc-eventlog/src/tcg.rs @@ -302,10 +302,8 @@ impl TcgEventLog { } pub fn decode_from_ccel_file() -> Result { - let path = ccel_file_path()?; - let data = fs_err::read(&path) - .with_context(|| format!("failed to read CCEL from {}", path.display()))?; - Self::decode(&mut data.as_slice()).context("failed to decode CCEL") + let data = read_ccel_raw()?; + Self::decode(&mut data.as_slice()) } pub fn to_cc_event_log(&self) -> Result> { @@ -318,10 +316,7 @@ impl TcgEventLog { } } -/// Resolve the CCEL path, honoring `DSTACK_CCEL_FILE` when set. -/// -/// Overrides must be non-empty absolute paths so relative values cannot silently -/// resolve against the process working directory. +/// Read the raw ACPI CCEL table bytes from `/sys/firmware/acpi/tables/data/CCEL`. fn ccel_file_path() -> Result { let Some(value) = std::env::var_os(CCEL_FILE_ENV) else { return Ok(PathBuf::from(CCEL_FILE)); @@ -339,6 +334,50 @@ fn ccel_file_path() -> Result { Ok(path) } +pub fn read_ccel_raw() -> Result> { + fs_err::read(ccel_file_path()?).context("Failed to read CCEL") +} + +/// Return the length of the valid TCG event log prefix within a raw CCEL buffer. +/// +/// ACPI CCEL tables are fixed-size regions padded with 0xFF; the event stream +/// either ends with a 0xFFFFFFFF terminator or by running into that padding. +/// This parses the buffer to find where real events end so the trailer can be +/// stripped before appending runtime events. +pub fn ccel_content_len(raw: &[u8]) -> Result { + let input = &mut &raw[..]; + TcgEventLog::decode(input)?; + Ok(raw.len() - input.len()) +} + +/// Encode dstack runtime events as TCG_PCR_EVENT2 records. +/// +/// Non-runtime events in the slice are skipped. Each runtime event becomes: +/// - pcrIndex = imr + 1 (0-based TdxEvent -> 1-based TCG pcrIndex) +/// - eventType = DSTACK_RUNTIME_EVENT_TYPE +/// - digests = [{ TPM_ALG_SHA384, <48-byte digest> }] +/// - event = the digest pre-image bytes (`hash_input`), so that +/// sha384(event) == digest holds for any TCG parser. +pub fn encode_runtime_events_as_tcg(events: &[TdxEvent]) -> Vec { + let mut out = Vec::new(); + for event in events { + let Some(runtime) = event.to_runtime_event() else { + continue; + }; + let hash_input = runtime.hash_input(); + let digest = event.digest(); + let pcr_index = event.imr.saturating_add(1); + out.extend_from_slice(&pcr_index.to_le_bytes()); + out.extend_from_slice(&event.event_type.to_le_bytes()); + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(&TPM_ALG_SHA384.to_le_bytes()); + out.extend_from_slice(&digest); + out.extend_from_slice(&(hash_input.len() as u32).to_le_bytes()); + out.extend_from_slice(&hash_input); + } + out +} + fn parse_spec_id_event_log( input: &mut I, ) -> Result<(TcgEvent, TcgEfiSpecIdEvent)> { @@ -395,6 +434,8 @@ impl TryFrom for TdxEvent { digest, event: Default::default(), event_payload: value.event.into(), + version: Default::default(), + hash_input: None, }) } } diff --git a/dstack/cc-eventlog/src/tdx.rs b/dstack/cc-eventlog/src/tdx.rs index 615c32ee9..452e17d95 100644 --- a/dstack/cc-eventlog/src/tdx.rs +++ b/dstack/cc-eventlog/src/tdx.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Result; +use dstack_types::EventLogVersion; use scale::{Decode, Encode}; use serde::{Deserialize, Serialize}; @@ -22,12 +23,33 @@ pub const TDX_ACPI_DATA_EVENT_NAMES: [&str; 3] = [ TDX_ACPI_TABLES_EVENT, ]; +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 Pre202505 OVMF ACPI DATA RTMR0 events stable semantic names. +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(); + } + } +} + /// 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 /// which is one-based. /// -/// As for RTMR3, the digest extended is calculated as `sha384(event_type.to_ne_bytes() || b":" || event || b":" || event_payload)`. +/// For dstack runtime events (`event_type == DSTACK_RUNTIME_EVENT_TYPE`), the digest is: +/// - V1: `sha384(event_type_le || ":" || event || ":" || payload)` +/// - V2: `sha384(canonical_json({"name":"...","type":134217729,"payload":"hex..."}))` #[derive(Clone, Debug, Serialize, Deserialize, Encode, Decode)] pub struct TdxEvent { /// IMR index, starts from 0 @@ -42,6 +64,28 @@ pub struct TdxEvent { /// Event payload #[serde(with = "serde_human_bytes")] pub event_payload: Vec, + /// Event log version (for dstack runtime events). + /// Skipped by scale codec for binary compat with legacy attestations + /// (which only ever contain V1 events). + /// Serde skips serialization when V1 so existing JSON outputs stay clean. + #[serde(default, skip_serializing_if = "is_v1")] + #[codec(skip)] + pub version: EventLogVersion, + + /// Optional digest pre-image, hex-encoded. + /// + /// The exact bytes hashed to produce `digest`. Only populated when + /// explicitly requested (e.g., via RPC opt-in) so that relying parties can + /// verify the digest computation or inspect v2 JSON content without + /// knowing the dstack schema. + /// Never included in scale encoding (derivable from other fields). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[codec(skip)] + pub hash_input: Option, +} + +fn is_v1(v: &EventLogVersion) -> bool { + matches!(v, EventLogVersion::V1) } impl TdxEvent { @@ -52,6 +96,8 @@ impl TdxEvent { digest: vec![], event, event_payload, + version: EventLogVersion::default(), + hash_input: None, } } @@ -65,6 +111,8 @@ impl TdxEvent { digest: Vec::new(), event: self.event.clone(), event_payload: self.event_payload.clone(), + version: self.version, + hash_input: self.hash_input.clone(), } } else { Self { @@ -73,10 +121,23 @@ impl TdxEvent { digest: self.digest.clone(), event: self.event.clone(), event_payload: Vec::new(), + version: self.version, + hash_input: self.hash_input.clone(), } } } + /// Populate `hash_input` with the digest pre-image. + /// + /// For runtime events, this is the byte sequence defined by V1/V2 digest algorithms. + /// For boot-time TCG events, the pre-image is inherent in the original log format + /// and not reconstructable from this struct, so `hash_input` stays `None`. + pub fn fill_hash_input(&mut self) { + if let Some(runtime_event) = self.to_runtime_event() { + self.hash_input = Some(hex::encode(runtime_event.hash_input())); + } + } + pub fn digest(&self) -> Vec { if let Some(runtime_event) = self.to_runtime_event() { return runtime_event.sha384_digest().to_vec(); @@ -89,43 +150,105 @@ impl TdxEvent { } pub fn to_runtime_event(&self) -> Option { - self.is_runtime_event().then_some(RuntimeEvent { + if !self.is_runtime_event() { + return None; + } + Some(RuntimeEvent { event: self.event.clone(), payload: self.event_payload.clone(), + version: self.version, }) } } impl From for TdxEvent { fn from(value: RuntimeEvent) -> Self { + let event_type = value.cc_event_type(); + let version = value.version; + let digest = value.sha384_digest().to_vec(); TdxEvent { imr: 3, - event_type: DSTACK_RUNTIME_EVENT_TYPE, - digest: value.sha384_digest().to_vec(), + event_type, + digest, event: value.event, event_payload: value.payload, + version, + hash_input: None, } } } -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 -} +#[cfg(test)] +mod tests { + use super::*; + use ez_hash::{Hasher, Sha384}; + use sha2::{Digest as _, Sha384 as Sha384Hasher}; -/// 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(); - } + #[test] + fn fill_hash_input_v1() { + let runtime = RuntimeEvent::new( + "compose-hash".to_string(), + vec![0xde, 0xad], + EventLogVersion::V1, + ); + let mut tdx: TdxEvent = runtime.into(); + assert_eq!(tdx.hash_input, None); + tdx.fill_hash_input(); + let input_hex = tdx.hash_input.as_ref().expect("hash_input populated"); + let input = hex::decode(input_hex).unwrap(); + // Hashing the hash_input must reproduce the event digest + let actual = Sha384Hasher::digest(&input); + assert_eq!(actual.as_slice(), &tdx.digest); + } + + #[test] + fn fill_hash_input_v2_is_canonical_json() { + let runtime = RuntimeEvent::new( + "compose-hash".to_string(), + vec![0xab, 0xcd], + EventLogVersion::V2, + ); + let mut tdx: TdxEvent = runtime.into(); + tdx.fill_hash_input(); + let input_hex = tdx.hash_input.as_ref().expect("hash_input populated"); + let input = hex::decode(input_hex).unwrap(); + let input_str = std::str::from_utf8(&input).unwrap(); + // V2 hash_input is the canonical JSON (version is carried out-of-band) + assert!(input_str.contains(r#""name":"compose-hash""#)); + assert!(input_str.contains(r#""type":134217729"#)); + assert!(input_str.contains(r#""payload":"abcd""#)); + assert!(!input_str.contains(r#""version""#)); + // And hashing it reproduces the digest + let actual = Sha384::hash([input.as_slice()]); + assert_eq!(actual.as_slice(), &tdx.digest); + } + + #[test] + fn fill_hash_input_skips_non_runtime_events() { + let mut boot_event = TdxEvent::new(0, 0x1, "EV_POST_CODE".to_string(), vec![1, 2, 3]); + boot_event.fill_hash_input(); + assert_eq!(boot_event.hash_input, None); + } + + #[test] + fn hash_input_not_serialized_by_scale() { + use scale::{Decode, Encode}; + let runtime = RuntimeEvent::new("test".to_string(), vec![1, 2], EventLogVersion::V2); + let mut tdx: TdxEvent = runtime.into(); + tdx.fill_hash_input(); + assert!(tdx.hash_input.is_some()); + let encoded = tdx.encode(); + let decoded = TdxEvent::decode(&mut &encoded[..]).unwrap(); + // hash_input is codec(skip) so it's None after round-trip + assert_eq!(decoded.hash_input, None); + } + + #[test] + fn hash_input_skipped_from_json_when_none() { + let runtime = RuntimeEvent::new("test".to_string(), vec![1], EventLogVersion::V1); + let tdx: TdxEvent = runtime.into(); + let json = serde_json::to_string(&tdx).unwrap(); + assert!(!json.contains("hash_input")); } } @@ -145,47 +268,18 @@ pub fn read_event_log() -> Result> { 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"); - } - - #[test] - fn decodes_the_bundled_ccel() { - let events = decode_ccel(include_bytes!("../samples/ccel.bin")).unwrap(); - assert!(!events.is_empty()); - assert!(events.iter().all(|event| event.imr <= 3)); - assert!(events.iter().all(|event| event.digest().len() == 48)); - } +/// Build a merged TCG binary event log: raw ACPI CCEL (boot-time) followed by +/// the given runtime events encoded as TCG_PCR_EVENT2 records. +/// +/// Non-runtime entries in `events` are ignored; only events with +/// `event_type == DSTACK_RUNTIME_EVENT_TYPE` are appended. +pub fn build_ccel_event_log(events: &[TdxEvent]) -> Result> { + let raw = crate::tcg::read_ccel_raw()?; + let end = crate::tcg::ccel_content_len(&raw)?; + let mut out = raw[..end].to_vec(); + out.extend_from_slice(&crate::tcg::encode_runtime_events_as_tcg(events)); + // Append the 0xFFFFFFFF terminator so parsers know where the event + // stream ends (the original ACPI table has trailing 0xFF padding). + out.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); + Ok(out) } diff --git a/dstack/dstack-attest/src/attestation.rs b/dstack/dstack-attest/src/attestation.rs index 7e75d0ba6..dbdcc53db 100644 --- a/dstack/dstack-attest/src/attestation.rs +++ b/dstack/dstack-attest/src/attestation.rs @@ -12,7 +12,7 @@ pub const TDX_QUOTE_REPORT_DATA_RANGE: std::ops::Range = 568..632; use std::{borrow::Cow, time::SystemTime}; use anyhow::{anyhow, bail, Context, Result}; -use cc_eventlog::{RuntimeEvent, TdxEvent}; +use cc_eventlog::{EventLogVersion, RuntimeEvent, TdxEvent}; use dcap_qvl::{ collateral::CollateralClient, quote::{EnclaveReport, Quote, Report, TDReport10, TDReport15}, @@ -231,9 +231,7 @@ fn tdx_root_der(root: Vec) -> Result> { pub use tpm_types::TpmQuote; use crate::amd_sev_snp::{AmdKdsClient, VerifiedAmdSnpReport}; -use crate::v1::{ - is_tdx_acpi_data_event, strip_tdx_event_log_for_config, strip_tdx_runtime_event_log, -}; +use crate::v1::{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; @@ -816,9 +814,21 @@ pub trait TdxAttestationExt { fn tdx_event_log(&self) -> Option<&[TdxEvent]>; /// Returns the TDX event log serialized as JSON. - fn tdx_event_log_string(&self) -> Option { - self.tdx_event_log() - .map(|event_log| serde_json::to_string(event_log).unwrap_or_default()) + /// + /// When `include_hash_inputs` is true, each runtime event carries its + /// digest pre-image (hex-encoded) so relying parties can verify it directly. + fn tdx_event_log_string(&self, include_hash_inputs: bool) -> Option { + self.tdx_event_log().map(|event_log| { + if include_hash_inputs { + let mut events: Vec = event_log.to_vec(); + for event in &mut events { + event.fill_hash_input(); + } + serde_json::to_string(&events).unwrap_or_default() + } else { + serde_json::to_string(event_log).unwrap_or_default() + } + }) } /// Returns the parsed TD10 report from the embedded TDX quote. @@ -1438,6 +1448,18 @@ impl Attestation { self.tdx_quote().map(|q| q.quote.clone()) } + /// Populate `hash_input` on every runtime event in the TDX event log. + /// + /// Useful before serializing an attestation so relying parties get the + /// digest pre-images alongside events. + pub fn fill_event_hash_inputs(&mut self) { + if let Some(q) = self.tdx_quote_mut() { + for event in &mut q.event_log { + event.fill_hash_input(); + } + } + } + /// Get TDX event log bytes pub fn get_tdx_event_log_bytes(&self) -> Option> { self.tdx_quote() @@ -1446,22 +1468,21 @@ 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("") + /// + /// When `include_hash_inputs` is true, each runtime event carries its digest + /// pre-image (hex-encoded) so relying parties can verify or inspect it directly. + pub fn get_tdx_event_log_string(&self, include_hash_inputs: bool) -> Option { + self.tdx_quote().map(|q| { + let mut stripped: Vec<_> = q.event_log.iter().map(|e| e.stripped()).collect(); + if include_hash_inputs { + for event in &mut stripped { + event.fill_hash_input(); + } + } + serde_json::to_string(&stripped).unwrap_or_default() + }) } - /// Get TDX event log string for a vm_config. - /// - /// Always keeps the `ACPI DATA` marker payloads on the three RTMR0 ACPI - /// digest events, regardless of the vm_config's `tdx_attestation_variant`, - /// so callers that consume the top-level `event_log` can semantically - /// identify the ACPI table digest events without consulting the - /// versioned attestation field, and a verifier can choose lite - /// verification for any TDX boot rather than only ones resolved to lite - /// at launch. - /// - /// `config` is accepted for API stability but no longer changes the - /// result. pub fn get_tdx_event_log_string_for_config(&self, _config: &str) -> Option { self.tdx_quote().map(|q| { let stripped: Vec<_> = q @@ -1469,7 +1490,7 @@ impl Attestation { .iter() .map(|e| { let mut stripped = e.stripped(); - if is_tdx_acpi_data_event(e) { + if cc_eventlog::tdx::is_tdx_acpi_data_event(e) { stripped.event_payload = e.event_payload.clone(); } stripped @@ -1484,6 +1505,18 @@ impl Attestation { .and_then(|q| Quote::parse(&q.quote).ok()) .and_then(|quote| quote.report.as_td10().cloned()) } + + /// Get the merged TCG binary CCEL event log (boot-time CCEL + runtime + /// events encoded as TCG_PCR_EVENT2 records). + /// + /// Returns an empty `Vec` on platforms without a TDX quote (e.g. Nitro). + /// Propagates errors from reading or parsing the ACPI CCEL file. + pub fn get_tdx_event_log_ccel(&self) -> Result> { + let Some(q) = self.tdx_quote() else { + return Ok(Vec::new()); + }; + cc_eventlog::tdx::build_ccel_event_log(&q.event_log) + } } pub trait GetDeviceId { @@ -2094,6 +2127,14 @@ impl Attestation { } pub fn quote_with_app_id(report_data: &[u8; 64], app_id: Option<[u8; 20]>) -> Result { + Self::quote_with_app_id_and_event_log_version(report_data, app_id, EventLogVersion::V1) + } + + pub fn quote_with_app_id_and_event_log_version( + report_data: &[u8; 64], + app_id: Option<[u8; 20]>, + event_log_version: EventLogVersion, + ) -> Result { // Lock to prevent concurrent quote generation (TDX driver doesn't support it) let _guard = QUOTE_LOCK .lock() @@ -2104,12 +2145,9 @@ impl Attestation { TeeVariant::DstackAmdSevSnp | TeeVariant::DstackTdx | TeeVariant::DstackGcpTdx - // AWS: prefer host-shared sys-config vm_config (carries - // aws_measurement + unified os_image_hash); validated below. | TeeVariant::DstackAwsNitroTpm => { read_vm_config().context("Failed to read vm config")? } - // NitroEnclave derives config from the quote's image hash below. TeeVariant::DstackNitroEnclave => String::new(), }; let runtime_events = match mode { @@ -2118,7 +2156,11 @@ impl Attestation { } TeeVariant::DstackAmdSevSnp => vec![], TeeVariant::DstackNitroEnclave => match app_id { - Some(app_id) => vec![RuntimeEvent::new("app-id".to_string(), app_id.to_vec())], + Some(app_id) => vec![RuntimeEvent::new( + "app-id".to_string(), + app_id.to_vec(), + event_log_version, + )], None => vec![], }, }; @@ -2298,9 +2340,24 @@ impl Attestation { }) } - /// Wrap into a versioned attestation for encoding + /// Wrap into a versioned attestation for encoding. + /// + /// When any runtime event uses a non-V1 event-log version, force the V1 + /// msgpack wire format so the `version` field is preserved (SCALE + /// V0 skips it for legacy binary compat). Otherwise default to V0 for + /// backward compat with callers that expect the SCALE format. pub fn into_versioned(self) -> VersionedAttestation { - VersionedAttestation::V0 { attestation: self } + let has_v2 = self + .runtime_events + .iter() + .any(|e| !matches!(e.version, EventLogVersion::V1)); + if has_v2 { + VersionedAttestation::V1 { + attestation: self.into(), + } + } else { + VersionedAttestation::V0 { attestation: self } + } } /// Verify the quote @@ -2607,6 +2664,8 @@ mod tests { digest: vec![event_type as u8; 48], event: String::new(), event_payload: event_payload.to_vec(), + version: EventLogVersion::V1, + hash_input: None, } } @@ -2750,216 +2809,37 @@ mod tests { } #[test] - fn nitro_pcrs_from_verified_extracts_0_1_2() { - let mut map = std::collections::BTreeMap::new(); - map.insert(0u16, vec![0xaa; 48]); - map.insert(1u16, vec![0xbb; 48]); - map.insert(2u16, vec![0xcc; 48]); - map.insert(3u16, vec![0xdd; 48]); // ignored - let pcrs = NitroPcrs::from_verified(&map).unwrap(); - assert_eq!(pcrs.pcr0, vec![0xaa; 48]); - assert_eq!(pcrs.pcr1, vec![0xbb; 48]); - assert_eq!(pcrs.pcr2, vec![0xcc; 48]); - - // missing a required PCR is an error - map.remove(&1u16); - assert!(NitroPcrs::from_verified(&map).is_err()); - } - - #[test] - fn nitro_pcrs_debug_detection_and_image_hash() { - let debug = NitroPcrs { - pcr0: vec![0u8; 48], - pcr1: vec![0u8; 48], - pcr2: vec![0u8; 48], - }; - assert!(debug.is_debug()); - - let prod = NitroPcrs { - pcr0: vec![1u8; 48], - pcr1: vec![0u8; 48], - pcr2: vec![0u8; 48], - }; - assert!(!prod.is_debug()); - // image_hash = sha256(pcr0 || pcr1 || pcr2), never the all-zero sentinel - assert_eq!( - prod.image_hash(), - sha256([&prod.pcr0, &prod.pcr1, &prod.pcr2]).to_vec() - ); - } - - #[test] - fn aws_nitro_tpm_mr_aggregated_replays_pcr14_like_rtmr3() -> Result<()> { - let pcr4 = vec![0x04; 48]; - let pcr7 = vec![0x07; 48]; - let pcr12 = vec![0x12; 48]; - let mut pcrs = std::collections::BTreeMap::new(); - pcrs.insert(4u16, pcr4.clone()); - pcrs.insert(7u16, pcr7.clone()); - pcrs.insert(12u16, pcr12.clone()); - - let mr_key_provider = sha256(b"aws nitrotpm key provider"); - let events = vec![ - RuntimeEvent::new("system-preparing".into(), Vec::new()), - RuntimeEvent::new("app-id".into(), vec![0x11; 20]), - RuntimeEvent::new("compose-hash".into(), vec![0x22; 32]), - RuntimeEvent::new("instance-id".into(), vec![0x33; 20]), - RuntimeEvent::new("boot-mr-done".into(), Vec::new()), - RuntimeEvent::new("key-provider".into(), b"tpm".to_vec()), - RuntimeEvent::new("system-ready".into(), Vec::new()), - ]; - let replayed_pcr14 = cc_eventlog::replay_events::(&events, None); - pcrs.insert(AWS_NITRO_TPM_EVENT_PCR, replayed_pcr14.to_vec()); - - let mrs = decode_mr_aws_nitro_tpm_from_pcrs(false, &mr_key_provider, &pcrs, &events)?; - - assert_eq!( - mrs.mr_system, - sha256([ - pcr4.as_slice(), - pcr7.as_slice(), - pcr12.as_slice(), - mr_key_provider.as_slice(), - ]) - ); - assert_eq!( - mrs.mr_aggregated, - sha256([ - pcr4.as_slice(), - pcr7.as_slice(), - pcr12.as_slice(), - replayed_pcr14.as_slice(), - ]) + fn into_versioned_uses_v0_when_all_events_are_v1() { + let mut att = dummy_tdx_attestation([7u8; 64]); + att.runtime_events.push(cc_eventlog::RuntimeEvent::new( + "app-id".into(), + vec![1, 2, 3], + cc_eventlog::EventLogVersion::V1, + )); + let versioned = att.into_versioned(); + assert!( + matches!(versioned, VersionedAttestation::V0 { .. }), + "V1-only events should stay on the V0/SCALE wire format" ); - - let mut changed_events = events.clone(); - changed_events[2] = RuntimeEvent::new("compose-hash".into(), vec![0xee; 32]); - let changed_pcr14 = cc_eventlog::replay_events::(&changed_events, None); - let mut changed_pcrs = pcrs.clone(); - changed_pcrs.insert(AWS_NITRO_TPM_EVENT_PCR, changed_pcr14.to_vec()); - let changed_mrs = decode_mr_aws_nitro_tpm_from_pcrs( - false, - &mr_key_provider, - &changed_pcrs, - &changed_events, - )?; - - assert_eq!(mrs.mr_system, changed_mrs.mr_system); - assert_ne!(mrs.mr_aggregated, changed_mrs.mr_aggregated); - - let mut changed_pcrs = pcrs.clone(); - changed_pcrs.insert(12, vec![0x99; 48]); - let changed_pcr12 = - decode_mr_aws_nitro_tpm_from_pcrs(false, &mr_key_provider, &changed_pcrs, &events)?; - assert_ne!(mrs.mr_system, changed_pcr12.mr_system); - assert_ne!(mrs.mr_aggregated, changed_pcr12.mr_aggregated); - - let mut missing_pcrs = pcrs.clone(); - missing_pcrs.remove(&AWS_NITRO_TPM_EVENT_PCR); - let err = match decode_mr_aws_nitro_tpm_from_pcrs( - false, - &mr_key_provider, - &missing_pcrs, - &events, - ) { - Ok(_) => panic!("missing PCR14 must be rejected"), - Err(err) => err, - }; - assert!(format!("{err:#}").contains("PCR 14 not found")); - - let mut mismatched_pcrs = pcrs.clone(); - mismatched_pcrs.insert(AWS_NITRO_TPM_EVENT_PCR, vec![0xff; 48]); - let err = match decode_mr_aws_nitro_tpm_from_pcrs( - false, - &mr_key_provider, - &mismatched_pcrs, - &events, - ) { - Ok(_) => panic!("mismatched PCR14 must be rejected"), - Err(err) => err, - }; - assert!(format!("{err:#}").contains("PCR14 mismatch")); - Ok(()) } #[test] - fn aws_nitro_tpm_pcr14_replays_full_event_log_like_rtmr3() -> Result<()> { - // Single PCR14 lane: all events (including after system-ready) are - // measured and must be replayed for full (non-boottime) decode. - let events = vec![ - RuntimeEvent::new("system-preparing".into(), Vec::new()), - RuntimeEvent::new("app-id".into(), vec![0x11; 20]), - RuntimeEvent::new("compose-hash".into(), vec![0x22; 32]), - RuntimeEvent::new("instance-id".into(), vec![0x33; 20]), - RuntimeEvent::new("boot-mr-done".into(), Vec::new()), - RuntimeEvent::new("storage-fs".into(), b"ext4".to_vec()), - RuntimeEvent::new("system-ready".into(), Vec::new()), - RuntimeEvent::new("app-runtime".into(), b"ready".to_vec()), - ]; - - let full_pcr = cc_eventlog::replay_events::(&events, None); - let early_pcr = cc_eventlog::replay_events::(&events, Some("boot-mr-done")); - let mr_key_provider = sha256(b"aws nitrotpm key provider"); - let pcrs = std::collections::BTreeMap::from([ - (4u16, vec![0x04; 48]), - (7u16, vec![0x07; 48]), - (12u16, vec![0x12; 48]), - (AWS_NITRO_TPM_EVENT_PCR, full_pcr.to_vec()), - ]); - - let mrs = decode_mr_aws_nitro_tpm_from_pcrs(false, &mr_key_provider, &pcrs, &events)?; - assert_eq!( - mrs.mr_aggregated, - sha256([ - pcrs[&4].as_slice(), - pcrs[&7].as_slice(), - pcrs[&12].as_slice(), - full_pcr.as_slice(), - ]) - ); - - // A full runtime quote (PCR14 covers the whole log) decoded in - // boottime mode binds the full replay to the quoted register, then - // returns the boot-mr-done snapshot for the MR — it must NOT fail the - // integrity check. This is the SignCert path (runtime quote, boot-time - // MR). - let early_from_full = - decode_mr_aws_nitro_tpm_from_pcrs(true, &mr_key_provider, &pcrs, &events)?; - assert_eq!( - early_from_full.mr_aggregated, - sha256([ - pcrs[&4].as_slice(), - pcrs[&7].as_slice(), - pcrs[&12].as_slice(), - early_pcr.as_slice(), - ]) - ); - - // A genuine early quote carries both the truncated PCR14 and the - // truncated event log; the full replay of that log equals the quoted - // early PCR14, so the binding passes and the MR uses the same value. - let early_events: Vec = events - .iter() - .take_while(|event| event.event != "boot-mr-done") - .cloned() - .chain(std::iter::once(RuntimeEvent::new( - "boot-mr-done".into(), - Vec::new(), - ))) - .collect(); - let mut early_pcrs = pcrs.clone(); - early_pcrs.insert(AWS_NITRO_TPM_EVENT_PCR, early_pcr.to_vec()); - let early_ok = - decode_mr_aws_nitro_tpm_from_pcrs(true, &mr_key_provider, &early_pcrs, &early_events)?; - assert_eq!( - early_ok.mr_aggregated, - sha256([ - early_pcrs[&4].as_slice(), - early_pcrs[&7].as_slice(), - early_pcrs[&12].as_slice(), - early_pcr.as_slice(), - ]) + fn into_versioned_upgrades_to_v1_when_any_event_is_v2() { + let mut att = dummy_tdx_attestation([8u8; 64]); + att.runtime_events.push(cc_eventlog::RuntimeEvent::new( + "app-id".into(), + vec![1, 2, 3], + cc_eventlog::EventLogVersion::V1, + )); + att.runtime_events.push(cc_eventlog::RuntimeEvent::new( + "compose-hash".into(), + vec![4, 5, 6], + cc_eventlog::EventLogVersion::V2, + )); + let versioned = att.into_versioned(); + assert!( + matches!(versioned, VersionedAttestation::V1 { .. }), + "presence of a V2 event must force the V1 msgpack wire format to preserve `version`" ); - Ok(()) } } diff --git a/dstack/dstack-attest/src/lib.rs b/dstack/dstack-attest/src/lib.rs index cc5b4d3cc..2a0fb7676 100644 --- a/dstack/dstack-attest/src/lib.rs +++ b/dstack/dstack-attest/src/lib.rs @@ -5,7 +5,7 @@ use std::sync::{LazyLock, Mutex}; use anyhow::Context; -use cc_eventlog::RuntimeEvent; +use cc_eventlog::{EventLogVersion, RuntimeEvent}; pub use cc_eventlog as ccel; pub use tdx_attest as tdx; @@ -21,26 +21,20 @@ mod aws_nitro_tpm; mod sev_snp; mod v1; -/// Serializes measured event emission within this process. -/// -/// Appending to the event log and extending the platform measurement register -/// must happen atomically as a unit: the log order has to match the extension -/// order, otherwise replay during quote verification will not reproduce the -/// measured value. Concurrent callers, for example multiple `emit_event` RPCs -/// hitting the guest-agent at once, would otherwise be able to interleave their -/// log writes and register extensions. static EMIT_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); -/// Emit a dstack measured event and log the event. -/// -/// Semantics match bare TDX RTMR3: every dstack event extends a single -/// append-only measurement register. -/// -/// - TDX-family: RTMR3 -/// - GCP TPM: SHA256 PCR14 -/// - AWS NitroTPM: SHA384 PCR14 (not PCR23; no launch/runtime PCR split) +/// Emit a runtime event that extends RTMR3 and logs the event. pub fn emit_runtime_event(event: &str, payload: &[u8]) -> anyhow::Result<()> { - let event = RuntimeEvent::new(event.to_string(), payload.to_vec()); + emit_runtime_event_with_version(event, payload, EventLogVersion::V1) +} + +/// Emit a runtime event using an explicit event-log format. +pub fn emit_runtime_event_with_version( + event: &str, + payload: &[u8], + version: EventLogVersion, +) -> anyhow::Result<()> { + let event = RuntimeEvent::new(event.to_string(), payload.to_vec(), version); let mode = detect_tee_variant()?; diff --git a/dstack/dstack-attest/src/v1.rs b/dstack/dstack-attest/src/v1.rs index f201b9c73..70b68259b 100644 --- a/dstack/dstack-attest/src/v1.rs +++ b/dstack/dstack-attest/src/v1.rs @@ -7,7 +7,6 @@ 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; @@ -133,18 +132,13 @@ impl PlatformEvidence { } } - pub fn sev_snp_mr_config_document(&self) -> Option<&str> { + pub fn tdx_event_log_mut(&mut self) -> Option<&mut Vec> { match self { - Self::SevSnp { mr_config, .. } => Some(mr_config.as_str()), + Self::Tdx { event_log, .. } => Some(event_log), _ => None, } } - pub fn sev_snp_mr_config(&self) -> Option { - self.sev_snp_mr_config_document() - .and_then(|document| MrConfigV3::from_document(document).ok()) - } - pub fn into_stripped(self) -> Self { self.into_stripped_for_config("") } @@ -372,6 +366,8 @@ impl Attestation { mod tests { use super::*; use cc_eventlog::tdx::TDX_ACPI_DATA_EVENT_TYPE; + use dstack_types::mr_config::MrConfigV3; + use dstack_types::EventLogVersion; fn test_mr_config_document() -> String { MrConfigV3::new( @@ -396,6 +392,8 @@ mod tests { digest: vec![0xaa, 0xbb, 0xcc], event: "pod".into(), event_payload: vec![0xde, 0xad, 0xbe, 0xef], + version: EventLogVersion::V1, + hash_input: None, }], }, StackEvidence::DstackPod { @@ -403,6 +401,7 @@ mod tests { runtime_events: vec![RuntimeEvent { event: "pod".into(), payload: vec![0xca, 0xfe, 0xba, 0xbe], + version: EventLogVersion::V1, }], config: "{}".into(), report_data_payload: "{\"hello\":\"world\"}".into(), @@ -474,6 +473,8 @@ mod tests { digest: vec![idx as u8; 48], event: String::new(), event_payload: vec![0xff; idx + 1], + version: EventLogVersion::V1, + hash_input: None, } } @@ -484,6 +485,8 @@ mod tests { digest: vec![idx as u8; 48], event: String::new(), event_payload: TDX_ACPI_DATA_EVENT_PAYLOAD.to_vec(), + version: EventLogVersion::V1, + hash_input: None, } } @@ -491,6 +494,7 @@ mod tests { RuntimeEvent { event: "app-id".into(), payload: vec![0x42], + version: EventLogVersion::V1, } .into() } diff --git a/dstack/dstack-types/src/lib.rs b/dstack/dstack-types/src/lib.rs index 69870a5d9..f498c89e3 100644 --- a/dstack/dstack-types/src/lib.rs +++ b/dstack/dstack-types/src/lib.rs @@ -71,6 +71,47 @@ impl TdxAttestationVariant { } } +/// Event log version controlling the digest format. +/// +/// Using an enum ensures exhaustive matching — adding a new version +/// forces all match sites to be updated. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum EventLogVersion { + /// Legacy binary digest: `SHA384(event_type_le || ":" || name || ":" || payload)` + #[default] + V1, + /// JSON canonical digest (JCS RFC 8785), hashed as canonical JSON bytes: + /// `SHA384({"name":"...","payload":"hex...","type":134217729})` + V2, +} + +impl EventLogVersion { + pub fn from_u32(v: u32) -> Option { + match v { + 1 => Some(EventLogVersion::V1), + 2 => Some(EventLogVersion::V2), + _ => None, + } + } +} + +impl Serialize for EventLogVersion { + fn serialize(&self, serializer: S) -> Result { + match self { + EventLogVersion::V1 => serializer.serialize_u32(1), + EventLogVersion::V2 => serializer.serialize_u32(2), + } + } +} + +impl<'de> Deserialize<'de> for EventLogVersion { + fn deserialize>(deserializer: D) -> Result { + let v = u32::deserialize(deserializer)?; + EventLogVersion::from_u32(v) + .ok_or_else(|| serde::de::Error::custom(format!("unknown event log version: {v}"))) + } +} + #[derive(Deserialize, Serialize, Debug, Clone)] pub struct AppCompose { #[serde(deserialize_with = "deserialize_manifest_version")] @@ -112,6 +153,8 @@ pub struct AppCompose { pub storage_fs: Option, #[serde(default, with = "human_size")] pub swap_size: u64, + #[serde(default)] + pub event_log_version: EventLogVersion, /// Per-port policy consumed by the gateway (PROXY protocol opt-in, /// optional port whitelist). #[serde(default)] diff --git a/dstack/dstack-util/src/main.rs b/dstack/dstack-util/src/main.rs index 9c16465fe..bf4e8eb2a 100644 --- a/dstack/dstack-util/src/main.rs +++ b/dstack/dstack-util/src/main.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use dstack_attest::emit_runtime_event; +use dstack_attest::emit_runtime_event_with_version; use dstack_types::{KeyProvider, KeyProviderKind}; use fs_err as fs; use getrandom::fill as getrandom; @@ -686,7 +686,21 @@ fn hex_decode(hex_str: &str) -> Result> { fn cmd_extend(extend_args: ExtendArgs) -> Result<()> { let payload = hex_decode(&extend_args.payload).context("Failed to decode payload")?; - emit_runtime_event(&extend_args.event, &payload).context("Failed to extend RTMR") + let version = read_event_log_version(); + emit_runtime_event_with_version(&extend_args.event, &payload, version) + .context("Failed to extend RTMR") +} + +fn read_event_log_version() -> dstack_types::EventLogVersion { + let path = std::path::Path::new(dstack_types::shared_filenames::HOST_SHARED_DIR) + .join(dstack_types::shared_filenames::APP_COMPOSE); + let Ok(data) = std::fs::read_to_string(&path) else { + return Default::default(); + }; + let Ok(compose) = serde_json::from_str::(&data) else { + return Default::default(); + }; + compose.event_log_version } fn cmd_rand(rand_args: RandArgs) -> Result<()> { diff --git a/dstack/dstack-util/src/system_setup.rs b/dstack/dstack-util/src/system_setup.rs index 850914592..464f645f6 100644 --- a/dstack/dstack-util/src/system_setup.rs +++ b/dstack/dstack-util/src/system_setup.rs @@ -14,7 +14,7 @@ use std::{ }; use anyhow::{anyhow, bail, Context, Result}; -use dstack_attest::emit_runtime_event; +use dstack_attest::emit_runtime_event_with_version; use dstack_kms_rpc as rpc; use dstack_types::{ gpu_policy_hash, @@ -775,10 +775,13 @@ fn platform_instance_binding() -> Result>> { } } -fn emit_key_provider_info(provider_info: &KeyProviderInfo) -> Result<()> { +fn emit_key_provider_info( + provider_info: &KeyProviderInfo, + event_log_version: dstack_types::EventLogVersion, +) -> Result<()> { info!("Key provider info: {provider_info:?}"); let provider_info_json = serde_json::to_vec(&provider_info)?; - emit_runtime_event("key-provider", &provider_info_json)?; + emit_runtime_event_with_version("key-provider", &provider_info_json, event_log_version)?; Ok(()) } @@ -1471,11 +1474,14 @@ mod gpu { }) } - pub(super) fn measure_gpu_policy(compose_path: &Path) -> Result<[u8; 32]> { + pub(super) fn measure_gpu_policy( + compose_path: &Path, + event_log_version: dstack_types::EventLogVersion, + ) -> Result<[u8; 32]> { let compose_json = fs::read(compose_path) .with_context(|| format!("failed to read {}", compose_path.display()))?; let digest = gpu_policy_hash(&compose_json).context("failed to hash raw GPU policy")?; - emit_runtime_event("gpu-policy-hash", &digest) + emit_runtime_event_with_version("gpu-policy-hash", &digest, event_log_version) .context("failed to emit GPU policy measurement")?; Ok(digest) } @@ -1916,7 +1922,10 @@ impl Stage0<'_> { /// optional Rego policy is always evaluated; when no attestation is /// performed, its claims-array input is empty. async fn measure_gpu(&self) -> Result<[u8; 32]> { - let gpu_policy_hash = gpu::measure_gpu_policy(&self.shared.dir.app_compose_file())?; + let gpu_policy_hash = gpu::measure_gpu_policy( + &self.shared.dir.app_compose_file(), + self.shared.app_compose.event_log_version, + )?; let gpu_policy = self .shared @@ -1975,8 +1984,12 @@ impl Stage0<'_> { gpu_state.set_ready()?; let devtools = gpu_state.any_devtools(); let event = attestation.event(devtools)?; - emit_runtime_event("gpu-attestation", &event) - .context("failed to emit GPU attestation event")?; + emit_runtime_event_with_version( + "gpu-attestation", + &event, + self.shared.app_compose.event_log_version, + ) + .context("failed to emit GPU attestation event")?; info!("GPU TEE attestation succeeded"); Ok(gpu_policy_hash) } @@ -2098,7 +2111,9 @@ impl<'a> Stage0<'a> { .tls_client_key(cert_pair.key_pem) .tls_ca_cert(tmp_ca.ca_cert.clone()) .attestation_verifier(attestation_verifier) - .cert_validator(Box::new(|cert| { + .cert_validator({ + let event_log_version = self.shared.app_compose.event_log_version; + Box::new(move |cert| { let Some(cert) = cert else { bail!("Missing server cert"); }; @@ -2110,7 +2125,7 @@ impl<'a> Stage0<'a> { } if let Some(att) = &cert.attestation { match att.decode_app_info(false) { - Ok(kms_info) => emit_runtime_event("mr-kms", &kms_info.mr_aggregated) + Ok(kms_info) => emit_runtime_event_with_version("mr-kms", &kms_info.mr_aggregated, event_log_version) .context("failed to extend mr-kms to the launch measurement")?, Err(err) if is_unsupported_app_info_quote(&err) => { warn!("Skipping mr-kms runtime event for unsupported attestation quote: {err:#}"); @@ -2119,7 +2134,8 @@ impl<'a> Stage0<'a> { } } Ok(()) - })) + }) + }) .build() .into_client() .context("Failed to create client")?; @@ -2132,8 +2148,12 @@ impl<'a> Stage0<'a> { .await .context("Failed to get app key")?; - emit_runtime_event("os-image-hash", &response.os_image_hash) - .context("failed to extend os-image-hash to the launch measurement")?; + emit_runtime_event_with_version( + "os-image-hash", + &response.os_image_hash, + self.shared.app_compose.event_log_version, + ) + .context("failed to extend os-image-hash to the launch measurement")?; let (_, ca_pem) = x509_parser::pem::parse_x509_pem(tmp_ca.ca_cert.as_bytes()) .context("Failed to parse ca cert")?; @@ -2642,16 +2662,36 @@ impl<'a> Stage0<'a> { // no KMS to bind it, the relying party MUST gate the compose_hash // (which launcher build) separately from the app_id (which app). - emit_runtime_event("system-preparing", &[])?; - emit_runtime_event("app-id", &instance_info.app_id)?; - emit_runtime_event("compose-hash", &compose_hash)?; + emit_runtime_event_with_version( + "system-preparing", + &[], + self.shared.app_compose.event_log_version, + )?; + emit_runtime_event_with_version( + "app-id", + &instance_info.app_id, + self.shared.app_compose.event_log_version, + )?; + emit_runtime_event_with_version( + "compose-hash", + &compose_hash, + self.shared.app_compose.event_log_version, + )?; let gpu_policy_hash = self .measure_gpu() .await .context("failed to verify GPU TEE attestation")?; - emit_runtime_event("instance-id", &instance_id)?; - emit_runtime_event("boot-mr-done", &[])?; + emit_runtime_event_with_version( + "instance-id", + &instance_id, + self.shared.app_compose.event_log_version, + )?; + emit_runtime_event_with_version( + "boot-mr-done", + &[], + self.shared.app_compose.event_log_version, + )?; // AWS: commit the measured app identity into PCR8 (mr_config analogue). // The config id is computed from measured reality (MrConfig V2), so @@ -2709,7 +2749,7 @@ impl<'a> Stage0<'a> { KeyProviderInfo::new("kms".into(), hex::encode(keys.key_provider.id())) } }; - emit_key_provider_info(&kp_info)?; + emit_key_provider_info(&kp_info, self.shared.app_compose.event_log_version)?; Ok(()) } @@ -2741,7 +2781,11 @@ impl<'a> Stage0<'a> { // Parse kernel command line options let opts = parse_dstack_options(&self.shared).context("Failed to parse kernel cmdline")?; - emit_runtime_event("storage-fs", opts.storage_fs.to_string().as_bytes())?; + emit_runtime_event_with_version( + "storage-fs", + opts.storage_fs.to_string().as_bytes(), + self.shared.app_compose.event_log_version, + )?; info!( "Filesystem options: encryption={}, filesystem={:?}", opts.storage_encrypted, opts.storage_fs @@ -2757,7 +2801,11 @@ impl<'a> Stage0<'a> { &serde_json::to_string(&app_info.instance_info)?, ) .await; - emit_runtime_event("system-ready", &[])?; + emit_runtime_event_with_version( + "system-ready", + &[], + self.shared.app_compose.event_log_version, + )?; self.vmm.notify_q("boot.progress", "data disk ready").await; if !self.shared.app_compose.key_provider().is_kms() { diff --git a/dstack/gateway/src/distributed_certbot.rs b/dstack/gateway/src/distributed_certbot.rs index cbb316ea4..8963ba3d7 100644 --- a/dstack/gateway/src/distributed_certbot.rs +++ b/dstack/gateway/src/distributed_certbot.rs @@ -378,6 +378,7 @@ impl DistributedCertBot { let quote = match agent .get_quote(RawQuoteArgs { report_data: report_data.clone(), + include_hash_inputs: false, }) .await { @@ -389,7 +390,13 @@ impl DistributedCertBot { }; // Get attestation - let attestation_str = match agent.attest(RawQuoteArgs { report_data }).await { + let attestation_str = match agent + .attest(RawQuoteArgs { + report_data, + include_hash_inputs: false, + }) + .await + { Ok(resp) => serde_json::to_string(&resp).unwrap_or_default(), Err(err) => { warn!("failed to get attestation for ACME account: {err:?}"); @@ -448,6 +455,7 @@ impl DistributedCertBot { let quote = match agent .get_quote(RawQuoteArgs { report_data: report_data.clone(), + include_hash_inputs: false, }) .await { @@ -459,7 +467,13 @@ impl DistributedCertBot { }; // Get attestation - let attestation = match agent.attest(RawQuoteArgs { report_data }).await { + let attestation = match agent + .attest(RawQuoteArgs { + report_data, + include_hash_inputs: false, + }) + .await + { Ok(resp) => serde_json::to_string(&resp).unwrap_or_default(), Err(err) => { warn!(domain, "failed to get attestation: {err:?}"); diff --git a/dstack/gateway/src/gen_debug_key.rs b/dstack/gateway/src/gen_debug_key.rs index c710548a6..565f77403 100644 --- a/dstack/gateway/src/gen_debug_key.rs +++ b/dstack/gateway/src/gen_debug_key.rs @@ -50,6 +50,7 @@ async fn main() -> Result<()> { let quote_response = simulator_client .get_quote(RawQuoteArgs { report_data: report_data.to_vec(), + include_hash_inputs: false, }) .await .context("Failed to get quote from simulator")?; diff --git a/dstack/guest-agent-simulator/Cargo.toml b/dstack/guest-agent-simulator/Cargo.toml index f476e01c2..2695811d8 100644 --- a/dstack/guest-agent-simulator/Cargo.toml +++ b/dstack/guest-agent-simulator/Cargo.toml @@ -25,3 +25,4 @@ ra-rpc = { workspace = true, features = ["rocket"] } ra-tls = { workspace = true, features = ["quote"] } dstack-guest-agent = { path = "../guest-agent" } dstack-guest-agent-rpc.workspace = true +dstack-types.workspace = true diff --git a/dstack/guest-agent-simulator/src/main.rs b/dstack/guest-agent-simulator/src/main.rs index c2bf89bf1..42ae13aec 100644 --- a/dstack/guest-agent-simulator/src/main.rs +++ b/dstack/guest-agent-simulator/src/main.rs @@ -6,7 +6,7 @@ mod simulator; use std::sync::Arc; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use clap::Parser; use dstack_guest_agent::{ backend::PlatformBackend, @@ -14,6 +14,7 @@ use dstack_guest_agent::{ run_server, AppState, }; use dstack_guest_agent_rpc::{AttestResponse, GetQuoteResponse}; +use dstack_types::EventLogVersion; use ra_tls::attestation::VersionedAttestation; use serde::Deserialize; use tracing::warn; @@ -77,17 +78,36 @@ impl PlatformBackend for SimulatorPlatform { ) } - fn quote_response(&self, report_data: [u8; 64], vm_config: &str) -> Result { + fn quote_response( + &self, + report_data: [u8; 64], + vm_config: &str, + include_hash_inputs: bool, + ) -> Result { simulator::simulated_quote_response( &self.attestation, report_data, vm_config, self.patch_report_data, + include_hash_inputs, ) } - fn attest_response(&self, report_data: [u8; 64]) -> Result { - simulator::simulated_attest_response(&self.attestation, report_data, self.patch_report_data) + fn attest_response( + &self, + report_data: [u8; 64], + include_hash_inputs: bool, + ) -> Result { + simulator::simulated_attest_response( + &self.attestation, + report_data, + self.patch_report_data, + include_hash_inputs, + ) + } + + fn emit_event(&self, event: &str, _payload: &[u8], _version: EventLogVersion) -> Result<()> { + bail!("runtime event emission is unavailable in simulator mode: {event}") } } @@ -141,6 +161,15 @@ mod tests { SimulatorPlatform::new(fixture, true) } + #[test] + fn simulator_rejects_runtime_event_emission() { + let platform = load_fixture_platform(); + let err = platform + .emit_event("test.event", b"payload", EventLogVersion::V1) + .unwrap_err(); + assert!(err.to_string().contains("unavailable in simulator mode")); + } + #[test] fn simulator_provides_certificate_attestation() { let platform = load_fixture_platform(); @@ -155,7 +184,7 @@ mod tests { fn simulator_attest_response_uses_supplied_report_data() { let platform = load_fixture_platform(); let report_data = [0x5a; 64]; - let response = platform.attest_response(report_data).unwrap(); + let response = platform.attest_response(report_data, false).unwrap(); let patched = VersionedAttestation::from_bytes(&response.attestation) .unwrap() .into_v1(); @@ -172,7 +201,7 @@ mod tests { let original = fixture.clone().into_v1().report_data().unwrap(); let platform = SimulatorPlatform::new(fixture, false); let report_data = [0x5a; 64]; - let response = platform.attest_response(report_data).unwrap(); + let response = platform.attest_response(report_data, false).unwrap(); let patched = VersionedAttestation::from_bytes(&response.attestation) .unwrap() .into_v1(); diff --git a/dstack/guest-agent-simulator/src/simulator.rs b/dstack/guest-agent-simulator/src/simulator.rs index ed148429b..720d0626c 100644 --- a/dstack/guest-agent-simulator/src/simulator.rs +++ b/dstack/guest-agent-simulator/src/simulator.rs @@ -29,22 +29,21 @@ pub fn simulated_quote_response( report_data: [u8; 64], vm_config: &str, patch_report_data: bool, + include_hash_inputs: bool, ) -> Result { let attestation = maybe_patch_report_data(attestation, report_data, patch_report_data, "quote"); let Some(quote) = attestation.tdx_quote_bytes() else { return Err(anyhow!("Quote not found")); }; - let versioned = VersionedAttestation::V1 { - attestation: attestation.clone(), - } - .to_bytes()?; Ok(GetQuoteResponse { quote, - event_log: attestation.tdx_event_log_string().unwrap_or_default(), + event_log: attestation + .tdx_event_log_string(include_hash_inputs) + .unwrap_or_default(), report_data: report_data.to_vec(), vm_config: vm_config.to_string(), - attestation: versioned, + event_log_ccel: Vec::new(), }) } @@ -52,9 +51,17 @@ pub fn simulated_attest_response( attestation: &VersionedAttestation, report_data: [u8; 64], patch_report_data: bool, + include_hash_inputs: bool, ) -> Result { - let attestation = + let mut attestation = maybe_patch_report_data(attestation, report_data, patch_report_data, "attest"); + if include_hash_inputs { + if let Some(event_log) = attestation.platform.tdx_event_log_mut() { + for event in event_log { + event.fill_hash_input(); + } + } + } Ok(AttestResponse { attestation: VersionedAttestation::V1 { attestation }.to_bytes()?, }) diff --git a/dstack/guest-agent/rpc/proto/agent_rpc.proto b/dstack/guest-agent/rpc/proto/agent_rpc.proto index cef6ea274..b4cd68928 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -170,6 +170,15 @@ message TdxQuoteArgs { message RawQuoteArgs { // 64 bytes of report data bytes report_data = 1; + // If true, include the digest hash input for each runtime event in the response. + // Non-runtime events (boot-time/TCG events) will have hash_input unset. + // The hash_input is hex-encoded bytes of the digest pre-image: + // - v2 runtime events: hex of UTF-8 JCS canonical JSON + // - v1 runtime events: hex of binary concat (event_type_le || ":" || name || ":" || payload) + // Clients can verify: sha384(hex_decode(hash_input)) == event.digest + // Enables relying parties to verify digests or inspect claims without + // knowing the dstack event schema. + bool include_hash_inputs = 2; } message TdxQuoteResponse { @@ -205,10 +214,34 @@ message GetQuoteResponse { bytes report_data = 3; // Hw config string vm_config = 4; - // Platform-adaptive versioned attestation (SCALE/msgpack encoded). Populated - // on non-TDX TEE platforms (AMD SEV-SNP, ...). TDX uses `quote` + `event_log` - // above to keep this response compact. - bytes attestation = 5; + // TCG binary Confidential Computing Event Log (CCEL) containing both + // boot-time events (from the ACPI CCEL table) and dstack runtime events + // (RTMR3) merged into a single stream. + // + // Encoding per TCG PC Client Platform Firmware Profile: + // - Starts with a Spec ID header event (TCG_PCClientPCREvent). + // - Boot-time events are copied verbatim from the ACPI CCEL table + // (TCG_PCR_EVENT2 with the digests the firmware wrote). + // - Runtime events (event_type = 0x08000001) are appended as + // TCG_PCR_EVENT2 records with: + // * pcrIndex = imr + 1 (so RTMR3 becomes 4) + // * eventType = 0x08000001 + // * digests = [{ algId: TPM_ALG_SHA384 (0xC), hash: 48 bytes }] + // * event = the digest pre-image bytes, so that + // sha384(event) == digest holds independently of the + // dstack schema. The pre-image is: + // V1: event_type_le || ":" || name || ":" || payload + // V2: canonical JSON (UTF-8) + // + // Empty on platforms without an ACPI CCEL table (e.g. Nitro Enclaves). + bytes event_log_ccel = 5; +} + +message EmitEventArgs { + // The event name + string event = 1; + // The event data + bytes payload = 2; } // The request to derive a key diff --git a/dstack/guest-agent/src/backend.rs b/dstack/guest-agent/src/backend.rs index a87b6c6e2..12c6eae99 100644 --- a/dstack/guest-agent/src/backend.rs +++ b/dstack/guest-agent/src/backend.rs @@ -3,15 +3,27 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::{Context, Result}; +use dstack_attest::emit_runtime_event_with_version; use dstack_guest_agent_rpc::{AttestResponse, GetQuoteResponse}; +use dstack_types::EventLogVersion; use ra_tls::attestation::Attestation; use ra_tls::attestation::{QuoteContentType, VersionedAttestation}; pub trait PlatformBackend: Send + Sync { fn attestation_for_info(&self) -> Result; fn certificate_attestation(&self, pubkey: &[u8]) -> Result; - fn quote_response(&self, report_data: [u8; 64], vm_config: &str) -> Result; - fn attest_response(&self, report_data: [u8; 64]) -> Result; + fn quote_response( + &self, + report_data: [u8; 64], + vm_config: &str, + include_hash_inputs: bool, + ) -> Result; + fn attest_response( + &self, + report_data: [u8; 64], + include_hash_inputs: bool, + ) -> Result; + fn emit_event(&self, event: &str, payload: &[u8], version: EventLogVersion) -> Result<()>; } #[derive(Debug, Default)] @@ -31,33 +43,41 @@ impl PlatformBackend for RealPlatform { .into_versioned()) } - fn quote_response(&self, report_data: [u8; 64], vm_config: &str) -> Result { + fn quote_response( + &self, + report_data: [u8; 64], + vm_config: &str, + include_hash_inputs: bool, + ) -> 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_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")? - }; + let tdx_event_log = attestation.get_tdx_event_log_string(include_hash_inputs); + let event_log_ccel = attestation.get_tdx_event_log_ccel().unwrap_or_default(); Ok(GetQuoteResponse { quote: tdx_quote.unwrap_or_default(), event_log: tdx_event_log.unwrap_or_default(), report_data: report_data.to_vec(), vm_config: vm_config.to_string(), - attestation: versioned, + event_log_ccel, }) } - fn attest_response(&self, report_data: [u8; 64]) -> Result { - let attestation = Attestation::quote(&report_data).context("Failed to get attestation")?; + fn attest_response( + &self, + report_data: [u8; 64], + include_hash_inputs: bool, + ) -> Result { + let mut attestation = + Attestation::quote(&report_data).context("Failed to get attestation")?; + if include_hash_inputs { + attestation.fill_event_hash_inputs(); + } Ok(AttestResponse { attestation: attestation.into_versioned().to_bytes()?, }) } + + fn emit_event(&self, event: &str, payload: &[u8], version: EventLogVersion) -> Result<()> { + emit_runtime_event_with_version(event, payload, version) + } } diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index d9a8faa33..96056a6c2 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -189,14 +189,24 @@ impl AppState { &self.inner.config } - fn quote_response(&self, report_data: [u8; 64]) -> Result { + fn quote_response( + &self, + report_data: [u8; 64], + include_hash_inputs: bool, + ) -> Result { self.inner .platform - .quote_response(report_data, &self.inner.vm_config) + .quote_response(report_data, &self.inner.vm_config, include_hash_inputs) } - fn attest_response(&self, report_data: [u8; 64]) -> Result { - self.inner.platform.attest_response(report_data) + fn attest_response( + &self, + report_data: [u8; 64], + include_hash_inputs: bool, + ) -> Result { + self.inner + .platform + .attest_response(report_data, include_hash_inputs) } } @@ -340,7 +350,8 @@ impl DstackGuestRpc for InternalRpcHandler { async fn get_quote(self, request: RawQuoteArgs) -> Result { let report_data = pad64(&request.report_data).context("Report data is too long")?; - self.state.quote_response(report_data) + self.state + .quote_response(report_data, request.include_hash_inputs) } async fn info(self) -> Result { @@ -448,7 +459,8 @@ impl DstackGuestRpc for InternalRpcHandler { async fn attest(self, request: RawQuoteArgs) -> Result { let report_data = pad64(&request.report_data).context("Report data is too long")?; - self.state.attest_response(report_data) + self.state + .attest_response(report_data, request.include_hash_inputs) } async fn version(self) -> Result { @@ -550,7 +562,7 @@ impl TappdRpc for InternalRpcHandlerV0 { }; let report_data = content_type.to_report_data_with_hash(&request.report_data, &request.hash_algorithm)?; - let response = self.state.quote_response(report_data)?; + let response = self.state.quote_response(report_data, false)?; Ok(TdxQuoteResponse { quote: response.quote, event_log: response.event_log, @@ -643,7 +655,7 @@ impl WorkerRpc for ExternalRpcHandler { let ed_bytes = ed25519_report_string.as_bytes(); ed25519_report_data[..ed_bytes.len()].copy_from_slice(ed_bytes); - self.state.quote_response(ed25519_report_data) + self.state.quote_response(ed25519_report_data, false) } "secp256k1" | "secp256k1_prehashed" => { let secp256k1_key = SigningKey::from_slice(&key_response.key) @@ -656,7 +668,7 @@ impl WorkerRpc for ExternalRpcHandler { let secp_bytes = secp256k1_report_string.as_bytes(); secp256k1_report_data[..secp_bytes.len()].copy_from_slice(secp_bytes); - self.state.quote_response(secp256k1_report_data) + self.state.quote_response(secp256k1_report_data, false) } _ => Err(anyhow::anyhow!("Unsupported algorithm")), } @@ -681,7 +693,7 @@ mod tests { config::{AppComposeWrapper, Config}, }; use dstack_guest_agent_rpc::{GetAttestationForAppKeyRequest, SignRequest}; - use dstack_types::{AppCompose, AppKeys, KeyProvider}; + use dstack_types::{AppCompose, AppKeys, EventLogVersion, KeyProvider}; use ed25519_dalek::ed25519::signature::hazmat::PrehashVerifier; use ed25519_dalek::{ Signature as Ed25519Signature, Verifier, VerifyingKey as Ed25519VerifyingKey, @@ -752,6 +764,7 @@ mod tests { secure_time: false, storage_fs: None, swap_size: 0, + event_log_version: EventLogVersion::V1, port_policy: Default::default(), requirements: None, verity_volumes: Vec::new(), @@ -870,6 +883,7 @@ pNs85uhOZE8z2jr8Pg== &self, report_data: [u8; 64], vm_config: &str, + _include_hash_inputs: bool, ) -> Result { let attestation = patch_report_data(&self.attestation, report_data); let Some(quote) = attestation.platform.tdx_quote().map(ToOwned::to_owned) else { @@ -883,16 +897,29 @@ pNs85uhOZE8z2jr8Pg== .unwrap_or_default(), report_data: report_data.to_vec(), vm_config: vm_config.to_string(), - attestation: Vec::new(), + event_log_ccel: Vec::new(), }) } - fn attest_response(&self, report_data: [u8; 64]) -> Result { + fn attest_response( + &self, + report_data: [u8; 64], + _include_hash_inputs: bool, + ) -> Result { let attestation = patch_report_data(&self.attestation, report_data); Ok(AttestResponse { attestation: VersionedAttestation::V1 { attestation }.to_bytes()?, }) } + + fn emit_event( + &self, + _event: &str, + _payload: &[u8], + _version: EventLogVersion, + ) -> Result<()> { + Ok(()) + } } let inner = AppStateInner { diff --git a/dstack/kms/src/main_service/upgrade_authority.rs b/dstack/kms/src/main_service/upgrade_authority.rs index bb63df6b5..90ad73d06 100644 --- a/dstack/kms/src/main_service/upgrade_authority.rs +++ b/dstack/kms/src/main_service/upgrade_authority.rs @@ -182,7 +182,12 @@ pub(crate) fn dstack_client() -> DstackGuestClient { } pub(crate) async fn app_attest(report_data: Vec) -> Result { - dstack_client().attest(RawQuoteArgs { report_data }).await + dstack_client() + .attest(RawQuoteArgs { + report_data, + include_hash_inputs: false, + }) + .await } pub(crate) fn pad64(hash: [u8; 32]) -> Vec { diff --git a/dstack/vmm/src/vmm-cli.py b/dstack/vmm/src/vmm-cli.py index b86fc8981..f1128aae1 100755 --- a/dstack/vmm/src/vmm-cli.py +++ b/dstack/vmm/src/vmm-cli.py @@ -841,6 +841,8 @@ def create_app_compose(self, args) -> None: app_compose["swap_size"] = swap_bytes else: app_compose.pop("swap_size", None) + if args.event_log_version is not None: + app_compose["event_log_version"] = args.event_log_version compose_file = json.dumps(app_compose, indent=4, ensure_ascii=False).encode( "utf-8" @@ -1720,6 +1722,13 @@ def _patched_format_help(): default=None, help="Swap size (e.g. 4G). Set to 0 to disable", ) + compose_parser.add_argument( + "--event-log-version", + type=int, + choices=[1, 2], + default=None, + help="RTMR3 runtime event-log digest format (1: legacy binary, 2: JCS canonical JSON). Omit to use the guest default (1).", + ) compose_parser.add_argument( "--output", required=True, help="Path to output app-compose.json file" ) diff --git a/dstack/vmm/ui/src/components/CreateVmDialog.ts b/dstack/vmm/ui/src/components/CreateVmDialog.ts index 0eabb9cd3..05b363477 100644 --- a/dstack/vmm/ui/src/components/CreateVmDialog.ts +++ b/dstack/vmm/ui/src/components/CreateVmDialog.ts @@ -177,6 +177,16 @@ const CreateVmDialogComponent = { +
+ + +
+
diff --git a/dstack/vmm/ui/src/composables/useVmManager.ts b/dstack/vmm/ui/src/composables/useVmManager.ts index 609de8aa5..62420fcb0 100644 --- a/dstack/vmm/ui/src/composables/useVmManager.ts +++ b/dstack/vmm/ui/src/composables/useVmManager.ts @@ -32,6 +32,7 @@ type AppCompose = { launch_token_hash?: string; pre_launch_script?: string; init_script?: string; + event_log_version?: number; }; type KeyProviderKind = 'none' | 'kms' | 'local' | 'tpm'; @@ -129,6 +130,7 @@ type VmFormState = { kms_urls: string[]; gateway_urls: string[]; stopped: boolean; + event_log_version: number; }; type UpdateDialogState = { @@ -215,6 +217,7 @@ function createVmFormState(preLaunchScript: string): VmFormState { kms_urls: [], gateway_urls: [], stopped: false, + event_log_version: 1, }; } @@ -850,6 +853,10 @@ type CreateVmPayloadSource = { appCompose.swap_size = swapBytes; } + if (vmForm.value.event_log_version && vmForm.value.event_log_version !== 1) { + appCompose.event_log_version = vmForm.value.event_log_version; + } + const launchToken = vmForm.value.encryptedEnvs.find((env) => env.key === 'APP_LAUNCH_TOKEN'); if (launchToken) { appCompose.launch_token_hash = await calcComposeHash(launchToken.value); @@ -1240,6 +1247,7 @@ type CreateVmPayloadSource = { no_tee: !!config.no_tee, user_config: config.user_config || '', stopped: !!config.stopped, + event_log_version: theVm.appCompose?.event_log_version || 1, }; // Show Create VM dialog instead of Clone Config dialog diff --git a/sdk/go/dstack/client.go b/sdk/go/dstack/client.go index 4e8fa30e7..9389cf6d9 100644 --- a/sdk/go/dstack/client.go +++ b/sdk/go/dstack/client.go @@ -86,11 +86,11 @@ func (r *GetKeyResponse) DecodeSignatureChain() ([][]byte, error) { // Represents the response from a quote request. type GetQuoteResponse struct { - Quote string `json:"quote"` - EventLog string `json:"event_log"` - ReportData string `json:"report_data"` - VmConfig string `json:"vm_config"` - Attestation string `json:"attestation"` + Quote string `json:"quote"` + EventLog string `json:"event_log"` + ReportData string `json:"report_data"` + VmConfig string `json:"vm_config"` + EventLogCCEL string `json:"event_log_ccel"` } // DecodeQuote returns the quote bytes @@ -114,6 +114,11 @@ func (r *GetQuoteResponse) DecodeEventLog() ([]EventLog, error) { return events, err } +// DecodeEventLogCCEL returns the TCG binary CCEL event log bytes. +func (r *GetQuoteResponse) DecodeEventLogCCEL() ([]byte, error) { + return hex.DecodeString(r.EventLogCCEL) +} + // Represents the response from an attestation request. type AttestResponse struct { Attestation []byte diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index 8226cbc27..8a67953b3 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -98,7 +98,7 @@ export interface GetQuoteResponse { event_log: string report_data?: Hex vm_config?: string - attestation?: Hex + event_log_ccel?: Hex replayRtmrs: () => string[] } diff --git a/sdk/python/src/dstack_sdk/dstack_client.py b/sdk/python/src/dstack_sdk/dstack_client.py index 07cfd49bf..4ee17fec0 100644 --- a/sdk/python/src/dstack_sdk/dstack_client.py +++ b/sdk/python/src/dstack_sdk/dstack_client.py @@ -160,7 +160,7 @@ class GetQuoteResponse(BaseModel): event_log: str report_data: str = "" vm_config: str = "" - attestation: str = "" + event_log_ccel: str = "" def decode_quote(self) -> bytes: return bytes.fromhex(self.quote) @@ -168,6 +168,9 @@ def decode_quote(self) -> bytes: def decode_event_log(self) -> "List[EventLog]": return [EventLog(**event) for event in json.loads(self.event_log)] + def decode_event_log_ccel(self) -> bytes: + return bytes.fromhex(self.event_log_ccel) + def replay_rtmrs(self) -> Dict[int, str]: parsed_event_log = json.loads(self.event_log) rtmrs: Dict[int, str] = {} diff --git a/sdk/rust/types/src/dstack.rs b/sdk/rust/types/src/dstack.rs index d97b169b8..1edb86da5 100644 --- a/sdk/rust/types/src/dstack.rs +++ b/sdk/rust/types/src/dstack.rs @@ -111,11 +111,10 @@ pub struct GetQuoteResponse { /// VM configuration #[serde(default)] pub vm_config: String, - /// Platform-adaptive versioned attestation in hexadecimal format. Populated - /// for every TEE platform (TDX, AMD SEV-SNP, ...); this is the payload to - /// send to dstack-verifier for platform-agnostic verification. + /// Merged TCG binary event log (boot-time CCEL + runtime events as + /// TCG_PCR_EVENT2), hex-encoded. Empty on platforms without ACPI CCEL. #[serde(default)] - pub attestation: String, + pub event_log_ccel: String, } /// Response containing a versioned attestation @@ -156,6 +155,10 @@ impl GetQuoteResponse { serde_json::from_str(&self.event_log) } + pub fn decode_event_log_ccel(&self) -> Result, FromHexError> { + hex::decode(&self.event_log_ccel) + } + pub fn replay_rtmrs(&self) -> Result> { let parsed_event_log: Vec = self.decode_event_log()?; let mut rtmrs = BTreeMap::new(); From 7345bc9d1818154e87cbb14e45ade1ac54ad7c64 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 21 Jul 2026 23:52:11 -0700 Subject: [PATCH 2/7] fix: preserve GetQuote attestation compatibility after rebase --- dstack/guest-agent-simulator/src/simulator.rs | 5 + dstack/guest-agent/rpc/proto/agent_rpc.proto | 3 +- dstack/guest-agent/src/backend.rs | 10 + dstack/kms/src/main_service.rs | 2 +- dstack/vmm/src/console_v1.html | 21552 ++++++++++++++++ sdk/go/dstack/client.go | 1 + sdk/js/src/index.ts | 1 + sdk/python/src/dstack_sdk/dstack_client.py | 1 + sdk/rust/types/src/dstack.rs | 2 + 9 files changed, 21575 insertions(+), 2 deletions(-) create mode 100644 dstack/vmm/src/console_v1.html diff --git a/dstack/guest-agent-simulator/src/simulator.rs b/dstack/guest-agent-simulator/src/simulator.rs index 720d0626c..edbd0fcf5 100644 --- a/dstack/guest-agent-simulator/src/simulator.rs +++ b/dstack/guest-agent-simulator/src/simulator.rs @@ -35,6 +35,10 @@ pub fn simulated_quote_response( let Some(quote) = attestation.tdx_quote_bytes() else { return Err(anyhow!("Quote not found")); }; + let versioned = VersionedAttestation::V1 { + attestation: attestation.clone(), + } + .to_bytes()?; Ok(GetQuoteResponse { quote, @@ -43,6 +47,7 @@ pub fn simulated_quote_response( .unwrap_or_default(), report_data: report_data.to_vec(), vm_config: vm_config.to_string(), + attestation: versioned, event_log_ccel: Vec::new(), }) } diff --git a/dstack/guest-agent/rpc/proto/agent_rpc.proto b/dstack/guest-agent/rpc/proto/agent_rpc.proto index b4cd68928..33977fcca 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -234,7 +234,8 @@ message GetQuoteResponse { // V2: canonical JSON (UTF-8) // // Empty on platforms without an ACPI CCEL table (e.g. Nitro Enclaves). - bytes event_log_ccel = 5; + bytes attestation = 5; + bytes event_log_ccel = 6; } message EmitEventArgs { diff --git a/dstack/guest-agent/src/backend.rs b/dstack/guest-agent/src/backend.rs index 12c6eae99..b5b5fa31c 100644 --- a/dstack/guest-agent/src/backend.rs +++ b/dstack/guest-agent/src/backend.rs @@ -53,11 +53,21 @@ impl PlatformBackend for RealPlatform { let tdx_quote = attestation.get_tdx_quote_bytes(); let tdx_event_log = attestation.get_tdx_event_log_string(include_hash_inputs); let event_log_ccel = attestation.get_tdx_event_log_ccel().unwrap_or_default(); + let versioned = if tdx_quote.is_some() { + Vec::new() + } else { + attestation + .clone() + .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(), report_data: report_data.to_vec(), vm_config: vm_config.to_string(), + attestation: versioned, event_log_ccel, }) } diff --git a/dstack/kms/src/main_service.rs b/dstack/kms/src/main_service.rs index df51d48c0..339fbd3bc 100644 --- a/dstack/kms/src/main_service.rs +++ b/dstack/kms/src/main_service.rs @@ -781,7 +781,7 @@ mod tests { } fn runtime_event(event: &str, payload: Vec) -> RuntimeEvent { - RuntimeEvent::new(event.to_string(), payload) + RuntimeEvent::new(event.to_string(), payload, Default::default()) } fn verified_aws_nitro_tpm_attestation( diff --git a/dstack/vmm/src/console_v1.html b/dstack/vmm/src/console_v1.html new file mode 100644 index 000000000..b38ec2f5f --- /dev/null +++ b/dstack/vmm/src/console_v1.html @@ -0,0 +1,21552 @@ + + + + + + + + {{TITLE}} + + + + +
+ + + + + diff --git a/sdk/go/dstack/client.go b/sdk/go/dstack/client.go index 9389cf6d9..1bc97c68a 100644 --- a/sdk/go/dstack/client.go +++ b/sdk/go/dstack/client.go @@ -90,6 +90,7 @@ type GetQuoteResponse struct { EventLog string `json:"event_log"` ReportData string `json:"report_data"` VmConfig string `json:"vm_config"` + Attestation string `json:"attestation"` EventLogCCEL string `json:"event_log_ccel"` } diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index 8a67953b3..b7d22f1e8 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -98,6 +98,7 @@ export interface GetQuoteResponse { event_log: string report_data?: Hex vm_config?: string + attestation?: Hex event_log_ccel?: Hex replayRtmrs: () => string[] diff --git a/sdk/python/src/dstack_sdk/dstack_client.py b/sdk/python/src/dstack_sdk/dstack_client.py index 4ee17fec0..e2233d849 100644 --- a/sdk/python/src/dstack_sdk/dstack_client.py +++ b/sdk/python/src/dstack_sdk/dstack_client.py @@ -160,6 +160,7 @@ class GetQuoteResponse(BaseModel): event_log: str report_data: str = "" vm_config: str = "" + attestation: str = "" event_log_ccel: str = "" def decode_quote(self) -> bytes: diff --git a/sdk/rust/types/src/dstack.rs b/sdk/rust/types/src/dstack.rs index 1edb86da5..f3ded10dc 100644 --- a/sdk/rust/types/src/dstack.rs +++ b/sdk/rust/types/src/dstack.rs @@ -114,6 +114,8 @@ pub struct GetQuoteResponse { /// Merged TCG binary event log (boot-time CCEL + runtime events as /// TCG_PCR_EVENT2), hex-encoded. Empty on platforms without ACPI CCEL. #[serde(default)] + pub attestation: String, + #[serde(default)] pub event_log_ccel: String, } From 69160b038ae13392089fab90d90d6c2a2c5fedca Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 22 Jul 2026 00:02:07 -0700 Subject: [PATCH 3/7] fix: do not restore removed VMM console artifact --- dstack/vmm/src/console_v1.html | 21552 ------------------------------- 1 file changed, 21552 deletions(-) delete mode 100644 dstack/vmm/src/console_v1.html diff --git a/dstack/vmm/src/console_v1.html b/dstack/vmm/src/console_v1.html deleted file mode 100644 index b38ec2f5f..000000000 --- a/dstack/vmm/src/console_v1.html +++ /dev/null @@ -1,21552 +0,0 @@ - - - - - - - - {{TITLE}} - - - - -
- - - - - From 240770880904eee6f4ffb8d808fd4b77ed1fe6d8 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 22 Jul 2026 00:03:22 -0700 Subject: [PATCH 4/7] fix: initialize compatibility attestation in guest-agent test --- dstack/guest-agent/src/rpc_service.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index 96056a6c2..be49a6fde 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -897,6 +897,7 @@ pNs85uhOZE8z2jr8Pg== .unwrap_or_default(), report_data: report_data.to_vec(), vm_config: vm_config.to_string(), + attestation: Vec::new(), event_log_ccel: Vec::new(), }) } From 9fcf6a123ec28ecc67b8ec52a86b112acbc6a7fa Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 22 Jul 2026 00:04:34 -0700 Subject: [PATCH 5/7] fix: keep generated VMM console ignored --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3c5562374..b40b898a3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,5 @@ node_modules/ __pycache__ /.ruff_cache/ .planning/ -/vmm/src/console_v1.html +/dstack/vmm/src/console_v1.html .claude/worktrees/ From 3f81997f5156376728d5bc3cd5ee4682d6aebd0f Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 22 Jul 2026 00:18:15 -0700 Subject: [PATCH 6/7] fix: initialize event metadata in verifier test --- dstack/verifier/src/verification.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dstack/verifier/src/verification.rs b/dstack/verifier/src/verification.rs index 34c7a835e..1fdf9792d 100644 --- a/dstack/verifier/src/verification.rs +++ b/dstack/verifier/src/verification.rs @@ -1354,6 +1354,8 @@ mod tests { digest: vec![digest_byte; 48], event: name.to_string(), event_payload: TDX_ACPI_DATA_EVENT_PAYLOAD.to_vec(), + version: Default::default(), + hash_input: None, } } From cf6c216e610a39f1d3e1b32bd392eb8ce35b7bd9 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 22 Jul 2026 01:26:13 -0700 Subject: [PATCH 7/7] fix: address event log v2 compatibility review --- docs/security/security-best-practices.md | 14 + dstack/cc-eventlog/src/lib.rs | 7 + dstack/cc-eventlog/src/runtime_events.rs | 6 +- dstack/cc-eventlog/src/tcg.rs | 18 +- dstack/cc-eventlog/src/tdx.rs | 8 + dstack/dstack-attest/src/attestation.rs | 310 +++++++++++++++--- dstack/dstack-attest/src/lib.rs | 10 +- dstack/dstack-attest/src/v1.rs | 13 + dstack/dstack-util/src/main.rs | 17 +- dstack/guest-agent-simulator/src/main.rs | 16 +- dstack/guest-agent-simulator/src/simulator.rs | 2 +- dstack/guest-agent/rpc/proto/agent_rpc.proto | 15 +- dstack/guest-agent/src/backend.rs | 21 +- dstack/guest-agent/src/rpc_service.rs | 9 - sdk/rust/types/src/dstack.rs | 7 +- 15 files changed, 370 insertions(+), 103 deletions(-) diff --git a/docs/security/security-best-practices.md b/docs/security/security-best-practices.md index 73760ea82..1b74c3d2c 100644 --- a/docs/security/security-best-practices.md +++ b/docs/security/security-best-practices.md @@ -162,3 +162,17 @@ services: image: nginx@sha256:eee5eae48e79b2e75178328c7c585b89d676eaae616f03f9a1813aaed820745a network_mode: host ``` +## Runtime event-log V2 policies + +Event-log V2 exposes canonical digest pre-images so a relying party can check +individual claims such as `compose-hash`. Verifying +`sha384(hash_input) == digest` proves only that those bytes participate in the +quoted RTMR/PCR extension chain. It does **not** prove that trusted dstack boot +code originated the event name: privileged code inside the CVM can append +additional measured events after boot. + +Policies that trust a named V2 event must therefore also validate ordering and +the boot boundary. In particular, select the expected claim before +`boot-mr-done`/`system-ready`, reject duplicate trusted claim names, and replay +the complete quoted chain. Never accept an arbitrary later event solely because +its digest matches its supplied pre-image. diff --git a/dstack/cc-eventlog/src/lib.rs b/dstack/cc-eventlog/src/lib.rs index a9ff449c8..2720a9390 100644 --- a/dstack/cc-eventlog/src/lib.rs +++ b/dstack/cc-eventlog/src/lib.rs @@ -77,6 +77,13 @@ mod tests { // hash_input bytes we stored as TCG event data. let runtime = orig.to_runtime_event().unwrap(); assert_eq!(got.event_payload, runtime.hash_input()); + assert_eq!( + got.hash_input.as_deref(), + Some(hex::encode(&got.event_payload).as_str()) + ); + // Decoded CCEL records do not retain the structured dstack event + // tuple, so digest() must use the authoritative stored digest. + assert_eq!(got.digest(), got.digest); // Property: sha384(event_data) == digest let h = Sha384::hash([got.event_payload.as_slice()]); assert_eq!(h.as_slice(), got.digest.as_slice()); diff --git a/dstack/cc-eventlog/src/runtime_events.rs b/dstack/cc-eventlog/src/runtime_events.rs index c6ba8cedf..9fc3bfab7 100644 --- a/dstack/cc-eventlog/src/runtime_events.rs +++ b/dstack/cc-eventlog/src/runtime_events.rs @@ -33,11 +33,15 @@ pub struct RuntimeEvent { #[serde(with = "base64")] pub payload: Vec, /// Event log version - #[serde(default)] + #[serde(default, skip_serializing_if = "is_v1")] #[codec(skip)] pub version: EventLogVersion, } +fn is_v1(version: &EventLogVersion) -> bool { + matches!(version, EventLogVersion::V1) +} + impl RuntimeEvent { pub fn new(event: String, payload: Vec, version: EventLogVersion) -> Self { Self { diff --git a/dstack/cc-eventlog/src/tcg.rs b/dstack/cc-eventlog/src/tcg.rs index b2756db1c..aaadf1671 100644 --- a/dstack/cc-eventlog/src/tcg.rs +++ b/dstack/cc-eventlog/src/tcg.rs @@ -4,7 +4,7 @@ // // SPDX-License-Identifier: Apache-2.0 -use crate::{codecs::VecOf, tdx::TdxEvent}; +use crate::{codecs::VecOf, runtime_events::DSTACK_RUNTIME_EVENT_TYPE, tdx::TdxEvent}; use anyhow::{bail, Context, Result}; use scale::Decode; use std::path::PathBuf; @@ -316,7 +316,10 @@ impl TcgEventLog { } } -/// Read the raw ACPI CCEL table bytes from `/sys/firmware/acpi/tables/data/CCEL`. +/// Resolve the CCEL path, honoring `DSTACK_CCEL_FILE` when set. +/// +/// Overrides must be non-empty absolute paths so relative values cannot +/// silently resolve against the process working directory. fn ccel_file_path() -> Result { let Some(value) = std::env::var_os(CCEL_FILE_ENV) else { return Ok(PathBuf::from(CCEL_FILE)); @@ -334,8 +337,10 @@ fn ccel_file_path() -> Result { Ok(path) } +/// Read the raw ACPI CCEL table bytes. pub fn read_ccel_raw() -> Result> { - fs_err::read(ccel_file_path()?).context("Failed to read CCEL") + let path = ccel_file_path()?; + fs_err::read(&path).with_context(|| format!("failed to read CCEL from {}", path.display())) } /// Return the length of the valid TCG event log prefix within a raw CCEL buffer. @@ -425,6 +430,9 @@ impl TryFrom for TdxEvent { .next() .context("digest not found")? .hash; + let event_payload: Vec = value.event.into(); + let hash_input = + (value.event_type == DSTACK_RUNTIME_EVENT_TYPE).then(|| hex::encode(&event_payload)); Ok(TdxEvent { imr: value .imr_index @@ -433,9 +441,9 @@ impl TryFrom for TdxEvent { event_type: value.event_type, digest, event: Default::default(), - event_payload: value.event.into(), + event_payload, version: Default::default(), - hash_input: None, + hash_input, }) } } diff --git a/dstack/cc-eventlog/src/tdx.rs b/dstack/cc-eventlog/src/tdx.rs index 452e17d95..d9c902790 100644 --- a/dstack/cc-eventlog/src/tdx.rs +++ b/dstack/cc-eventlog/src/tdx.rs @@ -133,12 +133,20 @@ impl TdxEvent { /// For boot-time TCG events, the pre-image is inherent in the original log format /// and not reconstructable from this struct, so `hash_input` stays `None`. pub fn fill_hash_input(&mut self) { + if self.hash_input.is_some() { + return; + } if let Some(runtime_event) = self.to_runtime_event() { self.hash_input = Some(hex::encode(runtime_event.hash_input())); } } pub fn digest(&self) -> Vec { + // Parsed TCG/CCEL records already carry the authoritative digest, but + // do not necessarily retain dstack's structured event name/payload. + if !self.digest.is_empty() { + return self.digest.clone(); + } if let Some(runtime_event) = self.to_runtime_event() { return runtime_event.sha384_digest().to_vec(); } diff --git a/dstack/dstack-attest/src/attestation.rs b/dstack/dstack-attest/src/attestation.rs index dbdcc53db..be4419e5f 100644 --- a/dstack/dstack-attest/src/attestation.rs +++ b/dstack/dstack-attest/src/attestation.rs @@ -814,10 +814,12 @@ pub trait TdxAttestationExt { fn tdx_event_log(&self) -> Option<&[TdxEvent]>; /// Returns the TDX event log serialized as JSON. - /// - /// When `include_hash_inputs` is true, each runtime event carries its - /// digest pre-image (hex-encoded) so relying parties can verify it directly. - fn tdx_event_log_string(&self, include_hash_inputs: bool) -> Option { + fn tdx_event_log_string(&self) -> Option { + self.tdx_event_log_string_with_hash_inputs(false) + } + + /// Returns JSON and optionally attaches each runtime digest pre-image. + fn tdx_event_log_string_with_hash_inputs(&self, include_hash_inputs: bool) -> Option { self.tdx_event_log().map(|event_log| { if include_hash_inputs { let mut events: Vec = event_log.to_vec(); @@ -1471,35 +1473,44 @@ impl Attestation { /// /// When `include_hash_inputs` is true, each runtime event carries its digest /// pre-image (hex-encoded) so relying parties can verify or inspect it directly. - pub fn get_tdx_event_log_string(&self, include_hash_inputs: bool) -> Option { - self.tdx_quote().map(|q| { - let mut stripped: Vec<_> = q.event_log.iter().map(|e| e.stripped()).collect(); - if include_hash_inputs { - for event in &mut stripped { - event.fill_hash_input(); - } - } - serde_json::to_string(&stripped).unwrap_or_default() - }) + pub fn get_tdx_event_log_string(&self) -> Option { + self.get_tdx_event_log_string_with_hash_inputs(false) } - pub fn get_tdx_event_log_string_for_config(&self, _config: &str) -> Option { + /// Get the stripped TDX event log and optionally attach hash pre-images. + pub fn get_tdx_event_log_string_with_hash_inputs( + &self, + include_hash_inputs: bool, + ) -> Option { self.tdx_quote().map(|q| { - let stripped: Vec<_> = q + let mut stripped: Vec<_> = q .event_log .iter() - .map(|e| { - let mut stripped = e.stripped(); - if cc_eventlog::tdx::is_tdx_acpi_data_event(e) { - stripped.event_payload = e.event_payload.clone(); + .map(|event| { + let mut stripped = event.stripped(); + // Keep the marker used by TDX-lite verification to identify + // the three RTMR0 ACPI digest events. + if cc_eventlog::tdx::is_tdx_acpi_data_event(event) { + stripped.event_payload = event.event_payload.clone(); } stripped }) .collect(); + if include_hash_inputs { + for event in &mut stripped { + event.fill_hash_input(); + } + } serde_json::to_string(&stripped).unwrap_or_default() }) } + /// Compatibility wrapper retained for callers that still pass vm_config. + /// The config no longer changes event-log stripping behavior. + pub fn get_tdx_event_log_string_for_config(&self, _config: &str) -> Option { + self.get_tdx_event_log_string() + } + pub fn get_td10_report(&self) -> Option { self.tdx_quote() .and_then(|q| Quote::parse(&q.quote).ok()) @@ -2127,14 +2138,6 @@ impl Attestation { } pub fn quote_with_app_id(report_data: &[u8; 64], app_id: Option<[u8; 20]>) -> Result { - Self::quote_with_app_id_and_event_log_version(report_data, app_id, EventLogVersion::V1) - } - - pub fn quote_with_app_id_and_event_log_version( - report_data: &[u8; 64], - app_id: Option<[u8; 20]>, - event_log_version: EventLogVersion, - ) -> Result { // Lock to prevent concurrent quote generation (TDX driver doesn't support it) let _guard = QUOTE_LOCK .lock() @@ -2145,9 +2148,12 @@ impl Attestation { TeeVariant::DstackAmdSevSnp | TeeVariant::DstackTdx | TeeVariant::DstackGcpTdx + // AWS prefers host-shared vm_config because it carries the + // aws_measurement and unified os_image_hash validated below. | TeeVariant::DstackAwsNitroTpm => { read_vm_config().context("Failed to read vm config")? } + // NitroEnclave derives config from the signed image hash below. TeeVariant::DstackNitroEnclave => String::new(), }; let runtime_events = match mode { @@ -2159,7 +2165,7 @@ impl Attestation { Some(app_id) => vec![RuntimeEvent::new( "app-id".to_string(), app_id.to_vec(), - event_log_version, + EventLogVersion::V1, )], None => vec![], }, @@ -2670,37 +2676,43 @@ mod tests { } #[test] - fn tdx_event_log_string_always_keeps_acpi_data_payloads() { + fn get_quote_event_log_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, 10, b"ACPI DATA"), tdx_event(0, 10, b"ACPI DATA"), tdx_event(0, 4, b"boot-payload"), - tdx_event(3, 8, b"runtime-payload"), + tdx_event( + 3, + cc_eventlog::DSTACK_RUNTIME_EVENT_TYPE, + b"runtime-payload", + ), ]; // The ACPI DATA marker payload is retained regardless of the // vm_config's tdx_attestation_variant (including no vm_config at // all), so a verifier can choose lite verification for any TDX boot. - for config in [ - r#"{"tdx_attestation_variant":"lite"}"#, - r#"{"tdx_attestation_variant":"legacy"}"#, - "", - ] { + for include_hash_inputs in [false, true] { let events: Vec = serde_json::from_str( &attestation - .get_tdx_event_log_string_for_config(config) + .get_tdx_event_log_string_with_hash_inputs(include_hash_inputs) .expect("TDX event log"), ) - .unwrap_or_else(|e| panic!("decode event log for config {config:?}: {e}")); + .unwrap_or_else(|e| panic!("decode GetQuote event log: {e}")); assert_eq!( - events[0].event_payload, b"ACPI DATA", - "config {config:?} must keep the ACPI DATA marker payload" + events + .iter() + .filter(|event| cc_eventlog::tdx::is_tdx_acpi_data_event(event)) + .count(), + 3, + "GetQuote must retain all three TDX-lite ACPI DATA markers" ); - assert!(events[1].event_payload.is_empty()); - assert!(events[2].event_payload.is_empty()); + assert!(events[3].event_payload.is_empty()); + assert_eq!(events[4].event_payload, b"runtime-payload"); } } @@ -2842,4 +2854,218 @@ mod tests { "presence of a V2 event must force the V1 msgpack wire format to preserve `version`" ); } + fn v1_event(event: String, payload: Vec) -> RuntimeEvent { + RuntimeEvent::new(event, payload, EventLogVersion::V1) + } + + #[test] + fn nitro_pcrs_from_verified_extracts_0_1_2() { + let mut map = std::collections::BTreeMap::new(); + map.insert(0u16, vec![0xaa; 48]); + map.insert(1u16, vec![0xbb; 48]); + map.insert(2u16, vec![0xcc; 48]); + map.insert(3u16, vec![0xdd; 48]); // ignored + let pcrs = NitroPcrs::from_verified(&map).unwrap(); + assert_eq!(pcrs.pcr0, vec![0xaa; 48]); + assert_eq!(pcrs.pcr1, vec![0xbb; 48]); + assert_eq!(pcrs.pcr2, vec![0xcc; 48]); + + // missing a required PCR is an error + map.remove(&1u16); + assert!(NitroPcrs::from_verified(&map).is_err()); + } + + #[test] + fn nitro_pcrs_debug_detection_and_image_hash() { + let debug = NitroPcrs { + pcr0: vec![0u8; 48], + pcr1: vec![0u8; 48], + pcr2: vec![0u8; 48], + }; + assert!(debug.is_debug()); + + let prod = NitroPcrs { + pcr0: vec![1u8; 48], + pcr1: vec![0u8; 48], + pcr2: vec![0u8; 48], + }; + assert!(!prod.is_debug()); + // image_hash = sha256(pcr0 || pcr1 || pcr2), never the all-zero sentinel + assert_eq!( + prod.image_hash(), + sha256([&prod.pcr0, &prod.pcr1, &prod.pcr2]).to_vec() + ); + } + + #[test] + fn aws_nitro_tpm_mr_aggregated_replays_pcr14_like_rtmr3() -> Result<()> { + let pcr4 = vec![0x04; 48]; + let pcr7 = vec![0x07; 48]; + let pcr12 = vec![0x12; 48]; + let mut pcrs = std::collections::BTreeMap::new(); + pcrs.insert(4u16, pcr4.clone()); + pcrs.insert(7u16, pcr7.clone()); + pcrs.insert(12u16, pcr12.clone()); + + let mr_key_provider = sha256(b"aws nitrotpm key provider"); + let events = vec![ + v1_event("system-preparing".into(), Vec::new()), + v1_event("app-id".into(), vec![0x11; 20]), + v1_event("compose-hash".into(), vec![0x22; 32]), + v1_event("instance-id".into(), vec![0x33; 20]), + v1_event("boot-mr-done".into(), Vec::new()), + v1_event("key-provider".into(), b"tpm".to_vec()), + v1_event("system-ready".into(), Vec::new()), + ]; + let replayed_pcr14 = cc_eventlog::replay_events::(&events, None); + pcrs.insert(AWS_NITRO_TPM_EVENT_PCR, replayed_pcr14.to_vec()); + + let mrs = decode_mr_aws_nitro_tpm_from_pcrs(false, &mr_key_provider, &pcrs, &events)?; + + assert_eq!( + mrs.mr_system, + sha256([ + pcr4.as_slice(), + pcr7.as_slice(), + pcr12.as_slice(), + mr_key_provider.as_slice(), + ]) + ); + assert_eq!( + mrs.mr_aggregated, + sha256([ + pcr4.as_slice(), + pcr7.as_slice(), + pcr12.as_slice(), + replayed_pcr14.as_slice(), + ]) + ); + + let mut changed_events = events.clone(); + changed_events[2] = v1_event("compose-hash".into(), vec![0xee; 32]); + let changed_pcr14 = cc_eventlog::replay_events::(&changed_events, None); + let mut changed_pcrs = pcrs.clone(); + changed_pcrs.insert(AWS_NITRO_TPM_EVENT_PCR, changed_pcr14.to_vec()); + let changed_mrs = decode_mr_aws_nitro_tpm_from_pcrs( + false, + &mr_key_provider, + &changed_pcrs, + &changed_events, + )?; + + assert_eq!(mrs.mr_system, changed_mrs.mr_system); + assert_ne!(mrs.mr_aggregated, changed_mrs.mr_aggregated); + + let mut changed_pcrs = pcrs.clone(); + changed_pcrs.insert(12, vec![0x99; 48]); + let changed_pcr12 = + decode_mr_aws_nitro_tpm_from_pcrs(false, &mr_key_provider, &changed_pcrs, &events)?; + assert_ne!(mrs.mr_system, changed_pcr12.mr_system); + assert_ne!(mrs.mr_aggregated, changed_pcr12.mr_aggregated); + + let mut missing_pcrs = pcrs.clone(); + missing_pcrs.remove(&AWS_NITRO_TPM_EVENT_PCR); + let err = match decode_mr_aws_nitro_tpm_from_pcrs( + false, + &mr_key_provider, + &missing_pcrs, + &events, + ) { + Ok(_) => panic!("missing PCR14 must be rejected"), + Err(err) => err, + }; + assert!(format!("{err:#}").contains("PCR 14 not found")); + + let mut mismatched_pcrs = pcrs.clone(); + mismatched_pcrs.insert(AWS_NITRO_TPM_EVENT_PCR, vec![0xff; 48]); + let err = match decode_mr_aws_nitro_tpm_from_pcrs( + false, + &mr_key_provider, + &mismatched_pcrs, + &events, + ) { + Ok(_) => panic!("mismatched PCR14 must be rejected"), + Err(err) => err, + }; + assert!(format!("{err:#}").contains("PCR14 mismatch")); + Ok(()) + } + + #[test] + fn aws_nitro_tpm_pcr14_replays_full_event_log_like_rtmr3() -> Result<()> { + // Single PCR14 lane: all events (including after system-ready) are + // measured and must be replayed for full (non-boottime) decode. + let events = vec![ + v1_event("system-preparing".into(), Vec::new()), + v1_event("app-id".into(), vec![0x11; 20]), + v1_event("compose-hash".into(), vec![0x22; 32]), + v1_event("instance-id".into(), vec![0x33; 20]), + v1_event("boot-mr-done".into(), Vec::new()), + v1_event("storage-fs".into(), b"ext4".to_vec()), + v1_event("system-ready".into(), Vec::new()), + v1_event("app-runtime".into(), b"ready".to_vec()), + ]; + + let full_pcr = cc_eventlog::replay_events::(&events, None); + let early_pcr = cc_eventlog::replay_events::(&events, Some("boot-mr-done")); + let mr_key_provider = sha256(b"aws nitrotpm key provider"); + let pcrs = std::collections::BTreeMap::from([ + (4u16, vec![0x04; 48]), + (7u16, vec![0x07; 48]), + (12u16, vec![0x12; 48]), + (AWS_NITRO_TPM_EVENT_PCR, full_pcr.to_vec()), + ]); + + let mrs = decode_mr_aws_nitro_tpm_from_pcrs(false, &mr_key_provider, &pcrs, &events)?; + assert_eq!( + mrs.mr_aggregated, + sha256([ + pcrs[&4].as_slice(), + pcrs[&7].as_slice(), + pcrs[&12].as_slice(), + full_pcr.as_slice(), + ]) + ); + + // A full runtime quote (PCR14 covers the whole log) decoded in + // boottime mode binds the full replay to the quoted register, then + // returns the boot-mr-done snapshot for the MR — it must NOT fail the + // integrity check. This is the SignCert path (runtime quote, boot-time + // MR). + let early_from_full = + decode_mr_aws_nitro_tpm_from_pcrs(true, &mr_key_provider, &pcrs, &events)?; + assert_eq!( + early_from_full.mr_aggregated, + sha256([ + pcrs[&4].as_slice(), + pcrs[&7].as_slice(), + pcrs[&12].as_slice(), + early_pcr.as_slice(), + ]) + ); + + // A genuine early quote carries both the truncated PCR14 and the + // truncated event log; the full replay of that log equals the quoted + // early PCR14, so the binding passes and the MR uses the same value. + let early_events: Vec = events + .iter() + .take_while(|event| event.event != "boot-mr-done") + .cloned() + .chain(std::iter::once(v1_event("boot-mr-done".into(), Vec::new()))) + .collect(); + let mut early_pcrs = pcrs.clone(); + early_pcrs.insert(AWS_NITRO_TPM_EVENT_PCR, early_pcr.to_vec()); + let early_ok = + decode_mr_aws_nitro_tpm_from_pcrs(true, &mr_key_provider, &early_pcrs, &early_events)?; + assert_eq!( + early_ok.mr_aggregated, + sha256([ + early_pcrs[&4].as_slice(), + early_pcrs[&7].as_slice(), + early_pcrs[&12].as_slice(), + early_pcr.as_slice(), + ]) + ); + Ok(()) + } } diff --git a/dstack/dstack-attest/src/lib.rs b/dstack/dstack-attest/src/lib.rs index 2a0fb7676..1d95b1d14 100644 --- a/dstack/dstack-attest/src/lib.rs +++ b/dstack/dstack-attest/src/lib.rs @@ -21,9 +21,17 @@ mod aws_nitro_tpm; mod sev_snp; mod v1; +/// Serializes measured event emission within this process. +/// +/// Appending to the event log and extending the platform register must be one +/// atomic unit so log order always matches measurement-extension order. static EMIT_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); -/// Emit a runtime event that extends RTMR3 and logs the event. +/// Emit a dstack measured event using the legacy V1 digest format. +/// +/// - TDX-family: RTMR3 +/// - GCP TPM: SHA256 PCR14 +/// - AWS NitroTPM: SHA384 PCR14 pub fn emit_runtime_event(event: &str, payload: &[u8]) -> anyhow::Result<()> { emit_runtime_event_with_version(event, payload, EventLogVersion::V1) } diff --git a/dstack/dstack-attest/src/v1.rs b/dstack/dstack-attest/src/v1.rs index 70b68259b..bf848563d 100644 --- a/dstack/dstack-attest/src/v1.rs +++ b/dstack/dstack-attest/src/v1.rs @@ -7,6 +7,7 @@ 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; @@ -132,6 +133,18 @@ impl PlatformEvidence { } } + pub fn sev_snp_mr_config_document(&self) -> Option<&str> { + match self { + Self::SevSnp { mr_config, .. } => Some(mr_config.as_str()), + _ => None, + } + } + + pub fn sev_snp_mr_config(&self) -> Option { + self.sev_snp_mr_config_document() + .and_then(|document| MrConfigV3::from_document(document).ok()) + } + pub fn tdx_event_log_mut(&mut self) -> Option<&mut Vec> { match self { Self::Tdx { event_log, .. } => Some(event_log), diff --git a/dstack/dstack-util/src/main.rs b/dstack/dstack-util/src/main.rs index bf4e8eb2a..a32d91a79 100644 --- a/dstack/dstack-util/src/main.rs +++ b/dstack/dstack-util/src/main.rs @@ -686,21 +686,22 @@ fn hex_decode(hex_str: &str) -> Result> { fn cmd_extend(extend_args: ExtendArgs) -> Result<()> { let payload = hex_decode(&extend_args.payload).context("Failed to decode payload")?; - let version = read_event_log_version(); + let version = read_event_log_version()?; emit_runtime_event_with_version(&extend_args.event, &payload, version) .context("Failed to extend RTMR") } -fn read_event_log_version() -> dstack_types::EventLogVersion { +fn read_event_log_version() -> Result { let path = std::path::Path::new(dstack_types::shared_filenames::HOST_SHARED_DIR) .join(dstack_types::shared_filenames::APP_COMPOSE); - let Ok(data) = std::fs::read_to_string(&path) else { - return Default::default(); + let data = match fs::read_to_string(&path) { + Ok(data) => data, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Default::default()), + Err(err) => return Err(err).with_context(|| format!("failed to read {}", path.display())), }; - let Ok(compose) = serde_json::from_str::(&data) else { - return Default::default(); - }; - compose.event_log_version + let compose = serde_json::from_str::(&data) + .with_context(|| format!("failed to parse {}", path.display()))?; + Ok(compose.event_log_version) } fn cmd_rand(rand_args: RandArgs) -> Result<()> { diff --git a/dstack/guest-agent-simulator/src/main.rs b/dstack/guest-agent-simulator/src/main.rs index 42ae13aec..4757688c6 100644 --- a/dstack/guest-agent-simulator/src/main.rs +++ b/dstack/guest-agent-simulator/src/main.rs @@ -6,7 +6,7 @@ mod simulator; use std::sync::Arc; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use clap::Parser; use dstack_guest_agent::{ backend::PlatformBackend, @@ -14,7 +14,6 @@ use dstack_guest_agent::{ run_server, AppState, }; use dstack_guest_agent_rpc::{AttestResponse, GetQuoteResponse}; -use dstack_types::EventLogVersion; use ra_tls::attestation::VersionedAttestation; use serde::Deserialize; use tracing::warn; @@ -105,10 +104,6 @@ impl PlatformBackend for SimulatorPlatform { include_hash_inputs, ) } - - fn emit_event(&self, event: &str, _payload: &[u8], _version: EventLogVersion) -> Result<()> { - bail!("runtime event emission is unavailable in simulator mode: {event}") - } } #[rocket::main] @@ -161,15 +156,6 @@ mod tests { SimulatorPlatform::new(fixture, true) } - #[test] - fn simulator_rejects_runtime_event_emission() { - let platform = load_fixture_platform(); - let err = platform - .emit_event("test.event", b"payload", EventLogVersion::V1) - .unwrap_err(); - assert!(err.to_string().contains("unavailable in simulator mode")); - } - #[test] fn simulator_provides_certificate_attestation() { let platform = load_fixture_platform(); diff --git a/dstack/guest-agent-simulator/src/simulator.rs b/dstack/guest-agent-simulator/src/simulator.rs index edbd0fcf5..5c6adce66 100644 --- a/dstack/guest-agent-simulator/src/simulator.rs +++ b/dstack/guest-agent-simulator/src/simulator.rs @@ -43,7 +43,7 @@ pub fn simulated_quote_response( Ok(GetQuoteResponse { quote, event_log: attestation - .tdx_event_log_string(include_hash_inputs) + .tdx_event_log_string_with_hash_inputs(include_hash_inputs) .unwrap_or_default(), report_data: report_data.to_vec(), vm_config: vm_config.to_string(), diff --git a/dstack/guest-agent/rpc/proto/agent_rpc.proto b/dstack/guest-agent/rpc/proto/agent_rpc.proto index 33977fcca..f902cac01 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -170,7 +170,9 @@ message TdxQuoteArgs { message RawQuoteArgs { // 64 bytes of report data bytes report_data = 1; - // If true, include the digest hash input for each runtime event in the response. + // If true, include digest hash inputs for TDX runtime events and include the + // merged TDX CCEL binary in GetQuoteResponse. This flag has no effect on + // non-TDX platforms, whose GetQuote event_log is empty. // Non-runtime events (boot-time/TCG events) will have hash_input unset. // The hash_input is hex-encoded bytes of the digest pre-image: // - v2 runtime events: hex of UTF-8 JCS canonical JSON @@ -214,6 +216,9 @@ message GetQuoteResponse { bytes report_data = 3; // Hw config string vm_config = 4; + // Platform-adaptive versioned attestation (SCALE/msgpack encoded). Populated + // on non-TDX platforms; TDX uses quote + event_log above. + bytes attestation = 5; // TCG binary Confidential Computing Event Log (CCEL) containing both // boot-time events (from the ACPI CCEL table) and dstack runtime events // (RTMR3) merged into a single stream. @@ -234,17 +239,9 @@ message GetQuoteResponse { // V2: canonical JSON (UTF-8) // // Empty on platforms without an ACPI CCEL table (e.g. Nitro Enclaves). - bytes attestation = 5; bytes event_log_ccel = 6; } -message EmitEventArgs { - // The event name - string event = 1; - // The event data - bytes payload = 2; -} - // The request to derive a key message AppInfo { // App ID diff --git a/dstack/guest-agent/src/backend.rs b/dstack/guest-agent/src/backend.rs index b5b5fa31c..3a972bcc5 100644 --- a/dstack/guest-agent/src/backend.rs +++ b/dstack/guest-agent/src/backend.rs @@ -3,9 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::{Context, Result}; -use dstack_attest::emit_runtime_event_with_version; use dstack_guest_agent_rpc::{AttestResponse, GetQuoteResponse}; -use dstack_types::EventLogVersion; use ra_tls::attestation::Attestation; use ra_tls::attestation::{QuoteContentType, VersionedAttestation}; @@ -23,7 +21,6 @@ pub trait PlatformBackend: Send + Sync { report_data: [u8; 64], include_hash_inputs: bool, ) -> Result; - fn emit_event(&self, event: &str, payload: &[u8], version: EventLogVersion) -> Result<()>; } #[derive(Debug, Default)] @@ -51,13 +48,21 @@ impl PlatformBackend for RealPlatform { ) -> 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(include_hash_inputs); - let event_log_ccel = attestation.get_tdx_event_log_ccel().unwrap_or_default(); + let tdx_event_log = + attestation.get_tdx_event_log_string_with_hash_inputs(include_hash_inputs); + // CCEL can be tens of KiB. Reuse the existing explicit hash-input + // opt-in because CCEL runtime records contain those same pre-images. + let event_log_ccel = if include_hash_inputs { + attestation + .get_tdx_event_log_ccel() + .context("failed to build TDX CCEL event log")? + } else { + Vec::new() + }; let versioned = if tdx_quote.is_some() { Vec::new() } else { attestation - .clone() .into_versioned() .to_bytes() .context("Failed to encode versioned attestation")? @@ -86,8 +91,4 @@ impl PlatformBackend for RealPlatform { attestation: attestation.into_versioned().to_bytes()?, }) } - - fn emit_event(&self, event: &str, payload: &[u8], version: EventLogVersion) -> Result<()> { - emit_runtime_event_with_version(event, payload, version) - } } diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index be49a6fde..b4d2ad42d 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -912,15 +912,6 @@ pNs85uhOZE8z2jr8Pg== attestation: VersionedAttestation::V1 { attestation }.to_bytes()?, }) } - - fn emit_event( - &self, - _event: &str, - _payload: &[u8], - _version: EventLogVersion, - ) -> Result<()> { - Ok(()) - } } let inner = AppStateInner { diff --git a/sdk/rust/types/src/dstack.rs b/sdk/rust/types/src/dstack.rs index f3ded10dc..e51b77d3e 100644 --- a/sdk/rust/types/src/dstack.rs +++ b/sdk/rust/types/src/dstack.rs @@ -111,10 +111,13 @@ pub struct GetQuoteResponse { /// VM configuration #[serde(default)] pub vm_config: String, - /// Merged TCG binary event log (boot-time CCEL + runtime events as - /// TCG_PCR_EVENT2), hex-encoded. Empty on platforms without ACPI CCEL. + /// Platform-adaptive versioned attestation, hex-encoded. Populated on + /// non-TDX platforms; TDX uses `quote` and `event_log`. #[serde(default)] pub attestation: String, + /// Merged TCG binary event log (boot-time CCEL + runtime events as + /// TCG_PCR_EVENT2), hex-encoded. Empty unless hash inputs were requested, + /// or on platforms without ACPI CCEL. #[serde(default)] pub event_log_ccel: String, }