Skip to content

Commit 796683b

Browse files
committed
feat: add include_hash_inputs parameter to GetQuote and Attest RPCs
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<String> (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
1 parent 4dee4bb commit 796683b

13 files changed

Lines changed: 282 additions & 41 deletions

File tree

cc-eventlog/src/runtime_events.rs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -114,18 +114,28 @@ impl RuntimeEvent {
114114
/// - V1: `SHA(event_type_le || ":" || event_name || ":" || payload)`
115115
/// - V2: `SHA(canonical_json({"event":"...","event_type":134217729,"payload":"hex...","version":2}))`
116116
pub fn digest<H: Hasher>(&self) -> H::Output {
117+
H::hash([self.hash_input().as_slice()])
118+
}
119+
120+
/// The exact byte sequence that gets hashed to produce the digest.
121+
///
122+
/// Useful for relying parties that want to verify the digest computation
123+
/// or inspect event content without knowing the dstack schema.
124+
///
125+
/// - V1: binary concatenation `event_type_le || ":" || name || ":" || payload`
126+
/// - V2: UTF-8 bytes of the JCS canonical JSON
127+
pub fn hash_input(&self) -> Vec<u8> {
117128
match self.version {
118-
EventLogVersion::V1 => H::hash([
119-
&DSTACK_RUNTIME_EVENT_TYPE.to_ne_bytes()[..],
120-
b":",
121-
self.event.as_bytes(),
122-
b":",
123-
&self.payload,
124-
]),
125-
EventLogVersion::V2 => {
126-
let canonical = canonical_event_json_v2(&self.event, &self.payload);
127-
H::hash([canonical.as_bytes()])
129+
EventLogVersion::V1 => {
130+
let mut buf = Vec::with_capacity(4 + 1 + self.event.len() + 1 + self.payload.len());
131+
buf.extend_from_slice(&DSTACK_RUNTIME_EVENT_TYPE.to_ne_bytes());
132+
buf.push(b':');
133+
buf.extend_from_slice(self.event.as_bytes());
134+
buf.push(b':');
135+
buf.extend_from_slice(&self.payload);
136+
buf
128137
}
138+
EventLogVersion::V2 => canonical_event_json_v2(&self.event, &self.payload).into_bytes(),
129139
}
130140
}
131141

cc-eventlog/src/tcg.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ impl TryFrom<TcgEvent> for TdxEvent {
371371
event: Default::default(),
372372
event_payload: value.event.into(),
373373
version: Default::default(),
374+
hash_input: None,
374375
})
375376
}
376377
}

cc-eventlog/src/tdx.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ pub struct TdxEvent {
4141
#[serde(default, skip_serializing_if = "is_v1")]
4242
#[codec(skip)]
4343
pub version: EventLogVersion,
44+
45+
/// Optional digest pre-image, hex-encoded.
46+
///
47+
/// The exact bytes hashed to produce `digest`. Only populated when
48+
/// explicitly requested (e.g., via RPC opt-in) so that relying parties can
49+
/// verify the digest computation or inspect v2 JSON content without
50+
/// knowing the dstack schema.
51+
/// Never included in scale encoding (derivable from other fields).
52+
#[serde(default, skip_serializing_if = "Option::is_none")]
53+
#[codec(skip)]
54+
pub hash_input: Option<String>,
4455
}
4556

4657
fn is_v1(v: &EventLogVersion) -> bool {
@@ -56,6 +67,7 @@ impl TdxEvent {
5667
event,
5768
event_payload,
5869
version: EventLogVersion::default(),
70+
hash_input: None,
5971
}
6072
}
6173

@@ -70,6 +82,7 @@ impl TdxEvent {
7082
event: self.event.clone(),
7183
event_payload: self.event_payload.clone(),
7284
version: self.version,
85+
hash_input: self.hash_input.clone(),
7386
}
7487
} else {
7588
Self {
@@ -79,10 +92,22 @@ impl TdxEvent {
7992
event: self.event.clone(),
8093
event_payload: Vec::new(),
8194
version: self.version,
95+
hash_input: self.hash_input.clone(),
8296
}
8397
}
8498
}
8599

