use crate::schema::Message;
use crate::{crosshub, gitcmd, keyring, roster, store};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Trust {
Verified { fpr: String },
FirstSight { fpr: String },
Unverified { reason: String },
Mismatch { reason: String },
}
impl Trust {
pub fn glyph(&self) -> &'static str {
match self {
Trust::Verified { .. } => "✓",
Trust::FirstSight { .. } => "⚠",
Trust::Unverified { .. } => "·",
Trust::Mismatch { .. } => "‼",
}
}
pub fn tag(&self) -> String {
match self {
Trust::Verified { fpr } => format!("✓ verified ({fpr})"),
Trust::FirstSight { fpr } => format!("⚠ first-sight ({fpr}) — signed by a key not yet confirmed out-of-band; run `confer confirm-key`"),
Trust::Unverified { reason } => format!("· unverified — {reason}"),
Trust::Mismatch { reason } => format!("‼ KEY MISMATCH — {reason}"),
}
}
pub fn is_mismatch(&self) -> bool { matches!(self, Trust::Mismatch { .. }) }
}
#[derive(Default)]
pub struct Cache {
add_sha: HashMap<String, Option<String>>, card_sha: HashMap<String, Option<String>>, gsig: HashMap<String, char>, fpr: HashMap<String, String>, }
impl Cache {
fn msg_commit(&mut self, root: &Path, m: &Message) -> Option<String> {
if let Some(v) = self.add_sha.get(&m.front.id) {
return v.clone();
}
let topic = m.front.topic.as_deref().unwrap_or("general");
let file = store::message_path(root, topic, &m.front.id, &m.front.from, &m.front.ts);
let rel = file.strip_prefix(root).unwrap_or(&file).to_string_lossy().to_string();
let sha = gitcmd::output(root, &["log", "--format=%H", "-1", "--", &rel])
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty());
self.add_sha.insert(m.front.id.clone(), sha.clone());
sha
}
fn gsig(&mut self, root: &Path, role: &str, pinned: &str, sha: &str) -> char {
if let Some(c) = self.gsig.get(sha) {
return *c;
}
let c = (|| {
if !crate::valid_slug(role) {
return None;
}
let dir = root.join(".confer");
std::fs::create_dir_all(&dir).ok()?;
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let sf = dir.join(format!("verify_signers.{}.{n}", std::process::id()));
std::fs::write(&sf, format!("{role}@confer.local {pinned}\n")).ok()?;
let keygen = crate::ssh_keygen_path();
let out = gitcmd::output(
root,
&[
"-c", "gpg.format=ssh",
"-c", &format!("gpg.ssh.program={keygen}"),
"-c", &format!("gpg.ssh.allowedSignersFile={}", sf.display()),
"show", "-s", "--format=%G?", sha,
],
);
let _ = std::fs::remove_file(&sf); let out = out.ok()?;
String::from_utf8_lossy(&out.stdout).trim().chars().next()
})()
.unwrap_or('E');
self.gsig.insert(sha.to_string(), c);
c
}
fn card_commit(&mut self, root: &Path, role: &str) -> Option<String> {
if let Some(v) = self.card_sha.get(role) {
return v.clone();
}
let rel = format!("roles/{role}.md");
let sha = gitcmd::output(root, &["log", "--format=%H", "-1", "--", &rel])
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty());
self.card_sha.insert(role.to_string(), sha.clone());
sha
}
fn fingerprint(&mut self, pubkey: &str) -> String {
if let Some(f) = self.fpr.get(pubkey) {
return f.clone();
}
let f = crosshub::fingerprint(pubkey);
self.fpr.insert(pubkey.to_string(), f.clone());
f
}
}
pub fn card_trust(
root: &Path,
hub_key: &str,
ros: &roster::Roster,
cache: &mut Cache,
role: &str,
) -> Trust {
let Some(sha) = cache.card_commit(root, role) else {
if roster::pubkey(ros, role).is_none() {
return Trust::Unverified { reason: "no published signing key".into() };
}
return match commit_trust(root, hub_key, ros, cache, role, "") {
m @ Trust::Mismatch { .. } => m,
_ => Trust::Unverified { reason: "role card has no signed commit (legacy/unsigned)".into() },
};
};
commit_trust(root, hub_key, ros, cache, role, &sha)
}
pub fn commit_trust(
root: &Path,
hub_key: &str,
ros: &roster::Roster,
cache: &mut Cache,
role: &str,
sha: &str,
) -> Trust {
let Some(card) = roster::pubkey(ros, role) else {
return Trust::Unverified { reason: "no published signing key".into() };
};
if let Ok(keyring::Pin::Mismatch { .. }) = keyring::pin_or_check(hub_key, role, card, "") {
return Trust::Mismatch {
reason: format!(
"{role}'s published key differs from the one first pinned — the identity IS the key, so a changed key is never a legitimate rotation"
),
};
}
let pinned = keyring::pinned(hub_key, role).unwrap_or_else(|| card.to_string());
if sha.is_empty() {
return Trust::Unverified { reason: "no commit to verify".into() };
}
match cache.gsig(root, role, &pinned, sha) {
'G' => good_verdict(hub_key, role, cache.fingerprint(&pinned)),
'N' => Trust::Unverified { reason: "unsigned commit".into() },
_ => Trust::Unverified { reason: format!("not signed by {role}'s pinned key") },
}
}
fn good_verdict(hub_key: &str, role: &str, fpr: String) -> Trust {
if keyring::confirmed(hub_key, role) {
Trust::Verified { fpr }
} else {
Trust::FirstSight { fpr }
}
}
pub fn status(root: &Path, hub_key: &str, ros: &roster::Roster, cache: &mut Cache, m: &Message) -> Trust {
let role = m.front.from.as_str();
let Some(card) = roster::pubkey(ros, role) else {
return Trust::Unverified { reason: "no published signing key".into() };
};
if let Ok(keyring::Pin::Mismatch { .. }) = keyring::pin_or_check(hub_key, role, card, &m.front.ts) {
return Trust::Mismatch {
reason: format!(
"{role}'s published key differs from the one first pinned — the identity IS the key, so this is never a legitimate rotation (impersonation, or a new agent that must use its own role-id)"
),
};
}
let pinned = keyring::pinned(hub_key, role).unwrap_or_else(|| card.to_string());
let Some(sha) = cache.msg_commit(root, m) else {
return Trust::Unverified { reason: "could not locate the message's commit".into() };
};
match cache.gsig(root, role, &pinned, &sha) {
'G' => good_verdict(hub_key, role, cache.fingerprint(&pinned)),
'N' => Trust::Unverified { reason: "unsigned commit".into() },
_ => Trust::Unverified { reason: format!("signed, but not by {role}'s pinned key") },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glyph_and_tag_shapes() {
assert_eq!(Trust::Verified { fpr: "SHA256:x".into() }.glyph(), "✓");
assert_eq!(Trust::Unverified { reason: "unsigned commit".into() }.glyph(), "·");
assert_eq!(Trust::Mismatch { reason: "changed".into() }.glyph(), "‼");
assert!(Trust::Verified { fpr: "SHA256:x".into() }.tag().contains("verified"));
assert!(Trust::Mismatch { reason: "changed".into() }.tag().contains("MISMATCH"));
assert!(Trust::Mismatch { reason: "c".into() }.is_mismatch());
assert!(!Trust::Verified { fpr: "SHA256:x".into() }.is_mismatch());
}
}