Skip to main content

murk_cli/
pins.rs

1//! Trust-on-first-use pinning of a vault's signer registry.
2//!
3//! The signer registry (`Meta::signers`, pubkey → Ed25519 verifying key) lives
4//! in the encrypted meta blob, which anyone can re-encrypt using the public
5//! recipient keys. So on its own it's attacker-mutable: a repo-writer could
6//! register their *own* verifying key under an existing recipient's pubkey and
7//! sign with their own key, forging that recipient's signature.
8//!
9//! This pin closes that. For a native age key the mapping pubkey → verifying key
10//! is a fixed derivation (see [`crate::signing`]), so a given pubkey must always
11//! carry the *same* verifying key. We record the mapping locally on first sight
12//! and flag any later change for an existing pubkey — which is never legitimate
13//! and indicates the registry was tampered with. New pubkeys are trust-on-first-
14//! use (like GitHub key pinning): recorded, not rejected.
15//!
16//! The pin is local state under `~/.config/murk/signer-pins/`; it does not travel
17//! with the repo. It is best-effort — a missing home dir or unreadable pin never
18//! blocks a load, it just skips the check.
19
20use std::collections::{BTreeMap, BTreeSet};
21use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Default, Serialize, Deserialize)]
26struct SignerPin {
27    /// pubkey → base64 Ed25519 verifying key, as first seen.
28    signers: BTreeMap<String, String>,
29}
30
31/// Result of reconciling a vault's current signer registry against the local pin.
32#[derive(Debug, PartialEq, Eq)]
33pub enum PinVerdict {
34    /// No conflict. `first_use` lists signer pubkeys seen for the first time on
35    /// this machine (newly pinned) — their key is trust-on-first-use, not yet
36    /// anchored. A pubkey absent from `first_use` matched an existing pin, so its
37    /// key is anchored by a prior trusted load.
38    Ok { first_use: BTreeSet<String> },
39    /// An existing pubkey's verifying key changed since it was pinned. Never
40    /// legitimate: the registry was altered to forge this recipient's signature.
41    Conflict { signer: String },
42}
43
44/// Path to the pin file for a vault: `~/.config/murk/signer-pins/<vault-hash>.json`.
45/// The hash matches the scheme used for key auto-discovery (lexical abs path).
46fn pin_path(vault_path: &str) -> Option<PathBuf> {
47    use sha2::{Digest, Sha256};
48
49    let home = std::env::var("HOME")
50        .or_else(|_| std::env::var("USERPROFILE"))
51        .ok()?;
52
53    let p = std::path::Path::new(vault_path);
54    let abs = if p.is_absolute() {
55        p.to_path_buf()
56    } else {
57        std::env::current_dir().ok()?.join(p)
58    };
59    let hash = Sha256::digest(abs.to_string_lossy().as_bytes());
60    let short: String = hash.iter().take(8).fold(String::new(), |mut s, b| {
61        use std::fmt::Write;
62        let _ = write!(s, "{b:02x}");
63        s
64    });
65
66    Some(
67        std::path::Path::new(&home)
68            .join(".config")
69            .join("murk")
70            .join("signer-pins")
71            .join(format!("{short}.json")),
72    )
73}
74
75/// Reconcile the vault's current signer registry against the local pin.
76///
77/// Returns `Conflict` when an already-pinned pubkey now maps to a different
78/// verifying key. Otherwise records any new pubkeys and returns `Ok` with the
79/// set of first-seen (trust-on-first-use) signers. When pinning is unavailable
80/// (opted out, or no home dir) every signer is reported as first-use, since
81/// nothing is anchored.
82pub fn reconcile(vault_path: &str, signers: &BTreeMap<String, String>) -> PinVerdict {
83    // No anchor available → nothing is anchored; every signer is first-use.
84    let all_unanchored = || PinVerdict::Ok {
85        first_use: signers.keys().cloned().collect(),
86    };
87    if std::env::var_os("MURK_NO_SIGNER_PIN").is_some() {
88        return all_unanchored();
89    }
90    let Some(path) = pin_path(vault_path) else {
91        return all_unanchored();
92    };
93
94    let mut pin: SignerPin = std::fs::read_to_string(&path)
95        .ok()
96        .and_then(|s| serde_json::from_str(&s).ok())
97        .unwrap_or_default();
98
99    // Any existing pubkey whose verifying key changed is tampering.
100    for (pubkey, vk) in signers {
101        if let Some(pinned) = pin.signers.get(pubkey)
102            && pinned != vk
103        {
104            return PinVerdict::Conflict {
105                signer: pubkey.clone(),
106            };
107        }
108    }
109
110    // No conflict — extend the pin with any newly seen signers (TOFU), and report
111    // them as first-use so callers don't over-trust an unanchored key.
112    let mut first_use = BTreeSet::new();
113    for (pubkey, vk) in signers {
114        if !pin.signers.contains_key(pubkey) {
115            pin.signers.insert(pubkey.clone(), vk.clone());
116            first_use.insert(pubkey.clone());
117        }
118    }
119    if !first_use.is_empty() {
120        write_pin(&path, &pin);
121    }
122
123    PinVerdict::Ok { first_use }
124}
125
126fn write_pin(path: &std::path::Path, pin: &SignerPin) {
127    let Some(parent) = path.parent() else { return };
128    if std::fs::create_dir_all(parent).is_err() {
129        return;
130    }
131    #[cfg(unix)]
132    {
133        use std::os::unix::fs::PermissionsExt;
134        // ~/.config/murk should stay 0700 like the key dirs.
135        if let Some(murk_dir) = parent.parent() {
136            let _ = std::fs::set_permissions(murk_dir, std::fs::Permissions::from_mode(0o700));
137        }
138        let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
139    }
140    if let Ok(json) = serde_json::to_string_pretty(pin) {
141        let _ = std::fs::write(path, json);
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    /// Sandbox HOME so the pin lands in a temp dir, and serialize with other
150    /// tests that mutate HOME.
151    fn with_home<T>(f: impl FnOnce(&str) -> T) -> T {
152        use crate::testutil::ENV_LOCK;
153        let _lock = ENV_LOCK
154            .lock()
155            .unwrap_or_else(std::sync::PoisonError::into_inner);
156        let dir = tempfile::tempdir().unwrap();
157        let prev = std::env::var_os("HOME");
158        unsafe { std::env::set_var("HOME", dir.path()) };
159        unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
160        let out = f(dir.path().to_str().unwrap());
161        match prev {
162            Some(v) => unsafe { std::env::set_var("HOME", v) },
163            None => unsafe { std::env::remove_var("HOME") },
164        }
165        out
166    }
167
168    fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
169        pairs
170            .iter()
171            .map(|(k, v)| (k.to_string(), v.to_string()))
172            .collect()
173    }
174
175    /// The set of first-use signers from an `Ok` verdict (panics on `Conflict`).
176    fn first_use_of(v: PinVerdict) -> BTreeSet<String> {
177        match v {
178            PinVerdict::Ok { first_use } => first_use,
179            PinVerdict::Conflict { signer } => panic!("unexpected conflict: {signer}"),
180        }
181    }
182
183    #[test]
184    fn first_use_then_anchored() {
185        with_home(|_| {
186            let s = map(&[("age1alice", "vkALICE")]);
187            // First sight: reported as first-use (not yet anchored).
188            assert_eq!(
189                first_use_of(reconcile("/proj/.murk", &s)),
190                BTreeSet::from(["age1alice".to_string()])
191            );
192            // Second sight: matched the pin → anchored, so no longer first-use.
193            assert!(first_use_of(reconcile("/proj/.murk", &s)).is_empty());
194        });
195    }
196
197    #[test]
198    fn only_the_new_signer_is_first_use() {
199        with_home(|_| {
200            reconcile("/proj/.murk", &map(&[("age1alice", "vkALICE")]));
201            // Bob joins: alice is now anchored, only bob is first-use.
202            assert_eq!(
203                first_use_of(reconcile(
204                    "/proj/.murk",
205                    &map(&[("age1alice", "vkALICE"), ("age1bob", "vkBOB")])
206                )),
207                BTreeSet::from(["age1bob".to_string()])
208            );
209        });
210    }
211
212    #[test]
213    fn changed_verifying_key_for_existing_pubkey_conflicts() {
214        with_home(|_| {
215            reconcile("/proj/.murk", &map(&[("age1alice", "vkALICE")]));
216            // Attacker registers a different verifying key under alice's pubkey.
217            assert_eq!(
218                reconcile("/proj/.murk", &map(&[("age1alice", "vkATTACKER")])),
219                PinVerdict::Conflict {
220                    signer: "age1alice".into()
221                }
222            );
223        });
224    }
225
226    #[test]
227    fn pins_are_per_vault_path() {
228        with_home(|_| {
229            reconcile("/a/.murk", &map(&[("age1alice", "vkALICE")]));
230            // A different vault with the same pubkey but a different key: no
231            // cross-contamination — separate pin file, so no conflict.
232            assert_eq!(
233                first_use_of(reconcile("/b/.murk", &map(&[("age1alice", "vkOTHER")]))),
234                BTreeSet::from(["age1alice".to_string()])
235            );
236        });
237    }
238
239    #[test]
240    fn opt_out_disables_the_check_and_anchoring() {
241        with_home(|_| {
242            reconcile("/proj/.murk", &map(&[("age1alice", "vkALICE")]));
243            unsafe { std::env::set_var("MURK_NO_SIGNER_PIN", "1") };
244            // Opted out: even a changed key passes, and nothing is anchored
245            // (every signer reported first-use).
246            assert_eq!(
247                first_use_of(reconcile(
248                    "/proj/.murk",
249                    &map(&[("age1alice", "vkATTACKER")])
250                )),
251                BTreeSet::from(["age1alice".to_string()])
252            );
253            unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
254        });
255    }
256}