1pub mod legacy;
7pub mod revocations;
8pub mod rotation;
9pub mod skills;
10
11pub use revocations::{RevocationsList, RevokedEntry};
12
13use crate::muragent::MuragentError;
14use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum TrustLevel {
20 Known,
21 Pending,
22 Rejected,
23 Superseded,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct TrustEntry {
28 pub public_key: String,
29 pub display_name_seen: String,
30 pub first_seen: String,
31 pub last_seen: String,
32 pub last_seen_surface: String,
33 pub trust_level: TrustLevel,
34 pub fingerprint: String,
35 pub word_list: String,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub rotated_from: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub superseded_at: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub last_rotation_at: Option<String>,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct TrustStore {
46 #[serde(default)]
47 pub agents: Vec<TrustEntry>,
48}
49
50impl TrustStore {
51 pub fn load() -> Result<Self, MuragentError> {
55 let path = trust_store_path();
56 if let Some(parent) = path.parent() {
57 std::fs::create_dir_all(parent).map_err(MuragentError::Io)?;
58 }
59
60 let legacy_path = mur_home().join("trust.json");
61 if legacy_path.exists() && !path.exists() {
62 legacy::migrate_legacy(&legacy_path, &path)?;
63 }
64
65 if !path.exists() {
66 return Ok(Self::default());
67 }
68
69 let yaml = std::fs::read_to_string(&path).map_err(MuragentError::Io)?;
70 serde_yaml_ng::from_str(&yaml)
71 .map_err(|e| MuragentError::Other(format!("trust store parse: {e}")))
72 }
73
74 pub fn save(&self) -> Result<(), MuragentError> {
76 let path = trust_store_path();
77 if let Some(parent) = path.parent() {
78 std::fs::create_dir_all(parent).map_err(MuragentError::Io)?;
79 }
80 let yaml = serde_yaml_ng::to_string(self)
81 .map_err(|e| MuragentError::Other(format!("trust store serialize: {e}")))?;
82 let tmp = path.with_extension("yaml.tmp");
83 std::fs::write(&tmp, &yaml).map_err(MuragentError::Io)?;
84 std::fs::rename(&tmp, &path).map_err(MuragentError::Io)?;
85 Ok(())
86 }
87
88 pub fn find_by_pubkey(&self, pubkey_b64: &str) -> Option<&TrustEntry> {
90 self.agents.iter().find(|e| e.public_key == pubkey_b64)
91 }
92
93 pub fn find_by_display_name(&self, name: &str) -> Vec<&TrustEntry> {
95 self.agents
96 .iter()
97 .filter(|e| e.display_name_seen == name)
98 .collect()
99 }
100
101 pub fn upsert(&mut self, entry: TrustEntry) {
103 if let Some(existing) = self
104 .agents
105 .iter_mut()
106 .find(|e| e.public_key == entry.public_key)
107 {
108 *existing = entry;
109 } else {
110 self.agents.push(entry);
111 }
112 }
113
114 pub fn remove(&mut self, pubkey_b64: &str) {
116 self.agents.retain(|e| e.public_key != pubkey_b64);
117 }
118}
119
120pub fn word_list_fingerprint(pubkey: &[u8; 32]) -> String {
127 use sha2::Digest;
128 let hash = sha2::Sha256::digest(pubkey);
129 let raw: u64 = u64::from_be_bytes([
132 0, hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6],
133 ]);
134 let bits = raw >> 4; let w0 = ((bits >> 39) & 0x1FFF) as usize;
136 let w1 = ((bits >> 26) & 0x1FFF) as usize;
137 let w2 = ((bits >> 13) & 0x1FFF) as usize;
138 let w3 = (bits & 0x1FFF) as usize;
139
140 let list = PLACEHOLDER_WORD_LIST;
141 format!(
142 "{} {} {} {}",
143 list[w0 % list.len()],
144 list[w1 % list.len()],
145 list[w2 % list.len()],
146 list[w3 % list.len()],
147 )
148}
149
150pub fn short_fingerprint(pubkey: &[u8; 32]) -> String {
152 use sha2::Digest;
153 let hash = sha2::Sha256::digest(pubkey);
154 let hex = format!("{:x}", hash);
155 hex[..8].to_string()
156}
157
158fn trust_store_path() -> PathBuf {
159 mur_home().join("trust").join("trust.yaml")
160}
161
162pub fn mur_home() -> PathBuf {
165 if let Some(p) = std::env::var_os("MUR_HOME") {
166 return PathBuf::from(p);
167 }
168 dirs::home_dir().expect("home dir").join(".mur")
169}
170
171const PLACEHOLDER_WORD_LIST: &[&str] = &[
175 "abacus",
176 "abdomen",
177 "able",
178 "abrupt",
179 "absent",
180 "absorb",
181 "accept",
182 "access",
183 "accord",
184 "acid",
185 "acorn",
186 "acquit",
187 "acre",
188 "active",
189 "actor",
190 "adapt",
191 "adjust",
192 "admire",
193 "admit",
194 "adopt",
195 "adult",
196 "advance",
197 "advice",
198 "affair",
199 "afford",
200 "afraid",
201 "agency",
202 "agenda",
203 "agent",
204 "agile",
205 "alarm",
206 "albatross",
207];
208
209#[cfg(test)]
210pub(crate) mod test_env_lock {
211 use std::sync::Mutex;
212 pub(crate) static MUR_HOME_LOCK: Mutex<()> = Mutex::new(());
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn word_list_is_deterministic() {
224 let pk = [0x42u8; 32];
225 let a = word_list_fingerprint(&pk);
226 let b = word_list_fingerprint(&pk);
227 assert_eq!(a, b);
228 }
229
230 #[test]
231 fn word_list_has_four_words() {
232 let pk = [0x42u8; 32];
233 let fp = word_list_fingerprint(&pk);
234 assert_eq!(fp.split_whitespace().count(), 4);
235 }
236
237 #[test]
238 fn short_fingerprint_is_8_chars() {
239 let pk = [0x42u8; 32];
240 let fp = short_fingerprint(&pk);
241 assert_eq!(fp.len(), 8);
242 assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
243 }
244
245 #[test]
246 fn trust_store_roundtrip() {
247 let _guard = test_env_lock::MUR_HOME_LOCK.lock().unwrap();
248 let tmp = tempfile::TempDir::new().unwrap();
249 let prev_home = std::env::var_os("MUR_HOME");
250 unsafe { std::env::set_var("MUR_HOME", tmp.path()) };
251
252 let mut store = TrustStore::default();
253 store.upsert(TrustEntry {
254 public_key: "aaa".into(),
255 display_name_seen: "Coach".into(),
256 first_seen: "2026-05-20T00:00:00Z".into(),
257 last_seen: "2026-05-20T00:00:00Z".into(),
258 last_seen_surface: "hub".into(),
259 trust_level: TrustLevel::Pending,
260 fingerprint: "1234abcd".into(),
261 word_list: "a b c d".into(),
262 rotated_from: None,
263 superseded_at: None,
264 last_rotation_at: None,
265 });
266 store.save().unwrap();
267
268 let loaded = TrustStore::load().unwrap();
269 assert_eq!(loaded.agents.len(), 1);
270 assert_eq!(
271 loaded.find_by_pubkey("aaa").unwrap().display_name_seen,
272 "Coach"
273 );
274
275 unsafe {
276 if let Some(p) = prev_home {
277 std::env::set_var("MUR_HOME", p);
278 } else {
279 std::env::remove_var("MUR_HOME");
280 }
281 }
282 }
283}