Skip to content

Commit 4380a54

Browse files
authored
Merge pull request #742 from Dstack-TEE/feat/tdx-measurement-attestation
feat: add lite OS image verification for TDX and SNP
2 parents 08c78dc + be8a23f commit 4380a54

39 files changed

Lines changed: 3339 additions & 798 deletions

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/src/tdx.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ use crate::{
1111
tcg::TcgEventLog,
1212
};
1313

14+
pub const TDX_ACPI_DATA_EVENT_TYPE: u32 = 10;
15+
pub const TDX_ACPI_DATA_EVENT_PAYLOAD: &[u8] = b"ACPI DATA";
16+
pub const TDX_ACPI_LOADER_EVENT: &str = "acpi-loader";
17+
pub const TDX_ACPI_RSDP_EVENT: &str = "acpi-rsdp";
18+
pub const TDX_ACPI_TABLES_EVENT: &str = "acpi-tables";
19+
pub const TDX_ACPI_DATA_EVENT_NAMES: [&str; 3] = [
20+
TDX_ACPI_LOADER_EVENT,
21+
TDX_ACPI_RSDP_EVENT,
22+
TDX_ACPI_TABLES_EVENT,
23+
];
24+
1425
/// This is the TDX event log format that is used to store the event log in the TDX guest.
1526
/// It is a simplified version of the TCG event log format, containing only a single digest
1627
/// and the raw event data. The IMR index is zero-based, unlike the TCG event log format
@@ -97,9 +108,68 @@ impl From<RuntimeEvent> for TdxEvent {
97108
}
98109
}
99110

111+
pub fn is_tdx_acpi_data_event(event: &TdxEvent) -> bool {
112+
event.imr == 0
113+
&& event.event_type == TDX_ACPI_DATA_EVENT_TYPE
114+
&& event.event_payload == TDX_ACPI_DATA_EVENT_PAYLOAD
115+
}
116+
117+
/// Give dstack's three Pre202505 OVMF ACPI DATA RTMR0 events stable semantic
118+
/// names. The firmware event payload is the same "ACPI DATA" marker for all
119+
/// three entries, so the guest labels them before exposing the event log.
120+
pub fn label_tdx_acpi_data_events(event_logs: &mut [TdxEvent]) {
121+
for (acpi_idx, event) in event_logs
122+
.iter_mut()
123+
.filter(|event| is_tdx_acpi_data_event(event))
124+
.enumerate()
125+
{
126+
if let Some(name) = TDX_ACPI_DATA_EVENT_NAMES.get(acpi_idx) {
127+
event.event = (*name).to_string();
128+
}
129+
}
130+
}
131+
100132
/// Read both boottime and runtime event logs.
101133
pub fn read_event_log() -> Result<Vec<TdxEvent>> {
102134
let mut event_logs = TcgEventLog::decode_from_ccel_file()?.to_cc_event_log()?;
135+
label_tdx_acpi_data_events(&mut event_logs);
103136
event_logs.extend(RuntimeEvent::read_all()?.into_iter().map(Into::into));
104137
Ok(event_logs)
105138
}
139+
140+
#[cfg(test)]
141+
mod tests {
142+
use super::*;
143+
144+
fn acpi_data_event(digest_byte: u8) -> TdxEvent {
145+
TdxEvent {
146+
imr: 0,
147+
event_type: TDX_ACPI_DATA_EVENT_TYPE,
148+
digest: vec![digest_byte; 48],
149+
event: String::new(),
150+
event_payload: TDX_ACPI_DATA_EVENT_PAYLOAD.to_vec(),
151+
}
152+
}
153+
154+
#[test]
155+
fn labels_pre202505_acpi_data_events_in_order() {
156+
let mut events = vec![
157+
TdxEvent::new(0, 4, String::new(), vec![0]),
158+
acpi_data_event(1),
159+
acpi_data_event(2),
160+
acpi_data_event(3),
161+
TdxEvent::new(3, DSTACK_RUNTIME_EVENT_TYPE, "app-id".into(), vec![4]),
162+
];
163+
164+
label_tdx_acpi_data_events(&mut events);
165+
166+
let names = events
167+
.iter()
168+
.filter(|event| is_tdx_acpi_data_event(event))
169+
.map(|event| event.event.as_str())
170+
.collect::<Vec<_>>();
171+
assert_eq!(names, TDX_ACPI_DATA_EVENT_NAMES);
172+
assert_eq!(events[0].event, "");
173+
assert_eq!(events[4].event, "app-id");
174+
}
175+
}