100+
/// Populate `hash_input` with the digest pre-image.
101+
///
102+
/// For runtime events, this is the byte sequence defined by V1/V2 digest algorithms.
103+
/// For boot-time TCG events, the pre-image is inherent in the original log format
104+
/// and not reconstructable from this struct, so `hash_input` stays `None`.
105+
pub fn fill_hash_input(&mut self) {
106+
if let Some(runtime_event) = self.to_runtime_event() {
107+
self.hash_input = Some(hex::encode(runtime_event.hash_input()));
108+
}
109+
}
110+
86111
pub fn digest(&self) -> Vec<u8> {
87112
if let Some(runtime_event) = self.to_runtime_event() {
88113
return runtime_event.sha384_digest().to_vec();
@@ -118,10 +143,84 @@ impl From<RuntimeEvent> for TdxEvent {
118143
event: value.event,
119144
event_payload: value.payload,
120145
version,
146+
hash_input: None,
121147
}
122148
}
123149
}
124150

151+
#[cfg(test)]
152+
mod tests {
153+
use super::*;
154+
use ez_hash::{Hasher, Sha384};
155+
use sha2::{Digest as _, Sha384 as Sha384Hasher};
156+
157+
#[test]
158+
fn fill_hash_input_v1() {
159+
let runtime = RuntimeEvent::new(
160+
"compose-hash".to_string(),
161+
vec![0xde, 0xad],
162+
EventLogVersion::V1,
163+
);
164+
let mut tdx: TdxEvent = runtime.into();
165+
assert_eq!(tdx.hash_input, None);
166+
tdx.fill_hash_input();
167+
let input_hex = tdx.hash_input.as_ref().expect("hash_input populated");
168+
let input = hex::decode(input_hex).unwrap();
169+
// Hashing the hash_input must reproduce the event digest
170+
let actual = Sha384Hasher::digest(&input);
171+
assert_eq!(actual.as_slice(), &tdx.digest);
172+
}
173+
174+
#[test]
175+
fn fill_hash_input_v2_is_canonical_json() {
176+
let runtime = RuntimeEvent::new(
177+
"compose-hash".to_string(),
178+
vec![0xab, 0xcd],
179+
EventLogVersion::V2,
180+
);
181+
let mut tdx: TdxEvent = runtime.into();
182+
tdx.fill_hash_input();
183+
let input_hex = tdx.hash_input.as_ref().expect("hash_input populated");
184+
let input = hex::decode(input_hex).unwrap();
185+
let input_str = std::str::from_utf8(&input).unwrap();
186+
// V2 hash_input is the canonical JSON
187+
assert!(input_str.contains(r#""event":"compose-hash""#));
188+
assert!(input_str.contains(r#""version":2"#));
189+
assert!(input_str.contains(r#""payload":"abcd""#));
190+
// And hashing it reproduces the digest
191+
let actual = Sha384::hash([input.as_slice()]);
192+
assert_eq!(actual.as_slice(), &tdx.digest);
193+
}
194+
195+
#[test]
196+
fn fill_hash_input_skips_non_runtime_events() {
197+
let mut boot_event = TdxEvent::new(0, 0x1, "EV_POST_CODE".to_string(), vec![1, 2, 3]);
198+
boot_event.fill_hash_input();
199+
assert_eq!(boot_event.hash_input, None);
200+
}
201+
202+
#[test]
203+
fn hash_input_not_serialized_by_scale() {
204+
use scale::{Decode, Encode};
205+
let runtime = RuntimeEvent::new("test".to_string(), vec![1, 2], EventLogVersion::V2);
206+
let mut tdx: TdxEvent = runtime.into();
207+
tdx.fill_hash_input();
208+
assert!(tdx.hash_input.is_some());
209+
let encoded = tdx.encode();
210+
let decoded = TdxEvent::decode(&mut &encoded[..]).unwrap();
211+
// hash_input is codec(skip) so it's None after round-trip
212+
assert_eq!(decoded.hash_input, None);
213+
}
214+
215+
#[test]
216+
fn hash_input_skipped_from_json_when_none() {
217+
let runtime = RuntimeEvent::new("test".to_string(), vec![1], EventLogVersion::V1);
218+
let tdx: TdxEvent = runtime.into();
219+
let json = serde_json::to_string(&tdx).unwrap();
220+
assert!(!json.contains("hash_input"));
221+
}
222+
}
223+
125224
/// Read both boottime and runtime event logs.
126225
pub fn read_event_log() -> Result<Vec<TdxEvent>> {
127226
let mut event_logs = TcgEventLog::decode_from_ccel_file()?.to_cc_event_log()?;

dstack-attest/src/attestation.rs

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -476,9 +476,21 @@ pub trait TdxAttestationExt {
476476
fn tdx_event_log(&self) -> Option<&[TdxEvent]>;
477477

478478
/// Returns the TDX event log serialized as JSON.
479-
fn tdx_event_log_string(&self) -> Option<String> {
480-
self.tdx_event_log()
481-
.map(|event_log| serde_json::to_string(event_log).unwrap_or_default())
479+
///
480+
/// When `include_hash_inputs` is true, each runtime event carries its
481+
/// digest pre-image (hex-encoded) so relying parties can verify it directly.
482+
fn tdx_event_log_string(&self, include_hash_inputs: bool) -> Option<String> {
483+
self.tdx_event_log().map(|event_log| {
484+
if include_hash_inputs {
485+
let mut events: Vec<TdxEvent> = event_log.to_vec();
486+
for event in &mut events {
487+
event.fill_hash_input();
488+
}
489+
serde_json::to_string(&events).unwrap_or_default()
490+
} else {
491+
serde_json::to_string(event_log).unwrap_or_default()
492+
}
493+
})
482494
}
483495

484496
/// Returns the parsed TD10 report from the embedded TDX quote.
@@ -699,6 +711,18 @@ impl<T> Attestation<T> {
699711
self.tdx_quote().map(|q| q.quote.clone())
700712
}
701713

714+
/// Populate `hash_input` on every runtime event in the TDX event log.
715+
///
716+
/// Useful before serializing an attestation so relying parties get the
717+
/// digest pre-images alongside events.
718+
pub fn fill_event_hash_inputs(&mut self) {
719+
if let Some(q) = self.tdx_quote_mut() {
720+
for event in &mut q.event_log {
721+
event.fill_hash_input();
722+
}
723+
}
724+
}
725+
702726
/// Get TDX event log bytes
703727
pub fn get_tdx_event_log_bytes(&self) -> Option<Vec<u8>> {
704728
self.tdx_quote()
@@ -707,9 +731,17 @@ impl<T> Attestation<T> {
707731

708732
/// Get TDX event log string with RTMR[0-2] payloads stripped to reduce size.
709733
/// Only digests are kept for boot-time events; runtime events (RTMR3) retain full payload.
710-
pub fn get_tdx_event_log_string(&self) -> Option<String> {
734+
///
735+
/// When `include_hash_inputs` is true, each runtime event carries its digest
736+
/// pre-image (hex-encoded) so relying parties can verify or inspect it directly.
737+
pub fn get_tdx_event_log_string(&self, include_hash_inputs: bool) -> Option<String> {
711738
self.tdx_quote().map(|q| {
712-
let stripped: Vec<_> = q.event_log.iter().map(|e| e.stripped()).collect();
739+
let mut stripped: Vec<_> = q.event_log.iter().map(|e| e.stripped()).collect();
740+
if include_hash_inputs {
741+
for event in &mut stripped {
742+
event.fill_hash_input();
743+
}
744+
}
713745
serde_json::to_string(&stripped).unwrap_or_default()
714746
})
715747
}

dstack-attest/src/v1.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ impl PlatformEvidence {
3737
}
3838
}
3939

40+
pub fn tdx_event_log_mut(&mut self) -> Option<&mut Vec<TdxEvent>> {
41+
match self {
42+
Self::Tdx { event_log, .. } => Some(event_log),
43+
_ => None,
44+
}
45+
}
46+
4047
pub fn into_stripped(self) -> Self {
4148
match self {
4249
Self::Tdx { quote, event_log } => Self::Tdx {

gateway/src/distributed_certbot.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ impl DistributedCertBot {
378378
let quote = match agent
379379
.get_quote(RawQuoteArgs {
380380
report_data: report_data.clone(),
381+
include_hash_inputs: false,
381382
})
382383
.await
383384
{
@@ -389,7 +390,13 @@ impl DistributedCertBot {
389390
};
390391

391392
// Get attestation
392-
let attestation_str = match agent.attest(RawQuoteArgs { report_data }).await {
393+
let attestation_str = match agent
394+
.attest(RawQuoteArgs {
395+
report_data,
396+
include_hash_inputs: false,
397+
})
398+
.await
399+
{
393400
Ok(resp) => serde_json::to_string(&resp).unwrap_or_default(),
394401
Err(err) => {
395402
warn!("failed to get attestation for ACME account: {err:?}");
@@ -448,6 +455,7 @@ impl DistributedCertBot {
448455
let quote = match agent
449456
.get_quote(RawQuoteArgs {
450457
report_data: report_data.clone(),
458+
include_hash_inputs: false,
451459
})
452460
.await
453461
{
@@ -459,7 +467,13 @@ impl DistributedCertBot {
459467
};
460468

461469
// Get attestation
462-
let attestation = match agent.attest(RawQuoteArgs { report_data }).await {
470+
let attestation = match agent
471+
.attest(RawQuoteArgs {
472+
report_data,
473+
include_hash_inputs: false,
474+
})
475+
.await
476+
{
463477
Ok(resp) => serde_json::to_string(&resp).unwrap_or_default(),
464478
Err(err) => {
465479
warn!(domain, "failed to get attestation: {err:?}");

gateway/src/gen_debug_key.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ async fn main() -> Result<()> {
5050
let quote_response = simulator_client
5151
.get_quote(RawQuoteArgs {
5252
report_data: report_data.to_vec(),
53+
include_hash_inputs: false,
5354
})
5455
.await
5556
.context("Failed to get quote from simulator")?;

guest-agent-simulator/src/main.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,32 @@ impl PlatformBackend for SimulatorPlatform {
7878
)
7979
}
8080

81-
fn quote_response(&self, report_data: [u8; 64], vm_config: &str) -> Result<GetQuoteResponse> {
81+
fn quote_response(
82+
&self,
83+
report_data: [u8; 64],
84+
vm_config: &str,
85+
include_hash_inputs: bool,
86+
) -> Result<GetQuoteResponse> {
8287
simulator::simulated_quote_response(
8388
&self.attestation,
8489
report_data,
8590
vm_config,
8691
self.patch_report_data,
92+
include_hash_inputs,
8793
)
8894
}
8995

90-
fn attest_response(&self, report_data: [u8; 64]) -> Result<AttestResponse> {
91-
simulator::simulated_attest_response(&self.attestation, report_data, self.patch_report_data)
96+
fn attest_response(
97+
&self,
98+
report_data: [u8; 64],
99+
include_hash_inputs: bool,
100+
) -> Result<AttestResponse> {
101+
simulator::simulated_attest_response(
102+
&self.attestation,
103+
report_data,
104+
self.patch_report_data,
105+
include_hash_inputs,
106+
)
92107
}
93108

94109
fn emit_event(&self, event: &str, _payload: &[u8], _version: EventLogVersion) -> Result<()> {
@@ -169,7 +184,7 @@ mod tests {
169184
fn simulator_attest_response_uses_supplied_report_data() {
170185
let platform = load_fixture_platform();
171186
let report_data = [0x5a; 64];
172-
let response = platform.attest_response(report_data).unwrap();
187+
let response = platform.attest_response(report_data, false).unwrap();
173188
let patched = VersionedAttestation::from_bytes(&response.attestation)
174189
.unwrap()
175190
.into_v1();
@@ -186,7 +201,7 @@ mod tests {
186201
let original = fixture.clone().into_v1().report_data().unwrap();
187202
let platform = SimulatorPlatform::new(fixture, false);
188203
let report_data = [0x5a; 64];
189-
let response = platform.attest_response(report_data).unwrap();
204+
let response = platform.attest_response(report_data, false).unwrap();
190205
let patched = VersionedAttestation::from_bytes(&response.attestation)
191206
.unwrap()
192207
.into_v1();

0 commit comments

Comments
 (0)