community: friends, two signed answers on one request carried over soulseek, one answer is only ever pending

This commit is contained in:
KeelrLeek committed 2026-09-02 14:40:33 -05:00
1 parent 6ed6f5b8f8
commit 984738135a
9 files changed
+967 -4

No files matched your search

+281
View File
@@ -0,0 +1,281 @@
//! 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
&& &current_key(&a.signer, transitions) == signer
&& &current_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));
}
}
+1
View File
@@ -16,6 +16,7 @@ use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
pub mod slsk_link;
pub mod friend;
pub const SCHEMA_VERSION: u32 = 1;
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";