Skip to content

Commit d581179

Browse files
committed
feat(guest-agent): add CCEL binary event log to GetQuote response
Add `event_log_ccel` field to GetQuoteResponse carrying the merged TCG binary Confidential Computing Event Log. Boot-time events are copied verbatim from the ACPI CCEL table; runtime events (RTMR3) are appended as TCG_PCR_EVENT2 records whose event data is the digest pre-image, making sha384(event) == digest verifiable by any TCG parser without dstack-specific schema knowledge. Verified on a real TDX CVM: replaying the RTMR3 digests from the merged CCEL produces a value that matches td10.rt_mr3 from a fresh TDX quote.
1 parent be0eb0b commit d581179

15 files changed

Lines changed: 256 additions & 6 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cc-eventlog/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,6 @@ serde_json = { workspace = true, features = ["alloc"] }
2626
sha2.workspace = true
2727

2828
[dev-dependencies]
29+
dcap-qvl.workspace = true
2930
insta.workspace = true
31+
tdx-attest.workspace = true
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// End-to-end verification: runs on a real TDX CVM, reads the actual
2+
// /sys/firmware/acpi/tables/data/CCEL + /run/log/dstack/runtime_events.log,
3+
// builds the merged CCEL binary, decodes it back, and verifies invariants.
4+
5+
use anyhow::{Context, Result};
6+
use cc_eventlog::{
7+
tcg::TcgEventLog,
8+
tdx::{build_ccel_event_log, read_event_log, TdxEvent},
9+
DSTACK_RUNTIME_EVENT_TYPE,
10+
};
11+
use dcap_qvl::quote::Quote;
12+
use ez_hash::{Hasher, Sha384};
13+
14+
fn main() -> Result<()> {
15+
let events = read_event_log().context("read event log")?;
16+
let orig_runtime = events.iter().filter(|e| e.is_runtime_event()).count();
17+
eprintln!("runtime events: {orig_runtime}");
18+
eprintln!("total events: {}", events.len());
19+
20+
let merged = build_ccel_event_log(&events).context("build merged CCEL")?;
21+
eprintln!("merged CCEL size: {} bytes", merged.len());
22+
23+
let parsed = TcgEventLog::decode(&mut merged.as_slice()).context("decode merged CCEL")?;
24+
let decoded: Vec<TdxEvent> = parsed.to_cc_event_log().context("to_cc_event_log")?;
25+
eprintln!("decoded events: {}", decoded.len());
26+
27+
// Every runtime event in the output must satisfy sha384(event_data) == digest
28+
let mut checked = 0usize;
29+
for e in &decoded {
30+
if e.event_type != DSTACK_RUNTIME_EVENT_TYPE {
31+
continue;
32+
}
33+
let h = Sha384::hash([e.event_payload.as_slice()]);
34+
anyhow::ensure!(
35+
h.as_slice() == e.digest.as_slice(),
36+
"digest mismatch for runtime event at imr={}",
37+
e.imr,
38+
);
39+
checked += 1;
40+
}
41+
eprintln!("runtime events verified: {checked}");
42+
anyhow::ensure!(
43+
checked == orig_runtime,
44+
"expected {orig_runtime} runtime events in merged CCEL, got {checked}"
45+
);
46+
47+
// RTMR3 replay check: replay the runtime digests in order and compare with
48+
// what the TD10 report says. We can't easily read the quote here without
49+
// TDX attest, so just print the replayed RTMR3 for manual comparison.
50+
let mut rtmr3 = [0u8; 48];
51+
for e in decoded.iter().filter(|e| e.imr == 3) {
52+
let mut data = [0u8; 96];
53+
data[..48].copy_from_slice(&rtmr3);
54+
data[48..].copy_from_slice(&e.digest);
55+
rtmr3 = Sha384::hash([data.as_slice()]);
56+
}
57+
eprintln!("replayed RTMR3: {}", hex::encode(rtmr3));
58+
59+
// Compare against the real RTMR3 from a fresh TDX quote.
60+
let report_data = [0u8; 64];
61+
let quote_bytes = tdx_attest::get_quote(&report_data).context("tdx_attest::get_quote")?;
62+
let quote = Quote::parse(&quote_bytes).context("parse quote")?;
63+
let td10 = quote.report.as_td10().context("missing td10 report")?;
64+
eprintln!("td10 RTMR3: {}", hex::encode(td10.rt_mr3));
65+
anyhow::ensure!(
66+
rtmr3 == td10.rt_mr3,
67+
"replayed RTMR3 does not match td10 RTMR3"
68+
);
69+
70+
println!("OK");
71+
Ok(())
72+
}

