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