use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trust {
Known,
Unknown,
Declined,
Unverified,
Retired,
Revoked,
Unwitnessed,
}
pub const ALL: &[Trust] = &[
Trust::Known,
Trust::Unknown,
Trust::Declined,
Trust::Unverified,
Trust::Retired,
Trust::Revoked,
Trust::Unwitnessed,
];
impl Trust {
pub fn index(self) -> usize {
match self {
Trust::Known => 0,
Trust::Unknown => 1,
Trust::Declined => 2,
Trust::Unverified => 3,
Trust::Retired => 4,
Trust::Revoked => 5,
Trust::Unwitnessed => 6,
}
}
pub fn label(&self) -> &'static str {
match self {
Trust::Known => "SENDER VERIFIED (signature valid, key is on your list)",
Trust::Unknown => "SENDER NOT ON YOUR LIST (signature valid, you have never accepted this key)",
Trust::Declined => "SENDER REFUSED BEFORE (signature valid, and you declined this key rather than adding it)",
Trust::Unverified => "SENDER UNVERIFIABLE (signature missing or invalid)",
Trust::Retired => "SENDER KEY RETIRED (signature valid; you saw this bundle before you retired that key)",
Trust::Revoked => "SENDER KEY REJECTED (signature valid, but this bundle reached you after you retired that key)",
Trust::Unwitnessed => "ARRIVAL UNRECORDED (signature valid under a retired key, and no record of this bundle reaching you before then)",
}
}
}
pub const VERDICT_UNAVAILABLE: &str =
"VERDICT UNAVAILABLE (your inbox could not be recorded, so a verdict here could \
read milder than the truth)";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Retired {
pub name: String,
pub retired_at: String,
#[serde(default)]
pub replaced_by: Option<String>,
#[serde(default)]
pub retired_seq: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claimed_at: Option<String>,
#[serde(default)]
pub compromised: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Contacts {
#[serde(default)]
pub keys: BTreeMap<String, String>,
#[serde(default)]
pub groups: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub box_keys: BTreeMap<String, String>,
#[serde(default)]
pub retired: BTreeMap<String, Retired>,
#[serde(default)]
pub declined: BTreeMap<String, Declined>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Declined {
pub at: String,
#[serde(default)]
pub seq: u64,
}
impl Contacts {
pub fn load() -> Self {
let p = crate::identity::home().join("contacts.json");
fs::read_to_string(p)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self) -> std::io::Result<()> {
let dir = crate::identity::home();
fs::create_dir_all(&dir)?;
fs::write(dir.join("contacts.json"), serde_json::to_vec_pretty(self)?)
}
pub fn knows(&self, pubkey: &str) -> bool {
self.keys.contains_key(pubkey)
}
pub fn decline(&mut self, pubkey: &str, seq: u64) -> Result<bool, String> {
if !crate::bundle::b64_decode_ok(pubkey) {
return Err("that is not a valid key".to_string());
}
if let Some(n) = self.keys.get(pubkey) {
return Err(format!(
"that key is already on your list as {n}. Declining is for a key \
you have NOT added; to remove a contact, use gage retire {n}."
));
}
if self.declined.contains_key(pubkey) {
return Ok(false);
}
self.declined.insert(
pubkey.to_string(),
Declined { at: crate::bundle::now_rfc3339(), seq },
);
Ok(true)
}
pub fn declined_record(&self, pubkey: &str) -> Option<&Declined> {
self.declined.get(pubkey)
}
pub fn is_a_name_you_use(&self, s: &str, except: &str) -> bool {
let s = s.trim().to_lowercase();
let hit = |n: &str| n.trim().to_lowercase() == s;
self.keys.values().any(|n| hit(n))
|| self.retired.values().any(|r| hit(&r.name))
|| self.groups.keys().any(|n| hit(n))
|| self
.keys
.keys()
.chain(self.retired.keys())
.filter(|k| k.as_str() != except)
.any(|k| hit(&crate::words::label(k)))
}
pub fn name_for(&self, pubkey: &str) -> Option<&str> {
self.keys.get(pubkey).map(|s| s.as_str())
}
pub fn name_any(&self, pubkey: &str) -> Option<&str> {
self.name_for(pubkey)
.or_else(|| self.retired.get(pubkey).map(|r| r.name.as_str()))
}
pub fn retired_record(&self, pubkey: &str) -> Option<&Retired> {
self.retired.get(pubkey)
}
pub fn verdict(&self, pubkey: &str, witnessed: Option<u64>) -> Trust {
if self.knows(pubkey) {
return Trust::Known;
}
if self.declined.contains_key(pubkey) && !self.retired.contains_key(pubkey) {
return Trust::Declined;
}
match self.retired.get(pubkey) {
Some(r) if r.compromised => Trust::Revoked,
Some(r) => match witnessed {
Some(seq) if seq < r.retired_seq => Trust::Retired,
Some(_) => Trust::Revoked,
None => Trust::Unwitnessed,
},
None => Trust::Unknown,
}
}
pub fn retired_keys_for(&self, name: &str) -> Vec<(String, &Retired)> {
self.retired
.iter()
.filter(|(_, r)| r.name == name)
.map(|(k, r)| (k.clone(), r))
.collect()
}
pub fn mark_history_compromised(&mut self, name: &str, keys: &[String]) {
for k in keys {
if let Some(r) = self.retired.get_mut(k) {
if r.name == name {
r.compromised = true;
}
}
}
}
pub fn retire(&mut self, pubkey: &str, at: &str, seq: u64, compromised: bool,
replaced_by: Option<String>) -> bool {
let Some(name) = self.keys.remove(pubkey) else { return false };
self.box_keys.remove(pubkey);
self.retired.insert(
pubkey.to_string(),
Retired {
name,
retired_at: at.to_string(),
retired_seq: seq,
claimed_at: None,
replaced_by,
compromised,
},
);
true
}
pub fn retired_by_name(&self, name: &str) -> Option<(&str, &Retired)> {
self.retired
.iter()
.find(|(_, r)| r.name == name)
.map(|(k, r)| (k.as_str(), r))
}
pub fn key_for_name(&self, name: &str) -> Option<String> {
self.keys
.iter()
.find(|(_, n)| n.as_str() == name)
.map(|(k, _)| k.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn with_retired(compromised: bool) -> Contacts {
let mut c = Contacts::default();
c.keys.insert("OLD".into(), "bob".into());
c.retire("OLD", "2026-10-14T12:00:00Z", 10, compromised, Some("NEW".into()));
c.keys.insert("NEW".into(), "bob".into());
c
}
#[test]
fn a_retired_key_covers_only_what_was_here_first() {
let c = with_retired(false);
assert_eq!(c.verdict("OLD", Some(9)), Trust::Retired);
assert_eq!(c.verdict("OLD", Some(11)), Trust::Revoked);
assert_eq!(c.verdict("NEW", Some(11)), Trust::Known);
}
#[test]
fn the_boundary_itself_goes_against_the_old_key() {
let c = with_retired(false);
assert_eq!(c.verdict("OLD", Some(10)), Trust::Revoked);
}
#[test]
fn no_record_is_not_the_same_as_rejected() {
let c = with_retired(false);
assert_eq!(c.verdict("OLD", None), Trust::Unwitnessed);
assert_ne!(c.verdict("OLD", None), Trust::Revoked);
}
#[test]
fn a_timestamp_cannot_reach_the_verdict_at_all() {
let c = with_retired(false);
for forged in ["", "2026-10-14T12:00:00", "2026-10-14T12:00:00.9Z",
"2026-10-14T01:00:00-14:00", "0000-01-01T00:00:00Z"] {
let _ = forged; }
assert_eq!(c.verdict("OLD", None), Trust::Unwitnessed);
}
#[test]
fn a_compromised_key_is_rejected_however_it_arrived() {
let c = with_retired(true);
assert_eq!(c.verdict("OLD", Some(1)), Trust::Revoked);
assert_eq!(c.verdict("OLD", None), Trust::Revoked);
}
#[test]
fn retiring_removes_the_sealing_key_too() {
let mut c = Contacts::default();
c.keys.insert("OLD".into(), "bob".into());
c.box_keys.insert("OLD".into(), "OLDBOX".into());
assert!(c.retire("OLD", "2026-10-14T12:00:00Z", 10, false, None));
assert!(!c.box_keys.contains_key("OLD"));
assert!(!c.knows("OLD"));
assert_eq!(c.name_any("OLD"), Some("bob"));
}
#[test]
fn every_verdict_is_in_all() {
let mut seen = vec![false; ALL.len()];
for t in ALL {
let i = t.index();
assert!(i < ALL.len(), "{t:?} has index {i}, past the end of ALL");
assert!(!seen[i], "two verdicts share index {i}");
seen[i] = true;
}
assert!(
seen.iter().all(|x| *x),
"a verdict has an index but is missing from ALL: {seen:?}"
);
}
#[test]
fn no_verdict_label_is_a_prefix_of_another() {
let all = ALL;
for a in all {
for b in all {
if a != b {
assert!(
!a.label().starts_with(b.label()) && !a.label().contains(b.label()),
"{:?} starts with or contains {:?}", a, b
);
}
}
}
for l in all.iter().map(|t| t.label()).chain([VERDICT_UNAVAILABLE]) {
assert!(!l.contains(" "), "doubled whitespace in {l:?}");
assert_eq!(l.trim(), l, "stray edge whitespace in {l:?}");
}
for a in all {
assert!(
!a.label().starts_with(VERDICT_UNAVAILABLE)
&& !VERDICT_UNAVAILABLE.starts_with(a.label())
&& !a.label().contains(VERDICT_UNAVAILABLE)
&& !VERDICT_UNAVAILABLE.contains(a.label()),
"VERDICT UNAVAILABLE collides with {:?}", a
);
}
}
}
pub fn add_contact(name: &str, key: &str) -> Result<Vec<String>, String> {
let mut out: Vec<String> = Vec::new();
let (sign, boxpub) = crate::identity::split_key(key);
if !crate::bundle::b64_decode_ok(&sign) {
return Err("that signing key is not valid base64".to_string());
}
if let Some(b) = &boxpub {
if !crate::bundle::b64_decode_ok(b) {
return Err("that encryption key is not valid base64".to_string());
}
}
let mut c = Contacts::load();
if let Some(existing) = c.key_for_name(name) {
if existing != sign {
return Err(format!(
"{name} is already on your list under a different key. This would \
replace it and orphan everything that key signed.\n \
If they rotated: gage keychange <their notice file> --accept\n \
If you are doing it by hand: gage retire {name}, then add them \
again under a name you choose"
));
}
}
if let Some(r) = c.retired_record(&sign) {
if r.compromised {
return Err(format!(
"that key is recorded as COMPROMISED (retired {}). Re-adding it undoes \
that decision; if it really is safe again, edit contacts.json \
deliberately.",
r.retired_at
));
}
out.push(format!(
"note: you retired this key on {} -- re-adding it puts it back in service",
r.retired_at
));
let k = sign.clone();
c.retired.remove(&k);
}
if let Some(d) = c.declined.remove(&sign) {
out.push(format!(
"note: you declined this key on {} -- adding it now reverses that",
d.at
));
}
c.keys.insert(sign.clone(), name.to_string());
match &boxpub {
Some(b) => {
c.box_keys.insert(sign, b.clone());
out.push(format!("added {name} (verify + encrypt)"));
}
None => out.push(format!(
"added {name} (verify only -- no encryption key, so you cannot seal to them)"
)),
}
c.save().map_err(|e| e.to_string())?;
let words = c
.key_for_name(name)
.map(|k| crate::words::fingerprint_of(&k, c.box_keys.get(&k).map(|s| s.as_str())));
if let Some(w) = words {
out.push(String::new());
out.push(format!(" this key says : {w}"));
out.push(format!(" Check that with {name} over a channel that is not this one."));
out.push(" If their machine says anything else, you have the wrong key.".to_string());
}
Ok(out)
}