cc-eventlog/src/lib.rs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ pub use tdx::TdxEvent;
1010

1111
mod codecs;
1212
mod runtime_events;
13-
mod tcg;
13+
pub mod tcg;
1414
pub mod tdx;
1515

1616
#[cfg(test)]
1717
mod tests {
1818
use super::*;
19+
use dstack_types::EventLogVersion;
20+
use ez_hash::{Hasher, Sha384};
21+
use tdx::TdxEvent;
1922

2023
#[test]
2124
fn parse_ccel() {
@@ -26,4 +29,56 @@ mod tests {
2629
let json = serde_json::to_string_pretty(&tdx_event_logs).unwrap();
2730
insta::assert_snapshot!(json);
2831
}
32+
33+
#[test]
34+
fn encode_runtime_events_roundtrip() {
35+
// Synthesize a CCEL: sample boot-time bytes + encoded runtime events.
36+
// Decode with the same TcgEventLog parser and verify:
37+
// 1. all boot-time events are preserved
38+
// 2. runtime events appear with the expected pcrIndex / event_type / digest
39+
// 3. sha384(event_data) == digest (the property we document)
40+
let boot_raw = include_bytes!("../samples/ccel.bin");
41+
let valid = tcg::ccel_content_len(boot_raw).unwrap();
42+
43+
let runtime_v1: TdxEvent = RuntimeEvent::new(
44+
"app-id".to_string(),
45+
vec![0xaa, 0xbb, 0xcc],
46+
EventLogVersion::V1,
47+
)
48+
.into();
49+
let runtime_v2: TdxEvent = RuntimeEvent::new(
50+
"compose-hash".to_string(),
51+
vec![0xde, 0xad, 0xbe, 0xef],
52+
EventLogVersion::V2,
53+
)
54+
.into();
55+
let runtime_events = vec![runtime_v1.clone(), runtime_v2.clone()];
56+
57+
let mut merged = boot_raw[..valid].to_vec();
58+
merged.extend_from_slice(&tcg::encode_runtime_events_as_tcg(&runtime_events));
59+
merged.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
60+
61+
let parsed = tcg::TcgEventLog::decode(&mut merged.as_slice()).unwrap();
62+
let boot_count = tcg::TcgEventLog::decode(&mut boot_raw.as_slice())
63+
.unwrap()
64+
.event_logs
65+
.len();
66+
assert_eq!(parsed.event_logs.len(), boot_count + 2);
67+
68+
// Convert and verify runtime tail matches what we put in.
69+
let converted = parsed.to_cc_event_log().unwrap();
70+
let tail = &converted[converted.len() - 2..];
71+
for (orig, got) in runtime_events.iter().zip(tail.iter()) {
72+
assert_eq!(got.imr, orig.imr);
73+
assert_eq!(got.event_type, orig.event_type);
74+
assert_eq!(got.digest, orig.digest());
75+
// The event_payload carried in TdxEvent after TCG round-trip is the
76+
// hash_input bytes we stored as TCG event data.
77+
let runtime = orig.to_runtime_event().unwrap();
78+
assert_eq!(got.event_payload, runtime.hash_input());
79+
// Property: sha384(event_data) == digest
80+
let h = Sha384::hash([got.event_payload.as_slice()]);
81+
assert_eq!(h.as_slice(), got.digest.as_slice());
82+
}
83+
}
2984
}

cc-eventlog/src/tcg.rs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ impl TcgEventLog {
300300
}
301301

302302
pub fn decode_from_ccel_file() -> Result<Self> {
303-
let data = fs_err::read(CCEL_FILE).context("Failed to read CCEL")?;
303+
let data = read_ccel_raw()?;
304304
Self::decode(&mut data.as_slice())
305305
}
306306

@@ -314,6 +314,51 @@ impl TcgEventLog {
314314
}
315315
}
316316

