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