From 654021aef6855d5357727d4f68814c3329b1b521 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 19:00:47 -0700 Subject: [PATCH 01/24] Add event log v2 format with JSON canonical digest (RFC 8785) Introduce a new event log version (v2) that uses JCS canonical JSON as the digest input instead of the binary concatenation format. This enables relying parties to define fine-grained trust policies on individual event claims (e.g., check compose-hash directly) without needing to match the full RTMR3 value. V2 uses a new event_type (0x08000002) so verifiers can distinguish the format without access to app-compose.json. Controlled per-app via the `event_log_version` field in app-compose.json (default 1 for backward compatibility). --- cc-eventlog/src/lib.rs | 4 +- cc-eventlog/src/runtime_events.rs | 161 +++++++++++++++++++++++++++++- cc-eventlog/src/tdx.rs | 24 ++++- dstack-attest/src/attestation.rs | 2 +- dstack-attest/src/lib.rs | 10 +- dstack-types/src/lib.rs | 6 ++ dstack-util/src/main.rs | 7 +- dstack-util/src/system_setup.rs | 78 +++++++++------ guest-agent-simulator/src/main.rs | 6 +- guest-agent/src/backend.rs | 6 +- guest-agent/src/rpc_service.rs | 12 ++- 11 files changed, 264 insertions(+), 52 deletions(-) diff --git a/cc-eventlog/src/lib.rs b/cc-eventlog/src/lib.rs index e850f39c7..bd1691065 100644 --- a/cc-eventlog/src/lib.rs +++ b/cc-eventlog/src/lib.rs @@ -2,7 +2,9 @@ // // SPDX-License-Identifier: Apache-2.0 -pub use runtime_events::{replay_events, RuntimeEvent}; +pub use runtime_events::{ + canonical_event_json, replay_events, RuntimeEvent, DSTACK_RUNTIME_EVENT_TYPE_V2, +}; pub use tdx::TdxEvent; mod codecs; diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index fa948f36c..a58a403b6 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -11,10 +11,14 @@ use std::io::Write; use ez_hash::{Hasher, Sha256, Sha384}; -/// The event type for dstack runtime events. +/// The event type for dstack runtime events (v1). /// 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 pub const DSTACK_RUNTIME_EVENT_TYPE: u32 = 0x08000001; +/// The event type for dstack runtime events (v2, JSON canonical content). +/// V2 events use JCS (RFC 8785) canonical JSON as the digest input, enabling +/// relying parties to define fine-grained trust policies on individual event claims. +pub const DSTACK_RUNTIME_EVENT_TYPE_V2: u32 = 0x08000002; /// The path to the userspace TDX event log file. pub const RUNTIME_EVENT_LOG_FILE: &str = "/run/log/dstack/runtime_events.log"; @@ -26,11 +30,18 @@ pub struct RuntimeEvent { /// Event payload #[serde(with = "base64")] pub payload: Vec, + /// Event log version (0 or absent = v1, 2 = v2 JSON canonical) + #[serde(default, skip_serializing)] + pub version: u32, } impl RuntimeEvent { - pub fn new(event: String, payload: Vec) -> Self { - Self { event, payload } + pub fn new(event: String, payload: Vec, version: u32) -> Self { + Self { + event, + payload, + version, + } } pub fn read_all() -> Result> { @@ -97,7 +108,18 @@ impl RuntimeEvent { } /// Compute the digest of the event. + /// + /// For v1 (version 0 or 1): `SHA(event_type_le || ":" || event_name || ":" || payload)` + /// For v2 (version 2): `SHA(canonical_json({"event":"...","event_type":134217730,"payload":"hex..."}))` pub fn digest(&self) -> H::Output { + if self.is_v2() { + self.digest_v2::() + } else { + self.digest_v1::() + } + } + + fn digest_v1(&self) -> H::Output { H::hash([ &DSTACK_RUNTIME_EVENT_TYPE.to_ne_bytes()[..], b":", @@ -107,11 +129,46 @@ impl RuntimeEvent { ]) } + /// Compute the v2 digest using JCS (RFC 8785) canonical JSON. + /// + /// The canonical JSON has keys sorted alphabetically: + /// `{"event":"","event_type":134217730,"payload":""}` + fn digest_v2(&self) -> H::Output { + let canonical = + canonical_event_json(&self.event, DSTACK_RUNTIME_EVENT_TYPE_V2, &self.payload); + H::hash([canonical.as_bytes()]) + } + + pub fn is_v2(&self) -> bool { + self.version == 2 + } + pub fn cc_event_type(&self) -> u32 { - DSTACK_RUNTIME_EVENT_TYPE + if self.is_v2() { + DSTACK_RUNTIME_EVENT_TYPE_V2 + } else { + DSTACK_RUNTIME_EVENT_TYPE + } } } +/// Construct JCS (RFC 8785) canonical JSON for a runtime event. +/// +/// Keys are sorted alphabetically: `event`, `event_type`, `payload`. +/// The payload is hex-encoded for human readability. +/// +/// Output: `{"event":"","event_type":,"payload":""}` +pub fn canonical_event_json(event: &str, event_type: u32, payload: &[u8]) -> String { + // Per JCS, strings must use minimal JSON escaping. + // We use serde_json to correctly escape the event name. + let escaped_event = serde_json::to_string(event).expect("failed to serialize event name"); + let hex_payload = hex::encode(payload); + format!( + r#"{{"event":{},"event_type":{},"payload":"{}"}}"#, + escaped_event, event_type, hex_payload + ) +} + /// Replay event logs pub fn replay_events(eventlog: &[RuntimeEvent], to_event: Option<&str>) -> H::Output { let mut mr = H::zeros(); @@ -125,3 +182,99 @@ 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], 1); + let digest = event.digest::(); + let expected = Sha384::hash([ + &DSTACK_RUNTIME_EVENT_TYPE.to_ne_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], 2); + let canonical = + canonical_event_json(&event.event, DSTACK_RUNTIME_EVENT_TYPE_V2, &event.payload); + assert_eq!( + canonical, + r#"{"event":"compose-hash","event_type":134217730,"payload":"abcd"}"# + ); + 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], 1); + let v2 = RuntimeEvent::new("test".to_string(), vec![1, 2, 3], 2); + 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![], 1); + assert_eq!(event.cc_event_type(), DSTACK_RUNTIME_EVENT_TYPE); + } + + #[test] + fn v2_event_type() { + let event = RuntimeEvent::new("test".to_string(), vec![], 2); + assert_eq!(event.cc_event_type(), DSTACK_RUNTIME_EVENT_TYPE_V2); + } + + #[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, 0); + assert!(!event.is_v2()); + assert_eq!(event.cc_event_type(), DSTACK_RUNTIME_EVENT_TYPE); + } + + #[test] + fn serialize_omits_version() { + let v1 = RuntimeEvent::new("test".to_string(), vec![1], 1); + let v2 = RuntimeEvent::new("test".to_string(), vec![1], 2); + let json_v1 = serde_json::to_string(&v1).unwrap(); + let json_v2 = serde_json::to_string(&v2).unwrap(); + assert!( + !json_v1.contains("version"), + "version should never be serialized" + ); + assert!( + !json_v2.contains("version"), + "version should never be serialized" + ); + } + + #[test] + fn canonical_json_escapes_special_chars() { + let canonical = canonical_event_json( + "event\"with\\special\nchars", + DSTACK_RUNTIME_EVENT_TYPE_V2, + &[0xff], + ); + // Verify it's valid JSON + let parsed: serde_json::Value = serde_json::from_str(&canonical).unwrap(); + assert_eq!( + parsed["event"].as_str().unwrap(), + "event\"with\\special\nchars" + ); + } +} diff --git a/cc-eventlog/src/tdx.rs b/cc-eventlog/src/tdx.rs index bf7d677c0..e03093dbe 100644 --- a/cc-eventlog/src/tdx.rs +++ b/cc-eventlog/src/tdx.rs @@ -7,7 +7,7 @@ use scale::{Decode, Encode}; use serde::{Deserialize, Serialize}; use crate::{ - runtime_events::{RuntimeEvent, DSTACK_RUNTIME_EVENT_TYPE}, + runtime_events::{RuntimeEvent, DSTACK_RUNTIME_EVENT_TYPE, DSTACK_RUNTIME_EVENT_TYPE_V2}, tcg::TcgEventLog, }; @@ -16,7 +16,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)`. +/// As for RTMR3: +/// - V1 (event_type 0x08000001): digest = `sha384(event_type_le || ":" || event || ":" || payload)` +/// - V2 (event_type 0x08000002): digest = `sha384(canonical_json({"event":"...","event_type":134217730,"payload":"hex..."}))` #[derive(Clone, Debug, Serialize, Deserialize, Encode, Decode)] pub struct TdxEvent { /// IMR index, starts from 0 @@ -75,22 +77,34 @@ impl TdxEvent { pub fn is_runtime_event(&self) -> bool { self.event_type == DSTACK_RUNTIME_EVENT_TYPE + || self.event_type == DSTACK_RUNTIME_EVENT_TYPE_V2 } pub fn to_runtime_event(&self) -> Option { - self.is_runtime_event().then_some(RuntimeEvent { + if !self.is_runtime_event() { + return None; + } + let version = if self.event_type == DSTACK_RUNTIME_EVENT_TYPE_V2 { + 2 + } else { + 0 + }; + Some(RuntimeEvent { event: self.event.clone(), payload: self.event_payload.clone(), + version, }) } } impl From for TdxEvent { fn from(value: RuntimeEvent) -> Self { + let event_type = value.cc_event_type(); + 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, } diff --git a/dstack-attest/src/attestation.rs b/dstack-attest/src/attestation.rs index 55611b5f0..eb08a3581 100644 --- a/dstack-attest/src/attestation.rs +++ b/dstack-attest/src/attestation.rs @@ -1036,7 +1036,7 @@ 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(), 0)] } else { vec![] }; diff --git a/dstack-attest/src/lib.rs b/dstack-attest/src/lib.rs index 1f7bc814f..484719329 100644 --- a/dstack-attest/src/lib.rs +++ b/dstack-attest/src/lib.rs @@ -14,8 +14,14 @@ 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()); +/// +/// `event_log_version`: 1 for legacy binary digest, 2 for JSON canonical digest. +pub fn emit_runtime_event( + event: &str, + payload: &[u8], + event_log_version: u32, +) -> anyhow::Result<()> { + let event = RuntimeEvent::new(event.to_string(), payload.to_vec(), event_log_version); let mode = AttestationMode::detect()?; diff --git a/dstack-types/src/lib.rs b/dstack-types/src/lib.rs index 514e84fc1..6c5427432 100644 --- a/dstack-types/src/lib.rs +++ b/dstack-types/src/lib.rs @@ -45,12 +45,18 @@ pub struct AppCompose { pub storage_fs: Option, #[serde(default, with = "human_size")] pub swap_size: u64, + #[serde(default = "default_event_log_version")] + pub event_log_version: u32, } fn default_true() -> bool { true } +fn default_event_log_version() -> u32 { + 1 +} + fn deserialize_gateway_enabled<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, diff --git a/dstack-util/src/main.rs b/dstack-util/src/main.rs index 05ad9ad61..a068d2597 100644 --- a/dstack-util/src/main.rs +++ b/dstack-util/src/main.rs @@ -90,6 +90,10 @@ struct ExtendArgs { #[clap(short, long)] /// hex encoded payload of the event payload: String, + + #[clap(long, default_value_t = 1)] + /// event log version (1 = legacy, 2 = JSON canonical) + event_log_version: u32, } #[derive(Parser)] @@ -224,7 +228,8 @@ 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") + emit_runtime_event(&extend_args.event, &payload, extend_args.event_log_version) + .context("Failed to extend RTMR") } fn cmd_rand(rand_args: RandArgs) -> Result<()> { diff --git a/dstack-util/src/system_setup.rs b/dstack-util/src/system_setup.rs index b7f59cfd1..edf7d0646 100644 --- a/dstack-util/src/system_setup.rs +++ b/dstack-util/src/system_setup.rs @@ -688,10 +688,10 @@ 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: u32) -> 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 +824,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 +858,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 +1324,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 +1362,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 +1393,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 +1413,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/guest-agent-simulator/src/main.rs b/guest-agent-simulator/src/main.rs index c2d595359..9d03a3a58 100644 --- a/guest-agent-simulator/src/main.rs +++ b/guest-agent-simulator/src/main.rs @@ -90,7 +90,7 @@ impl PlatformBackend for SimulatorPlatform { simulator::simulated_attest_response(&self.attestation, report_data, self.patch_report_data) } - fn emit_event(&self, event: &str, _payload: &[u8]) -> Result<()> { + fn emit_event(&self, event: &str, _payload: &[u8], _event_log_version: u32) -> Result<()> { bail!("runtime event emission is unavailable in simulator mode: {event}") } } @@ -148,7 +148,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", 1) + .unwrap_err(); assert!(err.to_string().contains("unavailable in simulator mode")); } diff --git a/guest-agent/src/backend.rs b/guest-agent/src/backend.rs index 3344ff909..4de8d20a1 100644 --- a/guest-agent/src/backend.rs +++ b/guest-agent/src/backend.rs @@ -13,7 +13,7 @@ pub trait PlatformBackend: Send + Sync { 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 emit_event(&self, event: &str, payload: &[u8], event_log_version: u32) -> Result<()>; } #[derive(Debug, Default)] @@ -52,7 +52,7 @@ impl PlatformBackend for RealPlatform { }) } - fn emit_event(&self, event: &str, payload: &[u8]) -> Result<()> { - emit_runtime_event(event, payload) + fn emit_event(&self, event: &str, payload: &[u8], event_log_version: u32) -> Result<()> { + emit_runtime_event(event, payload, event_log_version) } } diff --git a/guest-agent/src/rpc_service.rs b/guest-agent/src/rpc_service.rs index 202ad73e6..1eaafd008 100644 --- a/guest-agent/src/rpc_service.rs +++ b/guest-agent/src/rpc_service.rs @@ -181,7 +181,10 @@ impl AppState { } 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) } } @@ -856,7 +859,12 @@ pNs85uhOZE8z2jr8Pg== }) } - fn emit_event(&self, _event: &str, _payload: &[u8]) -> Result<()> { + fn emit_event( + &self, + _event: &str, + _payload: &[u8], + _event_log_version: u32, + ) -> Result<()> { Ok(()) } } From 660e0e32ec650e68b7d571802399294866bfca66 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 19:12:04 -0700 Subject: [PATCH 02/24] refactor: use EventLogVersion enum instead of raw u32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace u32 version fields with an EventLogVersion enum to get exhaustive match checking — adding a v3 in the future will force all match sites to be updated at compile time. Also removes version field from event log serialization as it's unnecessary (verifier distinguishes v1/v2 via event_type). --- Cargo.lock | 2 + cc-eventlog/Cargo.toml | 1 + cc-eventlog/src/lib.rs | 1 + cc-eventlog/src/runtime_events.rs | 86 ++++++++++++++----------------- cc-eventlog/src/tdx.rs | 5 +- dstack-attest/src/attestation.rs | 8 ++- dstack-attest/src/lib.rs | 8 ++- dstack-types/src/lib.rs | 47 ++++++++++++++--- dstack-util/src/main.rs | 4 +- dstack-util/src/system_setup.rs | 7 ++- guest-agent-simulator/Cargo.toml | 1 + guest-agent-simulator/src/main.rs | 5 +- guest-agent/src/backend.rs | 7 +-- guest-agent/src/rpc_service.rs | 4 +- 14 files changed, 113 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59ac5fda0..b47822a60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1315,6 +1315,7 @@ version = "0.5.8" dependencies = [ "anyhow", "digest 0.10.7", + "dstack-types", "ez-hash", "fs-err", "hex", @@ -2409,6 +2410,7 @@ dependencies = [ "clap", "dstack-guest-agent", "dstack-guest-agent-rpc", + "dstack-types", "ra-rpc", "ra-tls", "rocket", diff --git a/cc-eventlog/Cargo.toml b/cc-eventlog/Cargo.toml index 2863760f7..9943de8df 100644 --- a/cc-eventlog/Cargo.toml +++ b/cc-eventlog/Cargo.toml @@ -13,6 +13,7 @@ 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 diff --git a/cc-eventlog/src/lib.rs b/cc-eventlog/src/lib.rs index bd1691065..182c791c1 100644 --- a/cc-eventlog/src/lib.rs +++ b/cc-eventlog/src/lib.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: Apache-2.0 +pub use dstack_types::EventLogVersion; pub use runtime_events::{ canonical_event_json, replay_events, RuntimeEvent, DSTACK_RUNTIME_EVENT_TYPE_V2, }; diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index a58a403b6..1ab5e5e36 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::{Context, Result}; +use dstack_types::EventLogVersion; use fs_err as fs; use scale::{Decode, Encode}; use serde::{Deserialize, Serialize}; @@ -30,13 +31,13 @@ pub struct RuntimeEvent { /// Event payload #[serde(with = "base64")] pub payload: Vec, - /// Event log version (0 or absent = v1, 2 = v2 JSON canonical) + /// Event log version #[serde(default, skip_serializing)] - pub version: u32, + pub version: EventLogVersion, } impl RuntimeEvent { - pub fn new(event: String, payload: Vec, version: u32) -> Self { + pub fn new(event: String, payload: Vec, version: EventLogVersion) -> Self { Self { event, payload, @@ -109,45 +110,29 @@ impl RuntimeEvent { /// Compute the digest of the event. /// - /// For v1 (version 0 or 1): `SHA(event_type_le || ":" || event_name || ":" || payload)` - /// For v2 (version 2): `SHA(canonical_json({"event":"...","event_type":134217730,"payload":"hex..."}))` + /// - V1: `SHA(event_type_le || ":" || event_name || ":" || payload)` + /// - V2: `SHA(canonical_json({"event":"...","event_type":134217730,"payload":"hex..."}))` pub fn digest(&self) -> H::Output { - if self.is_v2() { - self.digest_v2::() - } else { - self.digest_v1::() + match self.version { + EventLogVersion::V1 => H::hash([ + &DSTACK_RUNTIME_EVENT_TYPE.to_ne_bytes()[..], + b":", + self.event.as_bytes(), + b":", + &self.payload, + ]), + EventLogVersion::V2 => { + let canonical = + canonical_event_json(&self.event, DSTACK_RUNTIME_EVENT_TYPE_V2, &self.payload); + H::hash([canonical.as_bytes()]) + } } } - fn digest_v1(&self) -> H::Output { - H::hash([ - &DSTACK_RUNTIME_EVENT_TYPE.to_ne_bytes()[..], - b":", - self.event.as_bytes(), - b":", - &self.payload, - ]) - } - - /// Compute the v2 digest using JCS (RFC 8785) canonical JSON. - /// - /// The canonical JSON has keys sorted alphabetically: - /// `{"event":"","event_type":134217730,"payload":""}` - fn digest_v2(&self) -> H::Output { - let canonical = - canonical_event_json(&self.event, DSTACK_RUNTIME_EVENT_TYPE_V2, &self.payload); - H::hash([canonical.as_bytes()]) - } - - pub fn is_v2(&self) -> bool { - self.version == 2 - } - pub fn cc_event_type(&self) -> u32 { - if self.is_v2() { - DSTACK_RUNTIME_EVENT_TYPE_V2 - } else { - DSTACK_RUNTIME_EVENT_TYPE + match self.version { + EventLogVersion::V1 => DSTACK_RUNTIME_EVENT_TYPE, + EventLogVersion::V2 => DSTACK_RUNTIME_EVENT_TYPE_V2, } } } @@ -189,7 +174,11 @@ mod tests { #[test] fn v1_digest_unchanged() { - let event = RuntimeEvent::new("app-id".to_string(), vec![0xde, 0xad, 0xbe, 0xef], 1); + 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_ne_bytes()[..], @@ -203,7 +192,11 @@ mod tests { #[test] fn v2_digest_is_canonical_json_hash() { - let event = RuntimeEvent::new("compose-hash".to_string(), vec![0xab, 0xcd], 2); + let event = RuntimeEvent::new( + "compose-hash".to_string(), + vec![0xab, 0xcd], + EventLogVersion::V2, + ); let canonical = canonical_event_json(&event.event, DSTACK_RUNTIME_EVENT_TYPE_V2, &event.payload); assert_eq!( @@ -217,8 +210,8 @@ mod tests { #[test] fn v2_digest_differs_from_v1() { - let v1 = RuntimeEvent::new("test".to_string(), vec![1, 2, 3], 1); - let v2 = RuntimeEvent::new("test".to_string(), vec![1, 2, 3], 2); + 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::(), @@ -228,13 +221,13 @@ mod tests { #[test] fn v1_event_type() { - let event = RuntimeEvent::new("test".to_string(), vec![], 1); + 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() { - let event = RuntimeEvent::new("test".to_string(), vec![], 2); + let event = RuntimeEvent::new("test".to_string(), vec![], EventLogVersion::V2); assert_eq!(event.cc_event_type(), DSTACK_RUNTIME_EVENT_TYPE_V2); } @@ -242,15 +235,14 @@ mod tests { 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, 0); - assert!(!event.is_v2()); + assert_eq!(event.version, EventLogVersion::V1); assert_eq!(event.cc_event_type(), DSTACK_RUNTIME_EVENT_TYPE); } #[test] fn serialize_omits_version() { - let v1 = RuntimeEvent::new("test".to_string(), vec![1], 1); - let v2 = RuntimeEvent::new("test".to_string(), vec![1], 2); + let v1 = RuntimeEvent::new("test".to_string(), vec![1], EventLogVersion::V1); + let v2 = RuntimeEvent::new("test".to_string(), vec![1], EventLogVersion::V2); let json_v1 = serde_json::to_string(&v1).unwrap(); let json_v2 = serde_json::to_string(&v2).unwrap(); assert!( diff --git a/cc-eventlog/src/tdx.rs b/cc-eventlog/src/tdx.rs index e03093dbe..5ddd1a472 100644 --- a/cc-eventlog/src/tdx.rs +++ b/cc-eventlog/src/tdx.rs @@ -84,10 +84,11 @@ impl TdxEvent { if !self.is_runtime_event() { return None; } + use dstack_types::EventLogVersion; let version = if self.event_type == DSTACK_RUNTIME_EVENT_TYPE_V2 { - 2 + EventLogVersion::V2 } else { - 0 + EventLogVersion::V1 }; Some(RuntimeEvent { event: self.event.clone(), diff --git a/dstack-attest/src/attestation.rs b/dstack-attest/src/attestation.rs index eb08a3581..272ff3d23 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, @@ -1036,7 +1036,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(), 0)] + vec![RuntimeEvent::new( + "app-id".to_string(), + app_id.to_vec(), + EventLogVersion::V1, + )] } else { vec![] }; diff --git a/dstack-attest/src/lib.rs b/dstack-attest/src/lib.rs index 484719329..c4f5dfc83 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,14 +14,12 @@ pub mod attestation; mod v1; /// Emit a runtime event that extends RTMR3 and logs the event. -/// -/// `event_log_version`: 1 for legacy binary digest, 2 for JSON canonical digest. pub fn emit_runtime_event( event: &str, payload: &[u8], - event_log_version: u32, + version: EventLogVersion, ) -> anyhow::Result<()> { - let event = RuntimeEvent::new(event.to_string(), payload.to_vec(), event_log_version); + let event = RuntimeEvent::new(event.to_string(), payload.to_vec(), version); let mode = AttestationMode::detect()?; diff --git a/dstack-types/src/lib.rs b/dstack-types/src/lib.rs index 6c5427432..1f2e906a2 100644 --- a/dstack-types/src/lib.rs +++ b/dstack-types/src/lib.rs @@ -9,6 +9,45 @@ 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, Encode, Decode)] +pub enum EventLogVersion { + /// Legacy binary digest: `SHA(event_type_le || ":" || name || ":" || payload)` + #[default] + V1, + /// JSON canonical digest (JCS RFC 8785): + /// `SHA({"event":"...","event_type":134217730,"payload":"hex..."})` + V2, +} + +impl EventLogVersion { + pub fn from_u32(v: u32) -> Self { + match v { + 2 => EventLogVersion::V2, + _ => EventLogVersion::V1, + } + } +} + +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)?; + Ok(EventLogVersion::from_u32(v)) + } +} + #[derive(Deserialize, Serialize, Debug, Clone)] pub struct AppCompose { pub manifest_version: u32, @@ -45,18 +84,14 @@ pub struct AppCompose { pub storage_fs: Option, #[serde(default, with = "human_size")] pub swap_size: u64, - #[serde(default = "default_event_log_version")] - pub event_log_version: u32, + #[serde(default)] + pub event_log_version: EventLogVersion, } fn default_true() -> bool { true } -fn default_event_log_version() -> u32 { - 1 -} - fn deserialize_gateway_enabled<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, diff --git a/dstack-util/src/main.rs b/dstack-util/src/main.rs index a068d2597..1ea9a6436 100644 --- a/dstack-util/src/main.rs +++ b/dstack-util/src/main.rs @@ -228,8 +228,8 @@ 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, extend_args.event_log_version) - .context("Failed to extend RTMR") + let version = dstack_types::EventLogVersion::from_u32(extend_args.event_log_version); + emit_runtime_event(&extend_args.event, &payload, version).context("Failed to extend RTMR") } fn cmd_rand(rand_args: RandArgs) -> Result<()> { diff --git a/dstack-util/src/system_setup.rs b/dstack-util/src/system_setup.rs index edf7d0646..506fba8cd 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,7 +688,10 @@ fn truncate(s: &[u8], len: usize) -> &[u8] { } } -fn emit_key_provider_info(provider_info: &KeyProviderInfo, event_log_version: u32) -> 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, event_log_version)?; diff --git a/guest-agent-simulator/Cargo.toml b/guest-agent-simulator/Cargo.toml index f476e01c2..2695811d8 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 9d03a3a58..5d9dcfb04 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; @@ -90,7 +91,7 @@ impl PlatformBackend for SimulatorPlatform { simulator::simulated_attest_response(&self.attestation, report_data, self.patch_report_data) } - fn emit_event(&self, event: &str, _payload: &[u8], _event_log_version: u32) -> Result<()> { + fn emit_event(&self, event: &str, _payload: &[u8], _version: EventLogVersion) -> Result<()> { bail!("runtime event emission is unavailable in simulator mode: {event}") } } @@ -149,7 +150,7 @@ mod tests { fn simulator_rejects_runtime_event_emission() { let platform = load_fixture_platform(); let err = platform - .emit_event("test.event", b"payload", 1) + .emit_event("test.event", b"payload", EventLogVersion::V1) .unwrap_err(); assert!(err.to_string().contains("unavailable in simulator mode")); } diff --git a/guest-agent/src/backend.rs b/guest-agent/src/backend.rs index 4de8d20a1..0b9eb45f6 100644 --- a/guest-agent/src/backend.rs +++ b/guest-agent/src/backend.rs @@ -5,6 +5,7 @@ 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}; @@ -13,7 +14,7 @@ pub trait PlatformBackend: Send + Sync { 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], event_log_version: u32) -> Result<()>; + fn emit_event(&self, event: &str, payload: &[u8], version: EventLogVersion) -> Result<()>; } #[derive(Debug, Default)] @@ -52,7 +53,7 @@ impl PlatformBackend for RealPlatform { }) } - fn emit_event(&self, event: &str, payload: &[u8], event_log_version: u32) -> Result<()> { - emit_runtime_event(event, payload, event_log_version) + 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 1eaafd008..a396081d6 100644 --- a/guest-agent/src/rpc_service.rs +++ b/guest-agent/src/rpc_service.rs @@ -667,7 +667,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, @@ -863,7 +863,7 @@ pNs85uhOZE8z2jr8Pg== &self, _event: &str, _payload: &[u8], - _event_log_version: u32, + _version: EventLogVersion, ) -> Result<()> { Ok(()) } From 32241ad56932f37118322773cea0cba378e4e34c Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 19:33:15 -0700 Subject: [PATCH 03/24] fix: reject unknown event_log_version values instead of silent fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit from_u32 now returns Option, rejecting unknown values like 0 or 42 rather than silently degrading to V1. A silent downgrade could produce incorrect RTMR measurements — a security-relevant bug. --- dstack-types/src/lib.rs | 10 ++++++---- dstack-util/src/main.rs | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/dstack-types/src/lib.rs b/dstack-types/src/lib.rs index 1f2e906a2..b398aeb22 100644 --- a/dstack-types/src/lib.rs +++ b/dstack-types/src/lib.rs @@ -24,10 +24,11 @@ pub enum EventLogVersion { } impl EventLogVersion { - pub fn from_u32(v: u32) -> Self { + pub fn from_u32(v: u32) -> Option { match v { - 2 => EventLogVersion::V2, - _ => EventLogVersion::V1, + 1 => Some(EventLogVersion::V1), + 2 => Some(EventLogVersion::V2), + _ => None, } } } @@ -44,7 +45,8 @@ impl Serialize for EventLogVersion { impl<'de> Deserialize<'de> for EventLogVersion { fn deserialize>(deserializer: D) -> Result { let v = u32::deserialize(deserializer)?; - Ok(EventLogVersion::from_u32(v)) + EventLogVersion::from_u32(v) + .ok_or_else(|| serde::de::Error::custom(format!("unknown event log version: {v}"))) } } diff --git a/dstack-util/src/main.rs b/dstack-util/src/main.rs index 1ea9a6436..c10350604 100644 --- a/dstack-util/src/main.rs +++ b/dstack-util/src/main.rs @@ -228,7 +228,8 @@ fn hex_decode(hex_str: &str) -> Result> { fn cmd_extend(extend_args: ExtendArgs) -> Result<()> { let payload = hex_decode(&extend_args.payload).context("Failed to decode payload")?; - let version = dstack_types::EventLogVersion::from_u32(extend_args.event_log_version); + let version = dstack_types::EventLogVersion::from_u32(extend_args.event_log_version) + .context("unsupported event log version")?; emit_runtime_event(&extend_args.event, &payload, version).context("Failed to extend RTMR") } From 4cf928c2bbb7f92fce813c97ea23e445bd8bfb1c Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 19:50:21 -0700 Subject: [PATCH 04/24] fix: address CI failures and PR review comments - Replace expect() with manual JSON escaping to satisfy clippy::expect_used - Add #[codec(skip)] on RuntimeEvent.version to preserve scale binary compat with existing attestation fixtures - Pass EventLogVersion from caller in Attestation::quote_with_app_id - Read event_log_version from app-compose.json in extend-rtmr CLI instead of hardcoded default - from_u32 now returns Option, rejecting unknown version values --- cc-eventlog/src/runtime_events.rs | 29 +++++++++++++++++++++++------ dstack-attest/src/attestation.rs | 10 +++++++--- dstack-types/src/lib.rs | 2 +- dstack-util/src/main.rs | 19 +++++++++++++------ ra-tls/src/cert.rs | 2 +- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index 1ab5e5e36..c239ca5f4 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -33,6 +33,7 @@ pub struct RuntimeEvent { pub payload: Vec, /// Event log version #[serde(default, skip_serializing)] + #[codec(skip)] pub version: EventLogVersion, } @@ -145,13 +146,29 @@ impl RuntimeEvent { /// Output: `{"event":"","event_type":,"payload":""}` pub fn canonical_event_json(event: &str, event_type: u32, payload: &[u8]) -> String { // Per JCS, strings must use minimal JSON escaping. - // We use serde_json to correctly escape the event name. - let escaped_event = serde_json::to_string(event).expect("failed to serialize event name"); + let escaped_event = json_escape_string(event); let hex_payload = hex::encode(payload); - format!( - r#"{{"event":{},"event_type":{},"payload":"{}"}}"#, - escaped_event, event_type, hex_payload - ) + format!(r#"{{"event":"{escaped_event}","event_type":{event_type},"payload":"{hex_payload}"}}"#,) +} + +/// Minimal JSON string escaping per RFC 8259. +fn json_escape_string(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c < '\x20' => { + use std::fmt::Write; + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out } /// Replay event logs diff --git a/dstack-attest/src/attestation.rs b/dstack-attest/src/attestation.rs index 272ff3d23..c1e521dba 100644 --- a/dstack-attest/src/attestation.rs +++ b/dstack-attest/src/attestation.rs @@ -1023,10 +1023,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() @@ -1039,7 +1043,7 @@ impl Attestation { vec![RuntimeEvent::new( "app-id".to_string(), app_id.to_vec(), - EventLogVersion::V1, + event_log_version, )] } else { vec![] diff --git a/dstack-types/src/lib.rs b/dstack-types/src/lib.rs index b398aeb22..e365a223a 100644 --- a/dstack-types/src/lib.rs +++ b/dstack-types/src/lib.rs @@ -13,7 +13,7 @@ use size_parser::human_size; /// /// Using an enum ensures exhaustive matching — adding a new version /// forces all match sites to be updated. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Encode, Decode)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum EventLogVersion { /// Legacy binary digest: `SHA(event_type_le || ":" || name || ":" || payload)` #[default] diff --git a/dstack-util/src/main.rs b/dstack-util/src/main.rs index c10350604..d519b7e82 100644 --- a/dstack-util/src/main.rs +++ b/dstack-util/src/main.rs @@ -90,10 +90,6 @@ struct ExtendArgs { #[clap(short, long)] /// hex encoded payload of the event payload: String, - - #[clap(long, default_value_t = 1)] - /// event log version (1 = legacy, 2 = JSON canonical) - event_log_version: u32, } #[derive(Parser)] @@ -228,11 +224,22 @@ fn hex_decode(hex_str: &str) -> Result> { fn cmd_extend(extend_args: ExtendArgs) -> Result<()> { let payload = hex_decode(&extend_args.payload).context("Failed to decode payload")?; - let version = dstack_types::EventLogVersion::from_u32(extend_args.event_log_version) - .context("unsupported event log version")?; + 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<()> { let mut data = vec![0u8; rand_args.bytes]; getrandom(&mut data).context("Failed to generate random data")?; diff --git a/ra-tls/src/cert.rs b/ra-tls/src/cert.rs index 683ee5874..23c312ca7 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(); From 96ce9d405229fd95382b0731c3a0c7b7351bd66f Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 19:51:35 -0700 Subject: [PATCH 05/24] test: add mixed v1/v2 replay and scale round-trip tests --- .claude/worktrees/pr361-pp-fix | 1 + cc-eventlog/src/runtime_events.rs | 44 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 160000 .claude/worktrees/pr361-pp-fix diff --git a/.claude/worktrees/pr361-pp-fix b/.claude/worktrees/pr361-pp-fix new file mode 160000 index 000000000..a673ab740 --- /dev/null +++ b/.claude/worktrees/pr361-pp-fix @@ -0,0 +1 @@ +Subproject commit a673ab740ab2224e0f8314e5041fc0a3fe3c3143 diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index c239ca5f4..7b24b2b2a 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -286,4 +286,48 @@ mod tests { "event\"with\\special\nchars" ); } + + #[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); + } } From f553c11f9828b55743c412e88a60680bcd3cca60 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 19:51:41 -0700 Subject: [PATCH 06/24] chore: remove accidentally committed worktree --- .claude/worktrees/pr361-pp-fix | 1 - 1 file changed, 1 deletion(-) delete mode 160000 .claude/worktrees/pr361-pp-fix diff --git a/.claude/worktrees/pr361-pp-fix b/.claude/worktrees/pr361-pp-fix deleted file mode 160000 index a673ab740..000000000 --- a/.claude/worktrees/pr361-pp-fix +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a673ab740ab2224e0f8314e5041fc0a3fe3c3143 From 2b31c5dbfbf4275ba0c5492a4fa7bb8b7e2f0a65 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 19:57:34 -0700 Subject: [PATCH 07/24] refactor: use BTreeMap for canonical JSON key ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual format string with BTreeMap serialization to guarantee alphabetical key ordering per JCS RFC 8785. This prevents bugs if fields are added in the future — BTreeMap handles ordering automatically. --- .claude/worktrees/pr361-pp-fix | 1 + cc-eventlog/src/runtime_events.rs | 34 +++++++------------------------ 2 files changed, 8 insertions(+), 27 deletions(-) create mode 160000 .claude/worktrees/pr361-pp-fix diff --git a/.claude/worktrees/pr361-pp-fix b/.claude/worktrees/pr361-pp-fix new file mode 160000 index 000000000..d5a137ca3 --- /dev/null +++ b/.claude/worktrees/pr361-pp-fix @@ -0,0 +1 @@ +Subproject commit d5a137ca384d3c117b969466c082a95e971a9123 diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index 7b24b2b2a..a30cb82d9 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -140,35 +140,15 @@ impl RuntimeEvent { /// Construct JCS (RFC 8785) canonical JSON for a runtime event. /// -/// Keys are sorted alphabetically: `event`, `event_type`, `payload`. +/// Uses `BTreeMap` to guarantee alphabetical key ordering per JCS. /// The payload is hex-encoded for human readability. -/// -/// Output: `{"event":"","event_type":,"payload":""}` pub fn canonical_event_json(event: &str, event_type: u32, payload: &[u8]) -> String { - // Per JCS, strings must use minimal JSON escaping. - let escaped_event = json_escape_string(event); - let hex_payload = hex::encode(payload); - format!(r#"{{"event":"{escaped_event}","event_type":{event_type},"payload":"{hex_payload}"}}"#,) -} - -/// Minimal JSON string escaping per RFC 8259. -fn json_escape_string(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for ch in s.chars() { - match ch { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if c < '\x20' => { - use std::fmt::Write; - let _ = write!(out, "\\u{:04x}", c as u32); - } - c => out.push(c), - } - } - out + use std::collections::BTreeMap; + let mut map = BTreeMap::new(); + map.insert("event", serde_json::Value::String(event.to_string())); + map.insert("event_type", serde_json::Value::Number(event_type.into())); + map.insert("payload", serde_json::Value::String(hex::encode(payload))); + serde_json::to_string(&map).unwrap_or_default() } /// Replay event logs From 47127105409100cd1ba340874e26f20aca79df1d Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 19:57:40 -0700 Subject: [PATCH 08/24] chore: gitignore worktrees directory --- .claude/worktrees/pr361-pp-fix | 1 - .gitignore | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 160000 .claude/worktrees/pr361-pp-fix diff --git a/.claude/worktrees/pr361-pp-fix b/.claude/worktrees/pr361-pp-fix deleted file mode 160000 index d5a137ca3..000000000 --- a/.claude/worktrees/pr361-pp-fix +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d5a137ca384d3c117b969466c082a95e971a9123 diff --git a/.gitignore b/.gitignore index 77f373288..0de25f4a3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ node_modules/ __pycache__ .planning/ /vmm/src/console_v1.html +.claude/worktrees/ From 9e761b81a75ada8683270323443ee7b27ff7f3a4 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 19:59:46 -0700 Subject: [PATCH 09/24] refactor: use serde_jcs for RFC 8785 compliant canonical JSON Replace manual BTreeMap + serde_json with serde_jcs::to_string which handles all JCS requirements (key ordering, number formatting, string escaping) per RFC 8785 specification. --- Cargo.lock | 18 ++++++++++++++++++ cc-eventlog/Cargo.toml | 1 + cc-eventlog/src/runtime_events.rs | 15 ++++++++------- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b47822a60..ebfb15cdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1323,6 +1323,7 @@ dependencies = [ "parity-scale-codec", "serde", "serde-human-bytes", + "serde_jcs", "serde_json", "sha2 0.10.9", ] @@ -6542,6 +6543,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" @@ -6967,6 +6974,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 9943de8df..866d7efbd 100644 --- a/cc-eventlog/Cargo.toml +++ b/cc-eventlog/Cargo.toml @@ -20,6 +20,7 @@ hex.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/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index a30cb82d9..68b9a734d 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -140,15 +140,16 @@ impl RuntimeEvent { /// Construct JCS (RFC 8785) canonical JSON for a runtime event. /// -/// Uses `BTreeMap` to guarantee alphabetical key ordering per JCS. +/// Uses `serde_jcs` for deterministic serialization per RFC 8785, +/// including alphabetical key ordering and canonical number/string formatting. /// The payload is hex-encoded for human readability. pub fn canonical_event_json(event: &str, event_type: u32, payload: &[u8]) -> String { - use std::collections::BTreeMap; - let mut map = BTreeMap::new(); - map.insert("event", serde_json::Value::String(event.to_string())); - map.insert("event_type", serde_json::Value::Number(event_type.into())); - map.insert("payload", serde_json::Value::String(hex::encode(payload))); - serde_json::to_string(&map).unwrap_or_default() + let obj = serde_json::json!({ + "event": event, + "event_type": event_type, + "payload": hex::encode(payload), + }); + serde_jcs::to_string(&obj).unwrap_or_default() } /// Replay event logs From 3320eb2b12171fe05bb553c4bbaf79492e5b1976 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 20:04:22 -0700 Subject: [PATCH 10/24] fix: serialize version in RuntimeEvent for attestation roundtrip The version field must survive serde serialization because the verify path in AttestationV1 uses RuntimeEvent directly from StackEvidence (serde-encoded). Without serializing version, all deserialized events default to V1, causing RTMR3 mismatch for V2 events. - Change from skip_serializing to always serialize version - Old data without version field still defaults to V1 via #[serde(default)] - Scale encoding still skips version via #[codec(skip)] for binary compat --- cc-eventlog/src/runtime_events.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index 68b9a734d..88e170bff 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -32,7 +32,7 @@ pub struct RuntimeEvent { #[serde(with = "base64")] pub payload: Vec, /// Event log version - #[serde(default, skip_serializing)] + #[serde(default)] #[codec(skip)] pub version: EventLogVersion, } @@ -238,19 +238,19 @@ mod tests { } #[test] - fn serialize_omits_version() { - let v1 = RuntimeEvent::new("test".to_string(), vec![1], EventLogVersion::V1); + fn serde_roundtrip_preserves_version() { let v2 = RuntimeEvent::new("test".to_string(), vec![1], EventLogVersion::V2); - let json_v1 = serde_json::to_string(&v1).unwrap(); - let json_v2 = serde_json::to_string(&v2).unwrap(); - assert!( - !json_v1.contains("version"), - "version should never be serialized" - ); - assert!( - !json_v2.contains("version"), - "version should never be serialized" - ); + 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] From d88d2c627c35fa1070c92122c0b53bbb498d31ed Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 20:18:02 -0700 Subject: [PATCH 11/24] refactor: use single event_type for both v1 and v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously v1 and v2 used different event_type values (0x08000001 vs 0x08000002) to let verifiers distinguish formats. Now that version is serialized on RuntimeEvent and TdxEvent via serde, a single event_type (0x08000001) suffices — version is carried explicitly. Changes: - Drop DSTACK_RUNTIME_EVENT_TYPE_V2 constant - cc_event_type() always returns DSTACK_RUNTIME_EVENT_TYPE - Add version field to TdxEvent (serde default V1, #[codec(skip)] for scale compat) - V2 canonical JSON now includes explicit "version": 2 field for self-describing content - Serde skips version when V1 to keep existing JSON outputs unchanged Scale backward compat: legacy V0 attestations contain only v1 events, so #[codec(skip)] defaulting to V1 on decode is correct. --- cc-eventlog/src/lib.rs | 2 +- cc-eventlog/src/runtime_events.rs | 49 ++++++++++++++----------------- cc-eventlog/src/tcg.rs | 1 + cc-eventlog/src/tdx.rs | 34 +++++++++++++-------- 4 files changed, 46 insertions(+), 40 deletions(-) diff --git a/cc-eventlog/src/lib.rs b/cc-eventlog/src/lib.rs index 182c791c1..a99484d9c 100644 --- a/cc-eventlog/src/lib.rs +++ b/cc-eventlog/src/lib.rs @@ -4,7 +4,7 @@ pub use dstack_types::EventLogVersion; pub use runtime_events::{ - canonical_event_json, replay_events, RuntimeEvent, DSTACK_RUNTIME_EVENT_TYPE_V2, + canonical_event_json_v2, replay_events, RuntimeEvent, DSTACK_RUNTIME_EVENT_TYPE, }; pub use tdx::TdxEvent; diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index 88e170bff..3aa56e03e 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -12,14 +12,14 @@ use std::io::Write; use ez_hash::{Hasher, Sha256, Sha384}; -/// The event type for dstack runtime events (v1). +/// 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 event type for dstack runtime events (v2, JSON canonical content). -/// V2 events use JCS (RFC 8785) canonical JSON as the digest input, enabling -/// relying parties to define fine-grained trust policies on individual event claims. -pub const DSTACK_RUNTIME_EVENT_TYPE_V2: u32 = 0x08000002; /// The path to the userspace TDX event log file. pub const RUNTIME_EVENT_LOG_FILE: &str = "/run/log/dstack/runtime_events.log"; @@ -112,7 +112,7 @@ impl RuntimeEvent { /// Compute the digest of the event. /// /// - V1: `SHA(event_type_le || ":" || event_name || ":" || payload)` - /// - V2: `SHA(canonical_json({"event":"...","event_type":134217730,"payload":"hex..."}))` + /// - V2: `SHA(canonical_json({"event":"...","event_type":134217729,"payload":"hex...","version":2}))` pub fn digest(&self) -> H::Output { match self.version { EventLogVersion::V1 => H::hash([ @@ -123,31 +123,30 @@ impl RuntimeEvent { &self.payload, ]), EventLogVersion::V2 => { - let canonical = - canonical_event_json(&self.event, DSTACK_RUNTIME_EVENT_TYPE_V2, &self.payload); + let canonical = canonical_event_json_v2(&self.event, &self.payload); H::hash([canonical.as_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 { - match self.version { - EventLogVersion::V1 => DSTACK_RUNTIME_EVENT_TYPE, - EventLogVersion::V2 => DSTACK_RUNTIME_EVENT_TYPE_V2, - } + DSTACK_RUNTIME_EVENT_TYPE } } -/// Construct JCS (RFC 8785) canonical JSON for a runtime event. +/// Construct the JCS (RFC 8785) canonical JSON used as the v2 digest input. /// -/// Uses `serde_jcs` for deterministic serialization per RFC 8785, -/// including alphabetical key ordering and canonical number/string formatting. -/// The payload is hex-encoded for human readability. -pub fn canonical_event_json(event: &str, event_type: u32, payload: &[u8]) -> String { +/// The JSON includes an explicit `version: 2` field so the content is +/// self-describing for relying parties that don't know dstack's event schema. +/// Keys and number/string formatting are handled by `serde_jcs` per RFC 8785. +pub fn canonical_event_json_v2(event: &str, payload: &[u8]) -> String { let obj = serde_json::json!({ "event": event, - "event_type": event_type, + "event_type": DSTACK_RUNTIME_EVENT_TYPE, "payload": hex::encode(payload), + "version": 2, }); serde_jcs::to_string(&obj).unwrap_or_default() } @@ -195,11 +194,10 @@ mod tests { vec![0xab, 0xcd], EventLogVersion::V2, ); - let canonical = - canonical_event_json(&event.event, DSTACK_RUNTIME_EVENT_TYPE_V2, &event.payload); + let canonical = canonical_event_json_v2(&event.event, &event.payload); assert_eq!( canonical, - r#"{"event":"compose-hash","event_type":134217730,"payload":"abcd"}"# + r#"{"event":"compose-hash","event_type":134217729,"payload":"abcd","version":2}"# ); let digest = event.digest::(); let expected = Sha384::hash([canonical.as_bytes()]); @@ -225,8 +223,9 @@ mod tests { #[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_V2); + assert_eq!(event.cc_event_type(), DSTACK_RUNTIME_EVENT_TYPE); } #[test] @@ -255,11 +254,7 @@ mod tests { #[test] fn canonical_json_escapes_special_chars() { - let canonical = canonical_event_json( - "event\"with\\special\nchars", - DSTACK_RUNTIME_EVENT_TYPE_V2, - &[0xff], - ); + let canonical = canonical_event_json_v2("event\"with\\special\nchars", &[0xff]); // Verify it's valid JSON let parsed: serde_json::Value = serde_json::from_str(&canonical).unwrap(); assert_eq!( diff --git a/cc-eventlog/src/tcg.rs b/cc-eventlog/src/tcg.rs index ff0aadc32..e1bee7290 100644 --- a/cc-eventlog/src/tcg.rs +++ b/cc-eventlog/src/tcg.rs @@ -370,6 +370,7 @@ impl TryFrom for TdxEvent { digest, event: Default::default(), event_payload: value.event.into(), + version: Default::default(), }) } } diff --git a/cc-eventlog/src/tdx.rs b/cc-eventlog/src/tdx.rs index 5ddd1a472..dd4942ac1 100644 --- a/cc-eventlog/src/tdx.rs +++ b/cc-eventlog/src/tdx.rs @@ -3,11 +3,12 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Result; +use dstack_types::EventLogVersion; use scale::{Decode, Encode}; use serde::{Deserialize, Serialize}; use crate::{ - runtime_events::{RuntimeEvent, DSTACK_RUNTIME_EVENT_TYPE, DSTACK_RUNTIME_EVENT_TYPE_V2}, + runtime_events::{RuntimeEvent, DSTACK_RUNTIME_EVENT_TYPE}, tcg::TcgEventLog, }; @@ -16,9 +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: -/// - V1 (event_type 0x08000001): digest = `sha384(event_type_le || ":" || event || ":" || payload)` -/// - V2 (event_type 0x08000002): digest = `sha384(canonical_json({"event":"...","event_type":134217730,"payload":"hex..."}))` +/// For dstack runtime events (`event_type == DSTACK_RUNTIME_EVENT_TYPE`), the digest is: +/// - V1: `sha384(event_type_le || ":" || event || ":" || payload)` +/// - V2: `sha384(canonical_json({"event":"...","event_type":134217729,"payload":"hex...","version":2}))` #[derive(Clone, Debug, Serialize, Deserialize, Encode, Decode)] pub struct TdxEvent { /// IMR index, starts from 0 @@ -33,6 +34,17 @@ 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, +} + +fn is_v1(v: &EventLogVersion) -> bool { + matches!(v, EventLogVersion::V1) } impl TdxEvent { @@ -43,6 +55,7 @@ impl TdxEvent { digest: vec![], event, event_payload, + version: EventLogVersion::default(), } } @@ -56,6 +69,7 @@ impl TdxEvent { digest: Vec::new(), event: self.event.clone(), event_payload: self.event_payload.clone(), + version: self.version, } } else { Self { @@ -64,6 +78,7 @@ impl TdxEvent { digest: self.digest.clone(), event: self.event.clone(), event_payload: Vec::new(), + version: self.version, } } } @@ -77,23 +92,16 @@ impl TdxEvent { pub fn is_runtime_event(&self) -> bool { self.event_type == DSTACK_RUNTIME_EVENT_TYPE - || self.event_type == DSTACK_RUNTIME_EVENT_TYPE_V2 } pub fn to_runtime_event(&self) -> Option { if !self.is_runtime_event() { return None; } - use dstack_types::EventLogVersion; - let version = if self.event_type == DSTACK_RUNTIME_EVENT_TYPE_V2 { - EventLogVersion::V2 - } else { - EventLogVersion::V1 - }; Some(RuntimeEvent { event: self.event.clone(), payload: self.event_payload.clone(), - version, + version: self.version, }) } } @@ -101,6 +109,7 @@ impl TdxEvent { 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, @@ -108,6 +117,7 @@ impl From for TdxEvent { digest, event: value.event, event_payload: value.payload, + version, } } } From 4dee4bb8be1953549aa42d1db2871e19228887c0 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 20:56:11 -0700 Subject: [PATCH 12/24] test: add comprehensive canonical JSON tests for RFC 8785 compliance Covers key aspects of JCS (RFC 8785) that the digest depends on: - Bytewise-exact output for known inputs - Alphabetical key ordering - Short-form escapes (\b \f \n \r \t) vs \uXXXX for other control chars - Non-ASCII Unicode emitted verbatim (not \uXXXX escaped) - Lowercase hex payload - Empty string / empty payload edge cases - Idempotency across 100 invocations (guards against HashMap randomization) - No whitespace, valid JSON structure --- cc-eventlog/src/runtime_events.rs | 88 +++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index 3aa56e03e..0c5efcc0e 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -255,14 +255,94 @@ mod tests { #[test] fn canonical_json_escapes_special_chars() { let canonical = canonical_event_json_v2("event\"with\\special\nchars", &[0xff]); - // Verify it's valid JSON - let parsed: serde_json::Value = serde_json::from_str(&canonical).unwrap(); + // Exact bytewise output — JCS must be deterministic assert_eq!( - parsed["event"].as_str().unwrap(), - "event\"with\\special\nchars" + canonical, + r#"{"event":"event\"with\\special\nchars","event_type":134217729,"payload":"ff","version":2}"# + ); + } + + #[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 event_pos = canonical.find(r#""event":"#).unwrap(); + let event_type_pos = canonical.find(r#""event_type":"#).unwrap(); + let payload_pos = canonical.find(r#""payload":"#).unwrap(); + let version_pos = canonical.find(r#""version":"#).unwrap(); + assert!(event_pos < event_type_pos); + assert!(event_type_pos < payload_pos); + assert!(payload_pos < version_pos); + } + + #[test] + fn canonical_json_empty_event_and_payload() { + let canonical = canonical_event_json_v2("", &[]); + assert_eq!( + canonical, + r#"{"event":"","event_type":134217729,"payload":"","version":2}"# + ); + } + + #[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["event"].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#""event":"\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![ From 796683b2c2cbb003b7711e0bb146e45665fdcf8f Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 21:06:57 -0700 Subject: [PATCH 13/24] feat: add include_hash_inputs parameter to GetQuote and Attest RPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow relying parties to opt-in to receiving the digest pre-image (hash_input) for each event, enabling digest verification and content inspection without knowing the dstack event schema. Proto changes: - RawQuoteArgs.include_hash_inputs: new bool field (default false) Runtime changes: - RuntimeEvent::hash_input() returns the bytes that get hashed: - V1: binary concatenation event_type_le || ":" || name || ":" || payload - V2: UTF-8 bytes of the JCS canonical JSON - TdxEvent gains optional hash_input: Option (hex-encoded) - TdxEvent::fill_hash_input() populates it for runtime events - When include_hash_inputs=true, guest-agent fills hash_input before returning the event log (in GetQuote) or serializing the attestation (in Attest) Compatibility: - Default off: existing clients see no change - scale codec: hash_input is #[codec(skip)], derivable from other fields - serde: hash_input uses skip_serializing_if Option::is_none — only appears in JSON when populated --- cc-eventlog/src/runtime_events.rs | 30 ++++--- cc-eventlog/src/tcg.rs | 1 + cc-eventlog/src/tdx.rs | 99 +++++++++++++++++++++++ dstack-attest/src/attestation.rs | 42 ++++++++-- dstack-attest/src/v1.rs | 7 ++ gateway/src/distributed_certbot.rs | 18 ++++- gateway/src/gen_debug_key.rs | 1 + guest-agent-simulator/src/main.rs | 25 ++++-- guest-agent-simulator/src/simulator.rs | 15 +++- guest-agent/rpc/proto/agent_rpc.proto | 6 ++ guest-agent/src/backend.rs | 34 ++++++-- guest-agent/src/rpc_service.rs | 38 ++++++--- kms/src/main_service/upgrade_authority.rs | 7 +- 13 files changed, 282 insertions(+), 41 deletions(-) diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index 0c5efcc0e..58005dcd1 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -114,18 +114,28 @@ impl RuntimeEvent { /// - V1: `SHA(event_type_le || ":" || event_name || ":" || payload)` /// - V2: `SHA(canonical_json({"event":"...","event_type":134217729,"payload":"hex...","version":2}))` pub fn digest(&self) -> H::Output { + 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 => H::hash([ - &DSTACK_RUNTIME_EVENT_TYPE.to_ne_bytes()[..], - b":", - self.event.as_bytes(), - b":", - &self.payload, - ]), - EventLogVersion::V2 => { - let canonical = canonical_event_json_v2(&self.event, &self.payload); - H::hash([canonical.as_bytes()]) + 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_ne_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(), } } diff --git a/cc-eventlog/src/tcg.rs b/cc-eventlog/src/tcg.rs index e1bee7290..c54563f7f 100644 --- a/cc-eventlog/src/tcg.rs +++ b/cc-eventlog/src/tcg.rs @@ -371,6 +371,7 @@ impl TryFrom for TdxEvent { 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 dd4942ac1..b579fcdc8 100644 --- a/cc-eventlog/src/tdx.rs +++ b/cc-eventlog/src/tdx.rs @@ -41,6 +41,17 @@ pub struct TdxEvent { #[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 { @@ -56,6 +67,7 @@ impl TdxEvent { event, event_payload, version: EventLogVersion::default(), + hash_input: None, } } @@ -70,6 +82,7 @@ impl TdxEvent { event: self.event.clone(), event_payload: self.event_payload.clone(), version: self.version, + hash_input: self.hash_input.clone(), } } else { Self { @@ -79,10 +92,22 @@ impl TdxEvent { 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(); @@ -118,10 +143,84 @@ impl From for TdxEvent { 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 + assert!(input_str.contains(r#""event":"compose-hash""#)); + assert!(input_str.contains(r#""version":2"#)); + assert!(input_str.contains(r#""payload":"abcd""#)); + // 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 c1e521dba..0fa362a27 100644 --- a/dstack-attest/src/attestation.rs +++ b/dstack-attest/src/attestation.rs @@ -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() }) } diff --git a/dstack-attest/src/v1.rs b/dstack-attest/src/v1.rs index 900e7aedb..52426d646 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 { diff --git a/gateway/src/distributed_certbot.rs b/gateway/src/distributed_certbot.rs index cbb316ea4..8963ba3d7 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 c710548a6..565f77403 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/src/main.rs b/guest-agent-simulator/src/main.rs index 5d9dcfb04..42ae13aec 100644 --- a/guest-agent-simulator/src/main.rs +++ b/guest-agent-simulator/src/main.rs @@ -78,17 +78,32 @@ impl PlatformBackend for SimulatorPlatform { ) } - fn quote_response(&self, report_data: [u8; 64], vm_config: &str) -> Result { + fn quote_response( + &self, + report_data: [u8; 64], + vm_config: &str, + include_hash_inputs: bool, + ) -> Result { simulator::simulated_quote_response( &self.attestation, report_data, vm_config, self.patch_report_data, + include_hash_inputs, ) } - fn attest_response(&self, report_data: [u8; 64]) -> Result { - simulator::simulated_attest_response(&self.attestation, report_data, self.patch_report_data) + fn attest_response( + &self, + report_data: [u8; 64], + include_hash_inputs: bool, + ) -> Result { + simulator::simulated_attest_response( + &self.attestation, + report_data, + self.patch_report_data, + include_hash_inputs, + ) } fn emit_event(&self, event: &str, _payload: &[u8], _version: EventLogVersion) -> Result<()> { @@ -169,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(); @@ -186,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 902dfe87e..f525cdad9 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 241b543bf..caeeda3bc 100644 --- a/guest-agent/rpc/proto/agent_rpc.proto +++ b/guest-agent/rpc/proto/agent_rpc.proto @@ -170,6 +170,12 @@ message TdxQuoteArgs { message RawQuoteArgs { // 64 bytes of report data bytes report_data = 1; + // If true, include the digest hash input for each event in the response. + // For v2 runtime events this is the canonical JSON content; for v1 it's + // the binary concatenation of event_type, name, and payload. + // 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 0b9eb45f6..54cd35106 100644 --- a/guest-agent/src/backend.rs +++ b/guest-agent/src/backend.rs @@ -12,8 +12,17 @@ use ra_tls::attestation::{QuoteContentType, VersionedAttestation}; pub trait PlatformBackend: Send + Sync { fn attestation_for_info(&self) -> Result; fn certificate_attestation(&self, pubkey: &[u8]) -> Result; - fn quote_response(&self, report_data: [u8; 64], vm_config: &str) -> Result; - fn attest_response(&self, report_data: [u8; 64]) -> Result; + fn quote_response( + &self, + report_data: [u8; 64], + vm_config: &str, + include_hash_inputs: bool, + ) -> Result; + fn attest_response( + &self, + report_data: [u8; 64], + include_hash_inputs: bool, + ) -> Result; fn emit_event(&self, event: &str, payload: &[u8], version: EventLogVersion) -> Result<()>; } @@ -34,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(), @@ -46,8 +60,16 @@ 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()?, }) diff --git a/guest-agent/src/rpc_service.rs b/guest-agent/src/rpc_service.rs index a396081d6..5c2a78129 100644 --- a/guest-agent/src/rpc_service.rs +++ b/guest-agent/src/rpc_service.rs @@ -170,14 +170,24 @@ impl AppState { &self.inner.config } - fn quote_response(&self, report_data: [u8; 64]) -> Result { + fn quote_response( + &self, + report_data: [u8; 64], + include_hash_inputs: bool, + ) -> Result { self.inner .platform - .quote_response(report_data, &self.inner.vm_config) + .quote_response(report_data, &self.inner.vm_config, include_hash_inputs) } - fn attest_response(&self, report_data: [u8; 64]) -> Result { - self.inner.platform.attest_response(report_data) + fn attest_response( + &self, + report_data: [u8; 64], + include_hash_inputs: bool, + ) -> Result { + self.inner + .platform + .attest_response(report_data, include_hash_inputs) } fn emit_event(&self, event: &str, payload: &[u8]) -> Result<()> { @@ -328,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<()> { @@ -434,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 { @@ -536,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, @@ -629,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) @@ -642,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")), } @@ -721,6 +733,7 @@ mod tests { secure_time: false, storage_fs: None, swap_size: 0, + event_log_version: EventLogVersion::V1, }; let dummy_appcompose_wrapper = AppComposeWrapper { @@ -836,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 { @@ -852,7 +866,11 @@ 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()?, diff --git a/kms/src/main_service/upgrade_authority.rs b/kms/src/main_service/upgrade_authority.rs index e98b436f7..e13515a84 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 { From 374f3d3ebfd94bd69b0ed7807fc7d85d59cab0af Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Apr 2026 23:36:18 -0700 Subject: [PATCH 14/24] fix: resolve CI failures from new v1 test and gated import - Update v1::tests::msgpack_roundtrip_preserves_attestation to initialize the newly-added `version`/`hash_input` fields on `TdxEvent` and the `version` field on `RuntimeEvent`. - Move the `cc_eventlog::EventLogVersion` import in attestation.rs behind `#[cfg(feature = "quote")]` since it is only referenced in that gated `impl` block; without the gate, the default `cargo clippy -- -D warnings` run fails with an unused-imports error. --- dstack-attest/src/attestation.rs | 4 +++- dstack-attest/src/v1.rs | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/dstack-attest/src/attestation.rs b/dstack-attest/src/attestation.rs index 0fa362a27..e2ddf929b 100644 --- a/dstack-attest/src/attestation.rs +++ b/dstack-attest/src/attestation.rs @@ -12,7 +12,9 @@ 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::{EventLogVersion, RuntimeEvent, TdxEvent}; +#[cfg(feature = "quote")] +use cc_eventlog::EventLogVersion; +use cc_eventlog::{RuntimeEvent, TdxEvent}; use dcap_qvl::{ quote::{EnclaveReport, Quote, Report, TDReport10, TDReport15}, verify::VerifiedReport as TdxVerifiedReport, diff --git a/dstack-attest/src/v1.rs b/dstack-attest/src/v1.rs index 52426d646..28cdede4b 100644 --- a/dstack-attest/src/v1.rs +++ b/dstack-attest/src/v1.rs @@ -246,6 +246,7 @@ impl Attestation { #[cfg(test)] mod tests { use super::*; + use dstack_types::EventLogVersion; #[test] fn msgpack_roundtrip_preserves_attestation() { @@ -258,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 { @@ -265,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(), From 270aaf8f71b67fd17db231f7d38a9b09c2b4719a Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 16 Apr 2026 00:37:17 -0700 Subject: [PATCH 15/24] fix: address copilot review on v1 digest encoding and v2 fallback - pin v1 digest event_type encoding to little-endian (to_le_bytes) so the on-wire format is platform-independent. x86 (dstack's only target) produces identical bytes, so existing RTMR3 values and verifiers are unaffected. the 2024 spec called out to_ne_bytes explicitly, but a stable wire format should not depend on the host platform. - fix the EventLogVersion::V2 doc comment in dstack-types: it listed event_type 134217730 (0x08000002), but v1 and v2 share the same event_type 134217729 (0x08000001). the version is distinguished by the EventLogVersion field / json version key, not event_type. - replace canonical_event_json_v2's .unwrap_or_default() with .or_panic(). silently returning "" on serialization failure would produce sha384("") as the digest, which is both wrong and hard to debug. the input is a well-formed json!{} literal so serialization cannot actually fail; or_panic makes that invariant explicit without tripping the expect_used clippy lint. --- Cargo.lock | 1 + cc-eventlog/Cargo.toml | 1 + cc-eventlog/src/runtime_events.rs | 7 ++++--- dstack-types/src/lib.rs | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ebfb15cdf..4ab18ed34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1320,6 +1320,7 @@ dependencies = [ "fs-err", "hex", "insta", + "or-panic", "parity-scale-codec", "serde", "serde-human-bytes", diff --git a/cc-eventlog/Cargo.toml b/cc-eventlog/Cargo.toml index 866d7efbd..5fa010cf2 100644 --- a/cc-eventlog/Cargo.toml +++ b/cc-eventlog/Cargo.toml @@ -17,6 +17,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 diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index 58005dcd1..c0b0286c0 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -5,6 +5,7 @@ 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; @@ -128,7 +129,7 @@ impl RuntimeEvent { 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_ne_bytes()); + 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':'); @@ -158,7 +159,7 @@ pub fn canonical_event_json_v2(event: &str, payload: &[u8]) -> String { "payload": hex::encode(payload), "version": 2, }); - serde_jcs::to_string(&obj).unwrap_or_default() + serde_jcs::to_string(&obj).or_panic("canonical JSON serialization failed") } /// Replay event logs @@ -188,7 +189,7 @@ mod tests { ); let digest = event.digest::(); let expected = Sha384::hash([ - &DSTACK_RUNTIME_EVENT_TYPE.to_ne_bytes()[..], + &DSTACK_RUNTIME_EVENT_TYPE.to_le_bytes()[..], b":", b"app-id", b":", diff --git a/dstack-types/src/lib.rs b/dstack-types/src/lib.rs index e365a223a..70416f73b 100644 --- a/dstack-types/src/lib.rs +++ b/dstack-types/src/lib.rs @@ -19,7 +19,7 @@ pub enum EventLogVersion { #[default] V1, /// JSON canonical digest (JCS RFC 8785): - /// `SHA({"event":"...","event_type":134217730,"payload":"hex..."})` + /// `SHA({"event":"...","event_type":134217729,"payload":"hex...","version":2})` V2, } From f1916030f354f755140b1116fee362a7ac0ee9b7 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 16 Apr 2026 01:34:03 -0700 Subject: [PATCH 16/24] docs: clarify include_hash_inputs returns hex-encoded bytes The previous proto comment described v2 hash_input as "canonical JSON content", which would suggest the field holds a raw JSON string. hash_input is actually hex-encoded bytes of the digest pre-image, so clients must hex-decode first before interpreting the payload (UTF-8 JSON for v2, the v1 binary concat for v1). Spell out the encoding and the verification recipe so relying parties don't guess wrong. --- guest-agent/rpc/proto/agent_rpc.proto | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/guest-agent/rpc/proto/agent_rpc.proto b/guest-agent/rpc/proto/agent_rpc.proto index caeeda3bc..90690c1ca 100644 --- a/guest-agent/rpc/proto/agent_rpc.proto +++ b/guest-agent/rpc/proto/agent_rpc.proto @@ -171,8 +171,10 @@ message RawQuoteArgs { // 64 bytes of report data bytes report_data = 1; // If true, include the digest hash input for each event in the response. - // For v2 runtime events this is the canonical JSON content; for v1 it's - // the binary concatenation of event_type, name, and payload. + // 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; From de1d4a0d036fb6911d79f05365995a4602b9b0ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:37:56 +0000 Subject: [PATCH 17/24] fix: clarify include_hash_inputs only applies to runtime events in proto comment Agent-Logs-Url: https://github.com/Dstack-TEE/dstack/sessions/042b74ec-8b33-465d-bd29-2871b0cfd72c Co-authored-by: kvinwang <6442159+kvinwang@users.noreply.github.com> --- guest-agent/rpc/proto/agent_rpc.proto | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/guest-agent/rpc/proto/agent_rpc.proto b/guest-agent/rpc/proto/agent_rpc.proto index 90690c1ca..bc3371c4a 100644 --- a/guest-agent/rpc/proto/agent_rpc.proto +++ b/guest-agent/rpc/proto/agent_rpc.proto @@ -170,7 +170,8 @@ message TdxQuoteArgs { message RawQuoteArgs { // 64 bytes of report data bytes report_data = 1; - // If true, include the digest hash input for each event in the response. + // 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) From 5b6b57d493eb04b1d80bbf208364cb97be2450c6 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 16 Apr 2026 02:05:21 -0700 Subject: [PATCH 18/24] refactor: drop version field from v2 canonical JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 canonical JSON previously included a "version":2 field to make the hashed content self-describing. Per review feedback, version is now carried out-of-band via RuntimeEvent.version (scale/serde) only, and the hashed content is a clean {event, event_type, payload} JSON. v2 canonical JSON is now: {"event":"","event_type":134217729,"payload":""} Updates: - canonical_event_json_v2: drop "version" key - doc comments in runtime_events.rs / tdx.rs / dstack-types/lib.rs updated to reflect the new v2 format - tests: expected canonical strings, the hash_input v2 assertion, and the key-order test no longer reference "version" Note: RuntimeEvent serde still emits "version":2 when writing events to /run/log/dstack/runtime_events.log (serde_roundtrip_preserves_version test covers that) — that is the on-disk format, not the hashed content. --- cc-eventlog/src/runtime_events.rs | 15 ++++++--------- cc-eventlog/src/tdx.rs | 7 ++++--- dstack-types/src/lib.rs | 2 +- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index c0b0286c0..7b7b89e44 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -113,7 +113,7 @@ impl RuntimeEvent { /// Compute the digest of the event. /// /// - V1: `SHA(event_type_le || ":" || event_name || ":" || payload)` - /// - V2: `SHA(canonical_json({"event":"...","event_type":134217729,"payload":"hex...","version":2}))` + /// - V2: `SHA(canonical_json({"event":"...","event_type":134217729,"payload":"hex..."}))` pub fn digest(&self) -> H::Output { H::hash([self.hash_input().as_slice()]) } @@ -149,15 +149,14 @@ impl RuntimeEvent { /// Construct the JCS (RFC 8785) canonical JSON used as the v2 digest input. /// -/// The JSON includes an explicit `version: 2` field so the content is -/// self-describing for relying parties that don't know dstack's event schema. /// 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!({ "event": event, "event_type": DSTACK_RUNTIME_EVENT_TYPE, "payload": hex::encode(payload), - "version": 2, }); serde_jcs::to_string(&obj).or_panic("canonical JSON serialization failed") } @@ -208,7 +207,7 @@ mod tests { let canonical = canonical_event_json_v2(&event.event, &event.payload); assert_eq!( canonical, - r#"{"event":"compose-hash","event_type":134217729,"payload":"abcd","version":2}"# + r#"{"event":"compose-hash","event_type":134217729,"payload":"abcd"}"# ); let digest = event.digest::(); let expected = Sha384::hash([canonical.as_bytes()]); @@ -269,7 +268,7 @@ mod tests { // Exact bytewise output — JCS must be deterministic assert_eq!( canonical, - r#"{"event":"event\"with\\special\nchars","event_type":134217729,"payload":"ff","version":2}"# + r#"{"event":"event\"with\\special\nchars","event_type":134217729,"payload":"ff"}"# ); } @@ -281,10 +280,8 @@ mod tests { let event_pos = canonical.find(r#""event":"#).unwrap(); let event_type_pos = canonical.find(r#""event_type":"#).unwrap(); let payload_pos = canonical.find(r#""payload":"#).unwrap(); - let version_pos = canonical.find(r#""version":"#).unwrap(); assert!(event_pos < event_type_pos); assert!(event_type_pos < payload_pos); - assert!(payload_pos < version_pos); } #[test] @@ -292,7 +289,7 @@ mod tests { let canonical = canonical_event_json_v2("", &[]); assert_eq!( canonical, - r#"{"event":"","event_type":134217729,"payload":"","version":2}"# + r#"{"event":"","event_type":134217729,"payload":""}"# ); } diff --git a/cc-eventlog/src/tdx.rs b/cc-eventlog/src/tdx.rs index b579fcdc8..2fec0b90b 100644 --- a/cc-eventlog/src/tdx.rs +++ b/cc-eventlog/src/tdx.rs @@ -19,7 +19,7 @@ use crate::{ /// /// For dstack runtime events (`event_type == DSTACK_RUNTIME_EVENT_TYPE`), the digest is: /// - V1: `sha384(event_type_le || ":" || event || ":" || payload)` -/// - V2: `sha384(canonical_json({"event":"...","event_type":134217729,"payload":"hex...","version":2}))` +/// - V2: `sha384(canonical_json({"event":"...","event_type":134217729,"payload":"hex..."}))` #[derive(Clone, Debug, Serialize, Deserialize, Encode, Decode)] pub struct TdxEvent { /// IMR index, starts from 0 @@ -183,10 +183,11 @@ mod tests { 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 + // V2 hash_input is the canonical JSON (version is carried out-of-band) assert!(input_str.contains(r#""event":"compose-hash""#)); - assert!(input_str.contains(r#""version":2"#)); + assert!(input_str.contains(r#""event_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); diff --git a/dstack-types/src/lib.rs b/dstack-types/src/lib.rs index 70416f73b..3f0c32a88 100644 --- a/dstack-types/src/lib.rs +++ b/dstack-types/src/lib.rs @@ -19,7 +19,7 @@ pub enum EventLogVersion { #[default] V1, /// JSON canonical digest (JCS RFC 8785): - /// `SHA({"event":"...","event_type":134217729,"payload":"hex...","version":2})` + /// `SHA({"event":"...","event_type":134217729,"payload":"hex..."})` V2, } From ca0f577770def0f67d03693fbdabdfa504cb0f88 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 16 Apr 2026 04:06:49 -0700 Subject: [PATCH 19/24] fix: upgrade to V1 msgpack attestation when v2 events present Root cause of a live RTMR3-mismatch observed during v2 CVM testing: `Attestation::into_versioned()` unconditionally wrapped as `VersionedAttestation::V0`, which SCALE-encodes the payload. But `RuntimeEvent::version` and `TdxEvent::version` carry `#[codec(skip)]` (added for V0 binary compat), so the `version` field is dropped on the wire. The KMS-side decoder then defaults each event to V1, replays with the V1 digest algorithm, and fails against the guest's V2-extended RTMR3. Fix: when any runtime event reports a non-V1 version, force V1 msgpack encoding (where `version` is a normal serde field and round-trips). Callers with only V1 events still get V0/SCALE, preserving wire-format compatibility with existing consumers. `from_bytes` already detects the encoding by leading byte (msgpack map prefix -> V1, else SCALE -> V0), so receivers don't need changes. Verified end-to-end: deployed a CVM with `event_log_version: 2`, boot now progresses past `get_app_key` without an RTMR3 mismatch; KMS replay sees `version=V2` and matches the quoted RTMR3. --- dstack-attest/src/attestation.rs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/dstack-attest/src/attestation.rs b/dstack-attest/src/attestation.rs index e2ddf929b..3b8af439a 100644 --- a/dstack-attest/src/attestation.rs +++ b/dstack-attest/src/attestation.rs @@ -12,9 +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}; -#[cfg(feature = "quote")] -use cc_eventlog::EventLogVersion; -use cc_eventlog::{RuntimeEvent, TdxEvent}; +use cc_eventlog::{EventLogVersion, RuntimeEvent, TdxEvent}; use dcap_qvl::{ quote::{EnclaveReport, Quote, Report, TDReport10, TDReport15}, verify::VerifiedReport as TdxVerifiedReport, @@ -1143,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 From 90765a3c6bdd488f67f70c6819c7ac1a67af8ede Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 16 Apr 2026 05:01:04 -0700 Subject: [PATCH 20/24] test: add into_versioned V0/V1 dispatch coverage Guard the conditional wire-format dispatch from regressions: - V1-only events must stay on the V0/SCALE path (backward compat). - Any V2 event must force the V1 msgpack path so the `version` field round-trips through the cert/RPC boundary. --- dstack-attest/src/attestation.rs | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/dstack-attest/src/attestation.rs b/dstack-attest/src/attestation.rs index 3b8af439a..ef437a8bf 100644 --- a/dstack-attest/src/attestation.rs +++ b/dstack-attest/src/attestation.rs @@ -1399,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`" + ); + } } From 55ce10f0df0ff6a732a417980a88e3dcc96e0f48 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 16 Apr 2026 06:24:25 -0700 Subject: [PATCH 21/24] refactor: rename v2 canonical JSON fields to name/type/content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the hashed v2 canonical JSON from {"event":"...","event_type":134217729,"payload":"hex..."} to {"name":"...","type":134217729,"content":"hex..."} Generic names match the wire-level intent better for third-party consumers ("event_type"/"payload" are dstack-schema terms; generic content descriptors are easier to align with policy languages like ITA's CEL expressions). Note: JCS key ordering changes — content < name < type — so the on-wire byte sequence (and therefore v2 digests) are different. This is a breaking change to the v2 digest format; safe because v2 is not yet released. --- cc-eventlog/src/runtime_events.rs | 37 ++++++++++++++----------------- cc-eventlog/src/tdx.rs | 8 +++---- dstack-types/src/lib.rs | 2 +- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index 7b7b89e44..6493780d9 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -113,7 +113,7 @@ impl RuntimeEvent { /// Compute the digest of the event. /// /// - V1: `SHA(event_type_le || ":" || event_name || ":" || payload)` - /// - V2: `SHA(canonical_json({"event":"...","event_type":134217729,"payload":"hex..."}))` + /// - V2: `SHA(canonical_json({"name":"...","type":134217729,"content":"hex..."}))` pub fn digest(&self) -> H::Output { H::hash([self.hash_input().as_slice()]) } @@ -154,9 +154,9 @@ impl RuntimeEvent { /// hashed content. pub fn canonical_event_json_v2(event: &str, payload: &[u8]) -> String { let obj = serde_json::json!({ - "event": event, - "event_type": DSTACK_RUNTIME_EVENT_TYPE, - "payload": hex::encode(payload), + "name": event, + "type": DSTACK_RUNTIME_EVENT_TYPE, + "content": hex::encode(payload), }); serde_jcs::to_string(&obj).or_panic("canonical JSON serialization failed") } @@ -207,7 +207,7 @@ mod tests { let canonical = canonical_event_json_v2(&event.event, &event.payload); assert_eq!( canonical, - r#"{"event":"compose-hash","event_type":134217729,"payload":"abcd"}"# + r#"{"content":"abcd","name":"compose-hash","type":134217729}"# ); let digest = event.digest::(); let expected = Sha384::hash([canonical.as_bytes()]); @@ -268,7 +268,7 @@ mod tests { // Exact bytewise output — JCS must be deterministic assert_eq!( canonical, - r#"{"event":"event\"with\\special\nchars","event_type":134217729,"payload":"ff"}"# + r#"{"content":"ff","name":"event\"with\\special\nchars","type":134217729}"# ); } @@ -277,20 +277,17 @@ mod tests { // 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 event_pos = canonical.find(r#""event":"#).unwrap(); - let event_type_pos = canonical.find(r#""event_type":"#).unwrap(); - let payload_pos = canonical.find(r#""payload":"#).unwrap(); - assert!(event_pos < event_type_pos); - assert!(event_type_pos < payload_pos); + let content_pos = canonical.find(r#""content":"#).unwrap(); + let name_pos = canonical.find(r#""name":"#).unwrap(); + let type_pos = canonical.find(r#""type":"#).unwrap(); + assert!(content_pos < name_pos); + assert!(name_pos < type_pos); } #[test] fn canonical_json_empty_event_and_payload() { let canonical = canonical_event_json_v2("", &[]); - assert_eq!( - canonical, - r#"{"event":"","event_type":134217729,"payload":""}"# - ); + assert_eq!(canonical, r#"{"content":"","name":"","type":134217729}"#); } #[test] @@ -315,7 +312,7 @@ mod tests { 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["event"].as_str().unwrap(), "测试-emoji-🦀"); + assert_eq!(parsed["name"].as_str().unwrap(), "测试-emoji-🦀"); } #[test] @@ -323,17 +320,17 @@ mod tests { // 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#""event":"\b\f\n\r\t\u0001""#), + 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. + fn canonical_json_content_lowercase_hex() { + // Content payload must be hex-encoded lowercase for determinism. let canonical = canonical_event_json_v2("test", &[0xAB, 0xCD, 0xEF]); assert!( - canonical.contains(r#""payload":"abcdef""#), + canonical.contains(r#""content":"abcdef""#), "got: {canonical}" ); } diff --git a/cc-eventlog/src/tdx.rs b/cc-eventlog/src/tdx.rs index 2fec0b90b..c5c67b71d 100644 --- a/cc-eventlog/src/tdx.rs +++ b/cc-eventlog/src/tdx.rs @@ -19,7 +19,7 @@ use crate::{ /// /// For dstack runtime events (`event_type == DSTACK_RUNTIME_EVENT_TYPE`), the digest is: /// - V1: `sha384(event_type_le || ":" || event || ":" || payload)` -/// - V2: `sha384(canonical_json({"event":"...","event_type":134217729,"payload":"hex..."}))` +/// - V2: `sha384(canonical_json({"name":"...","type":134217729,"content":"hex..."}))` #[derive(Clone, Debug, Serialize, Deserialize, Encode, Decode)] pub struct TdxEvent { /// IMR index, starts from 0 @@ -184,9 +184,9 @@ mod tests { 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#""event":"compose-hash""#)); - assert!(input_str.contains(r#""event_type":134217729"#)); - assert!(input_str.contains(r#""payload":"abcd""#)); + assert!(input_str.contains(r#""name":"compose-hash""#)); + assert!(input_str.contains(r#""type":134217729"#)); + assert!(input_str.contains(r#""content":"abcd""#)); assert!(!input_str.contains(r#""version""#)); // And hashing it reproduces the digest let actual = Sha384::hash([input.as_slice()]); diff --git a/dstack-types/src/lib.rs b/dstack-types/src/lib.rs index 3f0c32a88..04525ff12 100644 --- a/dstack-types/src/lib.rs +++ b/dstack-types/src/lib.rs @@ -19,7 +19,7 @@ pub enum EventLogVersion { #[default] V1, /// JSON canonical digest (JCS RFC 8785): - /// `SHA({"event":"...","event_type":134217729,"payload":"hex..."})` + /// `SHA({"name":"...","type":134217729,"content":"hex..."})` V2, } From 3e4b533f4b6fd6f834a521a917417ab7072ab4ac Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 16 Apr 2026 06:53:14 -0700 Subject: [PATCH 22/24] feat(vmm): expose event_log_version in vmm-cli and Web UI - vmm-cli.py compose: add --event-log-version {1,2} flag. Omitting the flag keeps the field out of app-compose.json, so existing compose hashes are unchanged when the flag isn't used. - Web UI CreateVmDialog: add a dropdown under Networking with V1 (legacy binary) / V2 (JCS canonical JSON); v2 is only written into the generated app-compose when explicitly selected. - useVmManager: thread event_log_version through VmFormState and the clone-config path so re-creating a VM preserves the selection. --- vmm/src/vmm-cli.py | 9 +++++++++ vmm/ui/src/components/CreateVmDialog.ts | 10 ++++++++++ vmm/ui/src/composables/useVmManager.ts | 8 ++++++++ 3 files changed, 27 insertions(+) diff --git a/vmm/src/vmm-cli.py b/vmm/src/vmm-cli.py index e0090231e..86af3ba59 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 90ab797d8..78ba2f03d 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 804d56fac..1929c19e8 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 From 5a7d1882dcef9677fa4cde2c7a9bfcc8df6fe26a Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 16 Apr 2026 07:05:45 -0700 Subject: [PATCH 23/24] refactor: rename v2 canonical JSON content field back to payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the v2 canonical JSON from {"content":"","name":"...","type":134217729} to {"name":"...","payload":"","type":134217729} `payload` is the more common name in event/messaging protocols (CloudEvents, MQTT, JWT) and — unlike `data` — has no risk of being read as an OPA/Rego root-namespace reference. It also matches the existing `event_payload` field name in `TdxEvent`, so dstack-internal serde/scale shapes and the policy-facing canonical form now speak the same vocabulary. Note: JCS key ordering becomes name < payload < type. On-wire bytes (and therefore v2 digests) change — safe since v2 is not yet released. --- cc-eventlog/src/runtime_events.rs | 22 +++++++++++----------- cc-eventlog/src/tdx.rs | 4 ++-- dstack-types/src/lib.rs | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cc-eventlog/src/runtime_events.rs b/cc-eventlog/src/runtime_events.rs index 6493780d9..c6ba8cedf 100644 --- a/cc-eventlog/src/runtime_events.rs +++ b/cc-eventlog/src/runtime_events.rs @@ -113,7 +113,7 @@ impl RuntimeEvent { /// Compute the digest of the event. /// /// - V1: `SHA(event_type_le || ":" || event_name || ":" || payload)` - /// - V2: `SHA(canonical_json({"name":"...","type":134217729,"content":"hex..."}))` + /// - V2: `SHA(canonical_json({"name":"...","type":134217729,"payload":"hex..."}))` pub fn digest(&self) -> H::Output { H::hash([self.hash_input().as_slice()]) } @@ -156,7 +156,7 @@ pub fn canonical_event_json_v2(event: &str, payload: &[u8]) -> String { let obj = serde_json::json!({ "name": event, "type": DSTACK_RUNTIME_EVENT_TYPE, - "content": hex::encode(payload), + "payload": hex::encode(payload), }); serde_jcs::to_string(&obj).or_panic("canonical JSON serialization failed") } @@ -207,7 +207,7 @@ mod tests { let canonical = canonical_event_json_v2(&event.event, &event.payload); assert_eq!( canonical, - r#"{"content":"abcd","name":"compose-hash","type":134217729}"# + r#"{"name":"compose-hash","payload":"abcd","type":134217729}"# ); let digest = event.digest::(); let expected = Sha384::hash([canonical.as_bytes()]); @@ -268,7 +268,7 @@ mod tests { // Exact bytewise output — JCS must be deterministic assert_eq!( canonical, - r#"{"content":"ff","name":"event\"with\\special\nchars","type":134217729}"# + r#"{"name":"event\"with\\special\nchars","payload":"ff","type":134217729}"# ); } @@ -277,17 +277,17 @@ mod tests { // 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 content_pos = canonical.find(r#""content":"#).unwrap(); 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!(content_pos < name_pos); - assert!(name_pos < type_pos); + 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#"{"content":"","name":"","type":134217729}"#); + assert_eq!(canonical, r#"{"name":"","payload":"","type":134217729}"#); } #[test] @@ -326,11 +326,11 @@ mod tests { } #[test] - fn canonical_json_content_lowercase_hex() { - // Content payload must be hex-encoded lowercase for determinism. + 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#""content":"abcdef""#), + canonical.contains(r#""payload":"abcdef""#), "got: {canonical}" ); } diff --git a/cc-eventlog/src/tdx.rs b/cc-eventlog/src/tdx.rs index c5c67b71d..cfe9f90f8 100644 --- a/cc-eventlog/src/tdx.rs +++ b/cc-eventlog/src/tdx.rs @@ -19,7 +19,7 @@ use crate::{ /// /// 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,"content":"hex..."}))` +/// - V2: `sha384(canonical_json({"name":"...","type":134217729,"payload":"hex..."}))` #[derive(Clone, Debug, Serialize, Deserialize, Encode, Decode)] pub struct TdxEvent { /// IMR index, starts from 0 @@ -186,7 +186,7 @@ mod tests { // 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#""content":"abcd""#)); + 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()]); diff --git a/dstack-types/src/lib.rs b/dstack-types/src/lib.rs index 04525ff12..7bc3e6322 100644 --- a/dstack-types/src/lib.rs +++ b/dstack-types/src/lib.rs @@ -19,7 +19,7 @@ pub enum EventLogVersion { #[default] V1, /// JSON canonical digest (JCS RFC 8785): - /// `SHA({"name":"...","type":134217729,"content":"hex..."})` + /// `SHA({"name":"...","type":134217729,"payload":"hex..."})` V2, } From be0eb0b9dae305365d3dc8848f136beea261abd4 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 16 Apr 2026 22:28:09 +0800 Subject: [PATCH 24/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dstack-types/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dstack-types/src/lib.rs b/dstack-types/src/lib.rs index 7bc3e6322..6abe99fb6 100644 --- a/dstack-types/src/lib.rs +++ b/dstack-types/src/lib.rs @@ -15,11 +15,11 @@ use size_parser::human_size; /// forces all match sites to be updated. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum EventLogVersion { - /// Legacy binary digest: `SHA(event_type_le || ":" || name || ":" || payload)` + /// Legacy binary digest: `SHA384(event_type_le || ":" || name || ":" || payload)` #[default] V1, - /// JSON canonical digest (JCS RFC 8785): - /// `SHA({"name":"...","type":134217729,"payload":"hex..."})` + /// JSON canonical digest (JCS RFC 8785), hashed as canonical JSON bytes: + /// `SHA384({"name":"...","payload":"hex...","type":134217729})` V2, }