use crate::identity::{split_key, verify};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Notice {
pub claim: String,
pub sig_new: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sig_old: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claim {
pub v: u32,
pub old_key: String,
pub new_key: String,
pub handle: String,
pub at: String,
pub reason: String,
}
#[derive(Debug)]
pub struct Verified {
pub claim: Claim,
pub old_sign: String,
pub new_sign: String,
pub new_box: Option<String>,
pub old_signed: bool,
}
pub fn verify_notice(n: &Notice) -> Result<Verified, String> {
let claim: Claim =
serde_json::from_str(&n.claim).map_err(|e| format!("notice claim is not readable: {e}"))?;
if claim.v != 1 {
return Err(format!("notice format v{} is not understood", claim.v));
}
let (old_sign, _) = split_key(&claim.old_key);
let (new_sign, new_box) = split_key(&claim.new_key);
let bytes = n.claim.as_bytes();
if !verify(&new_sign, bytes, &n.sig_new) {
return Err("the new key did not sign this notice; it is not usable".into());
}
let old_signed = match &n.sig_old {
Some(sig) => verify(&old_sign, bytes, sig),
None => false,
};
if n.sig_old.is_some() && !old_signed {
return Err(
"a countersignature from the old key is present but does NOT \
verify. Treat this notice as forged."
.into(),
);
}
Ok(Verified {
claim,
old_sign,
new_sign,
new_box,
old_signed,
})
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use ed25519_dalek::{Signer, SigningKey};
fn key(seed: u8) -> SigningKey {
SigningKey::from_bytes(&[seed; 32])
}
fn pubs(k: &SigningKey) -> String {
B64.encode(k.verifying_key().to_bytes())
}
fn notice(old: &SigningKey, new: &SigningKey, sign_old: bool, reason: &str) -> Notice {
let claim = serde_json::json!({
"v": 1,
"old_key": pubs(old),
"new_key": pubs(new),
"handle": "bob",
"at": "2026-10-14T12:00:00Z",
"reason": reason,
})
.to_string();
Notice {
sig_new: B64.encode(new.sign(claim.as_bytes()).to_bytes()),
sig_old: sign_old.then(|| B64.encode(old.sign(claim.as_bytes()).to_bytes())),
claim,
}
}
#[test]
fn a_countersigned_notice_verifies_both_halves() {
let v = verify_notice(¬ice(&key(1), &key(2), true, "rotated")).unwrap();
assert!(v.old_signed);
assert_eq!(v.new_sign, pubs(&key(2)));
}
#[test]
fn an_uncountersigned_notice_verifies_but_claims_nothing() {
let v = verify_notice(¬ice(&key(1), &key(2), false, "compromised")).unwrap();
assert!(
!v.old_signed,
"no old signature must not read as authorised"
);
}
#[test]
fn a_forged_countersignature_is_refused_outright() {
let mut n = notice(&key(1), &key(2), true, "rotated");
n.sig_old = Some(B64.encode(key(9).sign(n.claim.as_bytes()).to_bytes()));
let e = verify_notice(&n).unwrap_err();
assert!(e.contains("forged"), "got: {e}");
}
#[test]
fn a_notice_not_signed_by_the_new_key_is_refused() {
let mut n = notice(&key(1), &key(2), true, "rotated");
n.sig_new = B64.encode(key(9).sign(n.claim.as_bytes()).to_bytes());
assert!(verify_notice(&n).is_err());
}
#[test]
fn the_claim_bytes_are_what_is_verified_not_a_reserialization() {
let mut n = notice(&key(1), &key(2), true, "rotated");
n.claim = n.claim.replace("\"bob\"", "\"eve\"");
assert!(verify_notice(&n).is_err());
}
}