Skip to content

Commit 11a334c

Browse files
committed
fix(attest): derive simulated trust anchors inside the guest
1 parent 0110f2d commit 11a334c

14 files changed

Lines changed: 441 additions & 83 deletions

File tree

dstack/Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dstack/crates/mock-attestation/README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,22 @@ into verifier/KMS/gateway. The independently running host collateral service
6262
reconstructs the same hierarchy from the seed. Configure it under
6363
`[attestation.urls]`: TDX uses `pccs`, and SEV-SNP uses `amd_kds`.
6464

65-
Every verifier process must also explicitly set
65+
The guest needs those roots too, to verify the KMS and the gateway it talks to.
66+
They do not travel from the host: `dstack-tee-simulator` derives them from the
67+
same seed and writes them to `/run/dstack/attestation`, guest tmpfs the host
68+
cannot reach, before `dstack-prepare` starts. `dstack-util` reads that one
69+
directory and nothing else, so a host can never nominate the trust anchor that
70+
authenticates its guest's key provider. Only the development image ships the
71+
simulator, and image contents are measured, so on a production image the
72+
directory never exists and vendor production roots are the only outcome.
73+
74+
Every service configured with a mock root through its own TOML — KMS, gateway,
75+
`dstack-verifier` — must also explicitly set
6676
`attestation.insecure_allow_external_trust_anchors = true`. Merely mounting and
6777
configuring a mock root is rejected at startup while this flag remains false.
78+
The flag exists to make an operator acknowledge a hand-written non-production
79+
root, so it has no counterpart in the guest handoff above, where one program
80+
writes the roots and the next reads them out of a directory it authenticates.
6881

6982
The seed adds only 64 hex bytes (the simulator config is well below 1 KiB).
7083

dstack/dstack-attest/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ dstack-types.workspace = true
1818
ez-hash.workspace = true
1919
fs-err.workspace = true
2020
safe-write.workspace = true
21-
rustix.workspace = true
21+
rustix = { workspace = true, features = ["process"] }
2222
hex.workspace = true
2323
hex_fmt.workspace = true
2424
or-panic.workspace = true
@@ -62,3 +62,5 @@ quote = [
6262
futures = { workspace = true }
6363
tokio = { workspace = true, features = ["full"] }
6464
dstack-mr = { workspace = true }
65+
rcgen = { workspace = true }
66+
tempfile = { workspace = true }

dstack/dstack-attest/src/attestation.rs

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ pub struct AttestationVerifier {
6161
aws_nitro_tpm: nsm_qvl::QuoteVerifier,
6262
sev_snp: sev_snp_qvl::QuoteVerifier,
6363
amd_kds: AmdKdsClient,
64-
external_trust_anchors: bool,
6564
}
6665

6766
impl AttestationVerifier {
@@ -149,7 +148,6 @@ impl AttestationVerifier {
149148
aws_nitro_tpm: nsm(aws_nitro_tpm.as_deref(), "AWS NitroTPM")?,
150149
sev_snp,
151150
amd_kds: AmdKdsClient::with_base_url(amd_kds)?,
152-
external_trust_anchors: external_requested,
153151
})
154152
}
155153

@@ -175,33 +173,9 @@ impl AttestationVerifier {
175173
.filter(|url| !url.trim().is_empty())
176174
.unwrap_or(sev_snp_qvl::AMD_KDS_DEFAULT_BASE_URL),
177175
)?,
178-
external_trust_anchors: false,
179176
})
180177
}
181178

