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)]
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}