Skip to content

Commit 654021a

Browse files
committed
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).
1 parent a673ab7 commit 654021a

11 files changed

Lines changed: 264 additions & 52 deletions

File tree

cc-eventlog/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
//
33
// SPDX-License-Identifier: Apache-2.0
44

5-
pub use runtime_events::{replay_events, RuntimeEvent};
5+
pub use runtime_events::{
6+
canonical_event_json, replay_events, RuntimeEvent, DSTACK_RUNTIME_EVENT_TYPE_V2,
7+
};
68
pub use tdx::TdxEvent;
79

810
mod codecs;

cc-eventlog/src/runtime_events.rs

Lines changed: 157 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ use std::io::Write;
1111

1212
use ez_hash::{Hasher, Sha256, Sha384};
1313

14-
/// The event type for dstack runtime events.
14+
/// The event type for dstack runtime events (v1).
1515
/// This code is not defined in the TCG specification.
1616
/// See https://trustedcomputinggroup.org/wp-content/uploads/PC-ClientSpecific_Platform_Profile_for_TPM_2p0_Systems_v51.pdf
1717
pub const DSTACK_RUNTIME_EVENT_TYPE: u32 = 0x08000001;
18+
/// The event type for dstack runtime events (v2, JSON canonical content).
19+
/// V2 events use JCS (RFC 8785) canonical JSON as the digest input, enabling
20+
/// relying parties to define fine-grained trust policies on individual event claims.
21+
pub const DSTACK_RUNTIME_EVENT_TYPE_V2: u32 = 0x08000002;
1822
/// The path to the userspace TDX event log file.
1923
pub const RUNTIME_EVENT_LOG_FILE: &str = "/run/log/dstack/runtime_events.log";
2024

@@ -26,11 +30,18 @@ pub struct RuntimeEvent {
2630
/// Event payload
2731
#[serde(with = "base64")]
2832
pub payload: Vec<u8>,
33+
/// Event log version (0 or absent = v1, 2 = v2 JSON canonical)
34+
#[serde(default, skip_serializing)]
35+
pub version: u32,
2936
}
3037

