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    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
171/// Placeholder wordlist for v1 fingerprints. The full 7776-word EFF long
172/// wordlist will be embedded at build time in a follow-up change; until
173/// then, fingerprints are derived from this short list (modulo).
174const 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    /// Tests in this crate that set MUR_HOME must lock this mutex first —
213    /// the env var is process-global and parallel tests trampling it
214    /// produce spurious failures.
215    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}