spaces: a member wears a key minted for that room alone, so a roster stops naming personas

This commit is contained in:
KeelrLeek committed 2026-09-03 13:06:32 -05:00
1 parent b3c121fa94
commit 67e52697b2
6 files changed
+462 -58

No files matched your search

+14 -1
View File
@@ -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)
}
+108
View File
@@ -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]);