use crate::config;
use anyhow::Result;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pin {
First,
Match,
Mismatch { pinned: String },
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct Entry {
pubkey: String,
first_seen: String,
#[serde(default = "confirmed_default")]
confirmed: bool,
}
fn confirmed_default() -> bool {
true
}
fn dir() -> Result<PathBuf> {
Ok(config::home()?.join(".confer").join("keyring"))
}
fn file(base: &Path, hub_key: &str) -> PathBuf {
base.join(format!("{hub_key}.json"))
}
fn load_all(base: &Path, hub_key: &str) -> BTreeMap<String, Entry> {
std::fs::read_to_string(file(base, hub_key))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn save_all(base: &Path, hub_key: &str, map: &BTreeMap<String, Entry>) -> Result<()> {
std::fs::create_dir_all(base)?;
let p = file(base, hub_key);
let tmp = p.with_extension("tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(map)?)?;
std::fs::rename(&tmp, &p)?; Ok(())
}
fn normalize(pubkey: &str) -> String {
let mut it = pubkey.split_whitespace();
match (it.next(), it.next()) {
(Some(a), Some(b)) => format!("{a} {b}"),
_ => pubkey.trim().to_string(),
}
}
fn pin_or_check_in(base: &Path, hub_key: &str, role: &str, pubkey: &str, seen_at: &str) -> Result<Pin> {
let pubkey = normalize(pubkey);
let guard = crate::config::state_lock(&base.join("keyring.lock"));
let mut map = load_all(base, hub_key);
match map.get(role) {
Some(e) if normalize(&e.pubkey) == pubkey => Ok(Pin::Match),
Some(e) => Ok(Pin::Mismatch { pinned: e.pubkey.clone() }),
None => {
if guard.is_none() {
return Err(anyhow::anyhow!(
"keyring is locked by another confer process (couldn't acquire it) — retry"
));
}
map.insert(role.to_string(), Entry { pubkey, first_seen: seen_at.to_string(), confirmed: false });
save_all(base, hub_key, &map)?;
Ok(Pin::First)
}
}
}
fn pinned_in(base: &Path, hub_key: &str, role: &str) -> Option<String> {
load_all(base, hub_key).get(role).map(|e| e.pubkey.clone())
}
fn confirmed_in(base: &Path, hub_key: &str, role: &str) -> bool {
load_all(base, hub_key).get(role).map(|e| e.confirmed).unwrap_or(false)
}
fn confirm_in(base: &Path, hub_key: &str, role: &str) -> Result<bool> {
let guard = crate::config::state_lock(&base.join("keyring.lock"));
let mut map = load_all(base, hub_key);
match map.get_mut(role) {
Some(e) => {
let was = e.confirmed;
e.confirmed = true;
if !was {
if guard.is_none() {
return Err(anyhow::anyhow!("keyring is locked by another confer process — retry the confirm"));
}
save_all(base, hub_key, &map)?;
}
Ok(true)
}
None => Ok(false), }
}
pub fn pin_or_check(hub_key: &str, role: &str, pubkey: &str, seen_at: &str) -> Result<Pin> {
pin_or_check_in(&dir()?, hub_key, role, pubkey, seen_at)
}
pub fn pinned(hub_key: &str, role: &str) -> Option<String> {
dir().ok().and_then(|d| pinned_in(&d, hub_key, role))
}
pub fn confirmed(hub_key: &str, role: &str) -> bool {
dir().map(|d| confirmed_in(&d, hub_key, role)).unwrap_or(false)
}
pub fn confirm(hub_key: &str, role: &str) -> Result<bool> {
confirm_in(&dir()?, hub_key, role)
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("confer-keyring-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
const K1: &str = "ssh-ed25519 AAAAKEYMATERIALONE";
const K2: &str = "ssh-ed25519 AAAAKEYMATERIALTWO";
#[test]
fn first_sight_pins_then_matches() {
let d = tmp();
assert_eq!(pin_or_check_in(&d, "hub", "alice", K1, "t0").unwrap(), Pin::First);
assert_eq!(pin_or_check_in(&d, "hub", "alice", K1, "t1").unwrap(), Pin::Match);
assert_eq!(pinned_in(&d, "hub", "alice").as_deref(), Some(K1));
}
#[test]
fn comment_suffix_is_ignored() {
let d = tmp();
pin_or_check_in(&d, "hub", "alice", "ssh-ed25519 AAAAKEYMATERIALONE first@box", "t0").unwrap();
assert_eq!(
pin_or_check_in(&d, "hub", "alice", "ssh-ed25519 AAAAKEYMATERIALONE other@host", "t1").unwrap(),
Pin::Match
);
}
#[test]
fn changed_key_is_mismatch_and_pin_is_retained() {
let d = tmp();
pin_or_check_in(&d, "hub", "alice", K1, "t0").unwrap();
assert_eq!(
pin_or_check_in(&d, "hub", "alice", K2, "t1").unwrap(),
Pin::Mismatch { pinned: K1.to_string() }
);
assert_eq!(pinned_in(&d, "hub", "alice").as_deref(), Some(K1));
}
#[test]
fn a_mismatch_is_permanent_the_pin_never_moves() {
let d = tmp();
pin_or_check_in(&d, "hub", "alice", K1, "t0").unwrap();
for t in ["t1", "t2", "t3"] {
assert_eq!(
pin_or_check_in(&d, "hub", "alice", K2, t).unwrap(),
Pin::Mismatch { pinned: K1.to_string() }
);
}
assert_eq!(pinned_in(&d, "hub", "alice").as_deref(), Some(K1), "pin is immutable");
}
#[test]
fn a_new_pin_is_unconfirmed_until_confirmed_but_old_pins_default_confirmed() {
let d = tmp();
assert_eq!(pin_or_check_in(&d, "hub", "peer", K1, "t0").unwrap(), Pin::First);
assert!(!confirmed_in(&d, "hub", "peer"), "a new pin starts UNconfirmed");
assert!(confirm_in(&d, "hub", "peer").unwrap(), "confirm succeeds on a pinned role");
assert!(confirmed_in(&d, "hub", "peer"), "now confirmed");
assert!(!confirm_in(&d, "hub", "nobody").unwrap());
let d2 = tmp();
std::fs::create_dir_all(&d2).unwrap();
std::fs::write(
file(&d2, "hub"),
r#"{"old":{"pubkey":"ssh-ed25519 AAAAOLD","first_seen":"t0"}}"#,
).unwrap();
assert!(confirmed_in(&d2, "hub", "old"), "a legacy pin (no field) defaults to confirmed");
}
#[test]
fn pins_are_per_role_and_per_hub() {
let d = tmp();
pin_or_check_in(&d, "hubA", "alice", K1, "t0").unwrap();
assert_eq!(pin_or_check_in(&d, "hubA", "carol", K2, "t0").unwrap(), Pin::First);
assert_eq!(pin_or_check_in(&d, "hubB", "alice", K2, "t0").unwrap(), Pin::First);
assert_eq!(pinned_in(&d, "hubA", "alice").as_deref(), Some(K1));
}
}