diff --git a/docs/security/security-best-practices.md b/docs/security/security-best-practices.md index 73760ea82..b6dff185e 100644 --- a/docs/security/security-best-practices.md +++ b/docs/security/security-best-practices.md @@ -162,3 +162,23 @@ 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(preimage) == 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. + +V2 is a coordinated upgrade. Upgrade every KMS, gateway, verifier, and other +relying party before enabling `event_log_version: 2`; older verifiers interpret +runtime events as V1 and reject the quote. Older guest images may ignore the +compose field and emit V1 events, so confirm that the selected image advertises +V2 support before relying on per-event claims. diff --git a/dstack/Cargo.lock b/dstack/Cargo.lock index 7e940d40e..9abf20a4d 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", ] @@ -1785,6 +1788,8 @@ dependencies = [ "rand 0.8.6", "rmp-serde", "rsa", + "rustix 0.38.44", + "safe-write", "serde", "serde-human-bytes", "serde_json", @@ -1996,9 +2001,11 @@ name = "dstack-guest-agent-simulator" version = "0.6.0" dependencies = [ "anyhow", + "cc-eventlog", "clap", "dstack-guest-agent", "dstack-guest-agent-rpc", + "dstack-types", "ra-rpc", "ra-tls", "rocket", diff --git a/dstack/Cargo.toml b/dstack/Cargo.toml index 05011b843..ca5375628 100644 --- a/dstack/Cargo.toml +++ b/dstack/Cargo.toml @@ -139,6 +139,7 @@ regorus = { version = "0.10.1", default-features = false, features = ["full-opa" tracing = "0.1.40" tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } safe-write = "0.1.2" +rustix = { version = "0.38", features = ["fs"] } nix = "0.29.0" sd-notify = "0.4.5" listenfd = "1.0" 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..26664405e 100644 --- a/dstack/cc-eventlog/src/lib.rs +++ b/dstack/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/dstack/cc-eventlog/src/runtime_events.rs b/dstack/cc-eventlog/src/runtime_events.rs index fa948f36c..404d6450e 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, skip_serializing_if = "EventLogVersion::is_v1")] + #[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.preimage().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 preimage(&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..5e8e6384c 100644 --- a/dstack/cc-eventlog/src/tcg.rs +++ b/dstack/cc-eventlog/src/tcg.rs @@ -395,6 +395,8 @@ impl TryFrom for TdxEvent { digest, event: Default::default(), event_payload: value.event.into(), + version: Default::default(), + preimage: None, }) } } diff --git a/dstack/cc-eventlog/src/tdx.rs b/dstack/cc-eventlog/src/tdx.rs index 615c32ee9..37d647e5d 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}; @@ -27,7 +28,9 @@ pub const TDX_ACPI_DATA_EVENT_NAMES: [&str; 3] = [ /// 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 +45,23 @@ 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 = "EventLogVersion::is_v1")] + #[codec(skip)] + pub version: EventLogVersion, + + /// Optional digest pre-image, hex-encoded. + /// + /// The exact bytes hashed to produce `digest`. V2 events exposed through + /// quote and attestation APIs always include it, allowing relying parties + /// to verify `sha384(hex_decode(preimage)) == digest`. + /// Never included in scale encoding (derivable from other fields). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[codec(skip)] + pub preimage: Option, } impl TdxEvent { @@ -52,6 +72,8 @@ impl TdxEvent { digest: vec![], event, event_payload, + version: EventLogVersion::default(), + preimage: None, } } @@ -65,6 +87,8 @@ impl TdxEvent { digest: Vec::new(), event: self.event.clone(), event_payload: self.event_payload.clone(), + version: self.version, + preimage: self.preimage.clone(), } } else { Self { @@ -73,10 +97,26 @@ impl TdxEvent { digest: self.digest.clone(), event: self.event.clone(), event_payload: Vec::new(), + version: self.version, + preimage: self.preimage.clone(), } } } + /// Populate `preimage` 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 `preimage` stays `None`. + pub fn fill_preimage(&mut self) { + if self.preimage.is_some() { + return; + } + if let Some(runtime_event) = self.to_runtime_event() { + self.preimage = Some(hex::encode(runtime_event.preimage())); + } + } + pub fn digest(&self) -> Vec { if let Some(runtime_event) = self.to_runtime_event() { return runtime_event.sha384_digest().to_vec(); @@ -89,21 +129,39 @@ 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, + preimage: None, + } + } +} + +/// Populate digest preimages for all V2 runtime events. +pub fn fill_v2_preimages(events: &mut [TdxEvent]) { + for event in events { + if matches!(event.version, EventLogVersion::V2) { + event.fill_preimage(); } } } @@ -148,6 +206,8 @@ pub fn read_event_log() -> Result> { #[cfg(test)] mod tests { use super::*; + use ez_hash::{Hasher, Sha384}; + use sha2::{Digest as _, Sha384 as Sha384Hasher}; fn acpi_data_event(digest_byte: u8) -> TdxEvent { TdxEvent { @@ -156,6 +216,8 @@ mod tests { digest: vec![digest_byte; 48], event: String::new(), event_payload: TDX_ACPI_DATA_EVENT_PAYLOAD.to_vec(), + version: EventLogVersion::V1, + preimage: None, } } @@ -188,4 +250,71 @@ mod tests { assert!(events.iter().all(|event| event.imr <= 3)); assert!(events.iter().all(|event| event.digest().len() == 48)); } + + #[test] + fn fill_preimage_v1() { + let runtime = RuntimeEvent::new( + "compose-hash".to_string(), + vec![0xde, 0xad], + EventLogVersion::V1, + ); + let mut tdx: TdxEvent = runtime.into(); + assert_eq!(tdx.preimage, None); + tdx.fill_preimage(); + let input_hex = tdx.preimage.as_ref().expect("preimage populated"); + let input = hex::decode(input_hex).unwrap(); + // Hashing the preimage must reproduce the event digest + let actual = Sha384Hasher::digest(&input); + assert_eq!(actual.as_slice(), &tdx.digest); + } + + #[test] + fn fill_preimage_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_preimage(); + let input_hex = tdx.preimage.as_ref().expect("preimage populated"); + let input = hex::decode(input_hex).unwrap(); + let input_str = std::str::from_utf8(&input).unwrap(); + // V2 preimage 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_preimage_skips_non_runtime_events() { + let mut boot_event = TdxEvent::new(0, 0x1, "EV_POST_CODE".to_string(), vec![1, 2, 3]); + boot_event.fill_preimage(); + assert_eq!(boot_event.preimage, None); + } + + #[test] + fn preimage_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_preimage(); + assert!(tdx.preimage.is_some()); + let encoded = tdx.encode(); + let decoded = TdxEvent::decode(&mut &encoded[..]).unwrap(); + // preimage is codec(skip) so it's None after round-trip + assert_eq!(decoded.preimage, None); + } + + #[test] + fn preimage_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("preimage")); + } } diff --git a/dstack/dstack-attest/Cargo.toml b/dstack/dstack-attest/Cargo.toml index 50fdc5d1d..3c41c5e0d 100644 --- a/dstack/dstack-attest/Cargo.toml +++ b/dstack/dstack-attest/Cargo.toml @@ -17,6 +17,8 @@ dcap-qvl.workspace = true dstack-types.workspace = true ez-hash.workspace = true fs-err.workspace = true +safe-write.workspace = true +rustix.workspace = true hex.workspace = true hex_fmt.workspace = true or-panic.workspace = true diff --git a/dstack/dstack-attest/src/attestation.rs b/dstack/dstack-attest/src/attestation.rs index 7e75d0ba6..a3b5b323c 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; @@ -817,8 +815,11 @@ pub trait TdxAttestationExt { /// 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()) + self.tdx_event_log().map(|event_log| { + let mut events: Vec = event_log.to_vec(); + cc_eventlog::tdx::fill_v2_preimages(&mut events); + serde_json::to_string(&events).unwrap_or_default() + }) } /// Returns the parsed TD10 report from the embedded TDX quote. @@ -1438,6 +1439,16 @@ impl Attestation { self.tdx_quote().map(|q| q.quote.clone()) } + /// Populate `preimage` on every V2 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_preimages(&mut self) { + if let Some(q) = self.tdx_quote_mut() { + cc_eventlog::tdx::fill_v2_preimages(&mut q.event_log); + } + } + /// Get TDX event log bytes pub fn get_tdx_event_log_bytes(&self) -> Option> { self.tdx_quote() @@ -1446,35 +1457,23 @@ impl Attestation { /// Get TDX event log string with RTMR[0-2] payloads stripped to reduce size. /// Only digests are kept for boot-time events; runtime events (RTMR3) retain full payload. - pub fn get_tdx_event_log_string(&self) -> Option { - self.get_tdx_event_log_string_for_config("") - } - - /// Get TDX event log string for a vm_config. - /// - /// 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 { + pub fn get_tdx_event_log_string(&self) -> 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 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(); + cc_eventlog::tdx::fill_v2_preimages(&mut stripped); serde_json::to_string(&stripped).unwrap_or_default() }) } @@ -2104,12 +2103,12 @@ 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. + // 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 quote's image hash below. + // NitroEnclave derives config from the signed image hash below. TeeVariant::DstackNitroEnclave => String::new(), }; let runtime_events = match mode { @@ -2118,7 +2117,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(), + EventLogVersion::V1, + )], None => vec![], }, }; @@ -2298,9 +2301,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,42 +2625,63 @@ mod tests { digest: vec![event_type as u8; 48], event: String::new(), event_payload: event_payload.to_vec(), + version: EventLogVersion::V1, + preimage: None, } } #[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"v1-runtime-payload", + ), + { + let mut event = tdx_event( + 3, + cc_eventlog::DSTACK_RUNTIME_EVENT_TYPE, + b"v2-runtime-payload", + ); + event.version = EventLogVersion::V2; + event + }, ]; // 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"}"#, - "", - ] { - let events: Vec = serde_json::from_str( - &attestation - .get_tdx_event_log_string_for_config(config) - .expect("TDX event log"), - ) - .unwrap_or_else(|e| panic!("decode event log for config {config:?}: {e}")); - assert_eq!( - events[0].event_payload, b"ACPI DATA", - "config {config:?} must keep the ACPI DATA marker payload" - ); - assert!(events[1].event_payload.is_empty()); - assert!(events[2].event_payload.is_empty()); - } + let events: Vec = serde_json::from_str( + &attestation + .get_tdx_event_log_string() + .expect("TDX event log"), + ) + .unwrap_or_else(|e| panic!("decode GetQuote event log: {e}")); + assert_eq!( + 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[3].event_payload.is_empty()); + assert_eq!(events[4].event_payload, b"v1-runtime-payload"); + assert!( + events[4].preimage.is_none(), + "V1 output must remain unchanged" + ); + assert_eq!(events[5].event_payload, b"v2-runtime-payload"); + assert!(events[5].preimage.is_some(), "V2 must include its preimage"); } #[test] @@ -2749,6 +2788,44 @@ mod tests { } } + #[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`" + ); + } + 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(); @@ -2800,13 +2877,13 @@ mod tests { 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()), + 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()); @@ -2833,7 +2910,7 @@ mod tests { ); let mut changed_events = events.clone(); - changed_events[2] = RuntimeEvent::new("compose-hash".into(), vec![0xee; 32]); + 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()); @@ -2887,14 +2964,14 @@ mod tests { // 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()), + 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); @@ -2942,10 +3019,7 @@ mod tests { .iter() .take_while(|event| event.event != "boot-mr-done") .cloned() - .chain(std::iter::once(RuntimeEvent::new( - "boot-mr-done".into(), - Vec::new(), - ))) + .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()); diff --git a/dstack/dstack-attest/src/lib.rs b/dstack/dstack-attest/src/lib.rs index cc5b4d3cc..0b04d7ddf 100644 --- a/dstack/dstack-attest/src/lib.rs +++ b/dstack/dstack-attest/src/lib.rs @@ -2,10 +2,8 @@ // // SPDX-License-Identifier: Apache-2.0 -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,33 +19,137 @@ mod aws_nitro_tpm; mod sev_snp; mod v1; -/// Serializes measured event emission within this process. +const RUNTIME_EVENT_DIR: &str = "/run/log/dstack"; +const RUNTIME_EVENT_VERSION_FILE: &str = "/run/log/dstack/runtime_event_version"; +const RUNTIME_EVENT_LOCK_FILE: &str = "/run/log/dstack/runtime_event.lock"; + +/// Acquire the system-wide runtime event lock, blocking until it is available. /// -/// 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. +/// The wait is deliberately unbounded. The lock serializes the event-log append +/// with the measurement-register extension, and that ordering is what makes +/// replay reproduce the quoted register value. Proceeding after a timeout would +/// break the invariant, and failing after one would abort a boot that is merely +/// slow, so waiting is the only safe option. A holder that dies releases the +/// lock automatically (flock is dropped when the file descriptor closes), which +/// leaves a live but wedged holder as the sole way to block emission. +fn runtime_event_lock() -> anyhow::Result { + fs_err::create_dir_all(RUNTIME_EVENT_DIR) + .context("failed to create runtime event log directory")?; + let lock = fs_err::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(RUNTIME_EVENT_LOCK_FILE) + .context("failed to open runtime event lock")?; + rustix::fs::flock(&lock, rustix::fs::FlockOperation::LockExclusive) + .context("failed to lock runtime event emission")?; + Ok(lock) +} + +/// Configure the system-wide digest format used by subsequently emitted events. +/// +/// The setting is persisted under `/run/log/dstack`, so separate dstack-util +/// processes share it. This must be called before [`emit_runtime_event`]. +/// Repeating the same configuration is allowed; changing it is rejected. +pub fn set_runtime_event_version(version: EventLogVersion) -> anyhow::Result<()> { + let _lock = runtime_event_lock()?; + set_runtime_event_version_file(RUNTIME_EVENT_VERSION_FILE, version) +} + +fn set_runtime_event_version_file( + path: impl AsRef, + version: EventLogVersion, +) -> anyhow::Result<()> { + let path = path.as_ref(); + let value = match version { + EventLogVersion::V1 => "1", + EventLogVersion::V2 => "2", + }; + match fs_err::read_to_string(path) { + Ok(configured) => { + anyhow::ensure!( + configured.trim() == value, + "runtime event version is already set to {} for this boot and cannot be \ + changed to {value}; the setting is fixed when system setup runs, so restart \ + the CVM to apply a new app-compose `event_log_version`", + configured.trim() + ); + Ok(()) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + safe_write::safe_write(path, value.as_bytes()) + .context("failed to write runtime event version") + } + Err(err) => Err(err).context("failed to read runtime event version"), + } +} + +fn runtime_event_version() -> anyhow::Result { + runtime_event_version_file(RUNTIME_EVENT_VERSION_FILE) +} + +fn runtime_event_version_file( + path: impl AsRef, +) -> anyhow::Result { + let value = fs_err::read_to_string(path).context( + "runtime event version is not configured; complete dstack system setup before emitting events", + )?; + match value.trim() { + "1" => Ok(EventLogVersion::V1), + "2" => Ok(EventLogVersion::V2), + value => anyhow::bail!("invalid runtime event version: {value}"), + } +} + +#[cfg(test)] +mod runtime_event_version_tests { + use super::*; + + fn temp_path(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("dstack-{name}-{}", std::process::id())) + } + + #[test] + fn rejects_conflicting_runtime_event_version() { + let path = temp_path("event-version-conflict"); + let _ = fs_err::remove_file(&path); + set_runtime_event_version_file(&path, EventLogVersion::V1).unwrap(); + set_runtime_event_version_file(&path, EventLogVersion::V1).unwrap(); + let err = set_runtime_event_version_file(&path, EventLogVersion::V2).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("already set to 1"), "{message}"); + assert!( + message.contains("restart the CVM"), + "the conflict error must tell the operator how to apply a new version: {message}" + ); + let _ = fs_err::remove_file(path); + } + + #[test] + fn reports_unconfigured_runtime_event_version() { + let path = temp_path("event-version-missing"); + let _ = fs_err::remove_file(&path); + let err = runtime_event_version_file(path).unwrap_err(); + assert!(err.to_string().contains("complete dstack system setup")); + } +} + +/// Emit a dstack measured event using the system-configured digest format. /// -/// Semantics match bare TDX RTMR3: every dstack event extends a single -/// append-only measurement register. +/// The event-log append and platform-register extension are serialized by a +/// system-wide file lock so their ordering cannot diverge across processes. /// /// - TDX-family: RTMR3 /// - GCP TPM: SHA256 PCR14 -/// - AWS NitroTPM: SHA384 PCR14 (not PCR23; no launch/runtime PCR split) +/// - AWS NitroTPM: SHA384 PCR14 pub fn emit_runtime_event(event: &str, payload: &[u8]) -> anyhow::Result<()> { - let event = RuntimeEvent::new(event.to_string(), payload.to_vec()); - + // Hold the system-wide lock across both the log append and register + // extension so separate processes cannot make their ordering diverge. + let _lock = runtime_event_lock()?; + let version = runtime_event_version()?; + let event = RuntimeEvent::new(event.to_string(), payload.to_vec(), version); let mode = detect_tee_variant()?; - // Hold the lock across both the log append and the register extension so - // that the on-disk log order always matches the RTMR extension order. - let _guard = EMIT_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - event.emit().context("Failed to emit runtime event")?; if mode.has_tdx() { diff --git a/dstack/dstack-attest/src/v1.rs b/dstack/dstack-attest/src/v1.rs index f201b9c73..4175ee475 100644 --- a/dstack/dstack-attest/src/v1.rs +++ b/dstack/dstack-attest/src/v1.rs @@ -145,6 +145,13 @@ impl PlatformEvidence { .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), + _ => None, + } + } + pub fn into_stripped(self) -> Self { self.into_stripped_for_config("") } @@ -372,6 +379,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 +405,8 @@ mod tests { digest: vec![0xaa, 0xbb, 0xcc], event: "pod".into(), event_payload: vec![0xde, 0xad, 0xbe, 0xef], + version: EventLogVersion::V1, + preimage: None, }], }, StackEvidence::DstackPod { @@ -403,6 +414,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 +486,8 @@ mod tests { digest: vec![idx as u8; 48], event: String::new(), event_payload: vec![0xff; idx + 1], + version: EventLogVersion::V1, + preimage: None, } } @@ -484,6 +498,8 @@ mod tests { digest: vec![idx as u8; 48], event: String::new(), event_payload: TDX_ACPI_DATA_EVENT_PAYLOAD.to_vec(), + version: EventLogVersion::V1, + preimage: None, } } @@ -491,6 +507,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..221383e9e 100644 --- a/dstack/dstack-types/src/lib.rs +++ b/dstack/dstack-types/src/lib.rs @@ -71,6 +71,51 @@ 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 is_v1(&self) -> bool { + matches!(self, Self::V1) + } + + 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 +157,8 @@ pub struct AppCompose { pub storage_fs: Option, #[serde(default, with = "human_size")] pub swap_size: u64, + #[serde(default, skip_serializing_if = "EventLogVersion::is_v1")] + pub event_log_version: EventLogVersion, /// Per-port policy consumed by the gateway (PROXY protocol opt-in, /// optional port whitelist). #[serde(default)] @@ -528,6 +575,27 @@ mod app_compose_tests { assert_eq!(compose.manifest_version_u32(), Some(3)); } + #[test] + fn event_log_v1_is_omitted_but_v2_is_serialized() { + #[derive(Serialize)] + struct VersionField { + #[serde(skip_serializing_if = "EventLogVersion::is_v1")] + event_log_version: EventLogVersion, + } + + let v1 = serde_json::to_value(VersionField { + event_log_version: EventLogVersion::V1, + }) + .unwrap(); + assert!(v1.get("event_log_version").is_none()); + + let v2 = serde_json::to_value(VersionField { + event_log_version: EventLogVersion::V2, + }) + .unwrap(); + assert_eq!(v2["event_log_version"], 2); + } + #[test] fn parses_supported_container_snapshotters() { let compose: AppCompose = serde_json::from_value(serde_json::json!({ diff --git a/dstack/dstack-util/src/system_setup.rs b/dstack/dstack-util/src/system_setup.rs index 850914592..6f9aab923 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, set_runtime_event_version}; use dstack_kms_rpc as rpc; use dstack_types::{ gpu_policy_hash, @@ -1052,6 +1052,8 @@ fn unquote_os_release_value(value: &str) -> String { pub async fn cmd_sys_setup(args: SetupArgs) -> Result<()> { let stage0 = Stage0::load(&args)?; + set_runtime_event_version(stage0.shared.app_compose.event_log_version) + .context("failed to configure runtime event version")?; let vmm = stage0.host_api(); let result = do_sys_setup(stage0).await; if let Err(err) = &result { diff --git a/dstack/guest-agent-simulator/Cargo.toml b/dstack/guest-agent-simulator/Cargo.toml index f476e01c2..6fad16705 100644 --- a/dstack/guest-agent-simulator/Cargo.toml +++ b/dstack/guest-agent-simulator/Cargo.toml @@ -25,3 +25,5 @@ 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 +cc-eventlog.workspace = true diff --git a/dstack/guest-agent-simulator/src/simulator.rs b/dstack/guest-agent-simulator/src/simulator.rs index ed148429b..23a74d0ec 100644 --- a/dstack/guest-agent-simulator/src/simulator.rs +++ b/dstack/guest-agent-simulator/src/simulator.rs @@ -53,8 +53,11 @@ pub fn simulated_attest_response( report_data: [u8; 64], patch_report_data: bool, ) -> Result { - let attestation = + let mut attestation = maybe_patch_report_data(attestation, report_data, patch_report_data, "attest"); + if let Some(event_log) = attestation.platform.tdx_event_log_mut() { + cc_eventlog::tdx::fill_v2_preimages(event_log); + } 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..39c63134f 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -199,15 +199,16 @@ message GpuInfoResponse { message GetQuoteResponse { // TDX quote (empty on non-TDX platforms such as AMD SEV-SNP) bytes quote = 1; - // Event log (empty on non-TDX platforms) + // Event log (empty on non-TDX platforms). V2 runtime events always include + // their hex-encoded digest preimage. Clients should verify + // sha384(hex_decode(preimage)) == digest. string event_log = 2; // Report data 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. + // on non-TDX platforms; TDX uses quote + event_log above. bytes attestation = 5; } diff --git a/dstack/guest-agent/src/backend.rs b/dstack/guest-agent/src/backend.rs index a87b6c6e2..03012ef9e 100644 --- a/dstack/guest-agent/src/backend.rs +++ b/dstack/guest-agent/src/backend.rs @@ -34,9 +34,7 @@ impl PlatformBackend for RealPlatform { fn quote_response(&self, report_data: [u8; 64], vm_config: &str) -> Result { let attestation = Attestation::quote(&report_data).context("Failed to get quote")?; let tdx_quote = attestation.get_tdx_quote_bytes(); - let tdx_event_log = attestation.get_tdx_event_log_string_for_config(vm_config); - // TDX callers already have quote + event_log. Only non-TDX platforms - // need the platform-adaptive versioned attestation payload. + let tdx_event_log = attestation.get_tdx_event_log_string(); let versioned = if tdx_quote.is_some() { Vec::new() } else { @@ -55,7 +53,9 @@ impl PlatformBackend for RealPlatform { } fn attest_response(&self, report_data: [u8; 64]) -> Result { - let attestation = Attestation::quote(&report_data).context("Failed to get attestation")?; + let mut attestation = + Attestation::quote(&report_data).context("Failed to get attestation")?; + attestation.fill_event_preimages(); Ok(AttestResponse { attestation: attestation.into_versioned().to_bytes()?, }) diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index d9a8faa33..e2719786e 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -681,7 +681,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 +752,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(), diff --git a/dstack/kms/auth-eth/package-lock.json b/dstack/kms/auth-eth/package-lock.json index 22a3794c0..5a94f2edc 100644 --- a/dstack/kms/auth-eth/package-lock.json +++ b/dstack/kms/auth-eth/package-lock.json @@ -2336,9 +2336,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", 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/verifier/src/verification.rs b/dstack/verifier/src/verification.rs index 34c7a835e..54069ab8b 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(), + preimage: None, } } diff --git a/dstack/vmm/src/vmm-cli.py b/dstack/vmm/src/vmm-cli.py index b86fc8981..13fe5fae3 100755 --- a/dstack/vmm/src/vmm-cli.py +++ b/dstack/vmm/src/vmm-cli.py @@ -841,6 +841,10 @@ 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 == 2: + app_compose["event_log_version"] = args.event_log_version + elif args.event_log_version == 1: + app_compose.pop("event_log_version", None) compose_file = json.dumps(app_compose, indent=4, ensure_ascii=False).encode( "utf-8" @@ -1720,6 +1724,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/README.md b/sdk/go/README.md index c5b7f8bac..a359e590d 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -123,13 +123,6 @@ func main() { fmt.Println("TDX Quote:", quote.Quote) fmt.Println("Event Log:", quote.EventLog) - - // Verify measurement registers - rtmrs, err := quote.ReplayRTMRs() - if err != nil { - log.Fatal(err) - } - fmt.Println("RTMR0-3:", rtmrs) } ``` @@ -574,7 +567,6 @@ Generates a TDX attestation quote containing the provided report data. **Returns:** `GetQuoteResponse` - `Quote`: TDX quote as hex string - `EventLog`: JSON string of system events -- `ReplayRTMRs()`: Function returning computed RTMR values **Use Cases:** - Remote attestation of application state diff --git a/sdk/go/dstack/client.go b/sdk/go/dstack/client.go index 4e8fa30e7..241b6b312 100644 --- a/sdk/go/dstack/client.go +++ b/sdk/go/dstack/client.go @@ -11,7 +11,6 @@ import ( "context" "crypto/ecdsa" "crypto/ed25519" - "crypto/sha512" "crypto/x509" "encoding/hex" "encoding/json" @@ -131,6 +130,13 @@ type EventLog struct { Digest string `json:"digest"` Event string `json:"event"` EventPayload string `json:"event_payload"` + // Runtime event log version. The field is omitted from the wire format for + // version 1, so a zero value means V1 rather than "unset"; treat 0 and 1 + // alike. Only dstack runtime events carry a version. + Version int `json:"version,omitempty"` + // Hex-encoded digest pre-image, present on V2 runtime events. When set, + // sha384(hex_decode(Preimage)) equals Digest. + Preimage string `json:"preimage,omitempty"` } // Represents the TCB information @@ -184,63 +190,6 @@ func (r *InfoResponse) DecodeTcbInfo() (*TcbInfo, error) { return &tcbInfo, nil } -const INIT_MR = "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - -// Replays the RTMR history to calculate final RTMR values -func replayRTMR(history []string) (string, error) { - if len(history) == 0 { - return INIT_MR, nil - } - - mr := make([]byte, 48) - - for _, content := range history { - contentBytes, err := hex.DecodeString(content) - if err != nil { - return "", err - } - - if len(contentBytes) < 48 { - padding := make([]byte, 48-len(contentBytes)) - contentBytes = append(contentBytes, padding...) - } - - h := sha512.New384() - h.Write(append(mr, contentBytes...)) - mr = h.Sum(nil) - } - - return hex.EncodeToString(mr), nil -} - -// Replays the RTMR history to calculate final RTMR values -func (r *GetQuoteResponse) ReplayRTMRs() (map[int]string, error) { - var eventLog []struct { - IMR int `json:"imr"` - Digest string `json:"digest"` - } - json.Unmarshal([]byte(r.EventLog), &eventLog) - - rtmrs := make(map[int]string, 4) - for idx := 0; idx < 4; idx++ { - history := make([]string, 0) - for _, event := range eventLog { - if event.IMR == idx { - history = append(history, event.Digest) - } - } - - rtmr, err := replayRTMR(history) - if err != nil { - return nil, err - } - - rtmrs[idx] = rtmr - } - - return rtmrs, nil -} - // QuoteHashAlgorithm represents the hash algorithm used for quote generation type QuoteHashAlgorithm string diff --git a/sdk/go/dstack/client_test.go b/sdk/go/dstack/client_test.go index 13ee88c99..0a396d848 100644 --- a/sdk/go/dstack/client_test.go +++ b/sdk/go/dstack/client_test.go @@ -9,7 +9,6 @@ import ( "context" "crypto/sha256" "crypto/x509" - "encoding/hex" "encoding/json" "encoding/pem" "fmt" @@ -57,45 +56,6 @@ func TestGetQuote(t *testing.T) { if err != nil { t.Errorf("expected event log to be a valid JSON object: %v", err) } - - // Get quote RTMRs manually - quoteBytes, err := resp.DecodeQuote() - if err != nil { - t.Fatal(err) - } - - quoteRtmrs := [4][48]byte{ - [48]byte(quoteBytes[376:424]), - [48]byte(quoteBytes[424:472]), - [48]byte(quoteBytes[472:520]), - [48]byte(quoteBytes[520:568]), - } - - // Test ReplayRTMRs - rtmrs, err := resp.ReplayRTMRs() - if err != nil { - t.Fatal(err) - } - - if len(rtmrs) != 4 { - t.Errorf("expected 4 RTMRs, got %d", len(rtmrs)) - } - - // Verify RTMRs - for i := 0; i < 4; i++ { - if rtmrs[i] == "" { - t.Errorf("expected RTMR %d to not be empty", i) - } - - rtmrBytes, err := hex.DecodeString(rtmrs[i]) - if err != nil { - t.Errorf("expected RTMR %d to be valid hex: %v", i, err) - } - - if !bytes.Equal(rtmrBytes, quoteRtmrs[i][:]) { - t.Errorf("expected RTMR %d to be %s, got %s", i, hex.EncodeToString(quoteRtmrs[i][:]), rtmrs[i]) - } - } } func TestAttest(t *testing.T) { diff --git a/sdk/go/tappd/client.go b/sdk/go/tappd/client.go index 84cd47f5b..95099eade 100644 --- a/sdk/go/tappd/client.go +++ b/sdk/go/tappd/client.go @@ -11,7 +11,6 @@ package tappd import ( "bytes" "context" - "crypto/sha512" "encoding/base64" "encoding/hex" "encoding/json" @@ -100,63 +99,6 @@ type TappdInfoResponse struct { AppName string `json:"app_name"` } -const INIT_MR = "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - -// Replays the RTMR history to calculate final RTMR values -func replayRTMR(history []string) (string, error) { - if len(history) == 0 { - return INIT_MR, nil - } - - mr := make([]byte, 48) - - for _, content := range history { - contentBytes, err := hex.DecodeString(content) - if err != nil { - return "", err - } - - if len(contentBytes) < 48 { - padding := make([]byte, 48-len(contentBytes)) - contentBytes = append(contentBytes, padding...) - } - - h := sha512.New384() - h.Write(append(mr, contentBytes...)) - mr = h.Sum(nil) - } - - return hex.EncodeToString(mr), nil -} - -// Replays the RTMR history to calculate final RTMR values -func (r *TdxQuoteResponse) ReplayRTMRs() (map[int]string, error) { - var eventLog []struct { - IMR int `json:"imr"` - Digest string `json:"digest"` - } - json.Unmarshal([]byte(r.EventLog), &eventLog) - - rtmrs := make(map[int]string, 4) - for idx := 0; idx < 4; idx++ { - history := make([]string, 0) - for _, event := range eventLog { - if event.IMR == idx { - history = append(history, event.Digest) - } - } - - rtmr, err := replayRTMR(history) - if err != nil { - return nil, err - } - - rtmrs[idx] = rtmr - } - - return rtmrs, nil -} - // Handles communication with the Tappd service. type TappdClient struct { endpoint string diff --git a/sdk/go/tappd/client_test.go b/sdk/go/tappd/client_test.go index e2407b8cf..f4b5a6e23 100644 --- a/sdk/go/tappd/client_test.go +++ b/sdk/go/tappd/client_test.go @@ -5,7 +5,6 @@ package tappd_test import ( - "bytes" "context" "encoding/hex" "encoding/json" @@ -70,44 +69,10 @@ func TestTdxQuote(t *testing.T) { t.Errorf("expected event log to be a valid JSON object: %v", err) } - quoteBytes, err := hex.DecodeString(resp.Quote) + _, err = hex.DecodeString(resp.Quote) if err != nil { t.Errorf("expected quote to be a valid hex string: %v", err) } - - // Get quote RTMRs manually - quoteRtmrs := [4][48]byte{ - [48]byte(quoteBytes[376:424]), - [48]byte(quoteBytes[424:472]), - [48]byte(quoteBytes[472:520]), - [48]byte(quoteBytes[520:568]), - } - - // Test ReplayRTMRs - rtmrs, err := resp.ReplayRTMRs() - if err != nil { - t.Fatal(err) - } - - if len(rtmrs) != 4 { - t.Errorf("expected 4 RTMRs, got %d", len(rtmrs)) - } - - // Verify RTMRs - for i := 0; i < 4; i++ { - if rtmrs[i] == "" { - t.Errorf("expected RTMR %d to not be empty", i) - } - - rtmrBytes, err := hex.DecodeString(rtmrs[i]) - if err != nil { - t.Errorf("expected RTMR %d to be valid hex: %v", i, err) - } - - if !bytes.Equal(rtmrBytes, quoteRtmrs[i][:]) { - t.Errorf("expected RTMR %d to be %s, got %s", i, hex.EncodeToString(quoteRtmrs[i][:]), rtmrs[i]) - } - } } func TestTdxQuoteRawHash(t *testing.T) { diff --git a/sdk/js/README.md b/sdk/js/README.md index 1cc1d9316..8af123892 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -33,7 +33,7 @@ console.log(Buffer.from(key.key).toString('hex')) const quote = await client.getQuote('app-state-snapshot') console.log(quote.quote) -console.log(quote.replayRtmrs()) +console.log(quote.event_log) ``` The constructor probes `/var/run/dstack.sock`, then `/run/dstack.sock`, then the `/var/run/dstack/` and `/run/dstack/` variants. Pass an explicit endpoint for HTTP or for a non-default socket: @@ -86,7 +86,6 @@ Generate a raw TDX quote. `reportData` is up to 64 bytes (string, Buffer, or Uin const quote = await client.getQuote('user:alice:nonce123') quote.quote // hex-encoded TDX quote quote.event_log // JSON string of measured events -quote.replayRtmrs() // recompute RTMR[0..3] from the event log ``` ### `attest(reportData)` diff --git a/sdk/js/src/__tests__/index.test.ts b/sdk/js/src/__tests__/index.test.ts index c1e4564c8..15b8d4508 100644 --- a/sdk/js/src/__tests__/index.test.ts +++ b/sdk/js/src/__tests__/index.test.ts @@ -46,7 +46,6 @@ describe('DstackClient', () => { expect(result).toHaveProperty('event_log') expect(result.event_log.substring(0, 1) === '{') expect(() => JSON.parse(result.event_log)).not.toThrowError() - expect(result.replayRtmrs().length).toBe(4) }) it('should be able to attest', async () => { diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index 8226cbc27..a060d7656 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -3,7 +3,6 @@ // SPDX-License-Identifier: Apache-2.0 import fs from 'fs' -import { sha384 } from '@noble/hashes/sha512' import { send_rpc_request } from './send-rpc-request' export { getComposeHash } from './get-compose-hash' export { verifyEnvEncryptPublicKey, verifyEnvEncryptPublicKeyLegacy } from './verify-env-encrypt-public-key' @@ -52,6 +51,8 @@ export interface EventLog { digest: string event: string event_payload: string + version?: 1 | 2 + preimage?: string } export interface TcbInfo { @@ -99,8 +100,6 @@ export interface GetQuoteResponse { report_data?: Hex vm_config?: string attestation?: Hex - - replayRtmrs: () => string[] } export interface AttestResponse { @@ -147,36 +146,6 @@ function x509key_to_uint8array(pem: string, max_length?: number) { return result } -function replay_rtmr(history: string[]): string { - const INIT_MR = "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - if (history.length === 0) { - return INIT_MR - } - let mr = Buffer.from(INIT_MR, 'hex') - for (const content of history) { - // Convert hex string to buffer - let contentBuffer = Buffer.from(content, 'hex') - // Pad content with zeros if shorter than 48 bytes - if (contentBuffer.length < 48) { - const padding = Buffer.alloc(48 - contentBuffer.length, 0) - contentBuffer = Buffer.concat([contentBuffer, padding]) - } - mr = Buffer.from(sha384(Buffer.concat([mr, contentBuffer]))) - } - return mr.toString('hex') -} - -function reply_rtmrs(event_log: EventLog[]): Record { - const rtmrs: Array = [] - for (let idx = 0; idx < 4; idx++) { - const history = event_log - .filter(event => event.imr === idx) - .map(event => event.digest) - rtmrs[idx] = replay_rtmr(history) - } - return rtmrs -} - export interface TlsKeyOptions { path?: string; subject?: string; @@ -310,11 +279,6 @@ export class DstackClient { const err = result['error'] as string throw new Error(err) } - Object.defineProperty(result, 'replayRtmrs', { - get: () => () => reply_rtmrs(JSON.parse(result.event_log) as EventLog[]), - enumerable: true, - configurable: false, - }) return Object.freeze(result) } @@ -529,11 +493,6 @@ export class TappdClient extends DstackClient { const err = result['error'] as string throw new Error(err) } - Object.defineProperty(result, 'replayRtmrs', { - get: () => () => reply_rtmrs(JSON.parse(result.event_log) as EventLog[]), - enumerable: true, - configurable: false, - }) return Object.freeze(result) } diff --git a/sdk/python/README.md b/sdk/python/README.md index e112f18d4..d7c0de293 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -76,10 +76,7 @@ ed_key = client.get_key('signing/key', algorithm='ed25519') ```python quote = client.get_quote(b'user:alice:nonce123') - -# Replay RTMRs from the event log -rtmrs = quote.replay_rtmrs() -print(rtmrs) +print(quote.event_log) ``` **Parameters:** @@ -88,7 +85,6 @@ print(rtmrs) **Returns:** `GetQuoteResponse` - `quote`: Hex-encoded TDX quote - `event_log`: JSON string of measured events -- `replay_rtmrs()`: Method to compute RTMR values from the event log - `decode_quote()` / `decode_event_log()`: Helpers ### Versioned Attestation diff --git a/sdk/python/src/dstack_sdk/dstack_client.py b/sdk/python/src/dstack_sdk/dstack_client.py index 07cfd49bf..fd206483b 100644 --- a/sdk/python/src/dstack_sdk/dstack_client.py +++ b/sdk/python/src/dstack_sdk/dstack_client.py @@ -5,7 +5,6 @@ import base64 import binascii import functools -import hashlib from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version import json @@ -31,23 +30,6 @@ __version__ = "0.0.0+unknown" -INIT_MR = "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - - -def replay_rtmr(history: list[str]) -> str: - if len(history) == 0: - return INIT_MR - mr = bytes.fromhex(INIT_MR) - for content in history: - # mr = sha384(concat(mr, content)) - # if content is shorter than 48 bytes, pad it with zeros - content_bytes = bytes.fromhex(content) - if len(content_bytes) < 48: - content_bytes = content_bytes.ljust(48, b"\0") - mr = hashlib.sha384(mr + content_bytes).digest() - return mr.hex() - - def get_endpoint(endpoint: str | None = None) -> str: if endpoint: return endpoint @@ -168,16 +150,6 @@ def decode_quote(self) -> bytes: def decode_event_log(self) -> "List[EventLog]": return [EventLog(**event) for event in json.loads(self.event_log)] - def replay_rtmrs(self) -> Dict[int, str]: - parsed_event_log = json.loads(self.event_log) - rtmrs: Dict[int, str] = {} - for idx in range(4): - history = [ - event["digest"] for event in parsed_event_log if event.get("imr") == idx - ] - rtmrs[idx] = replay_rtmr(history) - return rtmrs - class AttestResponse(BaseModel): attestation: str @@ -220,6 +192,8 @@ class EventLog(BaseModel): digest: str event: str event_payload: str + version: Optional[int] = None + preimage: Optional[str] = None class TcbInfo(BaseModel): diff --git a/sdk/python/test_outputs.py b/sdk/python/test_outputs.py index f974d76a4..169b291e9 100644 --- a/sdk/python/test_outputs.py +++ b/sdk/python/test_outputs.py @@ -118,7 +118,6 @@ async def main(): # noqa: D103 tappd_quote = tappd_client.tdx_quote("test-data", "raw") print(f" quote length: {len(tappd_quote.quote)}") print(f" event_log length: {len(tappd_quote.event_log)}") - print(f" rtmrs count: {len(tappd_quote.replay_rtmrs())}") except Exception as error: print(f" error: {error}") @@ -137,7 +136,6 @@ async def main(): # noqa: D103 async_tappd_quote = await async_tappd_client.tdx_quote("test-data", "raw") print(f" quote length: {len(async_tappd_quote.quote)}") print(f" event_log length: {len(async_tappd_quote.event_log)}") - print(f" rtmrs count: {len(async_tappd_quote.replay_rtmrs())}") except Exception as error: print(f" error: {error}") @@ -147,14 +145,12 @@ async def main(): # noqa: D103 dstack_quote = client.get_quote("test-data-for-quote") print(f" quote length: {len(dstack_quote.quote)}") print(f" event_log length: {len(dstack_quote.event_log)}") - print(f" rtmrs count: {len(dstack_quote.replay_rtmrs())}") print("\n5.2 AsyncDstackClient.get_quote():") async_client = AsyncDstackClient() async_dstack_quote = await async_client.get_quote("test-data-for-quote") print(f" quote length: {len(async_dstack_quote.quote)}") print(f" event_log length: {len(async_dstack_quote.event_log)}") - print(f" rtmrs count: {len(async_dstack_quote.replay_rtmrs())}") # Test get_compose_hash print("\n6. Testing get_compose_hash") diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index cec867e72..119561e80 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -164,19 +164,6 @@ async def test_tls_key_uniqueness(): assert result1.key != result2.key -@pytest.mark.asyncio -async def test_replay_rtmr(): - client = AsyncDstackClient() - result = await client.get_quote("test") - # TODO evidence_api is a bit out-of-date, we need an up-to-date implementation. - tdxQuote = TdxQuote(bytearray(bytes.fromhex(result.quote))) - rtmrs = result.replay_rtmrs() - assert rtmrs[0] == tdxQuote.body.rtmr0.hex() - assert rtmrs[1] == tdxQuote.body.rtmr1.hex() - assert rtmrs[2] == tdxQuote.body.rtmr2.hex() - assert rtmrs[3] == tdxQuote.body.rtmr3.hex() - - @pytest.mark.asyncio async def test_get_quote_raw_hash_error(): with pytest.raises(ValueError) as excinfo: diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index f400a1e08..18a5bc7d1 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -1551,7 +1551,6 @@ dependencies = [ "pkcs8", "serde", "serde_json", - "sha2", "tokio", ] diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 36a54a3ae..630ecf3da 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -70,10 +70,7 @@ The Rust SDK currently requests the default `secp256k1` key material. Use distin ```rust let quote = client.get_quote(b"user:alice:nonce123".to_vec()).await?; - -// Replay RTMRs from the event log -let rtmrs = quote.replay_rtmrs()?; -println!("{:?}", rtmrs); +println!("{}", quote.event_log); ``` **Parameters:** @@ -82,7 +79,6 @@ println!("{:?}", rtmrs); **Returns:** `GetQuoteResponse` - `quote`: Hex-encoded TDX quote - `event_log`: JSON string of measured events -- `replay_rtmrs()`: Method to compute RTMR values from event log ### Get Instance Info diff --git a/sdk/rust/examples/dstack_client_usage.rs b/sdk/rust/examples/dstack_client_usage.rs index 3540499ac..f0b19ed5c 100644 --- a/sdk/rust/examples/dstack_client_usage.rs +++ b/sdk/rust/examples/dstack_client_usage.rs @@ -58,13 +58,6 @@ async fn main() -> anyhow::Result<()> { let quote_bytes = response.decode_quote()?; println!(" Decoded quote bytes length: {}", quote_bytes.len()); - // Replay RTMRs from event log - let rtmrs = response.replay_rtmrs()?; - println!(" Replayed RTMRs: {} entries", rtmrs.len()); - for (idx, rtmr) in rtmrs.iter() { - println!(" RTMR{}: {}", idx, rtmr); - } - // 4. Get TLS key for server authentication let tls_config = TlsKeyConfig::builder() .subject("my-app.example.com") diff --git a/sdk/rust/tests/test_client.rs b/sdk/rust/tests/test_client.rs index 919407ae0..69da1d38f 100644 --- a/sdk/rust/tests/test_client.rs +++ b/sdk/rust/tests/test_client.rs @@ -54,21 +54,6 @@ async fn test_tls_key_uniqueness() { assert_ne!(result1.key, result2.key); } -#[tokio::test] -async fn test_replay_rtmr() { - let client = AsyncDstackClient::new(None); - let result = client.get_quote("test".into()).await.unwrap(); - let rtmrs = result.replay_rtmrs().unwrap(); - let quote = result.decode_quote().unwrap(); - - let tdx_quote = Quote::parse("e).unwrap(); - let quote_report = tdx_quote.report.as_td10().unwrap(); - assert_eq!(rtmrs[&0], hex::encode(quote_report.rt_mr0)); - assert_eq!(rtmrs[&1], hex::encode(quote_report.rt_mr1)); - assert_eq!(rtmrs[&2], hex::encode(quote_report.rt_mr2)); - assert_eq!(rtmrs[&3], hex::encode(quote_report.rt_mr3)); -} - #[tokio::test] async fn test_report_data() { let report_data = "test"; diff --git a/sdk/rust/tests/test_tappd_client.rs b/sdk/rust/tests/test_tappd_client.rs index b5afb5711..a5ef116f7 100644 --- a/sdk/rust/tests/test_tappd_client.rs +++ b/sdk/rust/tests/test_tappd_client.rs @@ -143,19 +143,6 @@ async fn test_tappd_client_get_quote_integration() { println!(" Quote decode error: {}", e); } } - - // Test RTMR replay - match response.replay_rtmrs() { - Ok(rtmrs) => { - println!(" Replayed RTMRs: {} entries", rtmrs.len()); - for (idx, rtmr) in rtmrs.iter() { - println!(" RTMR{}: {}", idx, rtmr); - } - } - Err(e) => { - println!(" RTMR replay error: {}", e); - } - } } // Helper function to get a test client diff --git a/sdk/rust/types/Cargo.toml b/sdk/rust/types/Cargo.toml index 2bedc93ad..21c1f4228 100644 --- a/sdk/rust/types/Cargo.toml +++ b/sdk/rust/types/Cargo.toml @@ -19,7 +19,6 @@ hex = { workspace = true, features = ["alloc"] } pkcs8 = { workspace = true, features = ["pem"] } serde.workspace = true serde_json = { workspace = true, features = ["alloc"] } -sha2.workspace = true [dev-dependencies] dcap-qvl.workspace = true @@ -36,6 +35,5 @@ std = [ "pkcs8/pem", "serde/std", "serde_json/std", - "sha2/std", "borsh?/std", ] diff --git a/sdk/rust/types/README.md b/sdk/rust/types/README.md index f2a269cfb..7ca4a40c6 100644 --- a/sdk/rust/types/README.md +++ b/sdk/rust/types/README.md @@ -26,9 +26,9 @@ use dstack_sdk_types::tappd::{DeriveKeyResponse, TdxQuoteResponse, TappdInfoResp let key_response: GetKeyResponse = serde_json::from_str(&json_data)?; let key_bytes = key_response.decode_key()?; -// Parse a quote response and replay RTMRs +// Parse a quote response let quote_response: GetQuoteResponse = serde_json::from_str(&json_data)?; -let rtmrs = quote_response.replay_rtmrs()?; +let events = quote_response.decode_event_log()?; // Work with legacy tappd types let derive_response: DeriveKeyResponse = serde_json::from_str(&json_data)?; diff --git a/sdk/rust/types/src/dstack.rs b/sdk/rust/types/src/dstack.rs index d97b169b8..01a358abd 100644 --- a/sdk/rust/types/src/dstack.rs +++ b/sdk/rust/types/src/dstack.rs @@ -2,40 +2,16 @@ // // SPDX-License-Identifier: Apache-2.0 -use alloc::{ - collections::BTreeMap, - string::{String, ToString}, - vec::Vec, -}; -use anyhow::{Context as _, Result}; -use hex::{encode as hex_encode, FromHexError}; +use alloc::{string::String, vec::Vec}; +use hex::FromHexError; use serde::{Deserialize, Serialize}; use serde_json::{from_str, Value}; -use sha2::Digest; #[cfg(feature = "borsh_schema")] use borsh::BorshSchema; #[cfg(feature = "borsh")] use borsh::{BorshDeserialize, BorshSerialize}; -const INIT_MR: &str = "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; - -fn replay_rtmr(history: Vec) -> Result { - if history.is_empty() { - return Ok(INIT_MR.to_string()); - } - let mut mr = hex::decode(INIT_MR)?; - for content in history { - let mut content_bytes = hex::decode(content)?; - if content_bytes.len() < 48 { - content_bytes.resize(48, 0); - } - mr.extend_from_slice(&content_bytes); - mr = sha2::Sha384::digest(&mr).to_vec(); - } - Ok(hex_encode(mr)) -} - /// Represents an event log entry in the system #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] @@ -111,9 +87,8 @@ 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. + /// Platform-adaptive versioned attestation, hex-encoded. Populated on + /// non-TDX platforms; TDX uses `quote` and `event_log`. #[serde(default)] pub attestation: String, } @@ -155,26 +130,6 @@ impl GetQuoteResponse { pub fn decode_event_log(&self) -> Result, serde_json::Error> { serde_json::from_str(&self.event_log) } - - pub fn replay_rtmrs(&self) -> Result> { - let parsed_event_log: Vec = self.decode_event_log()?; - let mut rtmrs = BTreeMap::new(); - for idx in 0..4 { - let mut history = Vec::new(); - for event in &parsed_event_log { - if event.imr == idx { - history.push(event.digest.clone()); - } - } - rtmrs.insert( - idx as u8, - replay_rtmr(history) - .ok() - .context("Invalid digest in event log")?, - ); - } - Ok(rtmrs) - } } /// Response containing instance information and attestation data diff --git a/sdk/rust/types/src/tappd.rs b/sdk/rust/types/src/tappd.rs index 9f3b9d50d..06e72e8d7 100644 --- a/sdk/rust/types/src/tappd.rs +++ b/sdk/rust/types/src/tappd.rs @@ -2,15 +2,10 @@ // // SPDX-License-Identifier: Apache-2.0 -use alloc::{ - collections::BTreeMap, - string::{String, ToString}, - vec::Vec, -}; -use anyhow::{bail, Context as _, Result}; -use hex::{encode as hex_encode, FromHexError}; +use alloc::{string::String, vec::Vec}; +use anyhow::{bail, Result}; +use hex::FromHexError; use serde::{Deserialize, Serialize}; -use sha2::Digest; #[cfg(feature = "borsh_schema")] use borsh::BorshSchema; @@ -19,8 +14,6 @@ use borsh::{BorshDeserialize, BorshSerialize}; use crate::dstack::EventLog; -const INIT_MR: &str = "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; - /// Hash algorithms supported by the TDX quote generation #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] @@ -55,22 +48,6 @@ impl QuoteHashAlgorithm { } } -fn replay_rtmr(history: Vec) -> Result { - if history.is_empty() { - return Ok(INIT_MR.to_string()); - } - let mut mr = hex::decode(INIT_MR)?; - for content in history { - let mut content_bytes = hex::decode(content)?; - if content_bytes.len() < 48 { - content_bytes.resize(48, 0); - } - mr.extend_from_slice(&content_bytes); - mr = sha2::Sha384::digest(&mr).to_vec(); - } - Ok(hex_encode(mr)) -} - /// Response from a key derivation request #[derive(Debug, Serialize, Deserialize)] #[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] @@ -168,27 +145,6 @@ impl TdxQuoteResponse { pub fn decode_event_log(&self) -> Result, serde_json::Error> { serde_json::from_str(&self.event_log) } - - /// Replays RTMR history to calculate final RTMR values - pub fn replay_rtmrs(&self) -> Result> { - let parsed_event_log: Vec = self.decode_event_log()?; - let mut rtmrs = BTreeMap::new(); - for idx in 0..4 { - let mut history = Vec::new(); - for event in &parsed_event_log { - if event.imr == idx { - history.push(event.digest.clone()); - } - } - rtmrs.insert( - idx as u8, - replay_rtmr(history) - .ok() - .context("Invalid digest in event log")?, - ); - } - Ok(rtmrs) - } } /// TCB (Trusted Computing Base) information