3138
impl RuntimeEvent {
32-
pub fn new(event: String, payload: Vec<u8>) -> Self {
33-
Self { event, payload }
39+
pub fn new(event: String, payload: Vec<u8>, version: u32) -> Self {
40+
Self {
41+
event,
42+
payload,
43+
version,
44+
}
3445
}
3546

3647
pub fn read_all() -> Result<Vec<RuntimeEvent>> {
@@ -97,7 +108,18 @@ impl RuntimeEvent {
97108
}
98109

99110
/// Compute the digest of the event.
111+
///
112+
/// For v1 (version 0 or 1): `SHA(event_type_le || ":" || event_name || ":" || payload)`
113+
/// For v2 (version 2): `SHA(canonical_json({"event":"...","event_type":134217730,"payload":"hex..."}))`
100114
pub fn digest<H: Hasher>(&self) -> H::Output {
115+
if self.is_v2() {
116+
self.digest_v2::<H>()
117+
} else {
118+
self.digest_v1::<H>()
119+
}
120+
}
121+
122+
fn digest_v1<H: Hasher>(&self) -> H::Output {
101123
H::hash([
102124
&DSTACK_RUNTIME_EVENT_TYPE.to_ne_bytes()[..],
103125
b":",
@@ -107,11 +129,46 @@ impl RuntimeEvent {
107129
])
108130
}
109131

132+
/// Compute the v2 digest using JCS (RFC 8785) canonical JSON.
133+
///
134+
/// The canonical JSON has keys sorted alphabetically:
135+
/// `{"event":"<name>","event_type":134217730,"payload":"<hex>"}`
136+
fn digest_v2<H: Hasher>(&self) -> H::Output {
137+
let canonical =
138+
canonical_event_json(&self.event, DSTACK_RUNTIME_EVENT_TYPE_V2, &self.payload);
139+
H::hash([canonical.as_bytes()])
140+
}
141+
142+
pub fn is_v2(&self) -> bool {
143+
self.version == 2
144+
}
145+
110146
pub fn cc_event_type(&self) -> u32 {
111-
DSTACK_RUNTIME_EVENT_TYPE
147+
if self.is_v2() {
148+
DSTACK_RUNTIME_EVENT_TYPE_V2
149+
} else {
150+
DSTACK_RUNTIME_EVENT_TYPE
151+
}
112152
}
113153
}
114154

155+
/// Construct JCS (RFC 8785) canonical JSON for a runtime event.
156+
///
157+
/// Keys are sorted alphabetically: `event`, `event_type`, `payload`.
158+
/// The payload is hex-encoded for human readability.
159+
///
160+
/// Output: `{"event":"<name>","event_type":<type>,"payload":"<hex>"}`
161+
pub fn canonical_event_json(event: &str, event_type: u32, payload: &[u8]) -> String {
162+
// Per JCS, strings must use minimal JSON escaping.
163+
// We use serde_json to correctly escape the event name.
164+
let escaped_event = serde_json::to_string(event).expect("failed to serialize event name");
165+
let hex_payload = hex::encode(payload);
166+
format!(
167+
r#"{{"event":{},"event_type":{},"payload":"{}"}}"#,
168+
escaped_event, event_type, hex_payload
169+
)
170+
}
171+
115172
/// Replay event logs
116173
pub fn replay_events<H: Hasher>(eventlog: &[RuntimeEvent], to_event: Option<&str>) -> H::Output {
117174
let mut mr = H::zeros();
@@ -125,3 +182,99 @@ pub fn replay_events<H: Hasher>(eventlog: &[RuntimeEvent], to_event: Option<&str
125182
}
126183
mr
127184
}
185+
186+
#[cfg(test)]
187+
mod tests {
188+
use super::*;
189+
190+
#[test]
191+
fn v1_digest_unchanged() {
192+
let event = RuntimeEvent::new("app-id".to_string(), vec![0xde, 0xad, 0xbe, 0xef], 1);
193+
let digest = event.digest::<Sha384>();
194+
let expected = Sha384::hash([
195+
&DSTACK_RUNTIME_EVENT_TYPE.to_ne_bytes()[..],
196+
b":",
197+
b"app-id",
198+
b":",
199+
&[0xde, 0xad, 0xbe, 0xef],
200+
]);
201+
assert_eq!(digest, expected, "v1 digest must be backward compatible");
202+
}
203+
204+
#[test]
205+
fn v2_digest_is_canonical_json_hash() {
206+
let event = RuntimeEvent::new("compose-hash".to_string(), vec![0xab, 0xcd], 2);
207+
let canonical =
208+
canonical_event_json(&event.event, DSTACK_RUNTIME_EVENT_TYPE_V2, &event.payload);
209+
assert_eq!(
210+
canonical,
211+
r#"{"event":"compose-hash","event_type":134217730,"payload":"abcd"}"#
212+
);
213+
let digest = event.digest::<Sha384>();
214+
let expected = Sha384::hash([canonical.as_bytes()]);
215+
assert_eq!(digest, expected);
216+
}
217+
218+
#[test]
219+
fn v2_digest_differs_from_v1() {
220+
let v1 = RuntimeEvent::new("test".to_string(), vec![1, 2, 3], 1);
221+
let v2 = RuntimeEvent::new("test".to_string(), vec![1, 2, 3], 2);
222+
assert_ne!(
223+
v1.digest::<Sha384>(),
224+
v2.digest::<Sha384>(),
225+
"v1 and v2 digests must differ"
226+
);
227+
}
228+
229+
#[test]
230+
fn v1_event_type() {
231+
let event = RuntimeEvent::new("test".to_string(), vec![], 1);
232+
assert_eq!(event.cc_event_type(), DSTACK_RUNTIME_EVENT_TYPE);
233+
}
234+
235+
#[test]
236+
fn v2_event_type() {
237+
let event = RuntimeEvent::new("test".to_string(), vec![], 2);
238+
assert_eq!(event.cc_event_type(), DSTACK_RUNTIME_EVENT_TYPE_V2);
239+
}
240+
241+
#[test]
242+
fn deserialize_v1_without_version_field() {
243+
let json = r#"{"event":"app-id","payload":"AQID"}"#;
244+
let event: RuntimeEvent = serde_json::from_str(json).unwrap();
245+
assert_eq!(event.version, 0);
246+
assert!(!event.is_v2());
247+
assert_eq!(event.cc_event_type(), DSTACK_RUNTIME_EVENT_TYPE);
248+
}
249+
250+
#[test]
251+
fn serialize_omits_version() {
252+
let v1 = RuntimeEvent::new("test".to_string(), vec![1], 1);
253+
let v2 = RuntimeEvent::new("test".to_string(), vec![1], 2);
254+
let json_v1 = serde_json::to_string(&v1).unwrap();
255+
let json_v2 = serde_json::to_string(&v2).unwrap();
256+
assert!(
257+
!json_v1.contains("version"),
258+
"version should never be serialized"
259+
);
260+
assert!(
261+
!json_v2.contains("version"),
262+
"version should never be serialized"
263+
);
264+
}
265+
266+
#[test]
267+
fn canonical_json_escapes_special_chars() {
268+
let canonical = canonical_event_json(
269+
"event\"with\\special\nchars",
270+
DSTACK_RUNTIME_EVENT_TYPE_V2,
271+
&[0xff],
272+
);
273+
// Verify it's valid JSON
274+
let parsed: serde_json::Value = serde_json::from_str(&canonical).unwrap();
275+
assert_eq!(
276+
parsed["event"].as_str().unwrap(),
277+
"event\"with\\special\nchars"
278+
);
279+
}
280+
}

cc-eventlog/src/tdx.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use scale::{Decode, Encode};
77
use serde::{Deserialize, Serialize};
88

99
use crate::{
10-
runtime_events::{RuntimeEvent, DSTACK_RUNTIME_EVENT_TYPE},
10+
runtime_events::{RuntimeEvent, DSTACK_RUNTIME_EVENT_TYPE, DSTACK_RUNTIME_EVENT_TYPE_V2},
1111
tcg::TcgEventLog,
1212
};
1313

@@ -16,7 +16,9 @@ use crate::{
1616
/// and the raw event data. The IMR index is zero-based, unlike the TCG event log format
1717
/// which is one-based.
1818
///
19-
/// As for RTMR3, the digest extended is calculated as `sha384(event_type.to_ne_bytes() || b":" || event || b":" || event_payload)`.
19+
/// As for RTMR3:
20+
/// - V1 (event_type 0x08000001): digest = `sha384(event_type_le || ":" || event || ":" || payload)`
21+
/// - V2 (event_type 0x08000002): digest = `sha384(canonical_json({"event":"...","event_type":134217730,"payload":"hex..."}))`
2022
#[derive(Clone, Debug, Serialize, Deserialize, Encode, Decode)]
2123
pub struct TdxEvent {
2224
/// IMR index, starts from 0
@@ -75,22 +77,34 @@ impl TdxEvent {
7577

7678
pub fn is_runtime_event(&self) -> bool {
7779
self.event_type == DSTACK_RUNTIME_EVENT_TYPE
80+
|| self.event_type == DSTACK_RUNTIME_EVENT_TYPE_V2
7881
}
7982

8083
pub fn to_runtime_event(&self) -> Option<RuntimeEvent> {
81-
self.is_runtime_event().then_some(RuntimeEvent {
84+
if !self.is_runtime_event() {
85+
return None;
86+
}
87+
let version = if self.event_type == DSTACK_RUNTIME_EVENT_TYPE_V2 {
88+
2
89+
} else {
90+
0
91+
};
92+
Some(RuntimeEvent {
8293
event: self.event.clone(),
8394
payload: self.event_payload.clone(),
95+
version,
8496
})
8597
}
8698
}
8799

88100
impl From<RuntimeEvent> for TdxEvent {
89101
fn from(value: RuntimeEvent) -> Self {
102+
let event_type = value.cc_event_type();
103+
let digest = value.sha384_digest().to_vec();
90104
TdxEvent {
91105
imr: 3,
92-
event_type: DSTACK_RUNTIME_EVENT_TYPE,
93-
digest: value.sha384_digest().to_vec(),
106+
event_type,
107+
digest,
94108
event: value.event,
95109
event_payload: value.payload,
96110
}

dstack-attest/src/attestation.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1036,7 +1036,7 @@ impl Attestation {
10361036
let runtime_events = if mode.is_composable() {
10371037
RuntimeEvent::read_all().context("Failed to read runtime events")?
10381038
} else if let Some(app_id) = app_id {
1039-
vec![RuntimeEvent::new("app-id".to_string(), app_id.to_vec())]
1039+
vec![RuntimeEvent::new("app-id".to_string(), app_id.to_vec(), 0)]
10401040
} else {
10411041
vec![]
10421042
};

dstack-attest/src/lib.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,14 @@ pub mod attestation;
1414
mod v1;
1515

1616
/// Emit a runtime event that extends RTMR3 and logs the event.
17-
pub fn emit_runtime_event(event: &str, payload: &[u8]) -> anyhow::Result<()> {
18-
let event = RuntimeEvent::new(event.to_string(), payload.to_vec());
17+
///
18+
/// `event_log_version`: 1 for legacy binary digest, 2 for JSON canonical digest.
19+
pub fn emit_runtime_event(
20+
event: &str,
21+
payload: &[u8],
22+
event_log_version: u32,
23+
) -> anyhow::Result<()> {
24+
let event = RuntimeEvent::new(event.to_string(), payload.to_vec(), event_log_version);
1925

2026
let mode = AttestationMode::detect()?;
2127

dstack-types/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,18 @@ pub struct AppCompose {
4545
pub storage_fs: Option<String>,
4646
#[serde(default, with = "human_size")]
4747
pub swap_size: u64,
48+
#[serde(default = "default_event_log_version")]
49+
pub event_log_version: u32,
4850
}
4951

5052
fn default_true() -> bool {
5153
true
5254
}
5355

56+
fn default_event_log_version() -> u32 {
57+
1
58+
}
59+
5460
fn deserialize_gateway_enabled<'de, D>(deserializer: D) -> Result<bool, D::Error>
5561
where
5662
D: serde::Deserializer<'de>,

dstack-util/src/main.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ struct ExtendArgs {
9090
#[clap(short, long)]
9191
/// hex encoded payload of the event
9292
payload: String,
93+
94+
#[clap(long, default_value_t = 1)]
95+
/// event log version (1 = legacy, 2 = JSON canonical)
96+
event_log_version: u32,
9397
}
9498

9599
#[derive(Parser)]
@@ -224,7 +228,8 @@ fn hex_decode(hex_str: &str) -> Result<Vec<u8>> {
224228

225229
fn cmd_extend(extend_args: ExtendArgs) -> Result<()> {
226230
let payload = hex_decode(&extend_args.payload).context("Failed to decode payload")?;
227-
emit_runtime_event(&extend_args.event, &payload).context("Failed to extend RTMR")
231+
emit_runtime_event(&extend_args.event, &payload, extend_args.event_log_version)
232+
.context("Failed to extend RTMR")
228233
}
229234

230235
fn cmd_rand(rand_args: RandArgs) -> Result<()> {

0 commit comments

Comments
 (0)