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)]
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn word_list_is_deterministic() {
216 let pk = [0x42u8; 32];
217 let a = word_list_fingerprint(&pk);
218 let b = word_list_fingerprint(&pk);
219 assert_eq!(a, b);
220 }
221
222 #[test]
223 fn word_list_has_four_words() {
224 let pk = [0x42u8; 32];
225 let fp = word_list_fingerprint(&pk);
226 assert_eq!(fp.split_whitespace().count(), 4);
227 }
228
229 #[test]
230 fn short_fingerprint_is_8_chars() {
231 let pk = [0x42u8; 32];
232 let fp = short_fingerprint(&pk);
233 assert_eq!(fp.len(), 8);
234 assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
235 }
236
237 #[test]
238 fn trust_store_roundtrip() {
239 let tmp = tempfile::TempDir::new().unwrap();
240 let _env = crate::test_env::EnvGuard::set([("MUR_HOME", tmp.path())]);
241
242 let mut store = TrustStore::default();
243 store.upsert(TrustEntry {
244 public_key: "aaa".into(),
245 display_name_seen: "Coach".into(),
246 first_seen: "2026-05-20T00:00:00Z".into(),
247 last_seen: "2026-05-20T00:00:00Z".into(),
248 last_seen_surface: "hub".into(),
249 trust_level: TrustLevel::Pending,
250 fingerprint: "1234abcd".into(),
251 word_list: "a b c d".into(),
252 rotated_from: None,
253 superseded_at: None,
254 last_rotation_at: None,
255 });
256 store.save().unwrap();
257
258 let loaded = TrustStore::load().unwrap();
259 assert_eq!(loaded.agents.len(), 1);
260 assert_eq!(
261 loaded.find_by_pubkey("aaa").unwrap().display_name_seen,
262 "Coach"
263 );
264 }
265}