1use std::collections::{BTreeMap, BTreeSet};
21use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Default, Serialize, Deserialize)]
26struct SignerPin {
27 signers: BTreeMap<String, String>,
29}
30
31#[derive(Debug, PartialEq, Eq)]
33pub enum PinVerdict {
34 Ok { first_use: BTreeSet<String> },
39 Conflict { signer: String },
42}
43
44fn 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
75pub fn reconcile(vault_path: &str, signers: &BTreeMap<String, String>) -> PinVerdict {
83 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 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 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 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 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 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 assert_eq!(
189 first_use_of(reconcile("/proj/.murk", &s)),
190 BTreeSet::from(["age1alice".to_string()])
191 );
192 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 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 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 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 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}