use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Serialize, Deserialize)]
struct SignerPin {
signers: BTreeMap<String, String>,
#[serde(default)]
was_signed: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub enum PinVerdict {
Ok {
first_use: BTreeSet<String>,
downgraded: bool,
},
Conflict { signer: String },
}
fn pin_path(vault_path: &str) -> Option<PathBuf> {
use sha2::{Digest, Sha256};
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()?;
let p = std::path::Path::new(vault_path);
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir().ok()?.join(p)
};
let hash = Sha256::digest(abs.to_string_lossy().as_bytes());
let short: String = hash.iter().take(8).fold(String::new(), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
});
Some(
std::path::Path::new(&home)
.join(".config")
.join("murk")
.join("signer-pins")
.join(format!("{short}.json")),
)
}
pub fn signer_pin_available() -> bool {
std::env::var_os("MURK_NO_SIGNER_PIN").is_none()
&& (std::env::var_os("HOME").is_some() || std::env::var_os("USERPROFILE").is_some())
}
pub fn reconcile(
vault_path: &str,
signers: &BTreeMap<String, String>,
currently_signed: bool,
) -> PinVerdict {
let all_unanchored = || PinVerdict::Ok {
first_use: signers.keys().cloned().collect(),
downgraded: false,
};
if std::env::var_os("MURK_NO_SIGNER_PIN").is_some() {
return all_unanchored();
}
let Some(path) = pin_path(vault_path) else {
return all_unanchored();
};
let mut pin: SignerPin = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
for (pubkey, vk) in signers {
if let Some(pinned) = pin.signers.get(pubkey)
&& pinned != vk
{
return PinVerdict::Conflict {
signer: pubkey.clone(),
};
}
}
let downgraded = pin.was_signed && !currently_signed;
let mut first_use = BTreeSet::new();
for (pubkey, vk) in signers {
if !pin.signers.contains_key(pubkey) {
pin.signers.insert(pubkey.clone(), vk.clone());
first_use.insert(pubkey.clone());
}
}
let newly_signed = currently_signed && !pin.was_signed;
if newly_signed {
pin.was_signed = true;
}
if !first_use.is_empty() || newly_signed {
write_pin(&path, &pin);
}
PinVerdict::Ok {
first_use,
downgraded,
}
}
fn write_pin(path: &std::path::Path, pin: &SignerPin) {
let Some(parent) = path.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(murk_dir) = parent.parent() {
let _ = std::fs::set_permissions(murk_dir, std::fs::Permissions::from_mode(0o700));
}
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
if let Ok(json) = serde_json::to_string_pretty(pin) {
let _ = std::fs::write(path, json);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn with_home<T>(f: impl FnOnce(&str) -> T) -> T {
use crate::testutil::ENV_LOCK;
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = tempfile::tempdir().unwrap();
let prev = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", dir.path()) };
unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
let out = f(dir.path().to_str().unwrap());
match prev {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
out
}
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn first_use_of(v: PinVerdict) -> BTreeSet<String> {
match v {
PinVerdict::Ok { first_use, .. } => first_use,
PinVerdict::Conflict { signer } => panic!("unexpected conflict: {signer}"),
}
}
fn downgraded_of(v: PinVerdict) -> bool {
match v {
PinVerdict::Ok { downgraded, .. } => downgraded,
PinVerdict::Conflict { signer } => panic!("unexpected conflict: {signer}"),
}
}
#[test]
fn first_use_then_anchored() {
with_home(|_| {
let s = map(&[("age1alice", "vkALICE")]);
assert_eq!(
first_use_of(reconcile("/proj/.murk", &s, true)),
BTreeSet::from(["age1alice".to_string()])
);
assert!(first_use_of(reconcile("/proj/.murk", &s, true)).is_empty());
});
}
#[test]
fn only_the_new_signer_is_first_use() {
with_home(|_| {
reconcile("/proj/.murk", &map(&[("age1alice", "vkALICE")]), true);
assert_eq!(
first_use_of(reconcile(
"/proj/.murk",
&map(&[("age1alice", "vkALICE"), ("age1bob", "vkBOB")]),
true
)),
BTreeSet::from(["age1bob".to_string()])
);
});
}
#[test]
fn changed_verifying_key_for_existing_pubkey_conflicts() {
with_home(|_| {
reconcile("/proj/.murk", &map(&[("age1alice", "vkALICE")]), true);
assert_eq!(
reconcile("/proj/.murk", &map(&[("age1alice", "vkATTACKER")]), true),
PinVerdict::Conflict {
signer: "age1alice".into()
}
);
});
}
#[test]
fn pins_are_per_vault_path() {
with_home(|_| {
reconcile("/a/.murk", &map(&[("age1alice", "vkALICE")]), true);
assert_eq!(
first_use_of(reconcile(
"/b/.murk",
&map(&[("age1alice", "vkOTHER")]),
true
)),
BTreeSet::from(["age1alice".to_string()])
);
});
}
#[test]
fn opt_out_disables_the_check_and_anchoring() {
with_home(|_| {
reconcile("/proj/.murk", &map(&[("age1alice", "vkALICE")]), true);
unsafe { std::env::set_var("MURK_NO_SIGNER_PIN", "1") };
assert_eq!(
first_use_of(reconcile(
"/proj/.murk",
&map(&[("age1alice", "vkATTACKER")]),
true
)),
BTreeSet::from(["age1alice".to_string()])
);
unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
});
}
#[test]
fn signed_then_unsigned_is_a_downgrade() {
with_home(|_| {
let s = map(&[("age1alice", "vkALICE")]);
assert!(!downgraded_of(reconcile("/proj/.murk", &s, true)));
assert!(downgraded_of(reconcile("/proj/.murk", &map(&[]), false)));
});
}
#[test]
fn never_signed_unsigned_is_not_a_downgrade() {
with_home(|_| {
assert!(!downgraded_of(reconcile("/proj/.murk", &map(&[]), false)));
assert!(!downgraded_of(reconcile("/proj/.murk", &map(&[]), false)));
});
}
#[test]
fn re_signing_clears_the_downgrade() {
with_home(|_| {
let s = map(&[("age1alice", "vkALICE")]);
reconcile("/proj/.murk", &s, true);
assert!(downgraded_of(reconcile("/proj/.murk", &map(&[]), false)));
assert!(!downgraded_of(reconcile("/proj/.murk", &s, true)));
});
}
#[test]
fn opt_out_suppresses_downgrade_detection() {
with_home(|_| {
reconcile("/proj/.murk", &map(&[("age1alice", "vkALICE")]), true);
unsafe { std::env::set_var("MURK_NO_SIGNER_PIN", "1") };
assert!(!downgraded_of(reconcile("/proj/.murk", &map(&[]), false)));
unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
});
}
}