community: spaces, an owner-signed roster with epochs, a bounded window of signed lines, bodies sealed to a rotating key when private

This commit is contained in:
KeelrLeek committed 2026-09-02 15:50:18 -05:00
1 parent 4378c4c0cc
commit 8b511b20e7
14 files changed
+2913 -13

No files matched your search

+4 -2
View File
@@ -13,8 +13,10 @@ serde.workspace = true
bincode = "1"
ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] }
blake3 = "1"
x25519-dalek = { version = "2", features = ["static_secrets"] }
chacha20poly1305 = "0.10"
x25519-dalek = { version = "2", default-features = false, features = ["static_secrets", "alloc", "precomputed-tables"] }
# no default features: the OS-RNG feature would drag getrandom into the
# wasm32 contract build; this crate draws no entropy of its own anyway
chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] }
[dev-dependencies]
ed25519-dalek = { version = "2", features = ["rand_core"] }
+1
View File
@@ -19,6 +19,7 @@ pub mod slsk_link;
pub mod friend;
pub mod playlist;
pub mod inbox;
pub mod space;
pub const SCHEMA_VERSION: u32 = 1;
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
+386
View File
@@ -0,0 +1,386 @@
//! 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<Member>,
pub pinned: Option<PlaylistRef>,
pub message_cap: u32,
pub sig: Vec<u8>,
}
#[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<PlaylistRef>,
message_cap: u32,
}
pub fn checkpoint_sig_body(c: &Checkpoint) -> Vec<u8> {
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<u8> {
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<u8>,
pub sig: Vec<u8>,
}
#[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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<Vec<u8>> {
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<Checkpoint>,
pub messages: Vec<Message>,
}
pub fn make_params(owner: &[u8; 32], space_id: &[u8; 16]) -> Vec<u8> {
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 || &params[..t] != PARAMS_FAMILY {
return None;
}
Some((
params[t..t + 32].try_into().unwrap(),
params[t + 32..t + 48].try_into().unwrap(),
))
}
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<SpaceState> = Vec::new();
for bytes in candidates {
if bytes.is_empty() || bytes.len() > MAX_STATE_BYTES {
continue;
}
let Ok(st) = bincode::deserialize::<SpaceState>(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<Message> = 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<Message> = 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<u8> {
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<u8> {
bincode::serialize(&SpaceState { schema_version: SCHEMA_VERSION, space_id: [4; 16], checkpoint: c.cloned(), messages: msgs.to_vec() }).unwrap()
}
#[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");
}
}
+1449
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "soulstream-space-contract"
version = "0.1.0"
edition = "2021"
description = "The SoulStream Space contract — a thin shell over persona_state::space::merge_space. Built to wasm32-unknown-unknown and bundled into the app; its code hash is a commitment (see src-tauri/assets)."
[lib]
crate-type = ["cdylib"]
[dependencies]
freenet-stdlib = { version = "=0.8.5" }
persona-state = { path = "../persona-state" }
bincode = "1"
blake3 = "1"
[features]
default = ["freenet-main-contract", "contract"]
freenet-main-contract = []
contract = ["freenet-stdlib/contract"]
trace = ["freenet-stdlib/trace"]
[profile.release]
strip = true
lto = true
# Its own workspace: this crate targets wasm32 and must never join the
# app's host build.
[workspace]
+118
View File
@@ -0,0 +1,118 @@
//! The SoulStream Space contract. Multi-writer: the owner offers
//! checkpoints, members offer messages; `persona_state::space::merge_space`
//! is the bouncer (owner signature, roster, dedup, per-author fairness,
//! the window). Deterministic by construction, so every delivery order
//! converges; a state that does not merge to itself is refused.
//!
//! Build: `cargo build --release --target wasm32-unknown-unknown` in this
//! directory; copy `target/wasm32-unknown-unknown/release/
//! soulstream_space_contract.wasm` to `src-tauri/assets/space_contract.wasm`
//! and re-pin the code hash in `src-tauri/src/spaces.rs`.
use freenet_stdlib::prelude::*;
use persona_state::space as t;
pub struct Contract;
fn parts(parameters: &Parameters<'static>) -> Result<([u8; 32], [u8; 16]), ContractError> {
t::params_parts(parameters.as_ref())
.ok_or_else(|| ContractError::Other("params are not family+owner+space".into()))
}
fn collect<'a>(state: &'a State<'static>, data: &'a [UpdateData<'static>]) -> (Vec<&'a [u8]>, bool) {
let mut candidates: Vec<&[u8]> = Vec::new();
if !state.as_ref().is_empty() {
candidates.push(state.as_ref());
}
let mut any_parse = false;
let mut push = |b: &'a [u8]| {
if !b.is_empty() {
any_parse |= bincode::deserialize::<t::SpaceState>(b).is_ok();
candidates.push(b);
}
};
for ud in data {
match ud {
UpdateData::State(s) => push(s.as_ref()),
UpdateData::Delta(d) => push(d.as_ref()),
UpdateData::StateAndDelta { state, delta } => {
push(state.as_ref());
push(delta.as_ref());
}
_ => {}
}
}
(candidates, any_parse)
}
#[contract]
impl ContractInterface for Contract {
fn validate_state(
parameters: Parameters<'static>,
state: State<'static>,
_related: RelatedContracts<'static>,
) -> Result<ValidateResult, ContractError> {
let (owner, space_id) = parts(&parameters)?;
let bytes = state.as_ref();
if bytes.is_empty() {
return Ok(ValidateResult::Valid);
}
if bytes.len() > t::MAX_STATE_BYTES {
return Err(ContractError::Other(format!(
"space state {} bytes over cap {}",
bytes.len(),
t::MAX_STATE_BYTES
)));
}
let merged = t::merge_space(&owner, &space_id, &[bytes]);
let canon = bincode::serialize(&merged).map_err(|e| ContractError::Deser(e.to_string()))?;
if canon != bytes {
return Err(ContractError::Other(
"state is not canonical (unsigned, off-roster, or over the window)".into(),
));
}
Ok(ValidateResult::Valid)
}
fn update_state(
parameters: Parameters<'static>,
state: State<'static>,
data: Vec<UpdateData<'static>>,
) -> Result<UpdateModification<'static>, ContractError> {
let (owner, space_id) = parts(&parameters)?;
let (candidates, any_parse) = collect(&state, &data);
if !any_parse && !data.is_empty() {
return Err(ContractError::InvalidUpdate);
}
let merged = t::merge_space(&owner, &space_id, &candidates);
let bytes = bincode::serialize(&merged).map_err(|e| ContractError::Deser(e.to_string()))?;
Ok(UpdateModification::valid(State::from(bytes)))
}
fn summarize_state(
parameters: Parameters<'static>,
state: State<'static>,
) -> Result<StateSummary<'static>, ContractError> {
let _ = parts(&parameters)?;
if state.as_ref().is_empty() {
return Ok(StateSummary::from(Vec::new()));
}
Ok(StateSummary::from(blake3::hash(state.as_ref()).as_bytes().to_vec()))
}
fn get_state_delta(
parameters: Parameters<'static>,
state: State<'static>,
summary: StateSummary<'static>,
) -> Result<StateDelta<'static>, ContractError> {
let _ = parts(&parameters)?;
if state.as_ref().is_empty() {
return Ok(StateDelta::from(Vec::new()));
}
if summary.as_ref() == blake3::hash(state.as_ref()).as_bytes() {
Ok(StateDelta::from(Vec::new()))
} else {
Ok(StateDelta::from(state.as_ref().to_vec()))
}
}
}