diff --git a/.gitignore b/.gitignore index 77f37328..0de25f4a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ node_modules/ __pycache__ .planning/ /vmm/src/console_v1.html +.claude/worktrees/ diff --git a/Cargo.lock b/Cargo.lock index 59ac5fda..4ab18ed3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1315,13 +1315,16 @@ version = "0.5.8" 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", ] @@ -2409,6 +2412,7 @@ dependencies = [ "clap", "dstack-guest-agent", "dstack-guest-agent-rpc", + "dstack-types", "ra-rpc", "ra-tls", "rocket", @@ -6540,6 +6544,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + [[package]] name = "s2n-codec" version = "0.76.0" @@ -6965,6 +6975,17 @@ dependencies = [ "void", ] +[[package]] +name = "serde_jcs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3a60f3fda61525e439ef6d67422118f11e986566997d9021c56867ad814a0aa" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/cc-eventlog/Cargo.toml b/cc-eventlog/Cargo.toml index 2863760f..5fa010cf 100644 --- a/cc-eventlog/Cargo.toml +++ b/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/cc-eventlog/src/lib.rs b/cc-eventlog/src/lib.rs index e850f39c..a99484d9 100644 --- a/cc-eventlog/src/lib.rs +++ b/cc-eventlog/src/lib.rs @@ -2,7 +2,10 @@ // // 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; diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index fa948f36..c6ba8ced 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/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/cc-eventlog/src/tcg.rs b/cc-eventlog/src/tcg.rs index ff0aadc3..c54563f7 100644 --- a/cc-eventlog/src/tcg.rs +++ b/cc-eventlog/src/tcg.rs @@ -370,6 +370,8 @@ impl TryFrom for TdxEvent { digest, event: Default::default(), event_payload: value.event.into(), + version: Default::default(), + hash_input: None, }) } } diff --git a/cc-eventlog/src/tdx.rs b/cc-eventlog/src/tdx.rs index bf7d677c..cfe9f90f 100644 --- a/cc-eventlog/src/tdx.rs +++ b/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}; @@ -16,7 +17,9 @@ use crate::{ /// 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 @@ -31,6 +34,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 { @@ -41,6 +66,8 @@ impl TdxEvent { digest: vec![], event, event_payload, + version: EventLogVersion::default(), + hash_input: None, } } @@ -54,6 +81,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 { @@ -62,10 +91,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(); @@ -78,25 +120,108 @@ 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, } } } +#[cfg(test)] +mod tests { + use super::*; + use ez_hash::{Hasher, Sha384}; + use sha2::{Digest as _, Sha384 as Sha384Hasher}; + + #[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")); + } +} + /// Read both boottime and runtime event logs. pub fn read_event_log() -> Result> { let mut event_logs = TcgEventLog::decode_from_ccel_file()?.to_cc_event_log()?; diff --git a/dstack-attest/src/attestation.rs b/dstack-attest/src/attestation.rs index 55611b5f..ef437a8b 100644 --- a/dstack-attest/src/attestation.rs +++ b/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::{ quote::{EnclaveReport, Quote, Report, TDReport10, TDReport15}, verify::VerifiedReport as TdxVerifiedReport, @@ -476,9 +476,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. @@ -699,6 +711,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() @@ -707,9 +731,17 @@ 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 { + /// + /// 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 stripped: Vec<_> = q.event_log.iter().map(|e| e.stripped()).collect(); + 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() }) } @@ -1023,10 +1055,14 @@ impl Attestation { /// Create an attestation from a report data pub fn quote(report_data: &[u8; 64]) -> Result { - Self::quote_with_app_id(report_data, None) + Self::quote_with_app_id(report_data, None, EventLogVersion::default()) } - pub fn quote_with_app_id(report_data: &[u8; 64], app_id: Option<[u8; 20]>) -> Result { + pub fn quote_with_app_id( + 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() @@ -1036,7 +1072,11 @@ impl Attestation { let runtime_events = if mode.is_composable() { RuntimeEvent::read_all().context("Failed to read runtime events")? } else if let Some(app_id) = app_id { - vec![RuntimeEvent::new("app-id".to_string(), app_id.to_vec())] + vec![RuntimeEvent::new( + "app-id".to_string(), + app_id.to_vec(), + event_log_version, + )] } else { vec![] }; @@ -1101,9 +1141,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 @@ -1344,4 +1399,39 @@ mod tests { _ => panic!("expected dstack stack"), } } + + #[test] + 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" + ); + } + + #[test] + 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`" + ); + } } diff --git a/dstack-attest/src/lib.rs b/dstack-attest/src/lib.rs index 1f7bc814..c4f5dfc8 100644 --- a/dstack-attest/src/lib.rs +++ b/dstack-attest/src/lib.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Context; -use cc_eventlog::RuntimeEvent; +use cc_eventlog::{EventLogVersion, RuntimeEvent}; pub use cc_eventlog as ccel; pub use tdx_attest as tdx; @@ -14,8 +14,12 @@ pub mod attestation; mod v1; /// 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()); +pub fn emit_runtime_event( + event: &str, + payload: &[u8], + version: EventLogVersion, +) -> anyhow::Result<()> { + let event = RuntimeEvent::new(event.to_string(), payload.to_vec(), version); let mode = AttestationMode::detect()?; diff --git a/dstack-attest/src/v1.rs b/dstack-attest/src/v1.rs index 900e7aed..28cdede4 100644 --- a/dstack-attest/src/v1.rs +++ b/dstack-attest/src/v1.rs @@ -37,6 +37,13 @@ impl PlatformEvidence { } } + pub fn tdx_event_log_mut(&mut self) -> Option<&mut Vec> { + match self { + Self::Tdx { event_log, .. } => Some(event_log), + _ => None, + } + } + pub fn into_stripped(self) -> Self { match self { Self::Tdx { quote, event_log } => Self::Tdx { @@ -239,6 +246,7 @@ impl Attestation { #[cfg(test)] mod tests { use super::*; + use dstack_types::EventLogVersion; #[test] fn msgpack_roundtrip_preserves_attestation() { @@ -251,6 +259,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 { @@ -258,6 +268,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(), diff --git a/dstack-types/src/lib.rs b/dstack-types/src/lib.rs index 514e84fc..6abe99fb 100644 --- a/dstack-types/src/lib.rs +++ b/dstack-types/src/lib.rs @@ -9,6 +9,47 @@ use serde::{Deserialize, Serialize}; use serde_human_bytes as hex_bytes; use size_parser::human_size; +/// 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 { pub manifest_version: u32, @@ -45,6 +86,8 @@ pub struct AppCompose { pub storage_fs: Option, #[serde(default, with = "human_size")] pub swap_size: u64, + #[serde(default)] + pub event_log_version: EventLogVersion, } fn default_true() -> bool { diff --git a/dstack-util/src/main.rs b/dstack-util/src/main.rs index 05ad9ad6..d519b7e8 100644 --- a/dstack-util/src/main.rs +++ b/dstack-util/src/main.rs @@ -224,7 +224,20 @@ 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(&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-util/src/system_setup.rs b/dstack-util/src/system_setup.rs index b7f59cfd..506fba8c 100644 --- a/dstack-util/src/system_setup.rs +++ b/dstack-util/src/system_setup.rs @@ -20,7 +20,7 @@ use dstack_types::{ APP_COMPOSE, APP_KEYS, DECRYPTED_ENV, DECRYPTED_ENV_JSON, ENCRYPTED_ENV, HOST_SHARED_DIR_NAME, HOST_SHARED_DISK_LABEL, INSTANCE_INFO, SYS_CONFIG, USER_CONFIG, }, - KeyProvider, KeyProviderInfo, + EventLogVersion, KeyProvider, KeyProviderInfo, }; use fs_err as fs; use luks2::{ @@ -688,10 +688,13 @@ fn truncate(s: &[u8], len: usize) -> &[u8] { } } -fn emit_key_provider_info(provider_info: &KeyProviderInfo) -> Result<()> { +fn emit_key_provider_info( + provider_info: &KeyProviderInfo, + event_log_version: 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("key-provider", &provider_info_json, event_log_version)?; Ok(()) } @@ -824,25 +827,28 @@ impl<'a> Stage0<'a> { .tls_client_key(cert_pair.key_pem) .tls_ca_cert(tmp_ca.ca_cert.clone()) .maybe_pccs_url(self.shared.sys_config.pccs_url.clone()) - .cert_validator(Box::new(|cert| { - let Some(cert) = cert else { - bail!("Missing server cert"); - }; - let Some(usage) = cert.special_usage else { - bail!("Missing server cert usage"); - }; - if usage != "kms:rpc" { - bail!("Invalid server cert usage: {usage}"); - } - if let Some(att) = &cert.attestation { - let kms_info = att - .decode_app_info(false) - .context("Failed to decode app_info")?; - emit_runtime_event("mr-kms", &kms_info.mr_aggregated) - .context("Failed to extend mr-kms to RTMR3")?; - } - Ok(()) - })) + .cert_validator({ + let elv = self.shared.app_compose.event_log_version; + Box::new(move |cert| { + let Some(cert) = cert else { + bail!("Missing server cert"); + }; + let Some(usage) = cert.special_usage else { + bail!("Missing server cert usage"); + }; + if usage != "kms:rpc" { + bail!("Invalid server cert usage: {usage}"); + } + if let Some(att) = &cert.attestation { + let kms_info = att + .decode_app_info(false) + .context("Failed to decode app_info")?; + emit_runtime_event("mr-kms", &kms_info.mr_aggregated, elv) + .context("Failed to extend mr-kms to RTMR3")?; + } + Ok(()) + }) + }) .build() .into_client() .context("Failed to create client")?; @@ -855,8 +861,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 RTMR3")?; + emit_runtime_event( + "os-image-hash", + &response.os_image_hash, + self.shared.app_compose.event_log_version, + ) + .context("Failed to extend os-image-hash to RTMR3")?; let (_, ca_pem) = x509_parser::pem::parse_x509_pem(tmp_ca.ca_cert.as_bytes()) .context("Failed to parse ca cert")?; @@ -1317,11 +1327,12 @@ impl<'a> Stage0<'a> { truncated_compose_hash.to_vec() }; - emit_runtime_event("system-preparing", &[])?; - emit_runtime_event("app-id", &app_id)?; - emit_runtime_event("compose-hash", &compose_hash)?; - emit_runtime_event("instance-id", &instance_id)?; - emit_runtime_event("boot-mr-done", &[])?; + let elv = self.shared.app_compose.event_log_version; + emit_runtime_event("system-preparing", &[], elv)?; + emit_runtime_event("app-id", &app_id, elv)?; + emit_runtime_event("compose-hash", &compose_hash, elv)?; + emit_runtime_event("instance-id", &instance_id, elv)?; + emit_runtime_event("boot-mr-done", &[], elv)?; Ok(AppInfo { instance_info, compose_hash, @@ -1354,7 +1365,7 @@ impl<'a> Stage0<'a> { KeyProviderInfo::new("kms".into(), hex::encode(pubkey)) } }; - emit_key_provider_info(&kp_info)?; + emit_key_provider_info(&kp_info, self.shared.app_compose.event_log_version)?; Ok(()) } @@ -1385,7 +1396,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( + "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 @@ -1401,7 +1416,11 @@ impl<'a> Stage0<'a> { &serde_json::to_string(&app_info.instance_info)?, ) .await; - emit_runtime_event("system-ready", &[])?; + emit_runtime_event( + "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/gateway/src/distributed_certbot.rs b/gateway/src/distributed_certbot.rs index cbb316ea..8963ba3d 100644 --- a/gateway/src/distributed_certbot.rs +++ b/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/gateway/src/gen_debug_key.rs b/gateway/src/gen_debug_key.rs index c710548a..565f7740 100644 --- a/gateway/src/gen_debug_key.rs +++ b/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/guest-agent-simulator/Cargo.toml b/guest-agent-simulator/Cargo.toml index f476e01c..2695811d 100644 --- a/guest-agent-simulator/Cargo.toml +++ b/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/guest-agent-simulator/src/main.rs b/guest-agent-simulator/src/main.rs index c2d59535..42ae13ae 100644 --- a/guest-agent-simulator/src/main.rs +++ b/guest-agent-simulator/src/main.rs @@ -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,20 +78,35 @@ 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]) -> Result<()> { + fn emit_event(&self, event: &str, _payload: &[u8], _version: EventLogVersion) -> Result<()> { bail!("runtime event emission is unavailable in simulator mode: {event}") } } @@ -148,7 +164,9 @@ mod tests { #[test] fn simulator_rejects_runtime_event_emission() { let platform = load_fixture_platform(); - let err = platform.emit_event("test.event", b"payload").unwrap_err(); + let err = platform + .emit_event("test.event", b"payload", EventLogVersion::V1) + .unwrap_err(); assert!(err.to_string().contains("unavailable in simulator mode")); } @@ -166,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(); @@ -183,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/guest-agent-simulator/src/simulator.rs b/guest-agent-simulator/src/simulator.rs index 902dfe87..f525cdad 100644 --- a/guest-agent-simulator/src/simulator.rs +++ b/guest-agent-simulator/src/simulator.rs @@ -29,6 +29,7 @@ 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 { @@ -37,7 +38,9 @@ pub fn simulated_quote_response( 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(), }) @@ -47,9 +50,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/guest-agent/rpc/proto/agent_rpc.proto b/guest-agent/rpc/proto/agent_rpc.proto index 241b543b..bc3371c4 100644 --- a/guest-agent/rpc/proto/agent_rpc.proto +++ b/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 { diff --git a/guest-agent/src/backend.rs b/guest-agent/src/backend.rs index 3344ff90..54cd3510 100644 --- a/guest-agent/src/backend.rs +++ b/guest-agent/src/backend.rs @@ -5,15 +5,25 @@ use anyhow::{Context, Result}; use dstack_attest::emit_runtime_event; 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 emit_event(&self, event: &str, payload: &[u8]) -> 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)] @@ -33,10 +43,15 @@ 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(); + let tdx_event_log = attestation.get_tdx_event_log_string(include_hash_inputs); Ok(GetQuoteResponse { quote: tdx_quote.unwrap_or_default(), event_log: tdx_event_log.unwrap_or_default(), @@ -45,14 +60,22 @@ impl PlatformBackend for RealPlatform { }) } - 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]) -> Result<()> { - emit_runtime_event(event, payload) + fn emit_event(&self, event: &str, payload: &[u8], version: EventLogVersion) -> Result<()> { + emit_runtime_event(event, payload, version) } } diff --git a/guest-agent/src/rpc_service.rs b/guest-agent/src/rpc_service.rs index 202ad73e..5c2a7812 100644 --- a/guest-agent/src/rpc_service.rs +++ b/guest-agent/src/rpc_service.rs @@ -170,18 +170,31 @@ 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) } fn emit_event(&self, event: &str, payload: &[u8]) -> Result<()> { - self.inner.platform.emit_event(event, payload) + let event_log_version = self.inner.config.app_compose.event_log_version; + self.inner + .platform + .emit_event(event, payload, event_log_version) } } @@ -325,7 +338,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 emit_event(self, request: EmitEventArgs) -> Result<()> { @@ -431,7 +445,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 { @@ -533,7 +548,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, @@ -626,7 +641,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) @@ -639,7 +654,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")), } @@ -664,7 +679,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, @@ -718,6 +733,7 @@ mod tests { secure_time: false, storage_fs: None, swap_size: 0, + event_log_version: EventLogVersion::V1, }; let dummy_appcompose_wrapper = AppComposeWrapper { @@ -833,6 +849,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 { @@ -849,14 +866,23 @@ pNs85uhOZE8z2jr8Pg== }) } - 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]) -> Result<()> { + fn emit_event( + &self, + _event: &str, + _payload: &[u8], + _version: EventLogVersion, + ) -> Result<()> { Ok(()) } } diff --git a/kms/src/main_service/upgrade_authority.rs b/kms/src/main_service/upgrade_authority.rs index e98b436f..e13515a8 100644 --- a/kms/src/main_service/upgrade_authority.rs +++ b/kms/src/main_service/upgrade_authority.rs @@ -195,7 +195,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/ra-tls/src/cert.rs b/ra-tls/src/cert.rs index 683ee587..23c312ca 100644 --- a/ra-tls/src/cert.rs +++ b/ra-tls/src/cert.rs @@ -542,7 +542,7 @@ pub fn generate_ra_cert_with_app_id( let report_data = QuoteContentType::RaTlsCert.to_report_data(&pubkey); - let attestation = Attestation::quote_with_app_id(&report_data, app_id) + let attestation = Attestation::quote_with_app_id(&report_data, app_id, Default::default()) .context("Failed to get quote for cert pubkey")? .into_versioned(); diff --git a/vmm/src/vmm-cli.py b/vmm/src/vmm-cli.py index e0090231..86af3ba5 100755 --- a/vmm/src/vmm-cli.py +++ b/vmm/src/vmm-cli.py @@ -818,6 +818,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" @@ -1684,6 +1686,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/vmm/ui/src/components/CreateVmDialog.ts b/vmm/ui/src/components/CreateVmDialog.ts index 90ab797d..78ba2f03 100644 --- a/vmm/ui/src/components/CreateVmDialog.ts +++ b/vmm/ui/src/components/CreateVmDialog.ts @@ -156,6 +156,16 @@ const CreateVmDialogComponent = { +
+ + +
+
diff --git a/vmm/ui/src/composables/useVmManager.ts b/vmm/ui/src/composables/useVmManager.ts index 804d56fa..1929c19e 100644 --- a/vmm/ui/src/composables/useVmManager.ts +++ b/vmm/ui/src/composables/useVmManager.ts @@ -31,6 +31,7 @@ type AppCompose = { launch_token_hash?: string; pre_launch_script?: string; init_script?: string; + event_log_version?: number; }; type KeyProviderKind = 'none' | 'kms' | 'local' | 'tpm'; @@ -111,6 +112,7 @@ type VmFormState = { kms_urls: string[]; gateway_urls: string[]; stopped: boolean; + event_log_version: number; }; type UpdateDialogState = { @@ -194,6 +196,7 @@ function createVmFormState(preLaunchScript: string): VmFormState { kms_urls: [], gateway_urls: [], stopped: false, + event_log_version: 1, }; } @@ -754,6 +757,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); @@ -1134,6 +1141,7 @@ type CreateVmPayloadSource = { net_mode: config.networking?.mode || '', 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