Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 24 additions & 2 deletions dstack-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,18 @@ pub struct Requirements {
/// an explicit empty list means no platform is allowed.
#[serde(skip_serializing_if = "Option::is_none")]
pub platforms: Option<Vec<String>>,
/// TDX-only ACPI table measurement requirement. When set, this overrides
/// the VMM-side TDX lite attestation policy: `true` requires legacy mode
/// with ACPI tables measured, while `false` requires lite mode.
#[serde(skip_serializing_if = "Option::is_none")]
pub tdx_measure_acpi_tables: Option<bool>,
}

impl Requirements {
pub fn is_empty(&self) -> bool {
self.os_version.is_none() && self.platforms.is_none()
self.os_version.is_none()
&& self.platforms.is_none()
&& self.tdx_measure_acpi_tables.is_none()
}
}

Expand Down Expand Up @@ -381,7 +388,8 @@ mod app_compose_tests {
"runner": "docker-compose",
"requirements": {
"os_version": ">=0.6.1",
"platforms": ["dstack-gcp-tdx", "dstack-tdx"]
"platforms": ["dstack-gcp-tdx", "dstack-tdx"],
"tdx_measure_acpi_tables": true
}
}))
.unwrap();
Expand All @@ -391,6 +399,7 @@ mod app_compose_tests {
requirements.platforms,
Some(vec!["dstack-gcp-tdx".to_string(), "dstack-tdx".to_string()])
);
assert_eq!(requirements.tdx_measure_acpi_tables, Some(true));

