Skip to main content

lean_ctx/core/
signer_trust.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Signer trust anchors for handoff bundles and context packages
3//! (GHSA-gvg8-285q-m62g).
4//!
5//! A signature embedded in an artifact together with its own public key only
6//! proves integrity: *someone* signed these bytes. Authenticity needs an anchor
7//! that the artifact cannot supply itself. Two anchors count:
8//!
9//! * `<data_dir>/trusted-signers.toml` — explicit `(subject, public_key)` pins,
10//!   written by hand for peers, or on first remote install of a package
11//!   namespace (trust on first use, see [`pin_if_absent`]).
12//! * A key held by a local agent identity (`<data_dir>/agents/…`): the private
13//!   key lives on this machine, so a matching public key is ours.
14//!
15//! ```toml
16//! [[signers]]
17//! subject = "release-bot"          # handoff signer agent id, or "@ns" for packages
18//! public_key = "<64 hex chars>"
19//! ```
20
21use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25
26const TRUST_FILE: &str = "trusted-signers.toml";
27
28#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
29pub(crate) struct TrustedSigner {
30    pub subject: String,
31    pub public_key: String,
32    #[serde(default, skip_serializing_if = "String::is_empty")]
33    pub source: String,
34}
35
36#[derive(Clone, Debug, Default, Serialize, Deserialize)]
37struct TrustFile {
38    #[serde(default)]
39    signers: Vec<TrustedSigner>,
40}
41
42fn trust_path() -> Result<PathBuf, String> {
43    Ok(crate::core::data_dir::resolve_data_dir()?.join(TRUST_FILE))
44}
45
46fn load() -> TrustFile {
47    let Ok(path) = trust_path() else {
48        return TrustFile::default();
49    };
50    match crate::core::policy::files::read(&path, false) {
51        Ok(Some(text)) => toml::from_str(&text).unwrap_or_else(|_| {
52            tracing::warn!("{TRUST_FILE} is invalid — no signer is trusted from it");
53            TrustFile::default()
54        }),
55        _ => TrustFile::default(),
56    }
57}
58
59fn save(file: &TrustFile) -> Result<(), String> {
60    let path = trust_path()?;
61    let parent = path.parent().ok_or("invalid trust store path")?;
62    std::fs::create_dir_all(parent).map_err(|e| format!("create trust store dir: {e}"))?;
63    let text = toml::to_string_pretty(file).map_err(|e| format!("serialize trust store: {e}"))?;
64    let tmp = parent.join(format!(".{TRUST_FILE}.tmp"));
65    std::fs::write(&tmp, text).map_err(|e| format!("write trust store: {e}"))?;
66    std::fs::rename(&tmp, &path).map_err(|e| format!("write trust store: {e}"))
67}
68
69fn normalize_key(public_key_hex: &str) -> String {
70    public_key_hex.trim().to_ascii_lowercase()
71}
72
73/// Short, stable fingerprint of a hex-encoded public key for display:
74/// the first 16 hex chars of SHA-256 over the raw key bytes.
75pub(crate) fn key_fingerprint(public_key_hex: &str) -> String {
76    let normalized = normalize_key(public_key_hex);
77    let bytes = crate::core::agent_identity::hex_decode(&normalized)
78        .unwrap_or_else(|_| normalized.into_bytes());
79    let digest = Sha256::digest(&bytes);
80    crate::core::agent_identity::hex_encode(&digest)[..16].to_string()
81}
82
83/// Whether `public_key_hex` is an anchored key for `subject`.
84pub(crate) fn is_trusted(subject: &str, public_key_hex: &str) -> bool {
85    let key = normalize_key(public_key_hex);
86    if key.is_empty() {
87        return false;
88    }
89    if load()
90        .signers
91        .iter()
92        .any(|s| s.subject == subject && normalize_key(&s.public_key) == key)
93    {
94        return true;
95    }
96    crate::core::agent_identity::get_stored_public_key(subject)
97        .is_ok_and(|local| crate::core::agent_identity::hex_encode(&local.to_bytes()) == key)
98}
99
100/// Outcome of [`pin_if_absent`].
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum PinOutcome {
103    /// The key was already the pinned key for this subject.
104    AlreadyPinned,
105    /// No key was pinned; this one is now (trust on first use).
106    NewlyPinned,
107    /// A *different* key is pinned — the caller must reject.
108    Mismatch { pinned_fingerprint: String },
109}
110
111/// Read-only TOFU check: the fingerprint of the key pinned for `subject` when
112/// it differs from `public_key_hex`; `None` when no key or the same key is
113/// pinned. Lets a caller refuse a re-signed artifact *before* installing it
114/// and record the pin with [`pin_if_absent`] only after the install succeeded.
115pub(crate) fn pinned_key_conflict(subject: &str, public_key_hex: &str) -> Option<String> {
116    let key = normalize_key(public_key_hex);
117    load()
118        .signers
119        .iter()
120        .find(|s| s.subject == subject)
121        .map(|existing| normalize_key(&existing.public_key))
122        .filter(|pinned| *pinned != key)
123        .map(|pinned| key_fingerprint(&pinned))
124}
125
126/// Trust-on-first-use pin: record `public_key_hex` for `subject` unless a key
127/// is already pinned, in which case it must match.
128pub fn pin_if_absent(
129    subject: &str,
130    public_key_hex: &str,
131    source: &str,
132) -> Result<PinOutcome, String> {
133    let key = normalize_key(public_key_hex);
134    let mut file = load();
135    if let Some(existing) = file.signers.iter().find(|s| s.subject == subject) {
136        let pinned = normalize_key(&existing.public_key);
137        return Ok(if pinned == key {
138            PinOutcome::AlreadyPinned
139        } else {
140            PinOutcome::Mismatch {
141                pinned_fingerprint: key_fingerprint(&pinned),
142            }
143        });
144    }
145    file.signers.push(TrustedSigner {
146        subject: subject.to_string(),
147        public_key: key,
148        source: source.to_string(),
149    });
150    save(&file)?;
151    Ok(PinOutcome::NewlyPinned)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn pins_are_subject_bound_and_tofu_rejects_key_change() {
160        let _lock = crate::core::data_dir::test_env_lock();
161        let dir = tempfile::tempdir().unwrap();
162        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
163
164        let key_a = "aa".repeat(32);
165        let key_b = "bb".repeat(32);
166        assert!(!is_trusted("@acme", &key_a));
167        assert_eq!(pinned_key_conflict("@acme", &key_a), None);
168        assert_eq!(
169            pin_if_absent("@acme", &key_a, "tofu").unwrap(),
170            PinOutcome::NewlyPinned
171        );
172        assert!(is_trusted("@acme", &key_a.to_ascii_uppercase()));
173        assert!(!is_trusted("@other", &key_a), "pins are subject-bound");
174        assert_eq!(
175            pin_if_absent("@acme", &key_a, "tofu").unwrap(),
176            PinOutcome::AlreadyPinned
177        );
178        assert!(matches!(
179            pin_if_absent("@acme", &key_b, "tofu").unwrap(),
180            PinOutcome::Mismatch { .. }
181        ));
182        assert!(!is_trusted("@acme", &key_b));
183        assert_eq!(pinned_key_conflict("@acme", &key_a), None);
184        assert_eq!(
185            pinned_key_conflict("@acme", &key_b),
186            Some(key_fingerprint(&key_a))
187        );
188        assert!(
189            is_trusted("@acme", &key_a),
190            "a rejected key never replaces the pin"
191        );
192        assert_eq!(key_fingerprint(&key_a).len(), 16);
193
194        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
195    }
196}