Skip to main content

mur_common/trust/
mod.rs

1//! Shared trust store at `~/.mur/trust/trust.yaml`.
2//!
3//! Spec §7.1: Hub and Commander share the same trust store.
4//! File-locked writes; lock-free reads with retry.
5
6pub 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    /// Load trust store from `~/.mur/trust/trust.yaml`.
52    /// If the file doesn't exist, returns an empty store.
53    /// Runs legacy migration if `~/.mur/trust.json` exists.
54    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    /// Atomically save the trust store.
75    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    /// Find an entry by public key (base64).
89    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    /// Find known entries by display name (for key-change detection).
94    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    /// Insert or update an entry.
102    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    /// Remove an entry by public key.
115    pub fn remove(&mut self, pubkey_b64: &str) {
116        self.agents.retain(|e| e.public_key != pubkey_b64);
117    }
118}
119
120/// Derive a 4-word fingerprint from a public key, using SHA-256(pubkey).
121///
122/// Takes 52 bits of the hash and splits them into 4 × 13-bit indices into
123/// a wordlist. The wordlist is sized to a power of two so all 13-bit values
124/// are valid (no modulo bias). v1 uses a small placeholder list; the full
125/// EFF long word list will be embedded via include_str! in a follow-up.
126pub fn word_list_fingerprint(pubkey: &[u8; 32]) -> String {
127    use sha2::Digest;
128    let hash = sha2::Sha256::digest(pubkey);
129    // Pack 7 hash bytes (56 bits) into the low end of a u64, then take the
130    // top 52 bits (shift right 4).
131    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; // 52 bits in low
135    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
150/// Short fingerprint: first 8 hex chars of SHA-256(pubkey).
151pub 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
162/// Resolve `~/.mur` (or `$MUR_HOME` if set). Shared root for the trust store,
163/// agent home dirs, and other on-disk state used by every surface.
164pub fn mur_home() -> PathBuf {
165    crate::home::mur_home()
166}
167
168/// Placeholder wordlist for v1 fingerprints. The full 7776-word EFF long
169/// wordlist will be embedded at build time in a follow-up change; until
170/// then, fingerprints are derived from this short list (modulo).
171const 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}