lean_ctx/core/
signer_trust.rs1use 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
73pub(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
83pub(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#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum PinOutcome {
103 AlreadyPinned,
105 NewlyPinned,
107 Mismatch { pinned_fingerprint: String },
109}
110
111pub(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
126pub 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}