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).
45 "untrusted comment: vanta registry root key\nRWTKdzWEeVXHgj5NxXCdfaJwJYJ5rdpNJ+MJ4IINh2RlSVBgOOt7QbKL",
46];
47
48/// The on-disk shape of `<trust_dir>/roots.toml`.
49#[derive(Debug, Default, Deserialize)]
50#[serde(default, deny_unknown_fields)]
51struct RootsFile {
52 /// Full minisign public-key texts, one per pinned root.
53 keys: Vec<String>,
54}
55
56/// Load the set of pinned root public-key texts: the compiled-in roots plus any
57/// the operator placed in the user-owned `<trust_dir>/roots.toml`. These are the
58/// only keys ever trusted to authenticate an index; they are never read from a
59/// fetched index.
60///
61/// A missing or malformed `roots.toml` contributes no keys (fail-closed: we do
62/// not invent trust on error).
63pub fn load_root_keys(trust_dir: &Path) -> Vec<String> {
64 let mut roots: Vec<String> = COMPILED_IN_ROOT_KEYS
65 .iter()
66 .map(|s| s.to_string())
67 .collect();
68 let path = trust_dir.join("roots.toml");
69 if let Ok(src) = std::fs::read_to_string(&path) {
70 if let Ok(parsed) = toml::from_str::<RootsFile>(&src) {
71 roots.extend(parsed.keys);
72 }
73 }
74 roots
75}
76
77/// Whether `index_bytes` carries a detached `signature` produced by one of the
78/// pinned `roots`. Tries every root and returns `true` on the first that
79/// verifies; returns `false` if none do (including when `roots` is empty).
80pub fn index_signed_by_root(index_bytes: &[u8], signature: &str, roots: &[String]) -> bool {
81 roots.iter().any(|root| match parse_minisign_pubkey(root) {
82 Ok(key) => minisign_verify(index_bytes, signature, &key).is_ok(),
83 Err(_) => false,
84 })
85}
86
87/// Whether a per-artifact signing key (carried by the index) may be trusted.
88///
89/// Trusted iff the index was verified against a pinned root (`index_verified`,
90/// transitive trust) **or** the key itself is one of the pinned roots. Otherwise
91/// the key is attacker-influenceable and must be treated as unverified.
92pub fn artifact_key_is_trusted(artifact_key: &str, index_verified: bool, roots: &[String]) -> bool {
93 index_verified || key_in_roots(artifact_key, roots)
94}
95
96/// Compare a public-key text against the pinned set by its canonical base64
97/// payload line (ignores comment lines / surrounding whitespace).
98fn key_in_roots(key: &str, roots: &[String]) -> bool {
99 match payload_line(key) {
100 Some(target) => roots.iter().any(|r| payload_line(r) == Some(target)),
101 None => false,
102 }
103}
104
105/// Extract a minisign key's base64 payload line (the last non-empty,
106/// non-comment line), mirroring [`parse_minisign_pubkey`]'s selection.
107fn payload_line(text: &str) -> Option<&str> {
108 text.lines()
109 .map(str::trim)
110 .rfind(|l| !l.is_empty() && !l.starts_with("untrusted comment:"))
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 // Build a minisign keypair (from `seed`) plus a legacy `Ed` detached
118 // signature over `data`; returns `(public_key_text, signature_text)`.
119 fn sign(seed: [u8; 32], data: &[u8]) -> (String, String) {
120 use base64::{engine::general_purpose::STANDARD, Engine};
121 use ed25519_dalek::{Signer, SigningKey};
122 let key_id = [1u8, 2, 3, 4, 5, 6, 7, 8];
123 let sk = SigningKey::from_bytes(&seed);
124 let pk = sk.verifying_key().to_bytes();
125 let sig = sk.sign(data).to_bytes();
126 let mut pk_raw = b"Ed".to_vec();
127 pk_raw.extend_from_slice(&key_id);
128 pk_raw.extend_from_slice(&pk);
129 let pubkey = format!("untrusted comment: test\n{}", STANDARD.encode(&pk_raw));
130 let mut sig_raw = b"Ed".to_vec();
131 sig_raw.extend_from_slice(&key_id);
132 sig_raw.extend_from_slice(&sig);
133 let sig_file = format!(
134 "untrusted comment: test sig\n{}\ntrusted comment: t\n{}",
135 STANDARD.encode(&sig_raw),
136 STANDARD.encode([0u8; 64])
137 );
138 (pubkey, sig_file)
139 }
140
141 #[test]
142 fn index_verified_only_against_pinned_root() {
143 let index = b"[tools.node]\n# the registry index bytes";
144 let (root_pub, sig) = sign([7u8; 32], index);
145 let roots = [root_pub];
146 // Signed by the pinned root → trusted.
147 assert!(index_signed_by_root(index, &sig, &roots));
148 // No pinned roots → cannot be trusted.
149 assert!(!index_signed_by_root(index, &sig, &[]));
150 // A different (attacker) key is pinned → the index's signature does not
151 // verify against it → rejected.
152 let (attacker_pub, _) = sign([9u8; 32], b"unrelated");
153 assert!(!index_signed_by_root(index, &sig, &[attacker_pub]));
154 // Tampered index bytes → signature no longer verifies.
155 assert!(!index_signed_by_root(b"tampered", &sig, &roots));
156 }
157
158 #[test]
159 fn artifact_key_trust_rules() {
160 let (attacker_pub, _) = sign([3u8; 32], b"x");
161 let (root_pub, _) = sign([4u8; 32], b"y");
162 let roots = [root_pub.clone()];
163
164 // Unsigned/unverified index + attacker-supplied key → NOT trusted.
165 assert!(!artifact_key_is_trusted(&attacker_pub, false, &roots));
166 // Verified index → transitive trust of whatever key it carried.
167 assert!(artifact_key_is_trusted(&attacker_pub, true, &roots));
168 // Unverified index, but the artifact key IS a pinned root → trusted.
169 assert!(artifact_key_is_trusted(&root_pub, false, &roots));
170 }
171}