Skip to content

Commit c9a18db

Browse files
authored
Merge pull request #762 from Dstack-TEE/feat/tdx-measure-acpi-requirement
feat(vmm): support tdx acpi table requirement
2 parents 3da9a08 + d3624ec commit c9a18db

13 files changed

Lines changed: 292 additions & 20 deletions

File tree

dstack-types/src/lib.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,18 @@ pub struct Requirements {
126126
/// an explicit empty list means no platform is allowed.
127127
#[serde(skip_serializing_if = "Option::is_none")]
128128
pub platforms: Option<Vec<String>>,
129+
/// TDX-only ACPI table measurement requirement. When set, this overrides
130+
/// the VMM-side TDX lite attestation policy: `true` requires legacy mode
131+
/// with ACPI tables measured, while `false` requires lite mode.
132+
#[serde(skip_serializing_if = "Option::is_none")]
133+
pub tdx_measure_acpi_tables: Option<bool>,
129134
}
130135

131136
impl Requirements {
132137
pub fn is_empty(&self) -> bool {
133-
self.os_version.is_none() && self.platforms.is_none()
138+
self.os_version.is_none()
139+
&& self.platforms.is_none()
140+
&& self.tdx_measure_acpi_tables.is_none()
134141
}
135142
}
136143

@@ -381,7 +388,8 @@ mod app_compose_tests {
381388
"runner": "docker-compose",
382389
"requirements": {
383390
"os_version": ">=0.6.1",
384-
"platforms": ["dstack-gcp-tdx", "dstack-tdx"]
391+
"platforms": ["dstack-gcp-tdx", "dstack-tdx"],
392+
"tdx_measure_acpi_tables": true
385393
}
386394
}))
387395
.unwrap();
@@ -391,6 +399,7 @@ mod app_compose_tests {
391399
requirements.platforms,
392400
Some(vec!["dstack-gcp-tdx".to_string(), "dstack-tdx".to_string()])
393401
);
402+
assert_eq!(requirements.tdx_measure_acpi_tables, Some(true));
394403

