Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
05bc741
feat: add TDX measurement verification mode
kvinwang Jun 26, 2026
687c466
test: add TDX measurement attestation fixture
kvinwang Jun 29, 2026
94aa8ef
test: remove debug flag from TDX measurement fixture
kvinwang Jun 29, 2026
891b4f9
refactor: rename TDX measurement variant to lite
kvinwang Jun 29, 2026
2b07c74
chore: fix clippy warnings
kvinwang Jun 29, 2026
ff3af61
fix: preserve ACPI DATA payload in TDX lite attestations
kvinwang Jun 29, 2026
6d26e73
test: recapture TDX lite fixtures
kvinwang Jun 29, 2026
dc11472
fix: keep lite ACPI payloads in getquote event log
kvinwang Jun 29, 2026
02bb247
fix: omit TDX getquote attestation payload
kvinwang Jun 29, 2026
648e498
fix: require SNP measured kernel cmdline
kvinwang Jun 29, 2026
0bb90b2
fix: make SNP base cmdline mandatory
kvinwang Jun 29, 2026
75397c1
refactor: avoid special casing empty SNP cmdline
kvinwang Jun 29, 2026
4cc8516
refactor: remove stable ovmf variant support
kvinwang Jun 29, 2026
c40c99c
docs: explain tdx lite acpi trust boundary
kvinwang Jun 30, 2026
c384a63
refactor: label tdx lite acpi events
kvinwang Jun 30, 2026
2e410d6
feat(vmm): auto-select TDX attestation variant
kvinwang Jun 30, 2026
53defce
refactor: unify os image hash materials
kvinwang Jun 30, 2026
2a30f8e
feat: base64 measurement material in vm config
kvinwang Jun 30, 2026
f671693
fix: satisfy clippy explicit counter lint
kvinwang Jun 30, 2026
891cdc2
fix: use request vm config for verifier app info
kvinwang Jun 30, 2026
2d73509
fix: keep verifier attestation inputs self-contained
kvinwang Jun 30, 2026
17a95ca
test: add SEV-SNP verifier fixture
kvinwang Jun 30, 2026
dcd5bbf
fix: use async AMD SNP KDS fetch
kvinwang Jun 30, 2026
6b9344b
docs: explain os_image_hash trust binding
kvinwang Jun 30, 2026
be8a23f
Merge remote-tracking branch 'origin/master' into feat/tdx-measuremen…
kvinwang Jun 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 70 additions & 0 deletions cc-eventlog/src/tdx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ use crate::{
tcg::TcgEventLog,
};

pub const TDX_ACPI_DATA_EVENT_TYPE: u32 = 10;
pub const TDX_ACPI_DATA_EVENT_PAYLOAD: &[u8] = b"ACPI DATA";
pub const TDX_ACPI_LOADER_EVENT: &str = "acpi-loader";
pub const TDX_ACPI_RSDP_EVENT: &str = "acpi-rsdp";
pub const TDX_ACPI_TABLES_EVENT: &str = "acpi-tables";
pub const TDX_ACPI_DATA_EVENT_NAMES: [&str; 3] = [
TDX_ACPI_LOADER_EVENT,
TDX_ACPI_RSDP_EVENT,
TDX_ACPI_TABLES_EVENT,
];

/// This is the TDX event log format that is used to store the event log in the TDX guest.
/// It is a simplified version of the TCG event log format, containing only a single digest
/// and the raw event data. The IMR index is zero-based, unlike the TCG event log format
Expand Down Expand Up @@ -97,9 +108,68 @@ impl From<RuntimeEvent> for TdxEvent {
}
}

pub fn is_tdx_acpi_data_event(event: &TdxEvent) -> bool {
event.imr == 0
&& event.event_type == TDX_ACPI_DATA_EVENT_TYPE
&& event.event_payload == TDX_ACPI_DATA_EVENT_PAYLOAD
}

/// Give dstack's three Pre202505 OVMF ACPI DATA RTMR0 events stable semantic
/// names. The firmware event payload is the same "ACPI DATA" marker for all
/// three entries, so the guest labels them before exposing the event log.
pub fn label_tdx_acpi_data_events(event_logs: &mut [TdxEvent]) {
for (acpi_idx, event) in event_logs
.iter_mut()
.filter(|event| is_tdx_acpi_data_event(event))
.enumerate()
{
if let Some(name) = TDX_ACPI_DATA_EVENT_NAMES.get(acpi_idx) {
event.event = (*name).to_string();
}
}
}

