diff --git a/dstack/Cargo.lock b/dstack/Cargo.lock index 63766de8f..37fff3af5 100644 --- a/dstack/Cargo.lock +++ b/dstack/Cargo.lock @@ -1786,6 +1786,7 @@ dependencies = [ "parity-scale-codec", "pem", "rand 0.8.6", + "rcgen", "rmp-serde", "rsa", "rustix 0.38.44", @@ -1798,6 +1799,7 @@ dependencies = [ "sha2 0.10.9", "sha3", "tdx-attest", + "tempfile", "tokio", "tpm-attest", "tpm-qvl", @@ -2007,9 +2009,12 @@ dependencies = [ "anyhow", "cc-eventlog", "clap", + "dcap-qvl", "dstack-guest-agent", "dstack-guest-agent-rpc", "dstack-types", + "hex", + "mock-attestation", "ra-rpc", "ra-tls", "rocket", @@ -2154,6 +2159,7 @@ dependencies = [ "cc-eventlog", "clap", "dcap-qvl", + "dstack-attest", "dstack-mr", "dstack-types", "fs-err", @@ -2166,6 +2172,7 @@ dependencies = [ "nsm-qvl", "pem", "reqwest", + "safe-write", "sd-notify", "serde_cbor", "serde_json", diff --git a/dstack/crates/mock-attestation/README.md b/dstack/crates/mock-attestation/README.md index eaa6d7e51..4ac04abf8 100644 --- a/dstack/crates/mock-attestation/README.md +++ b/dstack/crates/mock-attestation/README.md @@ -62,9 +62,22 @@ into verifier/KMS/gateway. The independently running host collateral service reconstructs the same hierarchy from the seed. Configure it under `[attestation.urls]`: TDX uses `pccs`, and SEV-SNP uses `amd_kds`. -Every verifier process must also explicitly set +The guest needs those roots too, to verify the KMS and the gateway it talks to. +They do not travel from the host: `dstack-tee-simulator` derives them from the +same seed and writes them to `/run/dstack/attestation`, guest tmpfs the host +cannot reach, before `dstack-prepare` starts. `dstack-util` reads that one +directory and nothing else, so a host can never nominate the trust anchor that +authenticates its guest's key provider. Only the development image ships the +simulator, and image contents are measured, so on a production image the +directory never exists and vendor production roots are the only outcome. + +Every service configured with a mock root through its own TOML — KMS, gateway, +`dstack-verifier` — must also explicitly set `attestation.insecure_allow_external_trust_anchors = true`. Merely mounting and configuring a mock root is rejected at startup while this flag remains false. +The flag exists to make an operator acknowledge a hand-written non-production +root, so it has no counterpart in the guest handoff above, where one program +writes the roots and the next reads them out of a directory it authenticates. The seed adds only 64 hex bytes (the simulator config is well below 1 KiB). diff --git a/dstack/crates/mock-attestation/src/tdx.rs b/dstack/crates/mock-attestation/src/tdx.rs index 9ae50a513..9e0b5a4c2 100644 --- a/dstack/crates/mock-attestation/src/tdx.rs +++ b/dstack/crates/mock-attestation/src/tdx.rs @@ -9,16 +9,19 @@ use dcap_qvl::quote::{ }; use dcap_qvl::QuoteCollateralV3; use p256::ecdsa::{signature::Signer, Signature, SigningKey}; -use p256::pkcs8::DecodePrivateKey; +use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey}; use rcgen::{ BasicConstraints, Certificate, CertificateParams, CertificateRevocationListParams, CertifiedKey, CustomExtension, DnType, ExtendedKeyUsagePurpose, IsCa, KeyIdMethod, KeyPair, - KeyUsagePurpose, SerialNumber, + KeyUsagePurpose, RemoteKeyPair, SerialNumber, SignatureAlgorithm, PKCS_ECDSA_P256_SHA256, }; use scale::Encode; use serde_json::json; use sha2::{Digest, Sha256}; -use time::{Duration, OffsetDateTime}; +use time::OffsetDateTime; + +const MOCK_PKI_NOT_BEFORE: i64 = 1_577_836_800; // 2020-01-01T00:00:00Z +const MOCK_PKI_NOT_AFTER: i64 = 4_102_444_800; // 2100-01-01T00:00:00Z const INTEL_QE_VENDOR_ID: [u8; 16] = [ 0x93, 0x9a, 0x72, 0x33, 0xf7, 0x9c, 0x4c, 0xa9, 0x94, 0x0a, 0x0d, 0xb3, 0x95, 0x7f, 0x06, 0x07, @@ -26,16 +29,47 @@ const INTEL_QE_VENDOR_ID: [u8; 16] = [ pub struct TdxGenerator { root: Certificate, - root_key: KeyPair, + root_signing_key: SigningKey, pck: Certificate, - pck_key: KeyPair, + pck_key: SigningKey, tcb_signer: Certificate, - tcb_signer_key: KeyPair, + tcb_signer_key: SigningKey, qe_signer: Certificate, - qe_signer_key: KeyPair, + qe_signer_key: SigningKey, root_crl: Vec, } +struct DeterministicP256KeyPair { + key: SigningKey, + public_key: Vec, +} + +impl DeterministicP256KeyPair { + fn new(key: SigningKey) -> Self { + let public_key = key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + Self { key, public_key } + } +} + +impl RemoteKeyPair for DeterministicP256KeyPair { + fn public_key(&self) -> &[u8] { + &self.public_key + } + + fn sign(&self, message: &[u8]) -> Result, rcgen::Error> { + let signature: Signature = self.key.sign(message); + Ok(signature.to_der().as_bytes().to_vec()) + } + + fn algorithm(&self) -> &'static SignatureAlgorithm { + &PKCS_ECDSA_P256_SHA256 + } +} + pub struct TdxEvidence { pub quote: Vec, pub collateral: QuoteCollateralV3, @@ -47,10 +81,13 @@ impl TdxGenerator { } pub fn from_seed(seed: [u8; 32]) -> Result { - let CertifiedKey { - cert: root, - key_pair: root_key, - } = make_root(&seed)?; + let ( + CertifiedKey { + cert: root, + key_pair: root_key, + }, + root_signing_key, + ) = make_root(&seed)?; let (pck, pck_key) = make_leaf( "Mock Intel SGX PCK Certificate", "tdx-pck", @@ -75,10 +112,9 @@ impl TdxGenerator { &root_key, false, )?; - let now = OffsetDateTime::now_utc(); let root_crl = CertificateRevocationListParams { - this_update: now - Duration::days(1), - next_update: now + Duration::days(30), + this_update: fixed_time(MOCK_PKI_NOT_BEFORE)?, + next_update: fixed_time(MOCK_PKI_NOT_AFTER)?, crl_number: SerialNumber::from(1u64), issuing_distribution_point: None, revoked_certs: Vec::new(), @@ -89,7 +125,7 @@ impl TdxGenerator { .to_vec(); Ok(Self { root, - root_key, + root_signing_key, pck, pck_key, tcb_signer, @@ -106,8 +142,11 @@ impl TdxGenerator { pub fn root_ca_pem(&self) -> String { self.root.pem() } - pub fn root_key_pem(&self) -> String { - self.root_key.serialize_pem() + pub fn root_key_pem(&self) -> Result { + Ok(self + .root_signing_key + .to_pkcs8_pem(Default::default())? + .to_string()) } pub fn sample_collateral(&self) -> Result { @@ -166,8 +205,7 @@ impl TdxGenerator { .encode() .try_into() .map_err(|bytes: Vec| anyhow::anyhow!("invalid QE report size {}", bytes.len()))?; - let pck_key = signing_key(&self.pck_key)?; - let qe_sig: Signature = pck_key.sign(&qe_report_bytes); + let qe_sig: Signature = self.pck_key.sign(&qe_report_bytes); let pck_chain = format!("{}{}", self.pck.pem(), self.root.pem()).into_bytes(); let qe_certification = QEReportCertificationData { @@ -263,8 +301,16 @@ impl TdxGenerator { } } -fn make_root(seed: &[u8; 32]) -> Result { - let key_pair = crate::p256_key(seed, "tdx-root")?; +fn deterministic_key_pair(seed: &[u8; 32], label: &str) -> Result<(KeyPair, SigningKey)> { + let serialized = crate::p256_key(seed, label)?; + let signing_key = SigningKey::from_pkcs8_pem(&serialized.serialize_pem())?; + let key_pair = + KeyPair::from_remote(Box::new(DeterministicP256KeyPair::new(signing_key.clone())))?; + Ok((key_pair, signing_key)) +} + +fn make_root(seed: &[u8; 32]) -> Result<(CertifiedKey, SigningKey)> { + let (key_pair, signing_key) = deterministic_key_pair(seed, "tdx-root")?; let mut params = cert_params("Mock Intel SGX Root CA")?; params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); params.key_usages.extend([ @@ -273,7 +319,7 @@ fn make_root(seed: &[u8; 32]) -> Result { KeyUsagePurpose::CrlSign, ]); let cert = params.self_signed(&key_pair)?; - Ok(CertifiedKey { cert, key_pair }) + Ok((CertifiedKey { cert, key_pair }, signing_key)) } fn make_leaf( @@ -283,8 +329,8 @@ fn make_leaf( root: &Certificate, root_key: &KeyPair, pck: bool, -) -> Result<(Certificate, KeyPair)> { - let key = crate::p256_key(seed, label)?; +) -> Result<(Certificate, SigningKey)> { + let (key, signing_key) = deterministic_key_pair(seed, label)?; let mut params = cert_params(name)?; params.key_usages.push(KeyUsagePurpose::DigitalSignature); params @@ -294,19 +340,22 @@ fn make_leaf( params.custom_extensions.push(pck_extension()); } let cert = params.signed_by(&key, root, root_key)?; - Ok((cert, key)) + Ok((cert, signing_key)) } fn cert_params(name: &str) -> Result { let mut params = CertificateParams::new(vec!["mock.dstack.invalid".into()])?; params.distinguished_name.push(DnType::CommonName, name); params.serial_number = Some(SerialNumber::from(42u64)); - let now = OffsetDateTime::now_utc(); - params.not_before = now - Duration::days(1); - params.not_after = now + Duration::days(30); + params.not_before = fixed_time(MOCK_PKI_NOT_BEFORE)?; + params.not_after = fixed_time(MOCK_PKI_NOT_AFTER)?; Ok(params) } +fn fixed_time(timestamp: i64) -> Result { + Ok(OffsetDateTime::from_unix_timestamp(timestamp)?) +} + fn pck_extension() -> CustomExtension { fn oid(writer: yasna::DERWriter, oid: &[u64]) { writer.write_oid(&yasna::models::ObjectIdentifier::from_slice(oid)); @@ -347,11 +396,8 @@ fn pck_extension() -> CustomExtension { CustomExtension::from_oid_content(&[1, 2, 840, 113741, 1, 13, 1], der) } -fn signing_key(key: &KeyPair) -> Result { - Ok(SigningKey::from_pkcs8_pem(&key.serialize_pem())?) -} -fn sign_raw(key: &KeyPair, message: &[u8]) -> Result> { - let sig: Signature = signing_key(key)?.sign(message); +fn sign_raw(key: &SigningKey, message: &[u8]) -> Result> { + let sig: Signature = key.sign(message); Ok(sig.to_bytes().to_vec()) } @@ -359,6 +405,26 @@ fn sign_raw(key: &KeyPair, message: &[u8]) -> Result> { mod tests { use super::*; + #[test] + fn seeded_hierarchies_are_cross_process_compatible() { + let first = TdxGenerator::from_seed([0x31; 32]).unwrap(); + let second = TdxGenerator::from_seed([0x31; 32]).unwrap(); + assert_eq!(first.root_ca_der(), second.root_ca_der()); + assert_eq!(first.root_crl_der(), second.root_crl_der()); + + let evidence = first.attest([0x42; 64]).unwrap(); + let collateral = second.sample_collateral().unwrap(); + assert_eq!(evidence.collateral, collateral); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + dcap_qvl::verify::QuoteVerifier::new(second.root_ca_der()) + .verify(&evidence.quote, &collateral, now) + .unwrap(); + } + #[test] fn generated_quote_passes_real_qvl_and_negative_cases_fail() { let generator = TdxGenerator::new().unwrap(); diff --git a/dstack/dstack-attest/Cargo.toml b/dstack/dstack-attest/Cargo.toml index 3c41c5e0d..c5f1d556c 100644 --- a/dstack/dstack-attest/Cargo.toml +++ b/dstack/dstack-attest/Cargo.toml @@ -18,7 +18,7 @@ dstack-types.workspace = true ez-hash.workspace = true fs-err.workspace = true safe-write.workspace = true -rustix.workspace = true +rustix = { workspace = true, features = ["process"] } hex.workspace = true hex_fmt.workspace = true or-panic.workspace = true @@ -62,3 +62,5 @@ quote = [ futures = { workspace = true } tokio = { workspace = true, features = ["full"] } dstack-mr = { workspace = true } +rcgen = { workspace = true } +tempfile = { workspace = true } diff --git a/dstack/dstack-attest/src/attestation.rs b/dstack/dstack-attest/src/attestation.rs index f5cd9d918..89f65d61f 100644 --- a/dstack/dstack-attest/src/attestation.rs +++ b/dstack/dstack-attest/src/attestation.rs @@ -2801,7 +2801,10 @@ mod tests { let content = b"test content"; let report_data = content_type.to_report_data(content); - assert_eq!(hex::encode(report_data), "7ea0b744ed5e9c0c83ff9f575668e1697652cd349f2027cdf26f918d4c53e8cd50b5ea9b449b4c3d50e20ae00ec29688d5a214e8daff8a10041f5d624dae8a01"); + assert_eq!( + hex::encode(report_data), + "7ea0b744ed5e9c0c83ff9f575668e1697652cd349f2027cdf26f918d4c53e8cd50b5ea9b449b4c3d50e20ae00ec29688d5a214e8daff8a10041f5d624dae8a01" + ); // Test SHA-256 let result = content_type diff --git a/dstack/dstack-attest/src/lib.rs b/dstack/dstack-attest/src/lib.rs index 30c73b5a9..0739059b1 100644 --- a/dstack/dstack-attest/src/lib.rs +++ b/dstack/dstack-attest/src/lib.rs @@ -17,12 +17,48 @@ pub mod attestation; mod aws_nitro_tpm; #[cfg(feature = "quote")] mod sev_snp; +pub mod trust_anchors; mod v1; const RUNTIME_EVENT_DIR: &str = "/run/log/dstack"; const RUNTIME_EVENT_VERSION_FILE: &str = "/run/log/dstack/runtime_event_version"; const RUNTIME_EVENT_LOCK_FILE: &str = "/run/log/dstack/runtime_event.lock"; +/// Build the verifier a guest authenticates the KMS and the gateway with. +/// +/// Trust anchors are taken from [`trust_anchors::ANCHOR_DIR`] when that +/// directory holds a set published inside this guest. When it does not — the +/// only outcome on a production image — the vendor production roots apply. +/// +/// `collateral_urls` selects where signed collateral is fetched from; the trust +/// anchor still has to sign it. +pub fn default_verifier( + collateral_urls: &attestation::CollateralUrls, +) -> anyhow::Result { + use attestation::{AttestationVerifier, AttestationVerifierConfig}; + + let Some(root_ca) = + trust_anchors::load_anchors(std::path::Path::new(trust_anchors::ANCHOR_DIR)) + .context("failed to load local attestation anchors")? + else { + return AttestationVerifier::new_prod(Some(collateral_urls)); + }; + tracing::warn!( + dir = trust_anchors::ANCHOR_DIR, + "verifying attestation against external trust anchors published by the in-guest TEE \ + simulator; this guest cannot verify production evidence" + ); + AttestationVerifier::load(&AttestationVerifierConfig { + // The opt-in exists to make an operator acknowledge a non-production + // root in a hand-written service config. Nothing here is hand-written: + // the roots came from a guest-local directory `load_anchors` already + // authenticated, so the flag has no one left to warn. + insecure_allow_external_trust_anchors: true, + urls: collateral_urls.clone(), + root_ca, + }) +} + /// Acquire the system-wide runtime event lock, blocking until it is available. /// /// The wait is deliberately unbounded. The lock serializes the event-log append diff --git a/dstack/dstack-attest/src/trust_anchors.rs b/dstack/dstack-attest/src/trust_anchors.rs new file mode 100644 index 000000000..fd2eb9b68 --- /dev/null +++ b/dstack/dstack-attest/src/trust_anchors.rs @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Trust anchors published inside a guest, and the checks that make them +//! trustworthy to read. +//! +//! A CVM must never let its host pick the trust anchor that verifies remote +//! attestation. The host sits outside the trust boundary, so a host-supplied +//! root would let it stand up a fake key provider and hand the guest keys it +//! never earned. +//! +//! An image that must verify non-production evidence still needs external +//! roots, so that handoff runs entirely inside the guest: `dstack-tee-simulator` +//! derives them from its seed and writes them to [`ANCHOR_DIR`], a tmpfs +//! directory the host cannot reach. Only the development image ships the +//! simulator, and image contents are measured, so on a production image nothing +//! ever creates that directory and the vendor production roots are the only +//! reachable outcome. +//! +//! [`crate::default_verifier`] is the only thing that should act on what +//! [`load_anchors`] returns. + +use std::{ + os::unix::fs::MetadataExt as _, + path::{Path, PathBuf}, +}; + +use anyhow::{bail, Context, Result}; + +use crate::attestation::RootCaPaths; + +/// Guest tmpfs directory carrying locally published trust anchors. +pub const ANCHOR_DIR: &str = "/run/dstack/attestation"; + +const ROOTS_FILE: &str = "roots.json"; + +/// Path of the published [`RootCaPaths`] within a trust anchor directory. +/// +/// The publisher writes it; [`load_anchors`] is the only reader. +pub fn roots_path(dir: &Path) -> PathBuf { + dir.join(ROOTS_FILE) +} + +/// Load trust anchors published inside this guest, if any. +/// +/// Returns `Ok(None)` when nothing published anchors, which is the only outcome +/// on a production image. +pub fn load_anchors(dir: &Path) -> Result> { + let path = roots_path(dir); + if !path.exists() { + return Ok(None); + } + ensure_owned_and_unwritable(dir, "trust anchor directory")?; + let meta = ensure_owned_and_unwritable(&path, "published roots")?; + if !meta.is_file() { + bail!("published roots is not a regular file"); + } + let root_ca: RootCaPaths = + serde_json::from_slice(&fs_err::read(&path).context("failed to read published roots")?) + .context("failed to parse published roots")?; + for root in [ + &root_ca.tdx, + &root_ca.gcp_tpm, + &root_ca.aws_nitro_enclave, + &root_ca.aws_nitro_tpm, + &root_ca.sev_snp_milan, + &root_ca.sev_snp_genoa, + &root_ca.sev_snp_turin, + ] + .into_iter() + .flatten() + { + // Confining every root to the published directory keeps a stale or + // tampered file from redirecting the verifier at a host-shared root. + if root.parent() != Some(dir) || root.file_name().is_none() { + bail!( + "trust anchor {} is outside {}", + root.display(), + dir.display() + ); + } + ensure_owned_and_unwritable(root, "trust anchor")?; + } + Ok(Some(root_ca)) +} + +/// Reject anything this process does not own or that others could rewrite. +/// +/// Symlink metadata, not the followed target: a symlink planted by another user +/// would otherwise pass the check while resolving somewhere unowned. +fn ensure_owned_and_unwritable(path: &Path, what: &str) -> Result { + let meta = fs_err::symlink_metadata(path) + .with_context(|| format!("failed to stat {what} {}", path.display()))?; + let euid = rustix::process::geteuid().as_raw(); + if meta.uid() != euid { + bail!( + "{what} {} is owned by uid {} instead of {euid}", + path.display(), + meta.uid() + ); + } + if meta.mode() & 0o022 != 0 { + bail!( + "{what} {} is writable by group or others (mode {:o})", + path.display(), + meta.mode() & 0o7777 + ); + } + Ok(meta) +} + +#[cfg(test)] +mod tests { + use super::*; + // Mirrors what `crate::default_verifier` does with a loaded set, so the + // published roots are proven usable by the real verifier. + use crate::attestation::{AttestationVerifier, AttestationVerifierConfig}; + use std::os::unix::fs::PermissionsExt as _; + + fn sample_root() -> String { + let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let mut params = rcgen::CertificateParams::new(vec![]).unwrap(); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + params.self_signed(&key).unwrap().pem() + } + + /// Stand in for the publisher, which lives in `dstack-tee-simulator`. + fn publish(dir: &Path, tdx_root: &str) -> RootCaPaths { + fs_err::create_dir_all(dir).unwrap(); + fs_err::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + let root = dir.join("tdx-root-ca.pem"); + safe_write::safe_write_with_mode(&root, tdx_root.as_bytes(), 0o600).unwrap(); + let root_ca = RootCaPaths { + tdx: Some(root), + ..Default::default() + }; + write_roots(dir, &root_ca); + root_ca + } + + fn write_roots(dir: &Path, root_ca: &RootCaPaths) { + safe_write::safe_write_with_mode( + roots_path(dir), + serde_json::to_vec(root_ca).unwrap(), + 0o600, + ) + .unwrap(); + } + + fn verifier_for(root_ca: RootCaPaths) -> Result { + AttestationVerifier::load(&AttestationVerifierConfig { + insecure_allow_external_trust_anchors: true, + urls: Default::default(), + root_ca, + }) + } + + #[test] + fn absent_directory_selects_production_roots() { + let dir = tempfile::tempdir().unwrap(); + assert!(load_anchors(&dir.path().join("missing")).unwrap().is_none()); + } + + #[test] + fn published_roots_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let dir = dir.path().join("attestation"); + let published = publish(&dir, &sample_root()); + + let root_ca = load_anchors(&dir).unwrap().expect("anchors should load"); + assert_eq!(root_ca.tdx, published.tdx); + assert_eq!(root_ca.gcp_tpm, None); + // What was published must be loadable by the real verifier. + verifier_for(root_ca).unwrap(); + } + + #[test] + fn a_malformed_root_fails_verifier_construction() { + let dir = tempfile::tempdir().unwrap(); + let dir = dir.path().join("attestation"); + publish(&dir, "not a certificate"); + let root_ca = load_anchors(&dir).unwrap().unwrap(); + assert!(verifier_for(root_ca).is_err()); + } + + #[test] + fn world_writable_roots_are_rejected() { + let dir = tempfile::tempdir().unwrap(); + let dir = dir.path().join("attestation"); + publish(&dir, &sample_root()); + fs_err::set_permissions(roots_path(&dir), std::fs::Permissions::from_mode(0o666)).unwrap(); + let error = load_anchors(&dir).unwrap_err().to_string(); + assert!(error.contains("writable by group or others"), "{error}"); + } + + #[test] + fn trust_anchor_outside_the_directory_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let anchors = dir.path().join("attestation"); + let mut root_ca = publish(&anchors, &sample_root()); + root_ca.tdx = Some(dir.path().join("host-shared-root.pem")); + write_roots(&anchors, &root_ca); + let error = load_anchors(&anchors).unwrap_err().to_string(); + assert!(error.contains("is outside"), "{error}"); + } +} diff --git a/dstack/dstack-util/src/system_setup.rs b/dstack/dstack-util/src/system_setup.rs index 6baa37912..ec2f4eecc 100644 --- a/dstack/dstack-util/src/system_setup.rs +++ b/dstack/dstack-util/src/system_setup.rs @@ -15,7 +15,7 @@ use std::{ }; use anyhow::{anyhow, bail, Context, Result}; -use dstack_attest::{emit_runtime_event, set_runtime_event_version}; +use dstack_attest::{default_verifier, emit_runtime_event, set_runtime_event_version}; use dstack_kms_rpc as rpc; use dstack_types::{ gpu_policy_hash, @@ -66,6 +66,10 @@ use serde_human_bytes as hex_bytes; use serde_json::Value; use tpm_attest::{self as tpm, TpmContext}; +fn attestation_verifier(sys_config: &SysConfig) -> Result> { + Ok(Arc::new(default_verifier(&sys_config.collateral_urls())?)) +} + async fn sign_cert_request( cert_client: &CertRequestClient, key: &KeyPair, @@ -480,8 +484,7 @@ impl<'a> GatewayContext<'a> { .map(|d| d.as_secs()) .unwrap_or(0); let cert_not_after = now + CERT_VALIDITY_SECS; - let collateral_urls = self.shared.sys_config.collateral_urls(); - let verifier = Arc::new(AttestationVerifier::new_prod(Some(&collateral_urls))?); + let verifier = attestation_verifier(&self.shared.sys_config)?; let cert_client = CertRequestClient::create( self.keys, verifier, @@ -2035,8 +2038,7 @@ impl<'a> Stage0<'a> { .context("Failed to get temp ca cert")? }; let cert_pair = generate_ra_cert(tmp_ca.temp_ca_cert.clone(), tmp_ca.temp_ca_key.clone())?; - let collateral_urls = self.shared.sys_config.collateral_urls(); - let attestation_verifier = Arc::new(AttestationVerifier::new_prod(Some(&collateral_urls))?); + let attestation_verifier = attestation_verifier(&self.shared.sys_config)?; let verified_kms_measurement = Arc::new(std::sync::Mutex::new(None::<[u8; 32]>)); let captured_kms_measurement = verified_kms_measurement.clone(); let ra_client = RaClientConfig::builder() diff --git a/dstack/guest-agent-simulator/Cargo.toml b/dstack/guest-agent-simulator/Cargo.toml index 6fad16705..8d7fd4c16 100644 --- a/dstack/guest-agent-simulator/Cargo.toml +++ b/dstack/guest-agent-simulator/Cargo.toml @@ -27,3 +27,6 @@ dstack-guest-agent = { path = "../guest-agent" } dstack-guest-agent-rpc.workspace = true dstack-types.workspace = true cc-eventlog.workspace = true +dcap-qvl.workspace = true +hex.workspace = true +mock-attestation = { path = "../crates/mock-attestation" } diff --git a/dstack/guest-agent-simulator/src/main.rs b/dstack/guest-agent-simulator/src/main.rs index c2bf89bf1..532a2ddd1 100644 --- a/dstack/guest-agent-simulator/src/main.rs +++ b/dstack/guest-agent-simulator/src/main.rs @@ -14,6 +14,7 @@ use dstack_guest_agent::{ run_server, AppState, }; use dstack_guest_agent_rpc::{AttestResponse, GetQuoteResponse}; +use mock_attestation::tdx::TdxGenerator; use ra_tls::attestation::VersionedAttestation; use serde::Deserialize; use tracing::warn; @@ -37,6 +38,8 @@ struct SimulatorSettings { attestation_file: String, #[serde(default = "default_patch_report_data")] patch_report_data: bool, + #[serde(default)] + mock_attestation_seed: Option, } #[derive(Debug, Clone, Deserialize)] @@ -49,14 +52,25 @@ struct SimulatorCoreConfig { struct SimulatorPlatform { attestation: VersionedAttestation, patch_report_data: bool, + generator: Option, } impl SimulatorPlatform { - fn new(attestation: VersionedAttestation, patch_report_data: bool) -> Self { - Self { + fn new( + attestation: VersionedAttestation, + patch_report_data: bool, + mock_attestation_seed: Option<&str>, + ) -> Result { + let generator = mock_attestation_seed + .map(mock_attestation::parse_seed) + .transpose()? + .map(TdxGenerator::from_seed) + .transpose()?; + Ok(Self { attestation, patch_report_data, - } + generator, + }) } } @@ -74,6 +88,7 @@ impl PlatformBackend for SimulatorPlatform { &self.attestation, pubkey, self.patch_report_data, + self.generator.as_ref(), ) } @@ -83,11 +98,17 @@ impl PlatformBackend for SimulatorPlatform { report_data, vm_config, self.patch_report_data, + self.generator.as_ref(), ) } fn attest_response(&self, report_data: [u8; 64]) -> Result { - simulator::simulated_attest_response(&self.attestation, report_data, self.patch_report_data) + simulator::simulated_attest_response( + &self.attestation, + report_data, + self.patch_report_data, + self.generator.as_ref(), + ) } } @@ -107,12 +128,17 @@ async fn main() -> Result<()> { warn!( attestation_file = %sim_config.simulator.attestation_file, patch_report_data = sim_config.simulator.patch_report_data, + signed_quotes = sim_config.simulator.mock_attestation_seed.is_some(), "starting dstack guest-agent simulator" ); if sim_config.simulator.patch_report_data { - warn!("simulator will rewrite report_data to match requests; quote verification may fail against the original fixture signature"); + warn!( + "simulator will rewrite report_data to match requests; quote verification may fail against the original fixture signature" + ); } else { - warn!("simulator will preserve fixture report_data; cert/key binding and requested report_data may not match"); + warn!( + "simulator will preserve fixture report_data; cert/key binding and requested report_data may not match" + ); } let attestation = simulator::load_versioned_attestation(&sim_config.simulator.attestation_file)?; @@ -121,7 +147,8 @@ async fn main() -> Result<()> { Arc::new(SimulatorPlatform::new( attestation, sim_config.simulator.patch_report_data, - )), + sim_config.simulator.mock_attestation_seed.as_deref(), + )?), ) .await .context("Failed to create simulator app state")?; @@ -131,6 +158,7 @@ async fn main() -> Result<()> { #[cfg(test)] mod tests { use super::*; + use ra_tls::attestation::TdxAttestationExt; fn load_fixture_platform() -> SimulatorPlatform { let fixture = simulator::load_versioned_attestation( @@ -138,7 +166,7 @@ mod tests { .join("../guest-agent/fixtures/attestation.bin"), ) .expect("fixture attestation should load"); - SimulatorPlatform::new(fixture, true) + SimulatorPlatform::new(fixture, true, None).unwrap() } #[test] @@ -162,6 +190,34 @@ mod tests { assert_eq!(patched.report_data().unwrap(), report_data); } + #[test] + fn seeded_simulator_resigns_certificate_attestation() { + let fixture = simulator::load_versioned_attestation( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../guest-agent/fixtures/attestation.bin"), + ) + .unwrap(); + let seed = [0x5a; 32]; + let platform = SimulatorPlatform::new(fixture, true, Some(&hex::encode(seed))).unwrap(); + let attestation = platform + .certificate_attestation(b"test-public-key") + .unwrap() + .into_v1(); + let quote = attestation.tdx_quote_bytes().unwrap(); + let generator = TdxGenerator::from_seed(seed).unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + dcap_qvl::verify::QuoteVerifier::new(generator.root_ca_der()) + .verify("e, &generator.sample_collateral().unwrap(), now) + .unwrap(); + assert_eq!( + attestation.report_data().unwrap(), + ra_tls::attestation::QuoteContentType::RaTlsCert.to_report_data(b"test-public-key") + ); + } + #[test] fn simulator_can_preserve_fixture_report_data() { let fixture = simulator::load_versioned_attestation( @@ -170,7 +226,7 @@ mod tests { ) .expect("fixture attestation should load"); let original = fixture.clone().into_v1().report_data().unwrap(); - let platform = SimulatorPlatform::new(fixture, false); + let platform = SimulatorPlatform::new(fixture, false, None).unwrap(); let report_data = [0x5a; 64]; let response = platform.attest_response(report_data).unwrap(); let patched = VersionedAttestation::from_bytes(&response.attestation) diff --git a/dstack/guest-agent-simulator/src/simulator.rs b/dstack/guest-agent-simulator/src/simulator.rs index 23a74d0ec..b6263e25a 100644 --- a/dstack/guest-agent-simulator/src/simulator.rs +++ b/dstack/guest-agent-simulator/src/simulator.rs @@ -5,9 +5,11 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; +use dcap_qvl::quote::Quote; use dstack_guest_agent_rpc::{AttestResponse, GetQuoteResponse}; +use mock_attestation::tdx::TdxGenerator; use ra_tls::attestation::{ - AttestationV1, QuoteContentType, TdxAttestationExt, VersionedAttestation, + AttestationV1, PlatformEvidence, QuoteContentType, TdxAttestationExt, VersionedAttestation, }; use std::fs; use tracing::warn; @@ -29,8 +31,15 @@ pub fn simulated_quote_response( report_data: [u8; 64], vm_config: &str, patch_report_data: bool, + generator: Option<&TdxGenerator>, ) -> Result { - let attestation = maybe_patch_report_data(attestation, report_data, patch_report_data, "quote"); + let attestation = prepare_attestation( + attestation, + report_data, + patch_report_data, + generator, + "quote", + )?; let Some(quote) = attestation.tdx_quote_bytes() else { return Err(anyhow!("Quote not found")); }; @@ -52,9 +61,15 @@ pub fn simulated_attest_response( attestation: &VersionedAttestation, report_data: [u8; 64], patch_report_data: bool, + generator: Option<&TdxGenerator>, ) -> Result { - let mut attestation = - maybe_patch_report_data(attestation, report_data, patch_report_data, "attest"); + let mut attestation = prepare_attestation( + attestation, + report_data, + patch_report_data, + generator, + "attest", + )?; if let Some(event_log) = attestation.platform.tdx_event_log_mut() { cc_eventlog::tdx::fill_v2_preimages(event_log); } @@ -71,17 +86,56 @@ pub fn simulated_certificate_attestation( attestation: &VersionedAttestation, pubkey: &[u8], patch_report_data: bool, + generator: Option<&TdxGenerator>, ) -> Result { let report_data = QuoteContentType::RaTlsCert.to_report_data(pubkey); - let attestation = maybe_patch_report_data( + let attestation = prepare_attestation( attestation, report_data, patch_report_data, + generator, "certificate_attestation", - ); + )?; Ok(VersionedAttestation::V1 { attestation }) } +fn prepare_attestation( + attestation: &VersionedAttestation, + report_data: [u8; 64], + patch_report_data: bool, + generator: Option<&TdxGenerator>, + context: &str, +) -> Result { + let Some(generator) = generator else { + return Ok(maybe_patch_report_data( + attestation, + report_data, + patch_report_data, + context, + )); + }; + let mut attestation = attestation.clone().into_v1().with_report_data(report_data); + let quote = attestation + .platform + .tdx_quote() + .context("TDX quote is unavailable in simulator fixture")?; + let quote = Quote::parse(quote).context("invalid simulator fixture TDX quote")?; + let report = quote + .report + .as_td10() + .context("simulator fixture does not contain a TDX 1.0 report")?; + let evidence = generator.attest_with_measurements( + report_data, + report.mr_td, + [report.rt_mr0, report.rt_mr1, report.rt_mr2, report.rt_mr3], + )?; + match &mut attestation.platform { + PlatformEvidence::Tdx { quote, .. } => *quote = evidence.quote, + _ => return Err(anyhow!("seeded simulator requires dstack TDX evidence")), + } + Ok(attestation) +} + fn maybe_patch_report_data( attestation: &VersionedAttestation, report_data: [u8; 64], diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index 4bfb701cf..6451c40b0 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -10,7 +10,7 @@ use std::{ use anyhow::{Context, Result}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use cert_client::CertRequestClient; -use dstack_attest::attestation::AttestationVerifier; +use dstack_attest::default_verifier; use dstack_guest_agent_rpc::{ dstack_guest_server::{DstackGuestRpc, DstackGuestServer}, tappd_server::{TappdRpc, TappdServer}, @@ -163,7 +163,9 @@ impl AppState { .context("Failed to parse VM config")?; let collateral_urls = sys_config.collateral_urls(); let vm_config = sys_config.vm_config; - let verifier = Arc::new(AttestationVerifier::new_prod(Some(&collateral_urls))?); + // Same trust anchor decision as dstack-util: never host-supplied, and + // development roots only when this guest published them itself. + let verifier = Arc::new(default_verifier(&collateral_urls)?); let cert_client = CertRequestClient::create(&keys, verifier, vm_config.clone()) .await .context("Failed to create cert signer")?; @@ -690,6 +692,7 @@ mod tests { backend::PlatformBackend, config::{AppComposeWrapper, Config}, }; + use dstack_attest::attestation::AttestationVerifier; use dstack_guest_agent_rpc::{GetAttestationForAppKeyRequest, SignRequest}; use dstack_types::{AppCompose, AppKeys, EventLogVersion, KeyProvider}; use ed25519_dalek::ed25519::signature::hazmat::PrehashVerifier; diff --git a/dstack/tee-simulator/Cargo.toml b/dstack/tee-simulator/Cargo.toml index 061c4b247..9fb1aa775 100644 --- a/dstack/tee-simulator/Cargo.toml +++ b/dstack/tee-simulator/Cargo.toml @@ -17,6 +17,7 @@ path = "src/main.rs" anyhow.workspace = true cc-eventlog.workspace = true clap.workspace = true +dstack-attest.workspace = true dstack-types.workspace = true dstack-mr.workspace = true fuser.workspace = true @@ -29,6 +30,7 @@ tracing.workspace = true tracing-subscriber.workspace = true serde_json.workspace = true fs-err.workspace = true +safe-write.workspace = true hex.workspace = true mock-attestation = { path = "../crates/mock-attestation" } tokio = { workspace = true, features = ["full"] } diff --git a/dstack/tee-simulator/src/main.rs b/dstack/tee-simulator/src/main.rs index f40507280..940a8eba3 100644 --- a/dstack/tee-simulator/src/main.rs +++ b/dstack/tee-simulator/src/main.rs @@ -4,12 +4,13 @@ use std::{ ffi::CString, - os::unix::ffi::OsStrExt, + os::unix::{ffi::OsStrExt, fs::PermissionsExt as _}, path::{Path, PathBuf}, }; use anyhow::{bail, Context, Result}; use clap::Parser; +use dstack_attest::{attestation::RootCaPaths, trust_anchors}; use dstack_types::{TeeSimulatorConfig, TeeVariant}; use fuser::{Filesystem, MountOption, Session}; use tracing::info; @@ -19,6 +20,12 @@ mod sev_snp; mod tdx; mod tpm; +/// Runtime-directory-relative location of [`trust_anchors::ANCHOR_DIR`]. +const TRUST_ANCHOR_SUBDIR: &str = "attestation"; + +/// Matches `MockCollateralState::new`, used when the host names no service. +const DEFAULT_COLLATERAL_BASE_URL: &str = "http://127.0.0.1:8088"; + #[derive(Parser)] #[command(about = "Development-only simulator for Linux TEE guest ABIs")] struct Args { @@ -140,6 +147,7 @@ fn main() -> Result<()> { let config = load_config(&args.config)?; let platform = args.platform.unwrap_or(config.platform); fs_err::create_dir_all(&args.runtime_dir)?; + publish_trust_anchors(&args.runtime_dir, &config)?; match platform { TeeVariant::DstackTdx => { simulate_dmi(&args.runtime_dir, &args.dmi_root, "Dstack", "dstack")?; @@ -175,6 +183,71 @@ fn main() -> Result<()> { } } +/// Publish the external trust anchors the guest verifier reads. +/// +/// The roots come from the same seed that signs the simulated evidence, so +/// nothing has to travel from the host: it supplies the seed for a fake TEE +/// device and never names a trust anchor. This binary ships only in the +/// development image, and the systemd unit orders it before `dstack-prepare`, +/// so `dstack-util` sees the roots when they exist and vendor production roots +/// everywhere else. +/// +/// Every platform's root is published regardless of the simulated platform: a +/// development guest also verifies a KMS and a gateway, and those need not run +/// on the platform this guest simulates. +fn publish_trust_anchors(runtime_dir: &Path, config: &TeeSimulatorConfig) -> Result<()> { + let seed = config + .mock_attestation_seed + .as_deref() + .context("tee_simulator.mock_attestation_seed is required")?; + let seed = mock_attestation::parse_seed(seed)?; + let base_url = config + .collateral_base_url + .as_deref() + .unwrap_or(DEFAULT_COLLATERAL_BASE_URL); + let pki = mock_attestation::server::MockCollateralState::from_seed(seed, base_url)?; + + let dir = trust_anchor_dir(runtime_dir); + fs_err::create_dir_all(&dir).context("failed to create the trust anchor directory")?; + // The reader refuses anything group- or world-writable, so the directory + // has to be tightened even when it already existed with a laxer mode. + fs_err::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)) + .context("failed to restrict the trust anchor directory")?; + let tdx = write_root(&dir, "tdx-root-ca.pem", &pki.tdx.root_ca_pem())?; + let tpm = write_root(&dir, "tpm-root-ca.pem", &pki.tpm.root_ca_pem())?; + let nsm = write_root(&dir, "nsm-root-ca.pem", &pki.nsm.root_ca_pem())?; + let sev_snp = write_root(&dir, "sev-snp-root-ca.pem", &pki.sev_snp.root_ca_pem())?; + + let root_ca = RootCaPaths { + tdx: Some(tdx), + gcp_tpm: Some(tpm.clone()), + aws_nitro_tpm: Some(tpm), + aws_nitro_enclave: Some(nsm), + sev_snp_milan: Some(sev_snp.clone()), + sev_snp_genoa: Some(sev_snp.clone()), + sev_snp_turin: Some(sev_snp), + }; + safe_write::safe_write_with_mode( + trust_anchors::roots_path(&dir), + serde_json::to_vec_pretty(&root_ca).context("failed to serialize published roots")?, + 0o600, + ) + .context("failed to write the published roots")?; + info!(dir = %dir.display(), "published external attestation trust anchors"); + Ok(()) +} + +fn write_root(dir: &Path, name: &str, pem: &str) -> Result { + let path = dir.join(name); + safe_write::safe_write_with_mode(&path, pem.as_bytes(), 0o600) + .with_context(|| format!("failed to write {name}"))?; + Ok(path) +} + +fn trust_anchor_dir(runtime_dir: &Path) -> PathBuf { + runtime_dir.join(TRUST_ANCHOR_SUBDIR) +} + /// Override the DMI strings exported by SeaBIOS through sysfs. Platform /// detection intentionally remains unchanged and observes the same values as /// it does on a real cloud VM. @@ -239,6 +312,47 @@ mod tests { assert!(load_config(Path::new("/definitely/missing/config")).is_err()); } + #[test] + fn default_runtime_dir_matches_the_verifier_handoff_path() { + let args = Args::try_parse_from(["dstack-tee-simulator"]).unwrap(); + assert_eq!( + trust_anchor_dir(&args.runtime_dir), + Path::new(trust_anchors::ANCHOR_DIR) + ); + } + + #[test] + fn published_tdx_root_matches_the_seeded_pki() { + let dir = tempfile::tempdir().unwrap(); + let seed = [0x21; 32]; + publish_trust_anchors( + dir.path(), + &TeeSimulatorConfig { + mock_attestation_seed: Some(hex::encode(seed)), + ..Default::default() + }, + ) + .unwrap(); + + let roots = trust_anchors::load_anchors(&trust_anchor_dir(dir.path())) + .unwrap() + .expect("simulator should publish trust anchors"); + let published = fs_err::read(roots.tdx.as_ref().unwrap()).unwrap(); + let expected = mock_attestation::tdx::TdxGenerator::from_seed(seed) + .unwrap() + .root_ca_pem(); + assert_eq!(String::from_utf8(published).unwrap(), expected); + } + + #[test] + fn a_seedless_config_publishes_nothing() { + let dir = tempfile::tempdir().unwrap(); + assert!(publish_trust_anchors(dir.path(), &TeeSimulatorConfig::default()).is_err()); + assert!(trust_anchors::load_anchors(&trust_anchor_dir(dir.path())) + .unwrap() + .is_none()); + } + #[test] fn config_selects_each_platform() { for (name, expected) in [ diff --git a/dstack/tee-simulator/tests/process_e2e.rs b/dstack/tee-simulator/tests/process_e2e.rs index 18d559034..df13689eb 100644 --- a/dstack/tee-simulator/tests/process_e2e.rs +++ b/dstack/tee-simulator/tests/process_e2e.rs @@ -4,11 +4,20 @@ use std::{ process::{Child, Command, Stdio}, - time::Duration, + time::{Duration, Instant}, }; use mock_attestation::server::MockCollateralState; +/// The simulator derives a development PKI before it mounts anything, so the +/// budget has to cover process spawn plus four key generations, not just the +/// mount. A debug build on a loaded CI runner is an order of magnitude slower +/// than a local release-ish one, so keep the headroom generous: the test still +/// returns as soon as the mountpoint appears, and a dead child is reported +/// immediately rather than waited out. +const READY_TIMEOUT: Duration = Duration::from_secs(30); +const POLL_INTERVAL: Duration = Duration::from_millis(50); + struct ChildGuard(Child); impl Drop for ChildGuard { fn drop(&mut self) { @@ -44,7 +53,7 @@ async fn start(platform: &str, seed: [u8; 32]) -> (tempfile::TempDir, ChildGuard if matches!(platform, "dstack-tdx" | "dstack-amd-sev-snp") { args.extend(["--mountpoint".into(), mountpoint.display().to_string()]); } - let child = ChildGuard( + let mut child = ChildGuard( Command::new(env!("CARGO_BIN_EXE_dstack-tee-simulator")) .args(args) .stdout(Stdio::null()) @@ -52,7 +61,8 @@ async fn start(platform: &str, seed: [u8; 32]) -> (tempfile::TempDir, ChildGuard .spawn() .unwrap(), ); - for _ in 0..50 { + let deadline = Instant::now() + READY_TIMEOUT; + loop { let ready = match platform { "dstack-tdx" => mountpoint.join("com.intel.dcap/outblob").exists(), "dstack-amd-sev-snp" => mountpoint.join("provider").exists(), @@ -61,9 +71,17 @@ async fn start(platform: &str, seed: [u8; 32]) -> (tempfile::TempDir, ChildGuard if ready { return (dir, child); } - tokio::time::sleep(Duration::from_millis(20)).await; + // A simulator that already exited never becomes ready. Say which one + // and with what status instead of reporting a timeout that hides it. + if let Some(status) = child.0.try_wait().unwrap() { + panic!("{platform} simulator exited before becoming ready: {status}"); + } + assert!( + Instant::now() < deadline, + "{platform} simulator did not become ready within {READY_TIMEOUT:?}" + ); + tokio::time::sleep(POLL_INTERVAL).await; } - panic!("{platform} simulator process did not become ready") } #[tokio::test] @@ -83,6 +101,14 @@ async fn separate_simulator_process_imports_config_seed_for_tsm_platforms() { dcap_qvl::verify::QuoteVerifier::new(host.tdx.root_ca_der()) .verify("e, &host.tdx.sample_collateral().unwrap(), now) .unwrap(); + + // The guest verifier's trust anchor comes from the simulator, never from + // the host, so the published root must be the one that signed this quote. + let published = dstack_attest::trust_anchors::load_anchors(&dir.path().join("attestation")) + .unwrap() + .expect("simulator should publish external trust anchors"); + let root = fs_err::read(published.tdx.unwrap()).unwrap(); + assert_eq!(String::from_utf8(root).unwrap(), host.tdx.root_ca_pem()); drop(child); let (dir, child) = start("dstack-amd-sev-snp", seed).await; diff --git a/dstack/vmm/src/app.rs b/dstack/vmm/src/app.rs index d068481e1..fcc9d3f69 100644 --- a/dstack/vmm/src/app.rs +++ b/dstack/vmm/src/app.rs @@ -1325,6 +1325,11 @@ pub(crate) fn make_sys_config( "host_api_url": format!("vsock://2:{}/api", cfg.host_api.port), "vm_config": serde_json::to_string(&vm_config)?, }); + // No attestation trust anchor is ever written here. Simulated deployments + // receive only the development seed through `.tee-simulator.json`; the + // in-guest simulator derives the matching roots itself and publishes them + // to the guest verifier. A host cannot be allowed to choose the root that + // authenticates the guest's key provider. if let Some(mr_config) = mr_config { MrConfigV3::from_document(&mr_config).context("Invalid mr_config document")?; sys_config["mr_config"] = serde_json::to_value(mr_config)?; @@ -2177,6 +2182,19 @@ mod tests { make_sys_config(&config, &manifest, &compose_hash, Some(mr_config), None)?; let sys_config: serde_json::Value = serde_json::from_str(&sys_config_document)?; assert!(sys_config.get("tee_simulator").is_none()); + // A host must never nominate the trust anchor that authenticates its + // guest's key provider, in any deployment mode. Simulated guests derive + // their own roots from the seed in `.tee-simulator.json` instead. + for key in sys_config + .as_object() + .context("sys-config must be an object")? + .keys() + { + assert!( + !key.contains("root_ca") && !key.contains("trust_anchor"), + "sys-config must not carry an attestation trust anchor: {key}" + ); + } assert_eq!(sys_config["pccs_url"], config.cvm.pccs_url); assert_eq!(sys_config["collateral_urls"]["pccs"], config.cvm.pccs_url); let vm_config: serde_json::Value = serde_json::from_str( diff --git a/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/files/dstack-tee-simulator.service b/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/files/dstack-tee-simulator.service index fb09a4cb4..63b8bcf53 100644 --- a/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/files/dstack-tee-simulator.service +++ b/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/files/dstack-tee-simulator.service @@ -16,6 +16,9 @@ ExecStopPost=-/usr/bin/dstack-util host-shared unmount --mount-point /run/dstack ExecStopPost=-/bin/umount /sys/class/dmi/id/sys_vendor ExecStopPost=-/bin/umount /sys/class/dmi/id/product_name ExecStopPost=-/bin/sh -c 'test ! -e /run/dstack/created-tpm-marker || rm -f /dev/tpmrm0 /run/dstack/created-tpm-marker' +# External trust anchors outlive no simulator run: dstack-util must fall +# back to production roots the moment the simulated TEE is gone. +ExecStopPost=-/bin/rm -rf /run/dstack/attestation ExecStopPost=/bin/systemctl unset-environment DCAP_TDX_RTMR_SYSFS_PATH DSTACK_CCEL_FILE Restart=on-failure RestartSec=1s