395404
let err = serde_json::from_value::<AppCompose>(serde_json::json!({
396405
"manifest_version": "3",
@@ -429,6 +438,19 @@ mod app_compose_tests {
429438
let requirements = explicit_empty.requirements.as_ref().unwrap();
430439
assert_eq!(requirements.platforms, Some(vec![]));
431440
assert!(!requirements.is_empty());
441+
442+
let acpi_tables: AppCompose = serde_json::from_value(serde_json::json!({
443+
"manifest_version": "3",
444+
"name": "test",
445+
"runner": "docker-compose",
446+
"requirements": {
447+
"tdx_measure_acpi_tables": false
448+
}
449+
}))
450+
.unwrap();
451+
let requirements = acpi_tables.requirements.as_ref().unwrap();
452+
assert_eq!(requirements.tdx_measure_acpi_tables, Some(false));
453+
assert!(!requirements.is_empty());
432454
}
433455
}
434456

dstack-util/src/system_setup.rs

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -774,7 +774,7 @@ fn verify_manifest_version(app_compose: &AppCompose) -> Result<u32> {
774774
Ok(manifest_version)
775775
}
776776

777-
fn verify_app_compose_policy(app_compose: &AppCompose) -> Result<()> {
777+
fn verify_app_compose_policy(app_compose: &AppCompose, sys_config: &SysConfig) -> Result<()> {
778778
verify_manifest_feature_requirements(app_compose)?;
779779
let Some(requirements) = app_compose.requirements.as_ref() else {
780780
return Ok(());
@@ -789,9 +789,18 @@ fn verify_app_compose_policy(app_compose: &AppCompose) -> Result<()> {
789789
bail!("Unsupported attestation platform: requirements.platforms is empty");
790790
}
791791
let current_platform =
792-
AttestationMode::detect().context("Failed to detect current attestation platform")?;
792+
AttestationMode::detect().context("failed to detect current attestation platform")?;
793793
verify_platform_requirements(app_compose, current_platform)?;
794794
}
795+
if requirements.tdx_measure_acpi_tables.is_some() {
796+
let current_platform =
797+
AttestationMode::detect().context("failed to detect current attestation platform")?;
798+
verify_tdx_measure_acpi_tables_requirement(
799+
app_compose,
800+
&sys_config.vm_config,
801+
current_platform,
802+
)?;
803+
}
795804
Ok(())
796805
}
797806

@@ -867,6 +876,41 @@ fn format_requirement_platforms(platforms: &[String]) -> String {
867876
platforms.join(", ")
868877
}
869878

879+
fn verify_tdx_measure_acpi_tables_requirement(
880+
app_compose: &AppCompose,
881+
vm_config: &str,
882+
current_platform: AttestationMode,
883+
) -> Result<()> {
884+
let Some(measure_acpi_tables) = app_compose
885+
.requirements
886+
.as_ref()
887+
.and_then(|requirements| requirements.tdx_measure_acpi_tables)
888+
else {
889+
return Ok(());
890+
};
891+
if current_platform != AttestationMode::DstackTdx {
892+
return Ok(());
893+
}
894+
let vm_config: dstack_types::VmConfig = serde_json::from_str(vm_config)
895+
.context("failed to parse vm_config for requirements.tdx_measure_acpi_tables")?;
896+
let uses_lite = vm_config.tdx_attestation_variant.is_lite();
897+
if measure_acpi_tables && uses_lite {
898+
bail!(
899+
"unsupported TDX attestation mode: requirements.tdx_measure_acpi_tables=true requires ACPI table measurement"
900+
);
901+
}
902+
if !measure_acpi_tables && !uses_lite {
903+
bail!(
904+
"unsupported TDX attestation mode: requirements.tdx_measure_acpi_tables=false requires TDX lite attestation"
905+
);
906+
}
907+
info!(
908+
"tdx ACPI table measurement requirement satisfied: measure_acpi_tables={}",
909+
measure_acpi_tables
910+
);
911+
Ok(())
912+
}
913+
870914
fn read_current_os_version() -> Result<String> {
871915
const OS_RELEASE_PATHS: &[&str] = &["/etc/os-release", "/usr/lib/os-release"];
872916
for path in OS_RELEASE_PATHS {
@@ -930,7 +974,7 @@ pub async fn cmd_sys_setup(args: SetupArgs) -> Result<()> {
930974
}
931975

932976
async fn do_sys_setup(stage0: Stage0<'_>) -> Result<()> {
933-
verify_app_compose_policy(&stage0.shared.app_compose)
977+
verify_app_compose_policy(&stage0.shared.app_compose, &stage0.shared.sys_config)
934978
.context("Failed to verify app-compose policy")?;
935979
if stage0.shared.app_compose.secure_time {
936980
info!("Waiting for the system time to be synchronized");
@@ -2198,6 +2242,70 @@ fn test_platform_requirements_reject_invalid_platform_value() {
21982242
.contains("Invalid requirements.platforms[0]"));
21992243
}
22002244

2245+
#[test]
2246+
fn test_tdx_measure_acpi_tables_requirement_matches_vm_config() {
2247+
let app_compose: AppCompose = serde_json::from_value(serde_json::json!({
2248+
"manifest_version": "3",
2249+
"name": "test",
2250+
"runner": "docker-compose",
2251+
"requirements": {
2252+
"tdx_measure_acpi_tables": true
2253+
}
2254+
}))
2255+
.unwrap();
2256+
verify_tdx_measure_acpi_tables_requirement(&app_compose, r#"{}"#, AttestationMode::DstackTdx)
2257+
.unwrap();
2258+
let err = verify_tdx_measure_acpi_tables_requirement(
2259+
&app_compose,
2260+
r#"{"tdx_attestation_variant":"lite"}"#,
2261+
AttestationMode::DstackTdx,
2262+
)
2263+
.unwrap_err();
2264+
assert!(err.to_string().contains("tdx_measure_acpi_tables=true"));
2265+
2266+
let app_compose: AppCompose = serde_json::from_value(serde_json::json!({
2267+
"manifest_version": "3",
2268+
"name": "test",
2269+
"runner": "docker-compose",
2270+
"requirements": {
2271+
"tdx_measure_acpi_tables": false
2272+
}
2273+
}))
2274+
.unwrap();
2275+
verify_tdx_measure_acpi_tables_requirement(
2276+
&app_compose,
2277+
r#"{"tdx_attestation_variant":"lite"}"#,
2278+
AttestationMode::DstackTdx,
2279+
)
2280+
.unwrap();
2281+
let err = verify_tdx_measure_acpi_tables_requirement(
2282+
&app_compose,
2283+
r#"{}"#,
2284+
AttestationMode::DstackTdx,
2285+
)
2286+
.unwrap_err();
2287+
assert!(err.to_string().contains("tdx_measure_acpi_tables=false"));
2288+
}
2289+
2290+
#[test]
2291+
fn test_tdx_measure_acpi_tables_requirement_ignored_on_non_tdx() {
2292+
let app_compose: AppCompose = serde_json::from_value(serde_json::json!({
2293+
"manifest_version": "3",
2294+
"name": "test",
2295+
"runner": "docker-compose",
2296+
"requirements": {
2297+
"tdx_measure_acpi_tables": true
2298+
}
2299+
}))
2300+
.unwrap();
2301+
verify_tdx_measure_acpi_tables_requirement(
2302+
&app_compose,
2303+
r#"{"tdx_attestation_variant":"lite"}"#,
2304+
AttestationMode::DstackAmdSevSnp,
2305+
)
2306+
.unwrap();
2307+
}
2308+
22012309
#[test]
22022310
fn test_os_release_value_parses_quoted_version_id() {
22032311
let content = r#"

sdk/go/dstack/compose_hash.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ const (
3939

4040
// Requirements represents guest-side requirements.
4141
type Requirements struct {
42-
OsVersion string `json:"os_version,omitempty"`
43-
Platforms *[]RequirementPlatform `json:"platforms,omitempty"`
42+
OsVersion string `json:"os_version,omitempty"`
43+
Platforms *[]RequirementPlatform `json:"platforms,omitempty"`
44+
TdxMeasureAcpiTables *bool `json:"tdx_measure_acpi_tables,omitempty"`
4445
}
4546

4647
// AppCompose represents the application composition structure

sdk/js/src/get-compose-hash.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export interface DockerConfig extends SortableObject {
5151
export interface Requirements extends SortableObject {
5252
os_version?: string;
5353
platforms?: RequirementPlatform[];
54+
tdx_measure_acpi_tables?: boolean;
5455
}
5556

5657
export interface AppCompose extends SortableObject {

sdk/python/src/dstack_sdk/get_compose_hash.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,12 @@ def __init__(
5353
self,
5454
os_version: Optional[str] = None,
5555
platforms: Optional[List[str]] = None,
56+
tdx_measure_acpi_tables: Optional[bool] = None,
5657
) -> None:
5758
"""Initialize a new ``Requirements`` instance."""
5859
self.os_version = os_version
5960
self.platforms = platforms
61+
self.tdx_measure_acpi_tables = tdx_measure_acpi_tables
6062

6163
def to_dict(self) -> Dict[str, Any]:
6264
"""Return a dictionary representation excluding ``None`` fields."""
@@ -65,6 +67,8 @@ def to_dict(self) -> Dict[str, Any]:
6567
result["os_version"] = self.os_version
6668
if self.platforms is not None:
6769
result["platforms"] = self.platforms
70+
if self.tdx_measure_acpi_tables is not None:
71+
result["tdx_measure_acpi_tables"] = self.tdx_measure_acpi_tables
6872
return result
6973

7074

verifier/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ or
3333
"quote_verified": true,
3434
"event_log_verified": true, // See "Verification Process" for semantics
3535
"os_image_hash_verified": true,
36+
"acpi_tables_verified": true, // true only when TDX ACPI table contents are verified
3637
"os_image_is_dev": false, // true=dev image, false=prod, null=unknown/N/A
3738
"os_image_version": "0.5.10", // dstack OS version, null if unknown
3839
"attestation_mode": "dstack-tdx", // dstack-tdx | dstack-gcp-tdx | dstack-nitro-enclave | dstack-amd-sev-snp
@@ -154,6 +155,7 @@ $ curl -s -d @quote.json localhost:8080/verify | jq
154155
"quote_verified": true,
155156
"event_log_verified": true,
156157
"os_image_hash_verified": true,
158+
"acpi_tables_verified": true,
157159
"report_data": "12340000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
158160
"tcb_status": "UpToDate",
159161
"advisory_ids": [],
@@ -188,6 +190,8 @@ The verifier performs three main verification steps:
188190
- For the full-image TDX path, downloads or loads the image identified by `os_image_hash`, checks the image checksum manifest, uses dstack-mr to compute expected MRTD/RTMR0-2, and compares them against the verified measurements from the quote
189191
- For TDX lite, AMD SEV-SNP, and GCP TDX, verifies that `os_image_hash = sha256(sha256sum.txt)`, where `sha256sum.txt` is the image build's checksum manifest (`<sha256> <relative-file-name>` lines for image files), that the manifest entry for `measurement.tdx.cbor`, `measurement.snp.cbor`, or `measurement.gcp.cbor` matches the supplied measurement material, and that the measurement material replays to the quote's hardware-signed measurements or GCP TPM UKI event
190192

193+
`details.acpi_tables_verified` is `true` only for the full-image TDX path, where the verifier recomputes ACPI table contents and checks the resulting RTMRs against the quote. It is `false` for TDX lite, which uses the quote's named ACPI DATA digests without validating table contents, and for non-TDX platforms where ACPI table verification is not applicable.
194+
191195
All three steps must pass for the verification to be considered valid.
192196

193197
### Identifying the deployment
@@ -197,6 +201,7 @@ Beyond pass/fail, the result carries a few descriptive fields so a relying party
197201
- **`os_image_is_dev`** — `true` for a development OS image, `false` for production. Dev images are built for local testing and are not hardened for production use, so a relying party generally wants to reject them.
198202
- **`os_image_version`** — the dstack OS version (e.g. `0.5.10`), useful for enforcing a minimum version.
199203
- **`attestation_mode`** — the attestation mode that produced the verified quote, serialized as `AttestationMode`: `dstack-tdx`, `dstack-gcp-tdx`, `dstack-nitro-enclave`, or `dstack-amd-sev-snp`.
204+
- **`acpi_tables_verified`** — whether TDX ACPI table contents were verified. This is useful for relying parties that require `requirements.tdx_measure_acpi_tables = true`.
200205
- **`key_provider`** — the decoded `app_info.key_provider_info` (`{name, id}`); `name` is e.g. `kms` or `local`. A `local` key provider means the CVM is not KMS-backed, which is itself a dev/insecure posture signal. The raw bytes remain in `app_info.key_provider_info`.
201206

202207
`os_image_is_dev` and `os_image_version` are read from the image's `metadata.json`, which is part of `sha256sum.txt` and therefore bound to the `os_image_hash` that step 3 verifies against the quote — so they are as trustworthy as the os-image-hash check itself. They are `null` when the platform does not expose them (e.g. GCP TDX / Nitro Enclave) or when the image predates the field (images without `is_dev` are always production).

verifier/fixtures/tdx-lite.README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,5 @@ dstack-verifier --config verifier-no-download.toml \
6262
```
6363

6464
Expected result: `Valid: true`, with quote, event log, and OS image hash all
65-
verified.
65+
verified, and `ACPI tables verified: false` because lite mode does not validate
66+
ACPI table contents.

verifier/src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ async fn run_oneshot(file_path: &str, config: &Config) -> anyhow::Result<()> {
115115
"OS image hash verified: {}",
116116
response.details.os_image_hash_verified
117117
);
118+
println!(
119+
"ACPI tables verified: {}",
120+
response.details.acpi_tables_verified
121+
);
118122

119123
if let Some(tcb_status) = &response.details.tcb_status {
120124
println!("TCB status: {}", tcb_status);

verifier/src/types.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ pub struct VerificationDetails {
4141
/// event log payloads.
4242
pub event_log_verified: bool,
4343
pub os_image_hash_verified: bool,
44+
/// Indicates that TDX ACPI table contents were verified.
45+
///
46+
/// This is true for the full-image TDX path, where the verifier recomputes
47+
/// ACPI tables and checks the resulting RTMRs against the quote. It remains
48+
/// false for TDX lite, which replays ACPI DATA digests from the event log
49+
/// without validating the table contents.
50+
pub acpi_tables_verified: bool,
4451
/// dev vs prod OS image, from metadata.json (bound to os_image_hash). None if not exposed.
4552
pub os_image_is_dev: Option<bool>,
4653
/// dstack OS version, from the same metadata.json.

verifier/src/verification.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,9 @@ impl CvmVerifier {
794794
event_log,
795795
debug,
796796
details,
797-
)
797+
)?;
798+
details.acpi_tables_verified = true;
799+
Ok(())
798800
}
799801

800802
async fn verify_os_image_hash_for_dstack_tdx_lite(
@@ -1264,6 +1266,7 @@ mod tests {
12641266
assert!(response.details.quote_verified);
12651267
assert!(response.details.event_log_verified);
12661268
assert!(response.details.os_image_hash_verified);
1269+
assert!(!response.details.acpi_tables_verified);
12671270
assert_eq!(
12681271
response.details.attestation_mode,
12691272
Some(ra_tls::attestation::AttestationMode::DstackAmdSevSnp)
@@ -1297,4 +1300,34 @@ mod tests {
12971300
Some(ra_tls::attestation::AttestationMode::DstackAmdSevSnp)
12981301
);
12991302
}
1303+
1304+
#[tokio::test]
1305+
async fn verifies_tdx_lite_fixture_without_acpi_table_verification() {
1306+
let request: VerificationRequest =
1307+
serde_json::from_str(include_str!("../fixtures/tdx-lite-attestation.json"))
1308+
.expect("TDX lite verifier fixture parses");
1309+
let cache = tempfile::tempdir().expect("temp cache dir");
1310+
let image_cache_dir = cache.path().join("cache");
1311+
let verifier = CvmVerifier::new(
1312+
image_cache_dir.display().to_string(),
1313+
"http://127.0.0.1:9/should-not-download/{OS_IMAGE_HASH}.tar.gz".to_string(),
1314+
Duration::from_secs(1),
1315+
None,
1316+
);
1317+
1318+
let response = verifier.verify(request).await.expect("verifier runs");
1319+
assert!(response.is_valid, "{:?}", response.reason);
1320+
assert!(response.details.quote_verified);
1321+
assert!(response.details.event_log_verified);
1322+
assert!(response.details.os_image_hash_verified);
1323+
assert!(!response.details.acpi_tables_verified);
1324+
assert_eq!(
1325+
response.details.attestation_mode,
1326+
Some(ra_tls::attestation::AttestationMode::DstackTdx)
1327+
);
1328+
assert!(
1329+
!image_cache_dir.exists(),
1330+
"TDX lite verification must not download or cache OS images"
1331+
);
1332+
}
13001333
}

0 commit comments

Comments
 (0)