community: friends, two signed answers on one request carried over soulseek, one answer is only ever pending
This commit is contained in:
1 parent
6ed6f5b8f8
commit
984738135a
9 files changed
+967
-4
No files matched your search
@@ -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
|
||||
&& ¤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));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user