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}