forked from Keelrleek/SoulStream
spaces: a member wears a key minted for that room alone, so a roster stops naming personas
This commit is contained in:
1 parent
b3c121fa94
commit
67e52697b2
6 files changed
+462
-58
No files matched your search
@@ -12,7 +12,7 @@
|
||||
//! `profile_sign_payload` / `asset_sign_payload` return exactly what the
|
||||
//! identity delegate must be handed for each.
|
||||
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod slsk_link;
|
||||
@@ -93,6 +93,19 @@ pub(crate) fn signable(domain: &str, payload: &[u8]) -> Vec<u8> {
|
||||
msg
|
||||
}
|
||||
|
||||
/// Sign with a raw ed25519 secret in an explicit domain. The delegate holds
|
||||
/// the persona and signs for it; these are the room-local keys it does not
|
||||
/// hold, so their signing lives beside the verification it has to match.
|
||||
/// Deterministic — no entropy, which is what keeps getrandom out of wasm.
|
||||
pub fn sign_local(domain: &str, secret: &[u8; 32], payload: &[u8]) -> Vec<u8> {
|
||||
SigningKey::from_bytes(secret).sign(&signable(domain, payload)).to_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// The public half of a raw ed25519 secret.
|
||||
pub fn public_of(secret: &[u8; 32]) -> [u8; 32] {
|
||||
SigningKey::from_bytes(secret).verifying_key().to_bytes()
|
||||
}
|
||||
|
||||
pub fn verify_sig(public: &[u8; 32], payload: &[u8], sig: &[u8]) -> bool {
|
||||
verify_sig_in(SIGN_DOMAIN, public, payload, sig)
|
||||
}
|
||||
|
||||
@@ -206,6 +206,55 @@ pub fn params_parts(params: &[u8]) -> Option<([u8; 32], [u8; 16])> {
|
||||
))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ room-local keys
|
||||
//
|
||||
// A member appears on the roster as a key minted FOR THIS SPACE, not as their
|
||||
// persona. The contract never cared which it was — `message_valid` only asks
|
||||
// whether the author is on the roster and whether the signature checks out
|
||||
// against that key — so this costs no contract generation, and the same rules
|
||||
// admit both.
|
||||
//
|
||||
// The owner is the exception, and cannot be otherwise: the Space's address is
|
||||
// derived from the owner's persona key (`make_params`), so owning a Space is
|
||||
// public by construction. Joining one is what this makes unlinkable.
|
||||
//
|
||||
// These keys never reach the delegate — a member mints one per Space and the
|
||||
// app keeps it — so signing lives here, beside the verification it has to
|
||||
// match. Nothing here draws entropy: ed25519 signing is deterministic, which
|
||||
// is what keeps getrandom out of the wasm build.
|
||||
|
||||
/// The public half of a room-local member key.
|
||||
pub fn member_public(secret: &[u8; 32]) -> [u8; 32] {
|
||||
crate::public_of(secret)
|
||||
}
|
||||
|
||||
/// Sign as a room-local member, in the same domain the roster verifies in.
|
||||
pub fn sign_as_member(secret: &[u8; 32], payload: &[u8]) -> Vec<u8> {
|
||||
crate::sign_local(SIGN_DOMAIN, secret, payload)
|
||||
}
|
||||
|
||||
/// What a joiner signs with their PERSONA to tell the owner that a room key is
|
||||
/// theirs. It travels sealed and stops at the owner: published, it would undo
|
||||
/// the whole point by tying the room key back to the persona.
|
||||
pub fn join_proof_payload(space_id: &[u8; 16], owner: &[u8; 32], room_key: &[u8; 32]) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(16 + 32 + 32);
|
||||
v.extend_from_slice(space_id);
|
||||
v.extend_from_slice(owner);
|
||||
v.extend_from_slice(room_key);
|
||||
blake3::hash(&v).as_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// True when `persona` really did vouch for `room_key` in this Space.
|
||||
pub fn join_proof_valid(
|
||||
space_id: &[u8; 16],
|
||||
owner: &[u8; 32],
|
||||
room_key: &[u8; 32],
|
||||
persona: &[u8; 32],
|
||||
proof: &[u8],
|
||||
) -> bool {
|
||||
crate::verify_sig_in(SIGN_DOMAIN, persona, &join_proof_payload(space_id, owner, room_key), proof)
|
||||
}
|
||||
|
||||
pub fn checkpoint_valid(owner: &[u8; 32], space_id: &[u8; 16], c: &Checkpoint) -> bool {
|
||||
c.schema_version == SCHEMA_VERSION
|
||||
&& &c.space_id == space_id
|
||||
@@ -345,6 +394,65 @@ mod tests {
|
||||
bincode::serialize(&SpaceState { schema_version: SCHEMA_VERSION, space_id: [4; 16], checkpoint: c.cloned(), messages: msgs.to_vec() }).unwrap()
|
||||
}
|
||||
|
||||
/// The whole point of room-local keys: a member on the roster under a key
|
||||
/// minted for THIS Space posts and merges exactly like one on their
|
||||
/// persona, so nothing about the contract has to change. Their persona
|
||||
/// appears nowhere in anything published.
|
||||
#[test]
|
||||
fn a_room_local_member_is_admitted_like_any_other() {
|
||||
let owner = SigningKey::from_bytes(&[1; 32]);
|
||||
let ok = owner.verifying_key().to_bytes();
|
||||
// Bob's persona, and the key he mints for this Space alone.
|
||||
let bob_persona = SigningKey::from_bytes(&[2; 32]);
|
||||
let bob_room = SigningKey::from_bytes(&[9; 32]);
|
||||
let room_secret = [9u8; 32];
|
||||
assert_eq!(member_public(&room_secret), bob_room.verifying_key().to_bytes());
|
||||
|
||||
let c1 = checkpoint(&owner, 1, &[&bob_room], false);
|
||||
let m = message(&bob_room, 1, 10, 100, None);
|
||||
let merged = merge_space(&ok, &[4; 16], &[&state(Some(&c1), &[]), &state(None, &[m])]);
|
||||
assert_eq!(merged.messages.len(), 1, "a room-local member must post like anyone else");
|
||||
|
||||
// Signing through the app-side helper is the same signature.
|
||||
let mut m2 = Message {
|
||||
space_id: [4; 16],
|
||||
epoch: 1,
|
||||
msg_id: [11; 16],
|
||||
author: member_public(&room_secret),
|
||||
created_at: 101,
|
||||
body: b"hello".to_vec(),
|
||||
sig: vec![],
|
||||
};
|
||||
m2.sig = sign_as_member(&room_secret, &message_sign_payload(&m2));
|
||||
assert!(message_valid(&[4; 16], &c1, &m2), "sign_as_member must satisfy the roster check");
|
||||
|
||||
// Nothing published mentions the persona.
|
||||
let published = state(Some(&c1), &[m2]);
|
||||
let persona_bytes = bob_persona.verifying_key().to_bytes();
|
||||
assert!(
|
||||
!published.windows(32).any(|w| w == persona_bytes),
|
||||
"the persona key leaked into published Space bytes"
|
||||
);
|
||||
}
|
||||
|
||||
/// The owner still has to know the room key is the person they invited —
|
||||
/// sealed, never published.
|
||||
#[test]
|
||||
fn a_join_proof_binds_one_room_key_to_one_persona_and_space() {
|
||||
let owner = SigningKey::from_bytes(&[1; 32]);
|
||||
let ok = owner.verifying_key().to_bytes();
|
||||
let bob_persona = SigningKey::from_bytes(&[2; 32]);
|
||||
let room = member_public(&[9; 32]);
|
||||
let proof = sign(&bob_persona, &join_proof_payload(&[4; 16], &ok, &room));
|
||||
let bp = bob_persona.verifying_key().to_bytes();
|
||||
assert!(join_proof_valid(&[4; 16], &ok, &room, &bp, &proof));
|
||||
// Not reusable in another Space, for another key, or by another persona.
|
||||
assert!(!join_proof_valid(&[5; 16], &ok, &room, &bp, &proof), "replayed into another Space");
|
||||
assert!(!join_proof_valid(&[4; 16], &ok, &member_public(&[8; 32]), &bp, &proof), "bound to the wrong key");
|
||||
let mallory = SigningKey::from_bytes(&[3; 32]).verifying_key().to_bytes();
|
||||
assert!(!join_proof_valid(&[4; 16], &ok, &room, &mallory, &proof), "anyone could claim it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roster_is_the_authority_and_removal_is_an_epoch() {
|
||||
let owner = SigningKey::from_bytes(&[1; 32]);
|
||||
|
||||
Reference in new issue
Block a user