use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trust {
Known,
Unconfirmed,
Unknown,
Declined,
Unverified,
Retired,
Revoked,
Unwitnessed,
}
#[allow(dead_code)]
pub const ALL: &[Trust] = &[
Trust::Known,
Trust::Unconfirmed,
Trust::Unknown,
Trust::Declined,
Trust::Unverified,
Trust::Retired,
Trust::Revoked,
Trust::Unwitnessed,
];
impl Trust {
#[allow(dead_code)]
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,
Trust::Unconfirmed => 7,
}
}
pub fn label(&self) -> &'static str {
match self {
Trust::Known => {
"SENDER VERIFIED (signature valid, key is on your list, word-pairs confirmed)"
}
Trust::Unconfirmed => {
"SENDER ON YOUR LIST, UNCONFIRMED (signature valid, but nobody here ever compared this key's word-pairs)"
}
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>,
#[serde(default)]
pub confirmed: BTreeMap<String, Confirmed>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Confirmed {
pub at: String,
#[serde(default)]
pub seq: u64,
pub how: String,
pub covers: String,
}
#[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");
let raw = match fs::read_to_string(&p) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
Err(e) => {
eprintln!(
"gage: cannot read your contact list at {}: {e}",
p.display()
);
eprintln!(
" Refusing to continue: an unreadable allowlist is not an empty one,\n\
\x20 and carrying on would treat every contact you have as a stranger\n\
\x20 -- then overwrite the file the moment anything added a key."
);
std::process::exit(2);
}
};
match serde_json::from_str(&raw) {
Ok(c) => c,
Err(e) => {
eprintln!(
"gage: your contact list at {} does not parse: {e}",
p.display()
);
eprintln!(
" Refusing to continue, and nothing has been changed. Every verdict\n\
\x20 this program prints is computed from this file, so reading it as\n\
\x20 an empty list would turn every contact you have confirmed into a\n\
\x20 stranger without saying so. Fix the file, or move it aside and\n\
\x20 re-add your contacts -- there is a copy in your backup if you\n\
\x20 made one with `gage export-identity`."
);
std::process::exit(2);
}
}
}
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 fingerprint_for(&self, pubkey: &str) -> String {
crate::words::fingerprint_of(pubkey, self.box_keys.get(pubkey).map(|s| s.as_str()))
}
pub fn is_confirmed(&self, pubkey: &str) -> bool {
match self.confirmed.get(pubkey) {
Some(c) => c.covers == self.fingerprint_for(pubkey),
None => false,
}
}
pub fn stale_confirmation(&self, pubkey: &str) -> Option<&Confirmed> {
self.confirmed
.get(pubkey)
.filter(|c| c.covers != self.fingerprint_for(pubkey))
}
pub fn confirm(
&mut self,
pubkey: &str,
typed: &str,
how: &str,
seq: u64,
) -> Result<(), String> {
if !self.knows(pubkey) {
return Err("that key is not on your list, so there is nothing to confirm. Add it first, then confirm it."
.to_string());
}
let real = self.fingerprint_for(pubkey);
if !crate::words::echo_matches(typed, &real) {
return Err("those are not this key's word-pairs, so nothing was recorded. All four pairs must come back as the OTHER machine shows them. If nobody has compared them yet, do not retry -- go and compare them."
.to_string());
}
self.confirmed.insert(
pubkey.to_string(),
Confirmed {
at: crate::bundle::now_rfc3339(),
seq,
how: how.to_string(),
covers: real,
},
);
Ok(())
}
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 if self.is_confirmed(pubkey) {
Trust::Known
} else {
Trust::Unconfirmed
};
}
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)
&& 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())
}
}
pub struct AddOpts<'a> {
pub confirmed: Option<Confirmation<'a>>,
pub reinstate: bool,
pub same_machine: bool,
pub seq: u64,
}
pub struct Confirmation<'a> {
pub echo: &'a str,
pub how: &'a str,
}
impl AddOpts<'_> {
pub fn plain() -> Self {
AddOpts {
confirmed: None,
reinstate: false,
same_machine: false,
seq: 0,
}
}
}
pub fn add_contact(name: &str, key: &str, opts: AddOpts) -> 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
&& !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)
&& 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
));
}
if !opts.reinstate {
return Err(format!(
"you retired this key on {}. Adding it again puts a key you took out \
of service back into it, so it is not something `add` does quietly:\n \
gage add {name} <key> --reinstate\n \
If you did not retire it and do not know why it is on that list, do \
not reinstate it -- ask them over another channel first.",
r.retired_at
));
}
out.push(format!(
"REINSTATED: you retired this key on {} and it is back in service",
r.retired_at
));
let k = sign.clone();
c.retired.remove(&k);
}
if let Some((oldk, r)) = c.retired_by_name(name)
&& oldk != sign
&& !opts.reinstate
{
return Err(format!(
"you retired a DIFFERENT key under the name {name} on {}. Re-using that \
name hands this key anything you granted to the old one -- `gage authority` \
matches grants by name, not by key -- and makes every bundle the old key \
signed look like this one.\n \
Pick a name you have not used, or, if you mean to re-use it:\n \
gage add {name} <key> --reinstate\n \
Either way, check `gage authority` afterwards.",
r.retired_at
));
}
if let Some(d) = c.declined.get(&sign).cloned() {
if !opts.reinstate {
return Err(format!(
"you looked at this key on {} and refused it. Adding it now reverses \
that decision, so say so:\n \
gage add {name} <key> --reinstate\n \
The ordinary reason to be adding a key you refused is that something \
asked you twice.",
d.at
));
}
c.declined.remove(&sign);
out.push(format!(
"REINSTATED: you declined this key on {} and that is now reversed",
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)"
)),
}
let words = c.key_for_name(name).map(|k| c.fingerprint_for(&k));
if let Some(cf) = &opts.confirmed {
let k = c.key_for_name(name).unwrap_or_default();
c.confirm(&k, cf.echo, cf.how, opts.seq)?;
} else if opts.same_machine {
let k = c.key_for_name(name).unwrap_or_default();
let w = c.fingerprint_for(&k);
c.confirm(&k, &w, "key generated on this machine", opts.seq)?;
}
c.save().map_err(|e| e.to_string())?;
if let Some(w) = words {
out.push(String::new());
out.push(format!(" this key says : {w}"));
match &opts
.confirmed
.as_ref()
.map(|cf| cf.how)
.or(if opts.same_machine {
Some("key generated on this machine")
} else {
None
}) {
Some(how) => {
out.push(format!(" confirmed : {how}"));
out.push(" Bundles signed by this key will read SENDER VERIFIED.".to_string());
}
None => {
out.push(format!(
" Read those four pairs to {name} over a channel that is NOT this one \
-- a voice call, in person."
));
out.push(
" If their machine says anything else, you have the wrong key.".to_string(),
);
out.push(String::new());
out.push(
" Until you do, bundles from this key read SENDER ON YOUR LIST, \
UNCONFIRMED."
.to_string(),
);
out.push(format!(
" Once you have compared them: gage confirm {name}"
));
}
}
}
Ok(out)
}
#[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::Unconfirmed);
}
#[test]
fn on_the_list_is_not_confirmed_until_somebody_confirms_it() {
let mut c = Contacts::default();
let k = "yomSmC3TalJ5IM+H0yvkhhWVab4ujiAVIQFR4WvdXiM=";
c.keys.insert(k.into(), "bob".into());
assert_eq!(c.verdict(k, Some(1)), Trust::Unconfirmed);
assert!(!c.is_confirmed(k));
assert!(c.confirm(k, "wrong words entirely", "test", 1).is_err());
assert_eq!(c.verdict(k, Some(1)), Trust::Unconfirmed);
let fp = c.fingerprint_for(k);
c.confirm(k, &fp, "test", 1).unwrap();
assert_eq!(c.verdict(k, Some(1)), Trust::Known);
assert!(c.stale_confirmation(k).is_none());
c.box_keys.insert(
k.into(),
"7amQkpwKszbeF801n5EzMRm4jQlx3BylW8c59lsDcX0=".into(),
);
assert!(!c.is_confirmed(k));
assert_eq!(c.verdict(k, Some(1)), Trust::Unconfirmed);
assert!(
c.stale_confirmation(k).is_some(),
"a confirmation that stopped covering the key must be \
distinguishable from never having had one"
);
assert!(c.confirm("STRANGER", "anything", "test", 1).is_err());
}
#[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 every_verdict_is_listed_in_the_documents_that_tabulate_them() {
for doc in [
("docs/spec.md", include_str!("../docs/spec.md")),
("docs/agents.md", include_str!("../docs/agents.md")),
("AGENTS.md", include_str!("../AGENTS.md")),
] {
let (name, body) = doc;
for t in ALL {
let key = t.label().split(" (").next().unwrap_or(t.label());
assert!(
body.contains(key),
"{name} does not mention the verdict {key:?}. Every document that \
tabulates verdicts has to carry all of them -- a short list reads as \
a complete one."
);
}
}
}
#[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
);
}
}
}