/// Read both boottime and runtime event logs.
pub fn read_event_log() -> Result<Vec<TdxEvent>> {
let mut event_logs = TcgEventLog::decode_from_ccel_file()?.to_cc_event_log()?;
label_tdx_acpi_data_events(&mut event_logs);
event_logs.extend(RuntimeEvent::read_all()?.into_iter().map(Into::into));
Ok(event_logs)
}

#[cfg(test)]
mod tests {
use super::*;

fn acpi_data_event(digest_byte: u8) -> TdxEvent {
TdxEvent {
imr: 0,
event_type: TDX_ACPI_DATA_EVENT_TYPE,
digest: vec![digest_byte; 48],
event: String::new(),
event_payload: TDX_ACPI_DATA_EVENT_PAYLOAD.to_vec(),
}
}

#[test]
fn labels_pre202505_acpi_data_events_in_order() {
let mut events = vec![
TdxEvent::new(0, 4, String::new(), vec![0]),
acpi_data_event(1),
acpi_data_event(2),
acpi_data_event(3),
TdxEvent::new(3, DSTACK_RUNTIME_EVENT_TYPE, "app-id".into(), vec![4]),
];

label_tdx_acpi_data_events(&mut events);

let names = events
.iter()
.filter(|event| is_tdx_acpi_data_event(event))
.map(|event| event.event.as_str())
.collect::<Vec<_>>();
assert_eq!(names, TDX_ACPI_DATA_EVENT_NAMES);
assert_eq!(events[0].event, "");
assert_eq!(events[4].event, "app-id");
}
}
4 changes: 2 additions & 2 deletions docs/amd-sev-snp-review-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ After those fixes, the manual smoke progressed through full dstack-managed SNP g
- Configfs TSM report collection falls back to the SEV-SNP extended-report ioctl when configfs does not carry certificate collateral.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.

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

Expand Down
66 changes: 66 additions & 0 deletions docs/security/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,47 @@ dstack implements layered verification from hardware to application. Each layer

**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.

### How `os_image_hash` becomes trusted

The `os_image_hash` carried in `vm_config` is not trusted just because the guest
or host reports it. The verifier first validates the hardware-signed quote, then
uses the quoted measurements to bind `os_image_hash` to the software that
actually booted.

For the full-image TDX path, the verifier obtains the OS image identified by
`os_image_hash`, checks the image checksum manifest, recomputes the expected
MRTD and RTMR0-2 from the image and VM configuration, and requires those values
to match the measurements in the quote. If the host substitutes either the image
hash or the VM configuration, the recomputed measurements no longer match the
quote.

For the no-image-download TDX lite path and the AMD SEV-SNP path,
`os_image_hash` is the unified image identity: `sha256(sha256sum.txt)`. The
`sha256sum.txt` file is the image checksum manifest generated at image build
time. It is a text file whose lines contain a SHA-256 digest and relative file
name for each manifest entry, such as `metadata.json`, the kernel, initrd,
firmware, and the split measurement file. Some launch-critical artifacts are
represented indirectly instead of as direct manifest entries: for example, the
rootfs is committed by the measured `dstack.rootfs_hash` kernel command-line
parameter, and the SEV firmware is committed by `measurement.snp.cbor`. The exact
`sha256sum.txt` bytes are hashed, so the manifest contents, file names, ordering,
and line endings are all part of the image identity.

The attestation carries a copy of the image's `sha256sum.txt` plus the platform
specific measurement material (`measurement.tdx.cbor` or
`measurement.snp.cbor`). The verifier checks that:

1. `sha256(checksum_file) == os_image_hash`;
2. the checksum file contains the expected `measurement.*.cbor` entry and that
entry hashes to the supplied measurement material;
3. the supplied measurement material replays to the hardware-signed TDX
MRTD/RTMR values or SEV-SNP launch `MEASUREMENT`/`HOST_DATA`.

Only after these checks pass does the verifier treat the returned
`os_image_hash` as the measured OS image identity. Downstream authorization
systems can then compare that trusted value against an allowlist or governance
contract.