let err = serde_json::from_value::<AppCompose>(serde_json::json!({
"manifest_version": "3",
Expand Down Expand Up @@ -429,6 +438,19 @@ mod app_compose_tests {
let requirements = explicit_empty.requirements.as_ref().unwrap();
assert_eq!(requirements.platforms, Some(vec![]));
assert!(!requirements.is_empty());

let acpi_tables: AppCompose = serde_json::from_value(serde_json::json!({
"manifest_version": "3",
"name": "test",
"runner": "docker-compose",
"requirements": {
"tdx_measure_acpi_tables": false
}
}))
.unwrap();
let requirements = acpi_tables.requirements.as_ref().unwrap();
assert_eq!(requirements.tdx_measure_acpi_tables, Some(false));
assert!(!requirements.is_empty());
}
}

Expand Down
114 changes: 111 additions & 3 deletions dstack-util/src/system_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,7 @@ fn verify_manifest_version(app_compose: &AppCompose) -> Result<u32> {
Ok(manifest_version)
}

fn verify_app_compose_policy(app_compose: &AppCompose) -> Result<()> {
fn verify_app_compose_policy(app_compose: &AppCompose, sys_config: &SysConfig) -> Result<()> {
verify_manifest_feature_requirements(app_compose)?;
let Some(requirements) = app_compose.requirements.as_ref() else {
return Ok(());
Expand All @@ -789,9 +789,18 @@ fn verify_app_compose_policy(app_compose: &AppCompose) -> Result<()> {
bail!("Unsupported attestation platform: requirements.platforms is empty");
}
let current_platform =
AttestationMode::detect().context("Failed to detect current attestation platform")?;
AttestationMode::detect().context("failed to detect current attestation platform")?;
verify_platform_requirements(app_compose, current_platform)?;
}
if requirements.tdx_measure_acpi_tables.is_some() {
let current_platform =
AttestationMode::detect().context("failed to detect current attestation platform")?;
verify_tdx_measure_acpi_tables_requirement(
app_compose,
&sys_config.vm_config,
current_platform,
)?;
}
Ok(())
}

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

fn verify_tdx_measure_acpi_tables_requirement(
app_compose: &AppCompose,
vm_config: &str,
current_platform: AttestationMode,
) -> Result<()> {
let Some(measure_acpi_tables) = app_compose
.requirements
.as_ref()
.and_then(|requirements| requirements.tdx_measure_acpi_tables)
else {
return Ok(());
};
if current_platform != AttestationMode::DstackTdx {
return Ok(());
}
Comment thread
kvinwang marked this conversation as resolved.
let vm_config: dstack_types::VmConfig = serde_json::from_str(vm_config)
.context("failed to parse vm_config for requirements.tdx_measure_acpi_tables")?;
let uses_lite = vm_config.tdx_attestation_variant.is_lite();
if measure_acpi_tables && uses_lite {
bail!(
"unsupported TDX attestation mode: requirements.tdx_measure_acpi_tables=true requires ACPI table measurement"
);
}
if !measure_acpi_tables && !uses_lite {
bail!(
"unsupported TDX attestation mode: requirements.tdx_measure_acpi_tables=false requires TDX lite attestation"
);
}
info!(
"tdx ACPI table measurement requirement satisfied: measure_acpi_tables={}",
measure_acpi_tables
);
Ok(())
}

fn read_current_os_version() -> Result<String> {
const OS_RELEASE_PATHS: &[&str] = &["/etc/os-release", "/usr/lib/os-release"];
for path in OS_RELEASE_PATHS {
Expand Down Expand Up @@ -930,7 +974,7 @@ pub async fn cmd_sys_setup(args: SetupArgs) -> Result<()> {
}

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

#[test]
fn test_tdx_measure_acpi_tables_requirement_matches_vm_config() {
let app_compose: AppCompose = serde_json::from_value(serde_json::json!({
"manifest_version": "3",
"name": "test",
"runner": "docker-compose",
"requirements": {
"tdx_measure_acpi_tables": true
}
}))
.unwrap();
verify_tdx_measure_acpi_tables_requirement(&app_compose, r#"{}"#, AttestationMode::DstackTdx)
.unwrap();
let err = verify_tdx_measure_acpi_tables_requirement(
&app_compose,
r#"{"tdx_attestation_variant":"lite"}"#,
AttestationMode::DstackTdx,
)
.unwrap_err();
assert!(err.to_string().contains("tdx_measure_acpi_tables=true"));

let app_compose: AppCompose = serde_json::from_value(serde_json::json!({
"manifest_version": "3",
"name": "test",
"runner": "docker-compose",
"requirements": {
"tdx_measure_acpi_tables": false
}
}))
.unwrap();
verify_tdx_measure_acpi_tables_requirement(
&app_compose,
r#"{"tdx_attestation_variant":"lite"}"#,
AttestationMode::DstackTdx,
)
.unwrap();
let err = verify_tdx_measure_acpi_tables_requirement(
&app_compose,
r#"{}"#,
AttestationMode::DstackTdx,
)
.unwrap_err();
assert!(err.to_string().contains("tdx_measure_acpi_tables=false"));
}

#[test]
fn test_tdx_measure_acpi_tables_requirement_ignored_on_non_tdx() {
let app_compose: AppCompose = serde_json::from_value(serde_json::json!({
"manifest_version": "3",
"name": "test",
"runner": "docker-compose",
"requirements": {
"tdx_measure_acpi_tables": true
}
}))
.unwrap();
verify_tdx_measure_acpi_tables_requirement(
&app_compose,
r#"{"tdx_attestation_variant":"lite"}"#,
AttestationMode::DstackAmdSevSnp,
)
.unwrap();
}

#[test]
fn test_os_release_value_parses_quoted_version_id() {
let content = r#"
Expand Down
5 changes: 3 additions & 2 deletions sdk/go/dstack/compose_hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ const (

// Requirements represents guest-side requirements.
type Requirements struct {
OsVersion string `json:"os_version,omitempty"`
Platforms *[]RequirementPlatform `json:"platforms,omitempty"`
OsVersion string `json:"os_version,omitempty"`
Platforms *[]RequirementPlatform `json:"platforms,omitempty"`
TdxMeasureAcpiTables *bool `json:"tdx_measure_acpi_tables,omitempty"`
}

// AppCompose represents the application composition structure
Expand Down
1 change: 1 addition & 0 deletions sdk/js/src/get-compose-hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface DockerConfig extends SortableObject {
export interface Requirements extends SortableObject {
os_version?: string;
platforms?: RequirementPlatform[];
tdx_measure_acpi_tables?: boolean;
}

export interface AppCompose extends SortableObject {
Expand Down
4 changes: 4 additions & 0 deletions sdk/python/src/dstack_sdk/get_compose_hash.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ def __init__(
self,
os_version: Optional[str] = None,
platforms: Optional[List[str]] = None,
tdx_measure_acpi_tables: Optional[bool] = None,
) -> None:
"""Initialize a new ``Requirements`` instance."""
self.os_version = os_version
self.platforms = platforms
self.tdx_measure_acpi_tables = tdx_measure_acpi_tables

def to_dict(self) -> Dict[str, Any]:
"""Return a dictionary representation excluding ``None`` fields."""
Expand All @@ -65,6 +67,8 @@ def to_dict(self) -> Dict[str, Any]:
result["os_version"] = self.os_version
if self.platforms is not None:
result["platforms"] = self.platforms
if self.tdx_measure_acpi_tables is not None:
result["tdx_measure_acpi_tables"] = self.tdx_measure_acpi_tables
return result


Expand Down
5 changes: 5 additions & 0 deletions verifier/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ or
"quote_verified": true,
"event_log_verified": true, // See "Verification Process" for semantics
"os_image_hash_verified": true,
"acpi_tables_verified": true, // true only when TDX ACPI table contents are verified
"os_image_is_dev": false, // true=dev image, false=prod, null=unknown/N/A
"os_image_version": "0.5.10", // dstack OS version, null if unknown
"attestation_mode": "dstack-tdx", // dstack-tdx | dstack-gcp-tdx | dstack-nitro-enclave | dstack-amd-sev-snp
Expand Down Expand Up @@ -154,6 +155,7 @@ $ curl -s -d @quote.json localhost:8080/verify | jq
"quote_verified": true,
"event_log_verified": true,
"os_image_hash_verified": true,
"acpi_tables_verified": true,
"report_data": "12340000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"tcb_status": "UpToDate",
"advisory_ids": [],
Expand Down Expand Up @@ -188,6 +190,8 @@ The verifier performs three main verification steps:
- 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
- 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

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

All three steps must pass for the verification to be considered valid.

### Identifying the deployment
Expand All @@ -197,6 +201,7 @@ Beyond pass/fail, the result carries a few descriptive fields so a relying party
- **`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.
- **`os_image_version`** — the dstack OS version (e.g. `0.5.10`), useful for enforcing a minimum version.
- **`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`.
- **`acpi_tables_verified`** — whether TDX ACPI table contents were verified. This is useful for relying parties that require `requirements.tdx_measure_acpi_tables = true`.
- **`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`.

`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).
3 changes: 2 additions & 1 deletion verifier/fixtures/tdx-lite.README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,5 @@ dstack-verifier --config verifier-no-download.toml \
```

Expected result: `Valid: true`, with quote, event log, and OS image hash all
verified.
verified, and `ACPI tables verified: false` because lite mode does not validate
ACPI table contents.
4 changes: 4 additions & 0 deletions verifier/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ async fn run_oneshot(file_path: &str, config: &Config) -> anyhow::Result<()> {
"OS image hash verified: {}",
response.details.os_image_hash_verified
);
println!(
"ACPI tables verified: {}",
response.details.acpi_tables_verified
);

if let Some(tcb_status) = &response.details.tcb_status {
println!("TCB status: {}", tcb_status);
Expand Down
7 changes: 7 additions & 0 deletions verifier/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ pub struct VerificationDetails {
/// event log payloads.
pub event_log_verified: bool,
pub os_image_hash_verified: bool,
/// Indicates that TDX ACPI table contents were verified.
///
/// This is true for the full-image TDX path, where the verifier recomputes
/// ACPI tables and checks the resulting RTMRs against the quote. It remains
/// false for TDX lite, which replays ACPI DATA digests from the event log
/// without validating the table contents.
pub acpi_tables_verified: bool,
/// dev vs prod OS image, from metadata.json (bound to os_image_hash). None if not exposed.
pub os_image_is_dev: Option<bool>,
/// dstack OS version, from the same metadata.json.
Expand Down
35 changes: 34 additions & 1 deletion verifier/src/verification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -794,7 +794,9 @@ impl CvmVerifier {
event_log,
debug,
details,
)
)?;
details.acpi_tables_verified = true;
Ok(())
}

async fn verify_os_image_hash_for_dstack_tdx_lite(
Expand Down Expand Up @@ -1264,6 +1266,7 @@ mod tests {
assert!(response.details.quote_verified);
assert!(response.details.event_log_verified);
assert!(response.details.os_image_hash_verified);
assert!(!response.details.acpi_tables_verified);
assert_eq!(
response.details.attestation_mode,
Some(ra_tls::attestation::AttestationMode::DstackAmdSevSnp)
Expand Down Expand Up @@ -1297,4 +1300,34 @@ mod tests {
Some(ra_tls::attestation::AttestationMode::DstackAmdSevSnp)
);
}

#[tokio::test]
async fn verifies_tdx_lite_fixture_without_acpi_table_verification() {
let request: VerificationRequest =
serde_json::from_str(include_str!("../fixtures/tdx-lite-attestation.json"))
.expect("TDX lite verifier fixture parses");
let cache = tempfile::tempdir().expect("temp cache dir");
let image_cache_dir = cache.path().join("cache");
let verifier = CvmVerifier::new(
image_cache_dir.display().to_string(),
"http://127.0.0.1:9/should-not-download/{OS_IMAGE_HASH}.tar.gz".to_string(),
Duration::from_secs(1),
None,
);

let response = verifier.verify(request).await.expect("verifier runs");
assert!(response.is_valid, "{:?}", response.reason);
assert!(response.details.quote_verified);
assert!(response.details.event_log_verified);
assert!(response.details.os_image_hash_verified);
assert!(!response.details.acpi_tables_verified);
assert_eq!(
response.details.attestation_mode,
Some(ra_tls::attestation::AttestationMode::DstackTdx)
);
assert!(
!image_cache_dir.exists(),
"TDX lite verification must not download or cache OS images"
);
}
}
Loading
Loading