317+
/// Read the raw ACPI CCEL table bytes from `/sys/firmware/acpi/tables/data/CCEL`.
318+
pub fn read_ccel_raw() -> Result<Vec<u8>> {
319+
fs_err::read(CCEL_FILE).context("Failed to read CCEL")
320+
}
321+
322+
/// Return the length of the valid TCG event log prefix within a raw CCEL buffer.
323+
///
324+
/// ACPI CCEL tables are fixed-size regions padded with 0xFF; the event stream
325+
/// either ends with a 0xFFFFFFFF terminator or by running into that padding.
326+
/// This parses the buffer to find where real events end so the trailer can be
327+
/// stripped before appending runtime events.
328+
pub fn ccel_content_len(raw: &[u8]) -> Result<usize> {
329+
let input = &mut &raw[..];
330+
TcgEventLog::decode(input)?;
331+
Ok(raw.len() - input.len())
332+
}
333+
334+
/// Encode dstack runtime events as TCG_PCR_EVENT2 records.
335+
///
336+
/// Non-runtime events in the slice are skipped. Each runtime event becomes:
337+
/// - pcrIndex = imr + 1 (0-based TdxEvent -> 1-based TCG pcrIndex)
338+
/// - eventType = DSTACK_RUNTIME_EVENT_TYPE
339+
/// - digests = [{ TPM_ALG_SHA384, <48-byte digest> }]
340+
/// - event = the digest pre-image bytes (`hash_input`), so that
341+
/// sha384(event) == digest holds for any TCG parser.
342+
pub fn encode_runtime_events_as_tcg(events: &[TdxEvent]) -> Vec<u8> {
343+
let mut out = Vec::new();
344+
for event in events {
345+
let Some(runtime) = event.to_runtime_event() else {
346+
continue;
347+
};
348+
let hash_input = runtime.hash_input();
349+
let digest = event.digest();
350+
let pcr_index = event.imr.saturating_add(1);
351+
out.extend_from_slice(&pcr_index.to_le_bytes());
352+
out.extend_from_slice(&event.event_type.to_le_bytes());
353+
out.extend_from_slice(&1u32.to_le_bytes());
354+
out.extend_from_slice(&TPM_ALG_SHA384.to_le_bytes());
355+
out.extend_from_slice(&digest);
356+
out.extend_from_slice(&(hash_input.len() as u32).to_le_bytes());
357+
out.extend_from_slice(&hash_input);
358+
}
359+
out
360+
}
361+
317362
fn parse_spec_id_event_log<I: scale::Input>(
318363
input: &mut I,
319364
) -> Result<(TcgEvent, TcgEfiSpecIdEvent)> {

cc-eventlog/src/tdx.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,19 @@ pub fn read_event_log() -> Result<Vec<TdxEvent>> {
228228
event_logs.extend(RuntimeEvent::read_all()?.into_iter().map(Into::into));
229229
Ok(event_logs)
230230
}
231+
232+
/// Build a merged TCG binary event log: raw ACPI CCEL (boot-time) followed by
233+
/// the given runtime events encoded as TCG_PCR_EVENT2 records.
234+
///
235+
/// Non-runtime entries in `events` are ignored; only events with
236+
/// `event_type == DSTACK_RUNTIME_EVENT_TYPE` are appended.
237+
pub fn build_ccel_event_log(events: &[TdxEvent]) -> Result<Vec<u8>> {
238+
let raw = crate::tcg::read_ccel_raw()?;
239+
let end = crate::tcg::ccel_content_len(&raw)?;
240+
let mut out = raw[..end].to_vec();
241+
out.extend_from_slice(&crate::tcg::encode_runtime_events_as_tcg(events));
242+
// Append the 0xFFFFFFFF terminator so parsers know where the event
243+
// stream ends (the original ACPI table has trailing 0xFF padding).
244+
out.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
245+
Ok(out)
246+
}

dstack-attest/src/attestation.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,18 @@ impl<T> Attestation<T> {
751751
.and_then(|q| Quote::parse(&q.quote).ok())
752752
.and_then(|quote| quote.report.as_td10().cloned())
753753
}
754+
755+
/// Get the merged TCG binary CCEL event log (boot-time CCEL + runtime
756+
/// events encoded as TCG_PCR_EVENT2 records).
757+
///
758+
/// Returns an empty `Vec` on platforms without a TDX quote (e.g. Nitro).
759+
/// Propagates errors from reading or parsing the ACPI CCEL file.
760+
pub fn get_tdx_event_log_ccel(&self) -> Result<Vec<u8>> {
761+
let Some(q) = self.tdx_quote() else {
762+
return Ok(Vec::new());
763+
};
764+
cc_eventlog::tdx::build_ccel_event_log(&q.event_log)
765+
}
754766
}
755767

