//! A SoulStream Space — the persistent community the F6 decision chose to //! build on River's patterns rather than River's format: ONE contract per //! Space addressed by (owner, space id); an owner-signed CHECKPOINT that IS //! the authority (name, mode, the member roster, the pinned playlist) with //! a monotonic epoch, so a removal is a new epoch without that member and //! nothing about membership is ever inferred from who talked recently; a //! bounded window of member-signed MESSAGES admitted only from the current //! roster; and, for a private Space, message bodies sealed to a per-epoch //! space key the owner hands members through their encrypted inboxes — //! rotated on every removal. Deterministic merge, canonical-only //! validation: the F5 bouncer, again. BYTE-COMPATIBILITY IS THE CONTRACT: //! `crates/space-contract` compiles this very module into the wasm. //! //! Signing rides `soulstream.friend/v1` like the inbox does (the delegate //! generation in the app has no space domain yet). use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; pub const SCHEMA_VERSION: u32 = 1; pub const PARAMS_FAMILY: &[u8] = b"soulstream.space.v1"; pub const SIGN_DOMAIN: &str = "soulstream.friend/v1"; pub const MAX_STATE_BYTES: usize = 1024 * 1024; pub const MAX_MEMBERS: usize = 256; pub const MAX_MESSAGES: usize = 500; pub const DEFAULT_MESSAGE_CAP: u32 = 200; pub const MAX_BODY_BYTES: usize = 4096; pub const MAX_NAME_BYTES: usize = 64; pub const MAX_DESC_BYTES: usize = 512; pub const MAX_PER_AUTHOR_UNCAPPED: usize = 64; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Role { Owner, Member, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Member { pub key: [u8; 32], pub role: Role, pub since_epoch: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PlaylistRef { pub owner: [u8; 32], pub uuid: [u8; 16], } /// The owner's word on what the Space is and who is in it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Checkpoint { pub schema_version: u32, pub space_id: [u8; 16], pub owner: [u8; 32], pub epoch: u64, pub name: String, pub description: String, pub private: bool, pub members: Vec, pub pinned: Option, pub message_cap: u32, pub sig: Vec, } #[derive(Serialize)] struct CheckpointSigBody<'a> { schema_version: u32, space_id: &'a [u8; 16], owner: &'a [u8; 32], epoch: u64, name: &'a str, description: &'a str, private: bool, members: &'a [Member], pinned: &'a Option, message_cap: u32, } pub fn checkpoint_sig_body(c: &Checkpoint) -> Vec { bincode::serialize(&CheckpointSigBody { schema_version: c.schema_version, space_id: &c.space_id, owner: &c.owner, epoch: c.epoch, name: &c.name, description: &c.description, private: c.private, members: &c.members, pinned: &c.pinned, message_cap: c.message_cap, }) .expect("serializes") } pub fn checkpoint_sign_payload(c: &Checkpoint) -> Vec { blake3::hash(&checkpoint_sig_body(c)).as_bytes().to_vec() } /// One line in the Space. Public: `body` is UTF-8. Private: `body` is the /// ChaCha20-Poly1305 seal of the UTF-8 under the epoch's space key. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Message { pub space_id: [u8; 16], pub epoch: u64, pub msg_id: [u8; 16], pub author: [u8; 32], pub created_at: u64, pub body: Vec, pub sig: Vec, } #[derive(Serialize)] struct MessageSigBody<'a> { space_id: &'a [u8; 16], epoch: u64, msg_id: &'a [u8; 16], author: &'a [u8; 32], created_at: u64, body: &'a [u8], } pub fn message_sig_body(m: &Message) -> Vec { bincode::serialize(&MessageSigBody { space_id: &m.space_id, epoch: m.epoch, msg_id: &m.msg_id, author: &m.author, created_at: m.created_at, body: &m.body, }) .expect("serializes") } pub fn message_sign_payload(m: &Message) -> Vec { blake3::hash(&message_sig_body(m)).as_bytes().to_vec() } /// The AAD for a sealed body: everything about the message but the body. fn message_aad(m: &Message) -> Vec { let mut v = Vec::with_capacity(80); v.extend_from_slice(&m.space_id); v.extend_from_slice(&m.epoch.to_le_bytes()); v.extend_from_slice(&m.msg_id); v.extend_from_slice(&m.author); v.extend_from_slice(&m.created_at.to_le_bytes()); v } fn body_key(space_key: &[u8; 32]) -> [u8; 32] { blake3::derive_key("soulstream.space-body.v1", space_key) } fn body_nonce(msg_id: &[u8; 16]) -> [u8; 12] { blake3::hash(msg_id).as_bytes()[..12].try_into().unwrap() } /// Seal a private message body. Call BEFORE signing (the signature covers /// the sealed bytes). `m.body` must hold the plaintext going in. pub fn seal_body(space_key: &[u8; 32], m: &Message) -> Vec { use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; let key = body_key(space_key); ChaCha20Poly1305::new(Key::from_slice(&key)) .encrypt( Nonce::from_slice(&body_nonce(&m.msg_id)), Payload { msg: &m.body, aad: &message_aad(m) }, ) .expect("seal") } /// Open a private message body. None = wrong key for this epoch, or tampered. pub fn open_body(space_key: &[u8; 32], m: &Message) -> Option> { use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; let key = body_key(space_key); ChaCha20Poly1305::new(Key::from_slice(&key)) .decrypt( Nonce::from_slice(&body_nonce(&m.msg_id)), Payload { msg: &m.body, aad: &message_aad(m) }, ) .ok() } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SpaceState { pub schema_version: u32, pub space_id: [u8; 16], pub checkpoint: Option, pub messages: Vec, } pub fn make_params(owner: &[u8; 32], space_id: &[u8; 16]) -> Vec { let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48); p.extend_from_slice(PARAMS_FAMILY); p.extend_from_slice(owner); p.extend_from_slice(space_id); p } pub fn params_parts(params: &[u8]) -> Option<([u8; 32], [u8; 16])> { let t = PARAMS_FAMILY.len(); if params.len() != t + 48 || ¶ms[..t] != PARAMS_FAMILY { return None; } Some(( params[t..t + 32].try_into().unwrap(), params[t + 32..t + 48].try_into().unwrap(), )) } // ------------------------------------------------------------ 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 { 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 { 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) } /// What a member signs to give up their seat. Prefixed, so a join proof can /// never be replayed as a leave, and signed by the key that HOLDS the seat — /// holding it is the whole authority to vacate it, and the owner needs no /// second fact to act. pub fn leave_proof_payload(space_id: &[u8; 16], owner: &[u8; 32], worn: &[u8; 32]) -> Vec { let mut v = Vec::with_capacity(5 + 16 + 32 + 32); v.extend_from_slice(b"leave"); v.extend_from_slice(space_id); v.extend_from_slice(owner); v.extend_from_slice(worn); blake3::hash(&v).as_bytes().to_vec() } /// True when whoever sits under `worn` really did ask to leave this Space. pub fn leave_proof_valid(space_id: &[u8; 16], owner: &[u8; 32], worn: &[u8; 32], proof: &[u8]) -> bool { crate::verify_sig_in(SIGN_DOMAIN, worn, &leave_proof_payload(space_id, owner, worn), 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 && &c.owner == owner && c.epoch >= 1 && c.name.len() <= MAX_NAME_BYTES && c.description.len() <= MAX_DESC_BYTES && !c.members.is_empty() && c.members.len() <= MAX_MEMBERS && c.message_cap >= 1 && c.message_cap as usize <= MAX_MESSAGES && c.members.iter().any(|m| &m.key == owner && m.role == Role::Owner) && crate::verify_sig_in(SIGN_DOMAIN, owner, &checkpoint_sign_payload(c), &c.sig) } pub fn message_valid(space_id: &[u8; 16], c: &Checkpoint, m: &Message) -> bool { &m.space_id == space_id && m.epoch <= c.epoch && m.body.len() <= MAX_BODY_BYTES + 16 && c.members.iter().any(|mem| mem.key == m.author) && crate::verify_sig_in(SIGN_DOMAIN, &m.author, &message_sign_payload(m), &m.sig) } /// Deterministic multi-writer merge: the highest valid (epoch, hash) /// checkpoint wins; messages are admitted only from ITS roster, deduped by /// id (smaller content hash wins a collision), fairly bounded per author, /// then the newest `message_cap` kept in (created_at, id) order. pub fn merge_space(owner: &[u8; 32], space_id: &[u8; 16], candidates: &[&[u8]]) -> SpaceState { let mut best: Option<(u64, [u8; 32], Checkpoint)> = None; let mut parsed: Vec = Vec::new(); for bytes in candidates { if bytes.is_empty() || bytes.len() > MAX_STATE_BYTES { continue; } let Ok(st) = bincode::deserialize::(bytes) else { continue }; if st.schema_version != SCHEMA_VERSION || &st.space_id != space_id { continue; } if let Some(c) = &st.checkpoint { if checkpoint_valid(owner, space_id, c) { let rank = (c.epoch, *blake3::hash(&checkpoint_sig_body(c)).as_bytes()); let better = match &best { None => true, Some((e, h, _)) => rank > (*e, *h), }; if better { best = Some((rank.0, rank.1, c.clone())); } } } parsed.push(st); } let Some((_, _, checkpoint)) = best else { return SpaceState { schema_version: SCHEMA_VERSION, space_id: *space_id, checkpoint: None, messages: vec![] }; }; let mut by_id: BTreeMap<[u8; 16], (Message, [u8; 32])> = BTreeMap::new(); for st in &parsed { for m in &st.messages { if !message_valid(space_id, &checkpoint, m) { continue; } let h = *blake3::hash(&bincode::serialize(m).unwrap()).as_bytes(); match by_id.get(&m.msg_id) { Some((_, existing)) if existing <= &h => {} _ => { by_id.insert(m.msg_id, (m.clone(), h)); } } } } let mut all: Vec = by_id.into_values().map(|(m, _)| m).collect(); all.sort_by_key(|m| (m.created_at, m.msg_id)); // Per-author fairness before the global window: one voice cannot fill // the whole window and push everyone else out. let mut per_author: BTreeMap<[u8; 32], usize> = BTreeMap::new(); let mut kept: Vec = Vec::with_capacity(all.len()); for m in all.into_iter().rev() { let n = per_author.entry(m.author).or_insert(0); if *n < MAX_PER_AUTHOR_UNCAPPED { *n += 1; kept.push(m); } } kept.reverse(); let cap = checkpoint.message_cap as usize; if kept.len() > cap { kept.drain(..kept.len() - cap); } SpaceState { schema_version: SCHEMA_VERSION, space_id: *space_id, checkpoint: Some(checkpoint), messages: kept } } #[cfg(test)] mod tests { use super::*; use ed25519_dalek::{Signer, SigningKey}; fn sign(key: &SigningKey, payload: &[u8]) -> Vec { key.sign(&crate::signable(SIGN_DOMAIN, payload)).to_bytes().to_vec() } fn checkpoint(owner: &SigningKey, epoch: u64, members: &[&SigningKey], private: bool) -> Checkpoint { let mut c = Checkpoint { schema_version: SCHEMA_VERSION, space_id: [4; 16], owner: owner.verifying_key().to_bytes(), epoch, name: "Late Night Ambient".into(), description: String::new(), private, members: std::iter::once(Member { key: owner.verifying_key().to_bytes(), role: Role::Owner, since_epoch: 1 }) .chain(members.iter().map(|k| Member { key: k.verifying_key().to_bytes(), role: Role::Member, since_epoch: epoch })) .collect(), pinned: None, message_cap: DEFAULT_MESSAGE_CAP, sig: vec![], }; c.sig = sign(owner, &checkpoint_sign_payload(&c)); c } fn message(author: &SigningKey, epoch: u64, id: u8, at: u64, key: Option<&[u8; 32]>) -> Message { let mut m = Message { space_id: [4; 16], epoch, msg_id: [id; 16], author: author.verifying_key().to_bytes(), created_at: at, body: b"hello".to_vec(), sig: vec![], }; if let Some(k) = key { m.body = seal_body(k, &m); } m.sig = sign(author, &message_sign_payload(&m)); m } fn state(c: Option<&Checkpoint>, msgs: &[Message]) -> Vec { 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"); } /// A seat is given up by the key that holds it, and a join proof is not a /// leave proof — otherwise the owner's own invitation answer could be /// replayed to unseat the person who sent it. #[test] fn a_leave_proof_comes_from_the_seat_it_gives_up() { let owner = SigningKey::from_bytes(&[1; 32]); let ok = owner.verifying_key().to_bytes(); let room_secret = [9u8; 32]; let room = member_public(&room_secret); let proof = sign_as_member(&room_secret, &leave_proof_payload(&[4; 16], &ok, &room)); assert!(leave_proof_valid(&[4; 16], &ok, &room, &proof)); // Not reusable in another Space, and not against another seat. assert!(!leave_proof_valid(&[5; 16], &ok, &room, &proof), "replayed into another Space"); assert!(!leave_proof_valid(&[4; 16], &ok, &member_public(&[8; 32]), &proof), "bound to the wrong seat"); // A join proof carries a different payload, so it cannot stand in. let bob_persona = SigningKey::from_bytes(&[2; 32]); let bp = bob_persona.verifying_key().to_bytes(); let join = sign(&bob_persona, &join_proof_payload(&[4; 16], &ok, &bp)); assert!(!leave_proof_valid(&[4; 16], &ok, &bp, &join), "a join proof unseated its own sender"); } #[test] fn roster_is_the_authority_and_removal_is_an_epoch() { let owner = SigningKey::from_bytes(&[1; 32]); let bob = SigningKey::from_bytes(&[2; 32]); let mallory = SigningKey::from_bytes(&[3; 32]); let ok = owner.verifying_key().to_bytes(); let c1 = checkpoint(&owner, 1, &[&bob], false); let m_bob = message(&bob, 1, 10, 100, None); let m_mal = message(&mallory, 1, 11, 101, None); let merged = merge_space(&ok, &[4; 16], &[&state(Some(&c1), &[]), &state(None, &[m_bob.clone(), m_mal])]); assert_eq!(merged.messages.len(), 1, "an off-roster author never enters"); // the canonical state merges to itself let canon = bincode::serialize(&merged).unwrap(); assert_eq!(bincode::serialize(&merge_space(&ok, &[4; 16], &[&canon])).unwrap(), canon); // a newer epoch without bob drops bob's messages; an old checkpoint cannot restore him let c2 = checkpoint(&owner, 2, &[], false); let after = merge_space(&ok, &[4; 16], &[&canon, &state(Some(&c2), &[])]); assert_eq!(after.checkpoint.as_ref().map(|c| c.epoch), Some(2)); assert!(after.messages.is_empty()); let replay = merge_space(&ok, &[4; 16], &[&bincode::serialize(&after).unwrap(), &state(Some(&c1), &[m_bob])]); assert_eq!(replay.checkpoint.as_ref().map(|c| c.epoch), Some(2)); // a checkpoint signed by a non-owner is not a checkpoint let mut forged = checkpoint(&owner, 3, &[&mallory], false); forged.sig = sign(&mallory, &checkpoint_sign_payload(&forged)); assert!(!checkpoint_valid(&ok, &[4; 16], &forged)); } #[test] fn private_bodies_open_only_with_the_epoch_key() { let owner = SigningKey::from_bytes(&[1; 32]); let key = [9u8; 32]; let m = message(&owner, 1, 7, 100, Some(&key)); assert_eq!(open_body(&key, &m).as_deref(), Some(&b"hello"[..])); assert_eq!(open_body(&[8u8; 32], &m), None); let mut t = m.clone(); t.created_at += 1; assert_eq!(open_body(&key, &t), None, "the AAD binds the header"); } }