182-
/// Construct a verifier with a development-only external TDX trust root.
183-
///
184-
/// Callers must require an explicit insecure opt-in and surface the result
185-
/// as simulated evidence; production verification must use `new_prod`.
186-
pub fn new_with_tdx_root(
187-
collateral_urls: Option<&CollateralUrls>,
188-
root_ca: &[u8],
189-
) -> Result<Self> {
190-
validate_x509_certificate(root_ca, "TDX")?;
191-
let mut verifier = Self::new_prod(collateral_urls)?;
192-
verifier.tdx = dcap_qvl::verify::QuoteVerifier::new(tdx_root_der(root_ca.to_vec())?);
193-
verifier.external_trust_anchors = true;
194-
Ok(verifier)
195-
}
196-
197-
/// Whether this verifier accepts development-only external trust roots.
198-
///
199-
/// A true value must be surfaced as simulated evidence by every caller;
200-
/// production roots never set this flag.
201-
pub fn is_simulated(&self) -> bool {
202-
self.external_trust_anchors
203-
}
204-
205179
async fn verify_tdx_quote(&self, quote: &[u8]) -> Result<TdxVerifiedReport> {
206180
let collateral = self.tdx_collateral.fetch(quote).await?;
207181
let now = SystemTime::now()

dstack/dstack-attest/src/lib.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,48 @@ pub mod attestation;
1717
mod aws_nitro_tpm;
1818
#[cfg(feature = "quote")]
1919
mod sev_snp;
20+
pub mod trust_anchors;
2021
mod v1;
2122

2223
const RUNTIME_EVENT_DIR: &str = "/run/log/dstack";
2324
const RUNTIME_EVENT_VERSION_FILE: &str = "/run/log/dstack/runtime_event_version";
2425
const RUNTIME_EVENT_LOCK_FILE: &str = "/run/log/dstack/runtime_event.lock";
2526

27+
/// Build the verifier a guest authenticates the KMS and the gateway with.
28+
///
29+
/// Trust anchors are taken from [`trust_anchors::ANCHOR_DIR`] when that
30+
/// directory holds a set published inside this guest. When it does not — the
31+
/// only outcome on a production image — the vendor production roots apply.
32+
///
33+
/// `collateral_urls` selects where signed collateral is fetched from; the trust
34+
/// anchor still has to sign it.
35+
pub fn default_verifier(
36+
collateral_urls: &attestation::CollateralUrls,
37+
) -> anyhow::Result<attestation::AttestationVerifier> {
38+
use attestation::{AttestationVerifier, AttestationVerifierConfig};
39+
40+
let Some(root_ca) =
41+
trust_anchors::load_anchors(std::path::Path::new(trust_anchors::ANCHOR_DIR))
42+
.context("failed to load local attestation anchors")?
43+
else {
44+
return AttestationVerifier::new_prod(Some(collateral_urls));
45+
};
46+
tracing::warn!(
47+
dir = trust_anchors::ANCHOR_DIR,
48+
"verifying attestation against external trust anchors published by the in-guest TEE \
49+
simulator; this guest cannot verify production evidence"
50+
);
51+
AttestationVerifier::load(&AttestationVerifierConfig {
52+
// The opt-in exists to make an operator acknowledge a non-production
53+
// root in a hand-written service config. Nothing here is hand-written:
54+
// the roots came from a guest-local directory `load_anchors` already
55+
// authenticated, so the flag has no one left to warn.
56+
insecure_allow_external_trust_anchors: true,
57+
urls: collateral_urls.clone(),
58+
root_ca,
59+
})
60+
}
61+
2662
/// Acquire the system-wide runtime event lock, blocking until it is available.
2763
///
2864
/// The wait is deliberately unbounded. The lock serializes the event-log append
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
//! Trust anchors published inside a guest, and the checks that make them
6+
//! trustworthy to read.
7+
//!
8+
//! A CVM must never let its host pick the trust anchor that verifies remote
9+
//! attestation. The host sits outside the trust boundary, so a host-supplied
10+
//! root would let it stand up a fake key provider and hand the guest keys it
11+
//! never earned.
12+
//!
13+
//! An image that must verify non-production evidence still needs external
14+
//! roots, so that handoff runs entirely inside the guest: `dstack-tee-simulator`
15+
//! derives them from its seed and writes them to [`ANCHOR_DIR`], a tmpfs
16+
//! directory the host cannot reach. Only the development image ships the
17+
//! simulator, and image contents are measured, so on a production image nothing
18+
//! ever creates that directory and the vendor production roots are the only
19+
//! reachable outcome.
20+
//!
21+
//! [`crate::default_verifier`] is the only thing that should act on what
22+
//! [`load_anchors`] returns.
23+
24+
use std::{
25+
os::unix::fs::MetadataExt as _,
26+
path::{Path, PathBuf},
27+
};
28+
29+
use anyhow::{bail, Context, Result};
30+
31+
use crate::attestation::RootCaPaths;
32+
33+
/// Guest tmpfs directory carrying locally published trust anchors.
34+
pub const ANCHOR_DIR: &str = "/run/dstack/attestation";
35+
36+
const ROOTS_FILE: &str = "roots.json";
37+
38+
/// Path of the published [`RootCaPaths`] within a trust anchor directory.
39+
///
40+
/// The publisher writes it; [`load_anchors`] is the only reader.
41+
pub fn roots_path(dir: &Path) -> PathBuf {
42+
dir.join(ROOTS_FILE)
43+
}
44+
45+
/// Load trust anchors published inside this guest, if any.
46+
///
47+
/// Returns `Ok(None)` when nothing published anchors, which is the only outcome
48+
/// on a production image.
49+
pub fn load_anchors(dir: &Path) -> Result<Option<RootCaPaths>> {
50+
let path = roots_path(dir);
51+
if !path.exists() {
52+
return Ok(None);
53+
}
54+
ensure_owned_and_unwritable(dir, "trust anchor directory")?;
55+
let meta = ensure_owned_and_unwritable(&path, "published roots")?;
56+
if !meta.is_file() {
57+
bail!("published roots is not a regular file");
58+
}
59+
let root_ca: RootCaPaths =
60+
serde_json::from_slice(&fs_err::read(&path).context("failed to read published roots")?)
61+
.context("failed to parse published roots")?;
62+
for root in [
63+
&root_ca.tdx,
64+
&root_ca.gcp_tpm,
65+
&root_ca.aws_nitro_enclave,
66+
&root_ca.aws_nitro_tpm,
67+
&root_ca.sev_snp_milan,
68+
&root_ca.sev_snp_genoa,
69+
&root_ca.sev_snp_turin,
70+
]
71+
.into_iter()
72+
.flatten()
73+
{
74+
// Confining every root to the published directory keeps a stale or
75+
// tampered file from redirecting the verifier at a host-shared root.
76+
if root.parent() != Some(dir) || root.file_name().is_none() {
77+
bail!(
78+
"trust anchor {} is outside {}",
79+
root.display(),
80+
dir.display()
81+
);
82+
}
83+
ensure_owned_and_unwritable(root, "trust anchor")?;
84+
}
85+
Ok(Some(root_ca))
86+
}
87+
88+
/// Reject anything this process does not own or that others could rewrite.
89+
///
90+
/// Symlink metadata, not the followed target: a symlink planted by another user
91+
/// would otherwise pass the check while resolving somewhere unowned.
92+
fn ensure_owned_and_unwritable(path: &Path, what: &str) -> Result<std::fs::Metadata> {
93+
let meta = fs_err::symlink_metadata(path)
94+
.with_context(|| format!("failed to stat {what} {}", path.display()))?;
95+
let euid = rustix::process::geteuid().as_raw();
96+
if meta.uid() != euid {
97+
bail!(
98+
"{what} {} is owned by uid {} instead of {euid}",
99+
path.display(),
100+
meta.uid()
101+
);
102+
}
103+
if meta.mode() & 0o022 != 0 {
104+
bail!(
105+
"{what} {} is writable by group or others (mode {:o})",
106+
path.display(),
107+
meta.mode() & 0o7777
108+
);
109+
}
110+
Ok(meta)
111+
}
112+
113+
#[cfg(test)]
114+
mod tests {
115+
use super::*;
116+
// Mirrors what `crate::default_verifier` does with a loaded set, so the
117+
// published roots are proven usable by the real verifier.
118+
use crate::attestation::{AttestationVerifier, AttestationVerifierConfig};
119+
use std::os::unix::fs::PermissionsExt as _;
120+
121+
fn sample_root() -> String {
122+
let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap();
123+
let mut params = rcgen::CertificateParams::new(vec![]).unwrap();
124+
params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
125+
params.self_signed(&key).unwrap().pem()
126+
}
127+
128+
/// Stand in for the publisher, which lives in `dstack-tee-simulator`.
129+
fn publish(dir: &Path, tdx_root: &str) -> RootCaPaths {
130+
fs_err::create_dir_all(dir).unwrap();
131+
fs_err::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap();
132+
let root = dir.join("tdx-root-ca.pem");
133+
safe_write::safe_write_with_mode(&root, tdx_root.as_bytes(), 0o600).unwrap();
134+
let root_ca = RootCaPaths {
135+
tdx: Some(root),
136+
..Default::default()
137+
};
138+
write_roots(dir, &root_ca);
139+
root_ca
140+
}
141+
142+
fn write_roots(dir: &Path, root_ca: &RootCaPaths) {
143+
safe_write::safe_write_with_mode(
144+
roots_path(dir),
145+
serde_json::to_vec(root_ca).unwrap(),
146+
0o600,
147+
)
148+
.unwrap();
149+
}
150+
151+
fn verifier_for(root_ca: RootCaPaths) -> Result<AttestationVerifier> {
152+
AttestationVerifier::load(&AttestationVerifierConfig {
153+
insecure_allow_external_trust_anchors: true,
154+
urls: Default::default(),
155+
root_ca,
156+
})
157+
}
158+
159+
#[test]
160+
fn absent_directory_selects_production_roots() {
161+
let dir = tempfile::tempdir().unwrap();
162+
assert!(load_anchors(&dir.path().join("missing")).unwrap().is_none());
163+
}
164+
165+
#[test]
166+
fn published_roots_round_trip() {
167+
let dir = tempfile::tempdir().unwrap();
168+
let dir = dir.path().join("attestation");
169+
let published = publish(&dir, &sample_root());
170+
171+
let root_ca = load_anchors(&dir).unwrap().expect("anchors should load");
172+
assert_eq!(root_ca.tdx, published.tdx);
173+
assert_eq!(root_ca.gcp_tpm, None);
174+
// What was published must be loadable by the real verifier.
175+
verifier_for(root_ca).unwrap();
176+
}
177+
178+
#[test]
179+
fn a_malformed_root_fails_verifier_construction() {
180+
let dir = tempfile::tempdir().unwrap();
181+
let dir = dir.path().join("attestation");
182+
publish(&dir, "not a certificate");
183+
let root_ca = load_anchors(&dir).unwrap().unwrap();
184+
assert!(verifier_for(root_ca).is_err());
185+
}
186+
187+
#[test]
188+
fn world_writable_roots_are_rejected() {
189+
let dir = tempfile::tempdir().unwrap();
190+
let dir = dir.path().join("attestation");
191+
publish(&dir, &sample_root());
192+
fs_err::set_permissions(roots_path(&dir), std::fs::Permissions::from_mode(0o666)).unwrap();
193+
let error = load_anchors(&dir).unwrap_err().to_string();
194+
assert!(error.contains("writable by group or others"), "{error}");
195+
}
196+
197+
#[test]
198+
fn trust_anchor_outside_the_directory_is_rejected() {
199+
let dir = tempfile::tempdir().unwrap();
200+
let anchors = dir.path().join("attestation");
201+
let mut root_ca = publish(&anchors, &sample_root());
202+
root_ca.tdx = Some(dir.path().join("host-shared-root.pem"));
203+
write_roots(&anchors, &root_ca);
204+
let error = load_anchors(&anchors).unwrap_err().to_string();
205+
assert!(error.contains("is outside"), "{error}");
206+
}
207+
}

0 commit comments

Comments
 (0)