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 #[serde(default)]
32 was_signed: bool,
33}
34
35#[derive(Debug, PartialEq, Eq)]
37pub enum PinVerdict {
38 Ok {
43 first_use: BTreeSet<String>,
44 downgraded: bool,
48 },
49 Conflict { signer: String },
52}
53
54fn 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
85pub 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
94pub fn reconcile(
104 vault_path: &str,
105 signers: &BTreeMap<String, String>,
106 currently_signed: bool,
107) -> PinVerdict {
108 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 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 let downgraded = pin.was_signed && !currently_signed;
139
140 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 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 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 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 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 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 assert_eq!(
236 first_use_of(reconcile("/proj/.murk", &s, true)),
237 BTreeSet::from(["age1alice".to_string()])
238 );
239 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 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 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 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 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 assert!(!downgraded_of(reconcile("/proj/.murk", &s, true)));
316 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 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 assert!(downgraded_of(reconcile("/proj/.murk", &map(&[]), false)));
337 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}