docs/amd-sev-snp-review-readiness.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ After those fixes, the manual smoke progressed through full dstack-managed SNP g
119119
- Configfs TSM report collection falls back to the SEV-SNP extended-report ioctl when configfs does not carry certificate collateral.
120120
- If verifier-side evidence still lacks ASK/VCEK collateral, the verifier can fetch AMD KDS ARK/ASK/VCEK using the report `chip_id` and reported TCB, then verify the signed report fail-closed.
121121
- KMS measurement recomputation now uses the image's original kernel cmdline for SNP launch measurement, while app identity is bound by MrConfigV3/HOST_DATA instead of appended cmdline fields.
122-
- VMM now extracts the image OVMF SEV metadata and OVMF launch digest seed, includes them in the `sev_snp_measurement` document string, and passes that through the guest to KMS; KMS no longer needs a single locally configured `ovmf_path`, so different image/OVMF versions can be verified by their self-contained launch inputs.
123-
- SNP `BootInfo.os_image_hash` is the canonical image-invariant projection of the verified launch inputs: rootfs identity is derived from the measured `dstack.rootfs_hash=...` cmdline parameter, and the hash covers the cmdline, kernel/initrd hashes, and OVMF hash/sections while excluding per-deployment values like vCPU count/model and guest features.
122+
- VMM now passes the image's split `measurement.snp.cbor` material plus per-launch SNP fields through the guest to KMS; KMS no longer needs a single locally configured `ovmf_path`, so different image/OVMF versions can be verified by their self-contained launch inputs.
123+
- SNP `BootInfo.os_image_hash` is the unified image digest (`sha256(sha256sum.txt)`). The `measurement.snp.cbor` entry in `sha256sum.txt` commits to the cmdline, kernel/initrd hashes, and OVMF hash/sections while excluding per-deployment values like vCPU count/model and guest features.
124124

125125
Latest sanitized remote smoke result with PR-built host binaries and a coherent `MACHINE = "sev-snp"` guest image:
126126

docs/security/security-model.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,47 @@ dstack implements layered verification from hardware to application. Each layer
9292

9393
**Key management layer.** The KMS root CA public key hash is recorded in RTMR3 as the key-provider event. This binds your workload to a specific KMS instance. The KMS itself runs in a TEE with its own attestation quote, so you can verify the KMS the same way you verify any workload.
9494

95+
### How `os_image_hash` becomes trusted
96+
97+
The `os_image_hash` carried in `vm_config` is not trusted just because the guest
98+
or host reports it. The verifier first validates the hardware-signed quote, then
99+
uses the quoted measurements to bind `os_image_hash` to the software that
100+
actually booted.
101+
102+
For the full-image TDX path, the verifier obtains the OS image identified by
103+
`os_image_hash`, checks the image checksum manifest, recomputes the expected
104+
MRTD and RTMR0-2 from the image and VM configuration, and requires those values
105+
to match the measurements in the quote. If the host substitutes either the image
106+
hash or the VM configuration, the recomputed measurements no longer match the
107+
quote.
108+
109+
For the no-image-download TDX lite path and the AMD SEV-SNP path,
110+
`os_image_hash` is the unified image identity: `sha256(sha256sum.txt)`. The
111+
`sha256sum.txt` file is the image checksum manifest generated at image build
112+
time. It is a text file whose lines contain a SHA-256 digest and relative file
113+
name for each manifest entry, such as `metadata.json`, the kernel, initrd,
114+
firmware, and the split measurement file. Some launch-critical artifacts are
115+
represented indirectly instead of as direct manifest entries: for example, the
116+
rootfs is committed by the measured `dstack.rootfs_hash` kernel command-line
117+
parameter, and the SEV firmware is committed by `measurement.snp.cbor`. The exact
118+
`sha256sum.txt` bytes are hashed, so the manifest contents, file names, ordering,
119+
and line endings are all part of the image identity.
120+
121+
The attestation carries a copy of the image's `sha256sum.txt` plus the platform
122+
specific measurement material (`measurement.tdx.cbor` or
123+
`measurement.snp.cbor`). The verifier checks that:
124+
125+
1. `sha256(checksum_file) == os_image_hash`;
126+
2. the checksum file contains the expected `measurement.*.cbor` entry and that
127+
entry hashes to the supplied measurement material;
128+
3. the supplied measurement material replays to the hardware-signed TDX
129+
MRTD/RTMR values or SEV-SNP launch `MEASUREMENT`/`HOST_DATA`.
130+
131+
Only after these checks pass does the verifier treat the returned
132+
`os_image_hash` as the measured OS image identity. Downstream authorization
133+
systems can then compare that trusted value against an allowlist or governance
134+
contract.
135+
95136
## Verification Checklist
96137

