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).
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}