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;
9
10pub use revocations::{RevocationsList, RevokedEntry};
11
12use crate::muragent::MuragentError;
13use serde::{Deserialize, Serialize};
14use std::path::PathBuf;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "snake_case")]
18pub enum TrustLevel {
19    Known,
20    Pending,
21    Rejected,
22    Superseded,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TrustEntry {
27    pub public_key: String,
28    pub display_name_seen: String,
29    pub first_seen: String,
30    pub last_seen: String,
31    pub last_seen_surface: String,
32    pub trust_level: TrustLevel,
33    pub fingerprint: String,
34    pub word_list: String,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub rotated_from: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub superseded_at: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub last_rotation_at: Option<String>,
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct TrustStore {
45    #[serde(default)]
46    pub agents: Vec<TrustEntry>,
47}
48
49impl TrustStore {
50    /// Load trust store from `~/.mur/trust/trust.yaml`.
51    /// If the file doesn't exist, returns an empty store.
52    /// Runs legacy migration if `~/.mur/trust.json` exists.
53    pub fn load() -> Result<Self, MuragentError> {
54        let path = trust_store_path();
55        if let Some(parent) = path.parent() {
56            std::fs::create_dir_all(parent).map_err(MuragentError::Io)?;
57        }
58
59        let legacy_path = mur_home().join("trust.json");
60        if legacy_path.exists() && !path.exists() {
61            legacy::migrate_legacy(&legacy_path, &path)?;
62        }
63
64        if !path.exists() {
65            return Ok(Self::default());
66        }
67
68        let yaml = std::fs::read_to_string(&path).map_err(MuragentError::Io)?;
69        serde_yaml_ng::from_str(&yaml)
70            .map_err(|e| MuragentError::Other(format!("trust store parse: {e}")))
71    }
72
73    /// Atomically save the trust store.
74    pub fn save(&self) -> Result<(), MuragentError> {
75        let path = trust_store_path();
76        if let Some(parent) = path.parent() {
77            std::fs::create_dir_all(parent).map_err(MuragentError::Io)?;
78        }
79        let yaml = serde_yaml_ng::to_string(self)
80            .map_err(|e| MuragentError::Other(format!("trust store serialize: {e}")))?;
81        let tmp = path.with_extension("yaml.tmp");
82        std::fs::write(&tmp, &yaml).map_err(MuragentError::Io)?;
83        std::fs::rename(&tmp, &path).map_err(MuragentError::Io)?;
84        Ok(())
85    }
86
87    /// Find an entry by public key (base64).
88    pub fn find_by_pubkey(&self, pubkey_b64: &str) -> Option<&TrustEntry> {
89        self.agents.iter().find(|e| e.public_key == pubkey_b64)
90    }
91
92    /// Find known entries by display name (for key-change detection).
93    pub fn find_by_display_name(&self, name: &str) -> Vec<&TrustEntry> {
94        self.agents
95            .iter()
96            .filter(|e| e.display_name_seen == name)
97            .collect()
98    }
99
100    /// Insert or update an entry.
101    pub fn upsert(&mut self, entry: TrustEntry) {
102        if let Some(existing) = self
103            .agents
104            .iter_mut()
105            .find(|e| e.public_key == entry.public_key)
106        {
107            *existing = entry;
108        } else {
109            self.agents.push(entry);
110        }
111    }
112
113    /// Remove an entry by public key.
114    pub fn remove(&mut self, pubkey_b64: &str) {
115        self.agents.retain(|e| e.public_key != pubkey_b64);
116    }
117}
118
119/// Derive a 4-word fingerprint from a public key, using SHA-256(pubkey).
120///
121/// Takes 52 bits of the hash and splits them into 4 × 13-bit indices into
122/// a wordlist. The wordlist is sized to a power of two so all 13-bit values
123/// are valid (no modulo bias). v1 uses a small placeholder list; the full
124/// EFF long word list will be embedded via include_str! in a follow-up.
125pub fn word_list_fingerprint(pubkey: &[u8; 32]) -> String {
126    use sha2::Digest;
127    let hash = sha2::Sha256::digest(pubkey);
128    // Pack 7 hash bytes (56 bits) into the low end of a u64, then take the
129    // top 52 bits (shift right 4).
130    let raw: u64 = u64::from_be_bytes([
131        0, hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6],
132    ]);
133    let bits = raw >> 4; // 52 bits in low
134    let w0 = ((bits >> 39) & 0x1FFF) as usize;
135    let w1 = ((bits >> 26) & 0x1FFF) as usize;
136    let w2 = ((bits >> 13) & 0x1FFF) as usize;
137    let w3 = (bits & 0x1FFF) as usize;
138
139    let list = PLACEHOLDER_WORD_LIST;
140    format!(
141        "{} {} {} {}",
142        list[w0 % list.len()],
143        list[w1 % list.len()],
144        list[w2 % list.len()],
145        list[w3 % list.len()],
146    )
147}
148
149/// Short fingerprint: first 8 hex chars of SHA-256(pubkey).
150pub fn short_fingerprint(pubkey: &[u8; 32]) -> String {
151    use sha2::Digest;
152    let hash = sha2::Sha256::digest(pubkey);
153    let hex = format!("{:x}", hash);
154    hex[..8].to_string()
155}
156
157fn trust_store_path() -> PathBuf {
158    mur_home().join("trust").join("trust.yaml")
159}
160
161/// Resolve `~/.mur` (or `$MUR_HOME` if set). Shared root for the trust store,
162/// agent home dirs, and other on-disk state used by every surface.
163pub fn mur_home() -> PathBuf {
164    if let Some(p) = std::env::var_os("MUR_HOME") {
165        return PathBuf::from(p);
166    }
167    dirs::home_dir().expect("home dir").join(".mur")
168}
169
170/// Placeholder wordlist for v1 fingerprints. The full 7776-word EFF long
171/// wordlist will be embedded at build time in a follow-up change; until
172/// then, fingerprints are derived from this short list (modulo).
173const PLACEHOLDER_WORD_LIST: &[&str] = &[
174    "abacus",
175    "abdomen",
176    "able",
177    "abrupt",
178    "absent",
179    "absorb",
180    "accept",
181    "access",
182    "accord",
183    "acid",
184    "acorn",
185    "acquit",
186    "acre",
187    "active",
188    "actor",
189    "adapt",
190    "adjust",
191    "admire",
192    "admit",
193    "adopt",
194    "adult",
195    "advance",
196    "advice",
197    "affair",
198    "afford",
199    "afraid",
200    "agency",
201    "agenda",
202    "agent",
203    "agile",
204    "alarm",
205    "albatross",
206];
207
208#[cfg(test)]
209pub(crate) mod test_env_lock {
210    use std::sync::Mutex;
211    /// Tests in this crate that set MUR_HOME must lock this mutex first —
212    /// the env var is process-global and parallel tests trampling it
213    /// produce spurious failures.
214    pub(crate) static MUR_HOME_LOCK: Mutex<()> = Mutex::new(());
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn word_list_is_deterministic() {
223        let pk = [0x42u8; 32];
224        let a = word_list_fingerprint(&pk);
225        let b = word_list_fingerprint(&pk);
226        assert_eq!(a, b);
227    }
228
229    #[test]
230    fn word_list_has_four_words() {
231        let pk = [0x42u8; 32];
232        let fp = word_list_fingerprint(&pk);
233        assert_eq!(fp.split_whitespace().count(), 4);
234    }
235
236    #[test]
237    fn short_fingerprint_is_8_chars() {
238        let pk = [0x42u8; 32];
239        let fp = short_fingerprint(&pk);
240        assert_eq!(fp.len(), 8);
241        assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
242    }
243
244    #[test]
245    fn trust_store_roundtrip() {
246        let _guard = test_env_lock::MUR_HOME_LOCK.lock().unwrap();
247        let tmp = tempfile::TempDir::new().unwrap();
248        let prev_home = std::env::var_os("MUR_HOME");
249        unsafe { std::env::set_var("MUR_HOME", tmp.path()) };
250
251        let mut store = TrustStore::default();
252        store.upsert(TrustEntry {
253            public_key: "aaa".into(),
254            display_name_seen: "Coach".into(),
255            first_seen: "2026-05-20T00:00:00Z".into(),
256            last_seen: "2026-05-20T00:00:00Z".into(),
257            last_seen_surface: "hub".into(),
258            trust_level: TrustLevel::Pending,
259            fingerprint: "1234abcd".into(),
260            word_list: "a b c d".into(),
261            rotated_from: None,
262            superseded_at: None,
263            last_rotation_at: None,
264        });
265        store.save().unwrap();
266
267        let loaded = TrustStore::load().unwrap();
268        assert_eq!(loaded.agents.len(), 1);
269        assert_eq!(
270            loaded.find_by_pubkey("aaa").unwrap().display_name_seen,
271            "Coach"
272        );
273
274        unsafe {
275            if let Some(p) = prev_home {
276                std::env::set_var("MUR_HOME", p);
277            } else {
278                std::env::remove_var("MUR_HOME");
279            }
280        }
281    }
282}