97138
Use this checklist to verify a workload running in a dstack CVM.
@@ -124,6 +165,31 @@ This is also reflected at the source: the event log shipped alongside an attesta
124165

125166
The reason boot-time event log entries (RTMR0-2) are dropped is that **nothing downstream consumes them**. Verification recomputes the OS-layer measurements directly from the signed `rt_mr0/1/2` values and compares them to independently reproduced expected measurements, so the corresponding boot event log would be redundant. Keeping it would only bloat the RA-TLS certificate and expose extra detail without adding any verification capability. RTMR3, by contrast, is runtime-extended (compose-hash, key-provider, instance-id, and application-emitted events), so its event log is the only one with a real consumer — the replay that proves what was extended into RTMR3.
126167

168+
### Why TDX lite mode does not validate ACPI table contents
169+
170+
TDX lite mode verifies the OS image without downloading the image and without
171+
running QEMU to regenerate ACPI tables. It still uses the three RTMR0 `ACPI
172+
DATA` digests from the attestation event log as measurement inputs. The guest
173+
labels those three events as `acpi-loader`, `acpi-rsdp`, and `acpi-tables`
174+
before exposing the event log, and the verifier checks that the recomputed RTMR
175+
values match the hardware-signed quote. What it does not do is reconstruct and
176+
byte-compare the full ACPI table contents.
177+
178+
This is safe for dstack's threat model because ACPI tables are treated as
179+
untrusted host-provided platform description, not as trusted guest code. The
180+
dangerous executable part of ACPI is AML (ACPI Machine Language): malicious AML
181+
can try to use `SystemMemory` operation regions through the Linux ACPICA
182+
interpreter to read or write guest physical memory. dstack kernels include the
183+
BadAML sandbox patch (`0002-acpi-sandbox-block-aml-systemmemory-ram-access.patch`),
184+
which hooks the ACPI `SystemMemory` region handler, walks the guest page tables,
185+
and denies AML access to encrypted/private guest RAM. AML can only access
186+
unencrypted/shared mappings.
187+
188+
Therefore, an infrastructure operator can still provide bad ACPI data and cause
189+
misconfiguration or denial of service, but unvalidated ACPI/AML cannot tamper
190+
with confidential private memory or extract secrets. That residual availability
191+
risk is already outside dstack's confidentiality/integrity guarantees.
192+
127193
### TCB status is surfaced, not gated, during verification
128194

129195
dstack's `validate_tcb` does not reject a quote based on its TCB status string (`UpToDate`, `OutOfDate`, `ConfigurationNeeded`, `SWHardeningNeeded`, ...). It only enforces hard invariants: debug mode must be off, and the SEAM/service-TD measurements must be well-formed. The verified report carries the `status` field through to the caller.

dstack-attest/src/attestation.rs

Lines changed: 95 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ use tpm_qvl::verify::VerifiedReport as TpmVerifiedReport;
3232
pub use tpm_types::TpmQuote;
3333

