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    /// Whether this vault has ever loaded with a valid signature on this machine.
30    /// Monotonic: once true, a later unsigned load is a downgrade.
31    #[serde(default)]
32    was_signed: bool,
33}
34
35/// Result of reconciling a vault's current signer registry against the local pin.
36#[derive(Debug, PartialEq, Eq)]
37pub enum PinVerdict {
38    /// No conflict. `first_use` lists signer pubkeys seen for the first time on
39    /// this machine (newly pinned) — their key is trust-on-first-use, not yet
40    /// anchored. A pubkey absent from `first_use` matched an existing pin, so its
41    /// key is anchored by a prior trusted load.
42    Ok {
43        first_use: BTreeSet<String>,
44        /// True when the vault was signed before on this machine but loaded
45        /// unsigned now — a stripped signature, or a merge result not yet
46        /// re-signed. Not a hard conflict; the loader warns, strict refuses.
47        downgraded: bool,
48    },
49    /// An existing pubkey's verifying key changed since it was pinned. Never
50    /// legitimate: the registry was altered to forge this recipient's signature.
51    Conflict { signer: String },
52}
53
54/// Path to the pin file for a vault: `~/.config/murk/signer-pins/<vault-hash>.json`.
55/// The hash matches the scheme used for key auto-discovery (lexical abs path).
56fn pin_path(vault_path: &str) -> Option<PathBuf> {
57    use sha2::{Digest, Sha256};
58
59    let home = std::env::var("HOME")
60        .or_else(|_| std::env::var("USERPROFILE"))
61        .ok()?;
62
63    let p = std::path::Path::new(vault_path);
64    let abs = if p.is_absolute() {
65        p.to_path_buf()
66    } else {
67        std::env::current_dir().ok()?.join(p)
68    };
69    let hash = Sha256::digest(abs.to_string_lossy().as_bytes());
70    let short: String = hash.iter().take(8).fold(String::new(), |mut s, b| {
71        use std::fmt::Write;
72        let _ = write!(s, "{b:02x}");
73        s
74    });
75
76    Some(
77        std::path::Path::new(&home)
78            .join(".config")
79            .join("murk")
80            .join("signer-pins")
81            .join(format!("{short}.json")),
82    )
83}
84
85/// Whether signer pinning can actually anchor a signature on this machine —
86/// false when opted out (`MURK_NO_SIGNER_PIN`) or when there is no home dir to
87/// store the pin. When false, a present signature is trust-only, never anchored,
88/// so callers can surface the blind spot rather than letting it pass silently.
89pub fn signer_pin_available() -> bool {
90    std::env::var_os("MURK_NO_SIGNER_PIN").is_none()
91        && (std::env::var_os("HOME").is_some() || std::env::var_os("USERPROFILE").is_some())
92}
93
94/// Reconcile the vault's current signer registry against the local pin.
95///
96/// Returns `Conflict` when an already-pinned pubkey now maps to a different
97/// verifying key. Otherwise records any new pubkeys and returns `Ok` with the
98/// set of first-seen (trust-on-first-use) signers, plus `downgraded` — the vault
99/// was signed before on this machine but is unsigned now. `currently_signed` is
100/// whether this load carried a valid signature. When pinning is unavailable
101/// (opted out, or no home dir) every signer is first-use and no downgrade is
102/// reported, since nothing is anchored.
103pub fn reconcile(
104    vault_path: &str,
105    signers: &BTreeMap<String, String>,
106    currently_signed: bool,
107) -> PinVerdict {
108    // No anchor available → nothing is anchored and we can't detect a downgrade.
109    let all_unanchored = || PinVerdict::Ok {
110        first_use: signers.keys().cloned().collect(),
111        downgraded: false,
112    };
113    if std::env::var_os("MURK_NO_SIGNER_PIN").is_some() {
114        return all_unanchored();
115    }
116    let Some(path) = pin_path(vault_path) else {
117        return all_unanchored();
118    };
119
120    let mut pin: SignerPin = std::fs::read_to_string(&path)
121        .ok()
122        .and_then(|s| serde_json::from_str(&s).ok())
123        .unwrap_or_default();
124
125    // Any existing pubkey whose verifying key changed is tampering.
126    for (pubkey, vk) in signers {
127        if let Some(pinned) = pin.signers.get(pubkey)
128            && pinned != vk
129        {
130            return PinVerdict::Conflict {
131                signer: pubkey.clone(),
132            };
133        }
134    }
135
136    // A vault signed before but unsigned now has been downgraded — the signature
137    // was stripped, or it's a merge result awaiting re-signing.
138    let downgraded = pin.was_signed && !currently_signed;
139
140    // No conflict — extend the pin with any newly seen signers (TOFU), and report
141    // them as first-use so callers don't over-trust an unanchored key.
142    let mut first_use = BTreeSet::new();
143    for (pubkey, vk) in signers {
144        if !pin.signers.contains_key(pubkey) {
145            pin.signers.insert(pubkey.clone(), vk.clone());
146            first_use.insert(pubkey.clone());
147        }
148    }
149
150    // Record having-been-signed once, monotonically.
151    let newly_signed = currently_signed && !pin.was_signed;
152    if newly_signed {
153        pin.was_signed = true;
154    }
155    if !first_use.is_empty() || newly_signed {
156        write_pin(&path, &pin);
157    }
158
159    PinVerdict::Ok {
160        first_use,
161        downgraded,
162    }
163}
164
165fn write_pin(path: &std::path::Path, pin: &SignerPin) {
166    let Some(parent) = path.parent() else { return };
167    if std::fs::create_dir_all(parent).is_err() {
168        return;
169    }
170    #[cfg(unix)]
171    {
172        use std::os::unix::fs::PermissionsExt;
173        // ~/.config/murk should stay 0700 like the key dirs.
174        if let Some(murk_dir) = parent.parent() {
175            let _ = std::fs::set_permissions(murk_dir, std::fs::Permissions::from_mode(0o700));
176        }
177        let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
178    }
179    if let Ok(json) = serde_json::to_string_pretty(pin) {
180        let _ = std::fs::write(path, json);
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    /// Sandbox HOME so the pin lands in a temp dir, and serialize with other
189    /// tests that mutate HOME.
190    fn with_home<T>(f: impl FnOnce(&str) -> T) -> T {
191        use crate::testutil::ENV_LOCK;
192        let _lock = ENV_LOCK
193            .lock()
194            .unwrap_or_else(std::sync::PoisonError::into_inner);
195        let dir = tempfile::tempdir().unwrap();
196        let prev = std::env::var_os("HOME");
197        unsafe { std::env::set_var("HOME", dir.path()) };
198        unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
199        let out = f(dir.path().to_str().unwrap());
200        match prev {
201            Some(v) => unsafe { std::env::set_var("HOME", v) },
202            None => unsafe { std::env::remove_var("HOME") },
203        }
204        out
205    }
206
207    fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
208        pairs
209            .iter()
210            .map(|(k, v)| (k.to_string(), v.to_string()))
211            .collect()
212    }
213
214    /// The set of first-use signers from an `Ok` verdict (panics on `Conflict`).
215    fn first_use_of(v: PinVerdict) -> BTreeSet<String> {
216        match v {
217            PinVerdict::Ok { first_use, .. } => first_use,
218            PinVerdict::Conflict { signer } => panic!("unexpected conflict: {signer}"),
219        }
220    }
221
222    /// The `downgraded` flag from an `Ok` verdict (panics on `Conflict`).
223    fn downgraded_of(v: PinVerdict) -> bool {
224        match v {
225            PinVerdict::Ok { downgraded, .. } => downgraded,
226            PinVerdict::Conflict { signer } => panic!("unexpected conflict: {signer}"),
227        }
228    }
229
230    #[test]
231    fn first_use_then_anchored() {
232        with_home(|_| {
233            let s = map(&[("age1alice", "vkALICE")]);
234            // First sight: reported as first-use (not yet anchored).
235            assert_eq!(
236                first_use_of(reconcile("/proj/.murk", &s, true)),
237                BTreeSet::from(["age1alice".to_string()])
238            );
239            // Second sight: matched the pin → anchored, so no longer first-use.
240            assert!(first_use_of(reconcile("/proj/.murk", &s, true)).is_empty());
241        });
242    }
243
244    #[test]
245    fn only_the_new_signer_is_first_use() {
246        with_home(|_| {
247            reconcile("/proj/.murk", &map(&[("age1alice", "vkALICE")]), true);
248            // Bob joins: alice is now anchored, only bob is first-use.
249            assert_eq!(
250                first_use_of(reconcile(
251                    "/proj/.murk",
252                    &map(&[("age1alice", "vkALICE"), ("age1bob", "vkBOB")]),
253                    true
254                )),
255                BTreeSet::from(["age1bob".to_string()])
256            );
257        });
258    }
259
260    #[test]
261    fn changed_verifying_key_for_existing_pubkey_conflicts() {
262        with_home(|_| {
263            reconcile("/proj/.murk", &map(&[("age1alice", "vkALICE")]), true);
264            // Attacker registers a different verifying key under alice's pubkey.
265            assert_eq!(
266                reconcile("/proj/.murk", &map(&[("age1alice", "vkATTACKER")]), true),
267                PinVerdict::Conflict {
268                    signer: "age1alice".into()
269                }
270            );
271        });
272    }
273
274    #[test]
275    fn pins_are_per_vault_path() {
276        with_home(|_| {
277            reconcile("/a/.murk", &map(&[("age1alice", "vkALICE")]), true);
278            // A different vault with the same pubkey but a different key: no
279            // cross-contamination — separate pin file, so no conflict.
280            assert_eq!(
281                first_use_of(reconcile(
282                    "/b/.murk",
283                    &map(&[("age1alice", "vkOTHER")]),
284                    true
285                )),
286                BTreeSet::from(["age1alice".to_string()])
287            );
288        });
289    }
290
291    #[test]
292    fn opt_out_disables_the_check_and_anchoring() {
293        with_home(|_| {
294            reconcile("/proj/.murk", &map(&[("age1alice", "vkALICE")]), true);
295            unsafe { std::env::set_var("MURK_NO_SIGNER_PIN", "1") };
296            // Opted out: even a changed key passes, and nothing is anchored
297            // (every signer reported first-use).
298            assert_eq!(
299                first_use_of(reconcile(
300                    "/proj/.murk",
301                    &map(&[("age1alice", "vkATTACKER")]),
302                    true
303                )),
304                BTreeSet::from(["age1alice".to_string()])
305            );
306            unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
307        });
308    }
309
310    #[test]
311    fn signed_then_unsigned_is_a_downgrade() {
312        with_home(|_| {
313            let s = map(&[("age1alice", "vkALICE")]);
314            // First load carried a signature — records was_signed.
315            assert!(!downgraded_of(reconcile("/proj/.murk", &s, true)));
316            // Later load is unsigned → flagged as a downgrade.
317            assert!(downgraded_of(reconcile("/proj/.murk", &map(&[]), false)));
318        });
319    }
320
321    #[test]
322    fn never_signed_unsigned_is_not_a_downgrade() {
323        with_home(|_| {
324            // A vault only ever loaded unsigned (hardware/ssh-rsa team): no signal.
325            assert!(!downgraded_of(reconcile("/proj/.murk", &map(&[]), false)));
326            assert!(!downgraded_of(reconcile("/proj/.murk", &map(&[]), false)));
327        });
328    }
329
330    #[test]
331    fn re_signing_clears_the_downgrade() {
332        with_home(|_| {
333            let s = map(&[("age1alice", "vkALICE")]);
334            reconcile("/proj/.murk", &s, true);
335            // Unsigned right after a merge → downgrade flagged.
336            assert!(downgraded_of(reconcile("/proj/.murk", &map(&[]), false)));
337            // Re-signed → cleared.
338            assert!(!downgraded_of(reconcile("/proj/.murk", &s, true)));
339        });
340    }
341
342    #[test]
343    fn opt_out_suppresses_downgrade_detection() {
344        with_home(|_| {
345            reconcile("/proj/.murk", &map(&[("age1alice", "vkALICE")]), true);
346            unsafe { std::env::set_var("MURK_NO_SIGNER_PIN", "1") };
347            assert!(!downgraded_of(reconcile("/proj/.murk", &map(&[]), false)));
348            unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
349        });
350    }
351}