Skip to content
Open
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
14 changes: 14 additions & 0 deletions docs/security/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ Use this checklist to verify a workload running in a dstack CVM.
- [ ] Launch event log replays correctly (RTMR3 on TDX-family platforms, PCR14 on AWS NitroTPM)
- [ ] Config commitment matches the expected app/config target (on AWS: PCR14 replay; PCR8 is an optional shortcut — see the [AWS verifier runbook](../aws-ec2-production-verifier-runbook.md))
- [ ] reportData contains your challenge (replay protection)
- [ ] A `report_data` binding to a public key is treated as evidence only together with a live handshake or signature over that key — see [`report_data` domain tags are a parsing convention, not a capability](#report_data-domain-tags-are-a-parsing-convention-not-a-capability)
- [ ] No security-relevant check depends on `pre_launch_script` running before the application; such checks belong in `init_script` or in the application itself

**GPU verification (when required):**
Expand Down Expand Up @@ -377,6 +378,19 @@ The one case dstack does not leave to downstream is a genuinely invalid TCB: `dc

> **Future work:** this will be refactored toward a grace-period model, where an out-of-date TCB is accepted for a bounded window after a new TCB level is published rather than being a binary downstream decision.

### `report_data` domain tags are a parsing convention, not a capability

Every quote surface a container can reach lets it choose `report_data` outright. `DstackGuest.GetQuote`, `DstackGuest.Attest` and v1 `Attest` take up to 64 bytes and use them verbatim; the legacy `Tappd.TdxQuote` additionally takes a `prefix`, which maps to `QuoteContentType::Custom(prefix)` and only saves the caller from computing the hash itself. So an application can obtain a hardware-signed quote whose `report_data` is exactly `sha512("ratls-cert:" || <some SubjectPublicKeyInfo>)`, or `sha512("kms-root-ca:" || ...)`, for a key it does not hold.

The domain tags exist so that an external verifier can parse `report_data` unambiguously — so that an app-data quote is not mistaken for an RA-TLS binding. They are not an authorization boundary, and nothing is built on the assumption that they are:

1. **The identity in the quote is not the app's to choose.** `app_id`, `compose_hash`, `instance_id`, `mr_system` and `mr_aggregated` are replayed out of the RTMR3 runtime event log against the hardware-signed register, and RTMR3 events are system-owned: `EmitEvent` was removed in 0.6.0 precisely so that no application can extend it. A minted quote therefore always names the CVM that minted it. It cannot impersonate another app to a remote verifier.
2. **Key possession is proven by the channel, never by the tag.** Every consumer that reads `ratls-cert:` as a possession proof also makes the peer use the key: `ra-rpc` client and server verify the binding against the public key of a *completed* TLS handshake, and the KMS verifies the CSR signature before `verify_with_ra_pubkey`. A quote minted over a key the app does not hold is unusable at all of them.
3. **For a key it does hold, it proves nothing new.** `GetTlsKey` and `IssueCert` already hand the app an RA-TLS certificate over its own key with the same identity. Minting the same binding by hand is the same statement by another route.
4. **`kms-root-ca:` is not a privilege either.** A KMS root CA is trusted because of the on-chain identity of the KMS app, and a minted quote carries the minting app's identity. Minting one with the KMS's identity requires running inside the KMS CVM, where the KMS's keys are already reachable — a CVM is a single trust domain.

What this does mean is that a relying party must not treat "a quote exists whose `report_data` is `sha512("ratls-cert:" || K)`" as evidence that anyone holds *K*. It is evidence that the named CVM asked for that binding. Bind it to a live handshake or a signature, the way every consumer in this repository does.

### Development modes are auditable, not production-safe

dstack keeps several development switches as runtime or on-chain configuration rather than Cargo feature flags. Examples include KMS `attest_rpc_cert = false`, KMS `auth_api.type = "dev"`, and KMS contract `gateway_app_id = "any"`. These settings exist for local development and integration tests, not for production deployments.
Expand Down
157 changes: 157 additions & 0 deletions dstack/dstack-attest/src/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2615,6 +2615,163 @@ pub struct AppInfo {
mod tests {
use super::*;

/// The `report_data` domain tag is a parsing convention, not a capability.
///
/// It looked like one: the legacy `Tappd.TdxQuote` surface maps a non-empty
/// `prefix` straight to `QuoteContentType::Custom`, so any container can ask
/// for a hardware-signed quote whose `report_data` is exactly the RA-TLS
/// key-possession binding for a key it does not hold. Pin that this is the
/// same primitive the modern surfaces expose rather than a gap in one of
/// them -- `DstackGuest.GetQuote` and `Attest` take the 64 bytes verbatim
/// (`pad64`), so a caller that wants a particular binding can always just
/// hash it itself.
///
/// What actually keeps this from being a forgery is documented in
/// `docs/security/security-model.md` ("`report_data` domain tags are a
/// parsing convention, not a capability"): the quote's identity comes from
/// the system-owned RTMR3 event log, and every consumer that reads
/// `ratls-cert:` as possession also makes the peer use the key -- a
/// completed TLS handshake, or a CSR signature.
#[test]
fn report_data_domain_tags_are_reproducible_by_any_caller() {
let spki = b"a subject public key info";
for named in [QuoteContentType::RaTlsCert, QuoteContentType::KmsRootCa] {
assert_eq!(
QuoteContentType::Custom(named.tag()).to_report_data(spki),
named.to_report_data(spki),
"{}: a custom tag reproduces the named one byte for byte",
named.tag()
);
}

// And `raw` skips the tag entirely, which is also what `GetQuote` and
// `Attest` do with the bytes they are handed.
let binding = QuoteContentType::RaTlsCert.to_report_data(spki);
assert_eq!(
QuoteContentType::AppData
.to_report_data_with_hash(&binding, "raw")
.unwrap(),
binding
);

// The binding is to one specific key, so a quote minted for one SPKI
// says nothing about any other.
assert_ne!(
QuoteContentType::RaTlsCert.to_report_data(b"another public key"),
binding
);
}

/// Build a TD10 verified report carrying `status`. Everything else is the
/// shape `validate_tcb` accepts, so a row that fails failed on the status.
fn td10_verified_report(status: &str) -> TdxVerifiedReport {
TdxVerifiedReport {
status: status.to_string(),
advisory_ids: vec!["INTEL-SA-00001".to_string()],
report: Report::TD10(TDReport10 {
tee_tcb_svn: [0; 16],
mr_seam: [0; 48],
mr_signer_seam: [0; 48],
seam_attributes: [0; 8],
td_attributes: [0; 8],
xfam: [0; 8],
mr_td: [0; 48],
mr_config_id: [0; 48],
mr_owner: [0; 48],
mr_owner_config: [0; 48],
rt_mr0: [0; 48],
rt_mr1: [0; 48],
rt_mr2: [0; 48],
rt_mr3: [0; 48],
report_data: [0; 64],
}),
ppid: Vec::new(),
qe_status: dcap_qvl::tcb_info::TcbStatusWithAdvisory::new(
dcap_qvl::tcb_info::TcbStatus::UpToDate,
Vec::new(),
),
platform_status: dcap_qvl::tcb_info::TcbStatusWithAdvisory::new(
dcap_qvl::tcb_info::TcbStatus::UpToDate,
Vec::new(),
),
}
}

/// `validate_tcb` does not read `status`, by design: whether a non-current
/// TCB is acceptable is a downstream policy decision, and the verifier
/// surfaces the string instead of ruling on it
/// (`docs/security/security-model.md`, "TCB status is surfaced, not
/// gated"). Enumerate every `dcap_qvl::tcb_info::TcbStatus` so the day that
/// stops being true, it stops here rather than in a relying party's logs.
///
/// `Revoked` is in the list too, and it passes here as well: the only thing
/// that keeps a revoked TCB out is `dcap_qvl`'s own `is_valid()`, which
/// runs before this function ever sees the report. Nothing in dstack
/// re-checks it.
#[test]
fn validate_tcb_accepts_every_tcb_status_string() {
use dcap_qvl::tcb_info::TcbStatus;

let statuses = [
TcbStatus::UpToDate,
TcbStatus::SWHardeningNeeded,
TcbStatus::ConfigurationNeeded,
TcbStatus::ConfigurationAndSWHardeningNeeded,
TcbStatus::OutOfDate,
TcbStatus::OutOfDateConfigurationNeeded,
TcbStatus::Revoked,
];
for status in statuses {
let status = serde_json::to_value(status).unwrap();
let status = status.as_str().unwrap();
assert!(
validate_tcb(&td10_verified_report(status)).is_ok(),
"validate_tcb now rejects {status}; \
docs/security/security-model.md says it surfaces the status instead"
);
}
}

/// The three invariants `validate_tcb` does enforce. They are hard
/// invariants rather than policy: a debug TD's measurements mean nothing,
/// and a non-zero SEAM signer or service TD is a TD this verifier does not
/// model.
#[test]
fn validate_tcb_rejects_debug_mode_and_unexpected_seam_measurements() {
let mut debug = td10_verified_report("UpToDate");
if let Report::TD10(report) = &mut debug.report {
report.td_attributes[0] |= 0x01;
}
assert!(validate_tcb(&debug)
.unwrap_err()
.to_string()
.contains("Debug mode"));

let mut signer_seam = td10_verified_report("UpToDate");
if let Report::TD10(report) = &mut signer_seam.report {
report.mr_signer_seam[0] = 1;
}
assert!(validate_tcb(&signer_seam)
.unwrap_err()
.to_string()
.contains("mr signer seam"));

let td10 = td10_verified_report("UpToDate");
let Report::TD10(base) = td10.report.clone() else {
unreachable!("built as TD10")
};
let mut service_td = td10;
service_td.report = Report::TD15(TDReport15 {
base,
tee_tcb_svn2: [0; 16],
mr_service_td: [1; 48],
});
assert!(validate_tcb(&service_td)
.unwrap_err()
.to_string()
.contains("mr service td"));
}

#[test]
fn app_info_defaults_missing_init_script_hashes() {
let app_info: AppInfo = serde_json::from_value(serde_json::json!({
Expand Down
Loading
Loading