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 crate::home::mur_home()
166}
167
168const PLACEHOLDER_WORD_LIST: &[&str] = &[
172 "abacus",
173 "abdomen",
174 "able",
175 "abrupt",
176 "absent",
177 "absorb",
178 "accept",
179 "access",
180 "accord",
181 "acid",
182 "acorn",
183 "acquit",
184 "acre",
185 "active",
186 "actor",
187 "adapt",
188 "adjust",
189 "admire",
190 "admit",
191 "adopt",
192 "adult",
193 "advance",
194 "advice",
195 "affair",
196 "afford",
197 "afraid",
198 "agency",
199 "agenda",
200 "agent",
201 "agile",
202 "alarm",
203 "albatross",
204];
205
206#[cfg(test)]
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn word_list_is_deterministic() {
213 let pk = [0x42u8; 32];
214 let a = word_list_fingerprint(&pk);
215 let b = word_list_fingerprint(&pk);
216 assert_eq!(a, b);
217 }
218
219 #[test]
220 fn word_list_has_four_words() {
221 let pk = [0x42u8; 32];
222 let fp = word_list_fingerprint(&pk);
223 assert_eq!(fp.split_whitespace().count(), 4);
224 }
225
226 #[test]
227 fn short_fingerprint_is_8_chars() {
228 let pk = [0x42u8; 32];
229 let fp = short_fingerprint(&pk);
230 assert_eq!(fp.len(), 8);
231 assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
232 }
233
234 #[test]
235 fn trust_store_roundtrip() {
236 let tmp = tempfile::TempDir::new().unwrap();
237 let _env = crate::test_env::EnvGuard::set([("MUR_HOME", tmp.path())]);
238
239 let mut store = TrustStore::default();
240 store.upsert(TrustEntry {
241 public_key: "aaa".into(),
242 display_name_seen: "Coach".into(),
243 first_seen: "2026-05-20T00:00:00Z".into(),
244 last_seen: "2026-05-20T00:00:00Z".into(),
245 last_seen_surface: "hub".into(),
246 trust_level: TrustLevel::Pending,
247 fingerprint: "1234abcd".into(),
248 word_list: "a b c d".into(),
249 rotated_from: None,
250 superseded_at: None,
251 last_rotation_at: None,
252 });
253 store.save().unwrap();
254
255 let loaded = TrustStore::load().unwrap();
256 assert_eq!(loaded.agents.len(), 1);
257 assert_eq!(
258 loaded.find_by_pubkey("aaa").unwrap().display_name_seen,
259 "Coach"
260 );
261 }
262}