756768
pub trait GetDeviceId {

guest-agent-simulator/src/simulator.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub fn simulated_quote_response(
4343
.unwrap_or_default(),
4444
report_data: report_data.to_vec(),
4545
vm_config: vm_config.to_string(),
46+
event_log_ccel: Vec::new(),
4647
})
4748
}
4849

guest-agent/rpc/proto/agent_rpc.proto

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,27 @@ message GetQuoteResponse {
208208
bytes report_data = 3;
209209
// Hw config
210210
string vm_config = 4;
211+
// TCG binary Confidential Computing Event Log (CCEL) containing both
212+
// boot-time events (from the ACPI CCEL table) and dstack runtime events
213+
// (RTMR3) merged into a single stream.
214+
//
215+
// Encoding per TCG PC Client Platform Firmware Profile:
216+
// - Starts with a Spec ID header event (TCG_PCClientPCREvent).
217+
// - Boot-time events are copied verbatim from the ACPI CCEL table
218+
// (TCG_PCR_EVENT2 with the digests the firmware wrote).
219+
// - Runtime events (event_type = 0x08000001) are appended as
220+
// TCG_PCR_EVENT2 records with:
221+
// * pcrIndex = imr + 1 (so RTMR3 becomes 4)
222+
// * eventType = 0x08000001
223+
// * digests = [{ algId: TPM_ALG_SHA384 (0xC), hash: 48 bytes }]
224+
// * event = the digest pre-image bytes, so that
225+
// sha384(event) == digest holds independently of the
226+
// dstack schema. The pre-image is:
227+
// V1: event_type_le || ":" || name || ":" || payload
228+
// V2: canonical JSON (UTF-8)
229+
//
230+
// Empty on platforms without an ACPI CCEL table (e.g. Nitro Enclaves).
231+
bytes event_log_ccel = 5;
211232
}
212233

213234
message EmitEventArgs {

guest-agent/src/backend.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,15 @@ impl PlatformBackend for RealPlatform {
5252
let attestation = Attestation::quote(&report_data).context("Failed to get quote")?;
5353
let tdx_quote = attestation.get_tdx_quote_bytes();
5454
let tdx_event_log = attestation.get_tdx_event_log_string(include_hash_inputs);
55+
let event_log_ccel = attestation
56+
.get_tdx_event_log_ccel()
57+
.context("failed to build CCEL event log")?;
5558
Ok(GetQuoteResponse {
5659
quote: tdx_quote.unwrap_or_default(),
5760
event_log: tdx_event_log.unwrap_or_default(),
5861
report_data: report_data.to_vec(),
5962
vm_config: vm_config.to_string(),
63+
event_log_ccel,
6064
})
6165
}
6266

0 commit comments

Comments
 (0)