3434
use crate::amd_sev_snp::{AmdKdsClient, VerifiedAmdSnpReport};
35+
use crate::v1::{
36+
is_tdx_acpi_data_event, is_tdx_lite_config, strip_tdx_event_log_for_config,
37+
strip_tdx_runtime_event_log,
38+
};
3539
pub use crate::v1::{Attestation as AttestationV1, PlatformEvidence, StackEvidence};
3640

3741
pub const SNP_REPORT_DATA_RANGE: std::ops::Range<usize> = 0x50..0x90;
@@ -596,17 +600,24 @@ impl VersionedAttestation {
596600
}
597601
}
598602

599-
/// Strip data for certificate embedding (e.g. keep RTMR3 event logs only).
603+
/// Strip data for certificate embedding.
600604
pub fn into_stripped(self) -> Self {
601605
match self {
602606
Self::V0 { mut attestation } => {
603-
if let Some(tdx_quote) = attestation.tdx_quote_mut() {
604-
tdx_quote.event_log = tdx_quote
605-
.event_log
606-
.iter()
607-
.filter(|e| e.imr == 3)
608-
.map(|e| e.stripped())
609-
.collect();
607+
match &mut attestation.quote {
608+
AttestationQuote::DstackTdx(tdx_quote) => {
609+
tdx_quote.event_log = strip_tdx_event_log_for_config(
610+
std::mem::take(&mut tdx_quote.event_log),
611+
&attestation.config,
612+
);
613+
}
614+
AttestationQuote::DstackGcpTdx(quote) => {
615+
quote.tdx_quote.event_log = strip_tdx_runtime_event_log(std::mem::take(
616+
&mut quote.tdx_quote.event_log,
617+
));
618+
}
619+
AttestationQuote::DstackAmdSevSnp(_)
620+
| AttestationQuote::DstackNitroEnclave(_) => {}
610621
}
611622
Self::V0 { attestation }
612623
}
@@ -1009,17 +1020,16 @@ pub enum AttestationQuote {
10091020
DstackTdx(TdxQuote),
10101021
DstackGcpTdx(DstackGcpTdxQuote),
10111022
DstackNitroEnclave(DstackNitroQuote),
1012-
/// Keep this last to preserve SCALE discriminants for existing variants.
10131023
DstackAmdSevSnp(SnpQuote),
10141024
}
10151025

10161026
impl AttestationQuote {
10171027
pub fn mode(&self) -> AttestationMode {
10181028
match self {
1019-
AttestationQuote::DstackTdx { .. } => AttestationMode::DstackTdx,
1020-
AttestationQuote::DstackAmdSevSnp { .. } => AttestationMode::DstackAmdSevSnp,
1021-
AttestationQuote::DstackGcpTdx { .. } => AttestationMode::DstackGcpTdx,
1022-
AttestationQuote::DstackNitroEnclave { .. } => AttestationMode::DstackNitroEnclave,
1029+
AttestationQuote::DstackTdx(_) => AttestationMode::DstackTdx,
1030+
AttestationQuote::DstackAmdSevSnp(_) => AttestationMode::DstackAmdSevSnp,
1031+
AttestationQuote::DstackGcpTdx(_) => AttestationMode::DstackGcpTdx,
1032+
AttestationQuote::DstackNitroEnclave(_) => AttestationMode::DstackNitroEnclave,
10231033
}
10241034
}
10251035
}
@@ -1148,8 +1158,29 @@ impl<T> Attestation<T> {
11481158
/// Get TDX event log string with RTMR[0-2] payloads stripped to reduce size.
11491159
/// Only digests are kept for boot-time events; runtime events (RTMR3) retain full payload.
11501160
pub fn get_tdx_event_log_string(&self) -> Option<String> {
1161+
self.get_tdx_event_log_string_for_config("")
1162+
}
1163+
1164+
/// Get TDX event log string for a vm_config.
1165+
///
1166+
/// In lite mode, keep the `ACPI DATA` marker payloads in RTMR0 so callers
1167+
/// that still consume the top-level `event_log` can semantically identify
1168+
/// the ACPI table digest events without consulting the versioned
1169+
/// attestation field.
1170+
pub fn get_tdx_event_log_string_for_config(&self, config: &str) -> Option<String> {
11511171
self.tdx_quote().map(|q| {
1152-
let stripped: Vec<_> = q.event_log.iter().map(|e| e.stripped()).collect();
1172+
let keep_lite_acpi_payload = is_tdx_lite_config(config);
1173+
let stripped: Vec<_> = q
1174+
.event_log
1175+
.iter()
1176+
.map(|e| {
1177+
let mut stripped = e.stripped();
1178+
if keep_lite_acpi_payload && is_tdx_acpi_data_event(e) {
1179+
stripped.event_payload = e.event_payload.clone();
1180+
}
1181+
stripped
1182+
})
1183+
.collect();
11531184
serde_json::to_string(&stripped).unwrap_or_default()
11541185
})
11551186
}
@@ -1691,6 +1722,14 @@ impl Attestation {
16911722
.map_err(|_| anyhow!("Quote lock poisoned"))?;
16921723

16931724
let mode = AttestationMode::detect()?;
1725+
let config = match mode {
1726+
AttestationMode::DstackAmdSevSnp
1727+
| AttestationMode::DstackTdx
1728+
| AttestationMode::DstackGcpTdx => {
1729+
read_vm_config().context("Failed to read vm config")?
1730+
}
1731+
AttestationMode::DstackNitroEnclave => String::new(),
1732+
};
16941733
let runtime_events = match mode {
16951734
AttestationMode::DstackTdx | AttestationMode::DstackGcpTdx => {
16961735
RuntimeEvent::read_all().context("Failed to read runtime events")?
@@ -1739,9 +1778,7 @@ impl Attestation {
17391778
let config = match &quote {
17401779
AttestationQuote::DstackAmdSevSnp(_)
17411780
| AttestationQuote::DstackTdx(_)
1742-
| AttestationQuote::DstackGcpTdx(_) => {
1743-
read_vm_config().context("Failed to read vm config")?
1744-
}
1781+
| AttestationQuote::DstackGcpTdx(_) => config,
17451782
AttestationQuote::DstackNitroEnclave(quote) => {
17461783
let os_image_hash = quote
17471784
.decode_image_hash()
@@ -2054,6 +2091,47 @@ mod tests {
20542091
}
20552092
}
20562093

2094+
fn tdx_event(imr: u32, event_type: u32, event_payload: &[u8]) -> TdxEvent {
2095+
TdxEvent {
2096+
imr,
2097+
event_type,
2098+
digest: vec![event_type as u8; 48],
2099+
event: String::new(),
2100+
event_payload: event_payload.to_vec(),
2101+
}
2102+
}
2103+
2104+
#[test]
2105+
fn tdx_event_log_string_for_lite_keeps_acpi_data_payloads() {
2106+
let mut attestation = dummy_tdx_attestation([0u8; 64]);
2107+
let AttestationQuote::DstackTdx(tdx_quote) = &mut attestation.quote else {
2108+
panic!("expected TDX attestation");
2109+
};
2110+
tdx_quote.event_log = vec![
2111+
tdx_event(0, 10, b"ACPI DATA"),
2112+
tdx_event(0, 4, b"boot-payload"),
2113+
tdx_event(3, 8, b"runtime-payload"),
2114+
];
2115+
2116+
let lite_events: Vec<TdxEvent> = serde_json::from_str(
2117+
&attestation
2118+
.get_tdx_event_log_string_for_config(r#"{"tdx_attestation_variant":"lite"}"#)
2119+
.expect("TDX event log"),
2120+
)
2121+
.expect("decode lite event log");
2122+
assert_eq!(lite_events[0].event_payload, b"ACPI DATA");
2123+
assert!(lite_events[1].event_payload.is_empty());
2124+
assert!(lite_events[2].event_payload.is_empty());
2125+
2126+
let legacy_events: Vec<TdxEvent> = serde_json::from_str(
2127+
&attestation
2128+
.get_tdx_event_log_string()
2129+
.expect("TDX event log"),
2130+
)
2131+
.expect("decode legacy event log");
2132+
assert!(legacy_events[0].event_payload.is_empty());
2133+
}
2134+
20572135
#[test]
20582136
fn test_to_report_data_with_hash() {
20592137
let content_type = QuoteContentType::AppData;

0 commit comments

Comments
 (0)