## Verification Checklist

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

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.

### Why TDX lite mode does not validate ACPI table contents

TDX lite mode verifies the OS image without downloading the image and without
running QEMU to regenerate ACPI tables. It still uses the three RTMR0 `ACPI
DATA` digests from the attestation event log as measurement inputs. The guest
labels those three events as `acpi-loader`, `acpi-rsdp`, and `acpi-tables`
before exposing the event log, and the verifier checks that the recomputed RTMR
values match the hardware-signed quote. What it does not do is reconstruct and
byte-compare the full ACPI table contents.

This is safe for dstack's threat model because ACPI tables are treated as
untrusted host-provided platform description, not as trusted guest code. The
dangerous executable part of ACPI is AML (ACPI Machine Language): malicious AML
can try to use `SystemMemory` operation regions through the Linux ACPICA
interpreter to read or write guest physical memory. dstack kernels include the
BadAML sandbox patch (`0002-acpi-sandbox-block-aml-systemmemory-ram-access.patch`),
which hooks the ACPI `SystemMemory` region handler, walks the guest page tables,
and denies AML access to encrypted/private guest RAM. AML can only access
unencrypted/shared mappings.

Therefore, an infrastructure operator can still provide bad ACPI data and cause
misconfiguration or denial of service, but unvalidated ACPI/AML cannot tamper
with confidential private memory or extract secrets. That residual availability
risk is already outside dstack's confidentiality/integrity guarantees.

### TCB status is surfaced, not gated, during verification

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.
Expand Down
112 changes: 95 additions & 17 deletions dstack-attest/src/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ use tpm_qvl::verify::VerifiedReport as TpmVerifiedReport;
pub use tpm_types::TpmQuote;

use crate::amd_sev_snp::{AmdKdsClient, VerifiedAmdSnpReport};
use crate::v1::{
is_tdx_acpi_data_event, is_tdx_lite_config, strip_tdx_event_log_for_config,
strip_tdx_runtime_event_log,
};
pub use crate::v1::{Attestation as AttestationV1, PlatformEvidence, StackEvidence};

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

