Skip to main content

vanta_security/
trust.rs

1//! Pinned-root trust model for the registry index (audit C1).
2//!
3//! The registry index is the system's trust root: it names artifact URLs,
4//! checksums, detached signatures, **and** the public keys that verify those
5//! signatures. If the index itself is unauthenticated, an attacker who controls
6//! the index (MITM on a plaintext URL, a malicious `$VANTA_REGISTRY`, a
7//! compromised mirror) can supply their own keypair and sign malicious bytes —
8//! signature verification then provides zero protection.
9//!
10//! This module implements a minimal but real pinned-root model:
11//!
12//!  1. A small set of **root public keys** is pinned out-of-band — compiled in
13//!     ([`COMPILED_IN_ROOT_KEYS`]) and/or loaded from the user-owned trusted
14//!     config at `<trust_dir>/roots.toml` ([`load_root_keys`]). Roots are
15//!     **never** sourced from the fetched index.
16//!  2. A fetched index must carry a detached signature that
17//!     [`index_signed_by_root`] verifies against one of the pinned roots before
18//!     its entries are trusted.
19//!  3. A per-artifact signing key (carried in the index) is only trusted if the
20//!     index that carried it was itself verified against a pinned root
21//!     (transitive trust), **or** that key is itself in the pinned set
22//!     ([`artifact_key_is_trusted`]). Otherwise it is treated as unverified.
23//!
24//! Verification is fail-closed throughout: any parse/length/scheme failure
25//! denies trust rather than granting it.
26
27use crate::sign::{minisign_verify, parse_minisign_pubkey};
28use serde::Deserialize;
29use std::path::Path;
30
31/// Compiled-in trusted root public keys (minisign format, one full key text per
32/// entry — the same shape minisign writes, including the `untrusted comment:`
33/// line).
34///
35/// The pinned Vanta release registry root key(s). A fetched network index must
36/// carry a detached signature that verifies against one of these before its
37/// entries are trusted. The matching secret is held offline by the registry
38/// maintainer (see `registry/README.md`); it is never stored in the repository.
39///
40/// To rotate, append the new public key here (keep the old one until every
41/// published index is re-signed), rebuild, then regenerate + re-sign the
42/// registry with `cargo xtask registry-gen`.
43pub const COMPILED_IN_ROOT_KEYS: &[&str] = &[
44    // Vanta official registry root (minisign Ed25519). Rotated 2026-07;
45    // matching secret held offline (never in-repo). See docs/32-release-engineering.md.
46    "untrusted comment: vanta registry root key\nRWRtV+X73KzRrsfTdjc9nUSXUelwWX95JiLxURcQxpUDj4EQfCcf4NoS",
47];
48
49/// The on-disk shape of `<trust_dir>/roots.toml`.
50#[derive(Debug, Default, Deserialize)]
51#[serde(default, deny_unknown_fields)]
52struct RootsFile {
53    /// Full minisign public-key texts, one per pinned root.
54    keys: Vec<String>,
55}
56
57/// Load the set of pinned root public-key texts: the compiled-in roots plus any
58/// the operator placed in the user-owned `<trust_dir>/roots.toml`. These are the
59/// only keys ever trusted to authenticate an index; they are never read from a
60/// fetched index.
61///
62/// A missing or malformed `roots.toml` contributes no keys (fail-closed: we do
63/// not invent trust on error).
64pub fn load_root_keys(trust_dir: &Path) -> Vec<String> {
65    let mut roots: Vec<String> = COMPILED_IN_ROOT_KEYS
66        .iter()
67        .map(|s| s.to_string())
68        .collect();
69    let path = trust_dir.join("roots.toml");
70    if let Ok(src) = std::fs::read_to_string(&path) {
71        if let Ok(parsed) = toml::from_str::<RootsFile>(&src) {
72            roots.extend(parsed.keys);
73        }
74    }
75    roots
76}
77
78/// Whether `index_bytes` carries a detached `signature` produced by one of the
79/// pinned `roots`. Tries every root and returns `true` on the first that
80/// verifies; returns `false` if none do (including when `roots` is empty).
81pub fn index_signed_by_root(index_bytes: &[u8], signature: &str, roots: &[String]) -> bool {
82    roots.iter().any(|root| match parse_minisign_pubkey(root) {
83        Ok(key) => minisign_verify(index_bytes, signature, &key).is_ok(),
84        Err(_) => false,
85    })
86}
87
88/// Whether a per-artifact signing key (carried by the index) may be trusted.
89///
90/// Trusted iff the index was verified against a pinned root (`index_verified`,
91/// transitive trust) **or** the key itself is one of the pinned roots. Otherwise
92/// the key is attacker-influenceable and must be treated as unverified.
93pub fn artifact_key_is_trusted(artifact_key: &str, index_verified: bool, roots: &[String]) -> bool {
94    index_verified || key_in_roots(artifact_key, roots)
95}
96
97/// Compare a public-key text against the pinned set by its canonical base64
98/// payload line (ignores comment lines / surrounding whitespace).
99fn key_in_roots(key: &str, roots: &[String]) -> bool {
100    match payload_line(key) {
101        Some(target) => roots.iter().any(|r| payload_line(r) == Some(target)),
102        None => false,
103    }
104}
105
106/// Extract a minisign key's base64 payload line (the last non-empty,
107/// non-comment line), mirroring [`parse_minisign_pubkey`]'s selection.
108fn payload_line(text: &str) -> Option<&str> {
109    text.lines()
110        .map(str::trim)
111        .rfind(|l| !l.is_empty() && !l.starts_with("untrusted comment:"))
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    /// Regression guard: the registry shipped in the repo must verify against a
119    /// compiled-in root key. Catches a rotation that swaps the pinned pubkey but
120    /// forgets to re-sign the index (or vice versa) — which would brick every
121    /// client's `fetch_signed_index`. Skips gracefully if the files are absent
122    /// (e.g. a partial checkout).
123    #[test]
124    fn shipped_registry_verifies_against_pinned_root() {
125        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../registry");
126        let toml = dir.join("registry.toml");
127        let sig = dir.join("registry.toml.minisig");
128        let (Ok(bytes), Ok(signature)) = (std::fs::read(&toml), std::fs::read_to_string(&sig))
129        else {
130            eprintln!("registry files absent; skipping signed-registry check");
131            return;
132        };
133        let roots: Vec<String> = COMPILED_IN_ROOT_KEYS
134            .iter()
135            .map(|s| s.to_string())
136            .collect();
137        assert!(
138            index_signed_by_root(&bytes, &signature, &roots),
139            "registry/registry.toml is not signed by any COMPILED_IN_ROOT_KEYS entry \
140             — re-run `cargo xtask registry-gen` with the current root secret"
141        );
142    }
143
144    // Build a minisign keypair (from `seed`) plus a legacy `Ed` detached
145    // signature over `data`; returns `(public_key_text, signature_text)`.
146    fn sign(seed: [u8; 32], data: &[u8]) -> (String, String) {
147        use base64::{engine::general_purpose::STANDARD, Engine};
148        use ed25519_dalek::{Signer, SigningKey};
149        let key_id = [1u8, 2, 3, 4, 5, 6, 7, 8];
150        let sk = SigningKey::from_bytes(&seed);
151        let pk = sk.verifying_key().to_bytes();
152        let sig = sk.sign(data).to_bytes();
153        let mut pk_raw = b"Ed".to_vec();
154        pk_raw.extend_from_slice(&key_id);
155        pk_raw.extend_from_slice(&pk);
156        let pubkey = format!("untrusted comment: test\n{}", STANDARD.encode(&pk_raw));
157        let mut sig_raw = b"Ed".to_vec();
158        sig_raw.extend_from_slice(&key_id);
159        sig_raw.extend_from_slice(&sig);
160        let sig_file = format!(
161            "untrusted comment: test sig\n{}\ntrusted comment: t\n{}",
162            STANDARD.encode(&sig_raw),
163            STANDARD.encode([0u8; 64])
164        );
165        (pubkey, sig_file)
166    }
167
168    #[test]
169    fn index_verified_only_against_pinned_root() {
170        let index = b"[tools.node]\n# the registry index bytes";
171        let (root_pub, sig) = sign([7u8; 32], index);
172        let roots = [root_pub];
173        // Signed by the pinned root → trusted.
174        assert!(index_signed_by_root(index, &sig, &roots));
175        // No pinned roots → cannot be trusted.
176        assert!(!index_signed_by_root(index, &sig, &[]));
177        // A different (attacker) key is pinned → the index's signature does not
178        // verify against it → rejected.
179        let (attacker_pub, _) = sign([9u8; 32], b"unrelated");
180        assert!(!index_signed_by_root(index, &sig, &[attacker_pub]));
181        // Tampered index bytes → signature no longer verifies.
182        assert!(!index_signed_by_root(b"tampered", &sig, &roots));
183    }
184
185    #[test]
186    fn artifact_key_trust_rules() {
187        let (attacker_pub, _) = sign([3u8; 32], b"x");
188        let (root_pub, _) = sign([4u8; 32], b"y");
189        let roots = [root_pub.clone()];
190
191        // Unsigned/unverified index + attacker-supplied key → NOT trusted.
192        assert!(!artifact_key_is_trusted(&attacker_pub, false, &roots));
193        // Verified index → transitive trust of whatever key it carried.
194        assert!(artifact_key_is_trusted(&attacker_pub, true, &roots));
195        // Unverified index, but the artifact key IS a pinned root → trusted.
196        assert!(artifact_key_is_trusted(&root_pub, false, &roots));
197    }
198}