//! Friendship proofs — F4 §7.5's exact shapes and verifier. A friendship //! exists ONLY from two reciprocal, verified, currently-accepted assertions //! on the same relationship id and request hash; one assertion is a pending //! request and is never shown as friendship; a revocation is a NEWER //! assertion with accepted=false — nothing is erased. Names and avatars //! never enter any decision here. //! //! Sign convention: the delegate signs blake3(bincode(sig-body)) under //! `soulstream.friend/v1`; the verifiers mirror it. Field order is the wire. use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; pub const FRIEND_DOMAIN: &str = "soulstream.friend/v1"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FriendRequest { pub relationship_id: [u8; 16], pub sender: [u8; 32], pub recipient: [u8; 32], pub nonce: [u8; 16], pub issued_at: u64, pub expires_at: u64, pub sig: Vec, } #[derive(Serialize)] struct RequestSigBody<'a> { relationship_id: &'a [u8; 16], sender: &'a [u8; 32], recipient: &'a [u8; 32], nonce: &'a [u8; 16], issued_at: u64, expires_at: u64, } pub fn request_sig_body(r: &FriendRequest) -> Vec { bincode::serialize(&RequestSigBody { relationship_id: &r.relationship_id, sender: &r.sender, recipient: &r.recipient, nonce: &r.nonce, issued_at: r.issued_at, expires_at: r.expires_at, }) .expect("serializes") } /// What the delegate is handed to sign for a request. pub fn request_sign_payload(r: &FriendRequest) -> Vec { blake3::hash(&request_sig_body(r)).as_bytes().to_vec() } /// The request's identity: what both assertions bind to. pub fn request_hash(r: &FriendRequest) -> [u8; 32] { *blake3::hash(&request_sig_body(r)).as_bytes() } pub fn verify_request(r: &FriendRequest) -> bool { crate::verify_sig_in(FRIEND_DOMAIN, &r.sender, &request_sign_payload(r), &r.sig) } /// One party's signed stance on one relationship. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FriendAssertion { pub relationship_id: [u8; 16], pub signer: [u8; 32], pub peer: [u8; 32], pub request_hash: [u8; 32], pub accepted: bool, pub seq: u64, pub sig: Vec, } #[derive(Serialize)] struct AssertionSigBody<'a> { relationship_id: &'a [u8; 16], signer: &'a [u8; 32], peer: &'a [u8; 32], request_hash: &'a [u8; 32], accepted: bool, seq: u64, } pub fn assertion_sig_body(a: &FriendAssertion) -> Vec { bincode::serialize(&AssertionSigBody { relationship_id: &a.relationship_id, signer: &a.signer, peer: &a.peer, request_hash: &a.request_hash, accepted: a.accepted, seq: a.seq, }) .expect("serializes") } pub fn assertion_sign_payload(a: &FriendAssertion) -> Vec { blake3::hash(&assertion_sig_body(a)).as_bytes().to_vec() } pub fn verify_assertion(a: &FriendAssertion) -> bool { crate::verify_sig_in(FRIEND_DOMAIN, &a.signer, &assertion_sign_payload(a), &a.sig) } /// A key-rotation transition (the identity delegate's): old key vouches /// for new. Carried so a rotated friend can continue instead of re-accept. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct KeyTransition { pub old_public: [u8; 32], pub new_public: [u8; 32], pub key_version: u32, /// By the OLD key, the delegate's own transition construction. pub sig: Vec, } fn transition_signable(old: &[u8; 32], new: &[u8; 32], v: u32) -> Vec { let mut p = Vec::with_capacity(72); p.extend_from_slice(b"rotate:"); p.extend_from_slice(old); p.extend_from_slice(new); p.extend_from_slice(&v.to_le_bytes()); crate::signable("soulstream.key-transition/v1", &p) } pub fn verify_transition(t: &KeyTransition) -> bool { let Ok(vk) = VerifyingKey::from_bytes(&t.old_public) else { return false }; let Ok(sig) = Signature::from_slice(&t.sig) else { return false }; vk.verify(&transition_signable(&t.old_public, &t.new_public, t.key_version), &sig) .is_ok() } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum FriendStatus { /// Both reciprocal assertions verified and accepted. Friends, /// One valid assertion — pending, never displayed as friendship. PendingOneSided, /// A newer signed revocation stands from at least one side. Revoked, /// A key changed without a verifiable transition — must re-accept. ReacceptanceRequired, NotRelated, } /// One persona's current key through transition records (one hop). fn current_key(key: &[u8; 32], transitions: &[KeyTransition]) -> [u8; 32] { for t in transitions { if &t.old_public == key && verify_transition(t) { return t.new_public; } } *key } /// The verifier. Duplicates collapse (the newest seq per side wins); a /// rotated key without a transition forces re-acceptance. pub fn friendship_status( a_key: &[u8; 32], b_key: &[u8; 32], relationship_id: &[u8; 16], req_hash: &[u8; 32], assertions: &[FriendAssertion], transitions: &[KeyTransition], ) -> FriendStatus { let a_now = current_key(a_key, transitions); let b_now = current_key(b_key, transitions); let latest = |signer: &[u8; 32], peer: &[u8; 32]| -> Option<&FriendAssertion> { assertions .iter() .filter(|a| { &a.relationship_id == relationship_id && &a.request_hash == req_hash && ¤t_key(&a.signer, transitions) == signer && ¤t_key(&a.peer, transitions) == peer && verify_assertion(a) }) .max_by_key(|a| a.seq) }; let ab = latest(&a_now, &b_now); let ba = latest(&b_now, &a_now); match (ab, ba) { (Some(x), Some(y)) if x.accepted && y.accepted => FriendStatus::Friends, (Some(x), Some(y)) if !x.accepted || !y.accepted => FriendStatus::Revoked, (Some(x), None) if x.accepted => { let stale_exists = assertions.iter().any(|a| { &a.relationship_id == relationship_id && verify_assertion(a) && current_key(&a.signer, transitions) != a_now && current_key(&a.signer, transitions) != b_now }); if stale_exists { FriendStatus::ReacceptanceRequired } else { FriendStatus::PendingOneSided } } (None, Some(y)) if y.accepted => FriendStatus::PendingOneSided, (None, None) => { let stale_exists = assertions .iter() .any(|a| &a.relationship_id == relationship_id && verify_assertion(a)); if stale_exists { FriendStatus::ReacceptanceRequired } else { FriendStatus::NotRelated } } _ => FriendStatus::Revoked, } } #[cfg(test)] mod tests { use super::*; use ed25519_dalek::{Signer, SigningKey}; fn sign(key: &SigningKey, payload: &[u8]) -> Vec { key.sign(&crate::signable(FRIEND_DOMAIN, payload)).to_bytes().to_vec() } fn request(from: &SigningKey, to: &SigningKey) -> FriendRequest { let mut r = FriendRequest { relationship_id: [3; 16], sender: from.verifying_key().to_bytes(), recipient: to.verifying_key().to_bytes(), nonce: [4; 16], issued_at: 100, expires_at: 900, sig: vec![], }; r.sig = sign(from, &request_sign_payload(&r)); r } fn assertion(by: &SigningKey, peer: &SigningKey, r: &FriendRequest, accepted: bool, seq: u64) -> FriendAssertion { let mut a = FriendAssertion { relationship_id: r.relationship_id, signer: by.verifying_key().to_bytes(), peer: peer.verifying_key().to_bytes(), request_hash: request_hash(r), accepted, seq, sig: vec![], }; a.sig = sign(by, &assertion_sign_payload(&a)); a } #[test] fn one_sided_is_never_friends_and_revocation_sticks() { let a = SigningKey::from_bytes(&[1; 32]); let b = SigningKey::from_bytes(&[2; 32]); let mallory = SigningKey::from_bytes(&[3; 32]); let r = request(&a, &b); assert!(verify_request(&r)); let (ak, bk) = (a.verifying_key().to_bytes(), b.verifying_key().to_bytes()); let h = request_hash(&r); let st = |list: &[FriendAssertion]| friendship_status(&ak, &bk, &r.relationship_id, &h, list, &[]); assert_eq!(st(&[]), FriendStatus::NotRelated); let mine = assertion(&a, &b, &r, true, 10); assert_eq!(st(&[mine.clone()]), FriendStatus::PendingOneSided); let theirs = assertion(&b, &a, &r, true, 11); assert_eq!(st(&[mine.clone(), theirs.clone()]), FriendStatus::Friends); // duplicates collapse assert_eq!(st(&[mine.clone(), theirs.clone(), theirs.clone()]), FriendStatus::Friends); // a forged acceptance never counts as friendship. (To the verifier a // validly signed third-key assertion looks like an old key without a // transition, so it answers ReacceptanceRequired — still not friends; // the app never stores a foreign assertion in the first place.) let forged = assertion(&mallory, &a, &r, true, 12); assert_ne!(st(&[mine.clone(), forged]), FriendStatus::Friends); // a newer revocation stands, and an old acceptance cannot resurrect it let revoke = assertion(&b, &a, &r, false, 20); assert_eq!(st(&[mine.clone(), theirs.clone(), revoke.clone()]), FriendStatus::Revoked); assert_eq!(st(&[revoke, theirs, mine]), FriendStatus::Revoked); // tampering after signing let mut t = request(&a, &b); t.recipient = mallory.verifying_key().to_bytes(); assert!(!verify_request(&t)); } }