282 lines
10 KiB
Rust
282 lines
10 KiB
Rust
//! 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<u8>,
|
|
}
|
|
|
|
#[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<u8> {
|
|
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<u8> {
|
|
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<u8>,
|
|
}
|
|
|
|
#[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<u8> {
|
|
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<u8> {
|
|
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<u8>,
|
|
}
|
|
|
|
fn transition_signable(old: &[u8; 32], new: &[u8; 32], v: u32) -> Vec<u8> {
|
|
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<u8> {
|
|
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));
|
|
}
|
|
}
|