/// Strip data for certificate embedding (e.g. keep RTMR3 event logs only).
/// Strip data for certificate embedding.
pub fn into_stripped(self) -> Self {
match self {
Self::V0 { mut attestation } => {
if let Some(tdx_quote) = attestation.tdx_quote_mut() {
tdx_quote.event_log = tdx_quote
.event_log
.iter()
.filter(|e| e.imr == 3)
.map(|e| e.stripped())
.collect();
match &mut attestation.quote {
AttestationQuote::DstackTdx(tdx_quote) => {
tdx_quote.event_log = strip_tdx_event_log_for_config(
std::mem::take(&mut tdx_quote.event_log),
&attestation.config,
);
}
AttestationQuote::DstackGcpTdx(quote) => {
quote.tdx_quote.event_log = strip_tdx_runtime_event_log(std::mem::take(
&mut quote.tdx_quote.event_log,
));
}
AttestationQuote::DstackAmdSevSnp(_)
| AttestationQuote::DstackNitroEnclave(_) => {}
}
Self::V0 { attestation }
}
Expand Down Expand Up @@ -1009,17 +1020,16 @@ pub enum AttestationQuote {
DstackTdx(TdxQuote),
DstackGcpTdx(DstackGcpTdxQuote),
DstackNitroEnclave(DstackNitroQuote),
/// Keep this last to preserve SCALE discriminants for existing variants.
DstackAmdSevSnp(SnpQuote),
}

impl AttestationQuote {
pub fn mode(&self) -> AttestationMode {
match self {
AttestationQuote::DstackTdx { .. } => AttestationMode::DstackTdx,
AttestationQuote::DstackAmdSevSnp { .. } => AttestationMode::DstackAmdSevSnp,
AttestationQuote::DstackGcpTdx { .. } => AttestationMode::DstackGcpTdx,
AttestationQuote::DstackNitroEnclave { .. } => AttestationMode::DstackNitroEnclave,
AttestationQuote::DstackTdx(_) => AttestationMode::DstackTdx,
AttestationQuote::DstackAmdSevSnp(_) => AttestationMode::DstackAmdSevSnp,
AttestationQuote::DstackGcpTdx(_) => AttestationMode::DstackGcpTdx,
AttestationQuote::DstackNitroEnclave(_) => AttestationMode::DstackNitroEnclave,
}
}
}
Expand Down Expand Up @@ -1148,8 +1158,29 @@ impl<T> Attestation<T> {
/// 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<String> {
self.get_tdx_event_log_string_for_config("")
}

/// Get TDX event log string for a vm_config.
///
/// In lite mode, keep the `ACPI DATA` marker payloads in RTMR0 so callers
/// that still consume the top-level `event_log` can semantically identify
/// the ACPI table digest events without consulting the versioned
/// attestation field.
pub fn get_tdx_event_log_string_for_config(&self, config: &str) -> Option<String> {
self.tdx_quote().map(|q| {
let stripped: Vec<_> = q.event_log.iter().map(|e| e.stripped()).collect();
let keep_lite_acpi_payload = is_tdx_lite_config(config);
let stripped: Vec<_> = q
.event_log
.iter()
.map(|e| {
let mut stripped = e.stripped();
if keep_lite_acpi_payload && is_tdx_acpi_data_event(e) {
stripped.event_payload = e.event_payload.clone();
}
stripped
})
.collect();
serde_json::to_string(&stripped).unwrap_or_default()
})
}
Expand Down Expand Up @@ -1691,6 +1722,14 @@ impl Attestation {
.map_err(|_| anyhow!("Quote lock poisoned"))?;

let mode = AttestationMode::detect()?;
let config = match mode {
AttestationMode::DstackAmdSevSnp
| AttestationMode::DstackTdx
| AttestationMode::DstackGcpTdx => {
read_vm_config().context("Failed to read vm config")?
}
AttestationMode::DstackNitroEnclave => String::new(),
};
let runtime_events = match mode {
AttestationMode::DstackTdx | AttestationMode::DstackGcpTdx => {
RuntimeEvent::read_all().context("Failed to read runtime events")?
Expand Down Expand Up @@ -1739,9 +1778,7 @@ impl Attestation {
let config = match &quote {
AttestationQuote::DstackAmdSevSnp(_)
| AttestationQuote::DstackTdx(_)
| AttestationQuote::DstackGcpTdx(_) => {
read_vm_config().context("Failed to read vm config")?
}
| AttestationQuote::DstackGcpTdx(_) => config,
AttestationQuote::DstackNitroEnclave(quote) => {
let os_image_hash = quote
.decode_image_hash()
Expand Down Expand Up @@ -2054,6 +2091,47 @@ mod tests {
}
}

fn tdx_event(imr: u32, event_type: u32, event_payload: &[u8]) -> TdxEvent {
TdxEvent {
imr,
event_type,
digest: vec![event_type as u8; 48],
event: String::new(),
event_payload: event_payload.to_vec(),
}
}

#[test]
fn tdx_event_log_string_for_lite_keeps_acpi_data_payloads() {
let mut attestation = dummy_tdx_attestation([0u8; 64]);
let AttestationQuote::DstackTdx(tdx_quote) = &mut attestation.quote else {
panic!("expected TDX attestation");
};
tdx_quote.event_log = vec![
tdx_event(0, 10, b"ACPI DATA"),
tdx_event(0, 4, b"boot-payload"),
tdx_event(3, 8, b"runtime-payload"),
];

let lite_events: Vec<TdxEvent> = serde_json::from_str(
&attestation
.get_tdx_event_log_string_for_config(r#"{"tdx_attestation_variant":"lite"}"#)
.expect("TDX event log"),
)
.expect("decode lite event log");
assert_eq!(lite_events[0].event_payload, b"ACPI DATA");
assert!(lite_events[1].event_payload.is_empty());
assert!(lite_events[2].event_payload.is_empty());

let legacy_events: Vec<TdxEvent> = serde_json::from_str(
&attestation
.get_tdx_event_log_string()
.expect("TDX event log"),
)
.expect("decode legacy event log");
assert!(legacy_events[0].event_payload.is_empty());
}

#[test]
fn test_to_report_data_with_hash() {
let content_type = QuoteContentType::AppData;
Expand Down
Loading
Loading