diff --git a/crates/persona-state/src/lib.rs b/crates/persona-state/src/lib.rs index 7278f82..e64ab54 100644 --- a/crates/persona-state/src/lib.rs +++ b/crates/persona-state/src/lib.rs @@ -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 { 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 { + 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) } diff --git a/crates/persona-state/src/space.rs b/crates/persona-state/src/space.rs index 230754d..7f3df05 100644 --- a/crates/persona-state/src/space.rs +++ b/crates/persona-state/src/space.rs @@ -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 { + 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) +} + 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]); diff --git a/src-tauri/src/inbox.rs b/src-tauri/src/inbox.rs index e674c34..bbfcd5a 100644 --- a/src-tauri/src/inbox.rs +++ b/src-tauri/src/inbox.rs @@ -53,6 +53,19 @@ pub enum Cargo { SpaceInvite(SpaceInvite), SpaceKey(SpaceKeyCargo), ProposalPing(ProposalPing), + SpaceJoin(SpaceJoin), +} + +/// The answer to an invitation: "here is the key I will wear in your Space." +/// The room key is what lands on the published roster; `proof` is the joiner's +/// PERSONA signature over it, and it stops at the owner — publishing it would +/// tie the two together again and undo the point. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceJoin { + pub space_id: [u8; 16], + pub owner: [u8; 32], + pub room_key: [u8; 32], + pub proof: Vec, } /// "Someone put a proposal on your playlist's board." Carries only enough to @@ -495,6 +508,7 @@ async fn dispatch(app: &tauri::AppHandle, sender: [u8; 32], cargo: Cargo) { Cargo::SpaceInvite(i) => crate::spaces::on_inbox_invite(app, sender, i).await, Cargo::SpaceKey(k) => crate::spaces::on_inbox_space_key(app, sender, k).await, Cargo::ProposalPing(p) => crate::proposals::on_inbox_proposal(app, sender, p), + Cargo::SpaceJoin(j) => crate::spaces::on_inbox_join(app, sender, j).await, } } @@ -554,7 +568,14 @@ mod tests { }); assert_eq!(index(&share), 3, "Share moved — sealed shares in flight are now unreadable"); assert_eq!(index(&key), 5, "SpaceKey moved — sealed keys in flight are now unreadable"); + let join = Cargo::SpaceJoin(SpaceJoin { + space_id: [8u8; 16], + owner: [9u8; 32], + room_key: [10u8; 32], + proof: vec![1, 2, 3], + }); assert_eq!(index(&ping), 6, "a new variant must be appended, never inserted"); + assert_eq!(index(&join), 7, "a new variant must be appended, never inserted"); let back: Cargo = bincode::deserialize(&bincode::serialize(&ping).unwrap()).unwrap(); match back { diff --git a/src-tauri/src/proposals.rs b/src-tauri/src/proposals.rs index 8500c9b..f5dbd27 100644 --- a/src-tauri/src/proposals.rs +++ b/src-tauri/src/proposals.rs @@ -271,7 +271,24 @@ async fn pinned_of(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &st type Current = (pl::ManifestV1, [u8; 32]); -fn view_of(app: &tauri::AppHandle, pin: &Pinned, current: Option<&Current>, board: &pr::BoardState, me: &[u8; 32], reachable: bool) -> BoardView { +#[allow(clippy::too_many_arguments)] +fn view_of( + app: &tauri::AppHandle, + pin: &Pinned, + current: Option<&Current>, + board: &pr::BoardState, + me: &[u8; 32], + worn: &[u8; 32], + space: (&str, &str), + reachable: bool, +) -> BoardView { + // A room key has a name only on the device that seated it — the Space's + // owner. Everyone else sees the key, which is what unlinkable means. + let name_of = |k: &[u8; 32]| -> Option { + let b = b58(k); + let persona = spaces::persona_behind(app, space.0, space.1, &b).unwrap_or(b); + slsk_link::username_for_key(app, &persona) + }; let entries: Vec = current .map(|(m, _)| { m.entries @@ -306,8 +323,8 @@ fn view_of(app: &tauri::AppHandle, pin: &Pinned, current: Option<&Current>, boar .map(|p| ProposalView { id: b58(&p.proposal_id), author: b58(&p.author), - username: slsk_link::username_for_key(app, &b58(&p.author)), - is_me: &p.author == me, + username: name_of(&p.author), + is_me: &p.author == worn, created_at: p.created_at, note: p.note.clone(), base: b58(&p.base_revision_hash), @@ -374,10 +391,10 @@ fn view_of(app: &tauri::AppHandle, pin: &Pinned, current: Option<&Current>, boar epoch: board.authority.as_ref().map(|a| a.epoch).unwrap_or(0), contributors: roster .iter() - .map(|k| ContributorView { key: b58(k), username: slsk_link::username_for_key(app, &b58(k)), is_me: k == me }) + .map(|k| ContributorView { key: b58(k), username: name_of(k), is_me: k == worn }) .collect(), i_am_owner: &pin.owner == me, - can_propose: reachable && current.is_some() && roster.contains(me), + can_propose: reachable && current.is_some() && roster.contains(worn), proposals, decisions, pending_mine: board.proposals.iter().filter(|p| &p.author == me).count() as u32, @@ -417,7 +434,8 @@ pub async fn open(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str log::info!("proposals: roster epoch {} for board {}", epoch, b58(&pin.uuid)); } } - Ok(view_of(app, &pin, current.as_ref(), &board, &me, exists)) + let (worn, _) = spaces::worn_identity(app, space_owner_b58, space_b58, &me); + Ok(view_of(app, &pin, current.as_ref(), &board, &me, &worn, (space_owner_b58, space_b58), exists)) } /// A member proposes: the ops are dry-run against the exact base first, so @@ -441,8 +459,11 @@ pub async fn propose(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: & if !exists { return Err("the owner has not opened this board yet".into()); } + // The board's roster is the Space's roster, so we appear on it as the key + // we wear there — a room-local one unless the Space is ours. + let (worn, room) = spaces::worn_identity(app, space_owner_b58, space_b58, &me); let auth = board.authority.as_ref().ok_or("the board has no roster yet")?; - if !auth.contributors.contains(&me) { + if !auth.contributors.contains(&worn) { return Err("you are not on this board's roster — the owner refreshes it from the Space's members".into()); } let mut fresh: BTreeMap = BTreeMap::new(); @@ -492,7 +513,7 @@ pub async fn propose(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: & } let mut p = pr::Proposal { proposal_id: rand16(), - author: me, + author: worn, authority_epoch: auth.epoch, base_revision_hash: hash, ops: pops, @@ -501,7 +522,10 @@ pub async fn propose(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: & sig: vec![], }; pr::apply_proposal(&manifest, &hash, &p).map_err(|e| e.to_string())?; - p.sig = sign(app, pr::sign_payload(&pr::proposal_sig_body(&p))).await?; + p.sig = match room { + Some(secret) => persona_state::sign_local(pr::SIGN_DOMAIN, &secret, &pr::proposal_sig_body(&p)), + None => sign(app, pr::sign_payload(&pr::proposal_sig_body(&p))).await?, + }; if !pr::proposal_valid(&p) { return Err("the proposal did not validate (a size or field cap)".into()); } diff --git a/src-tauri/src/spaces.rs b/src-tauri/src/spaces.rs index 226617d..2ade4d3 100644 --- a/src-tauri/src/spaces.rs +++ b/src-tauri/src/spaces.rs @@ -24,7 +24,7 @@ use persona_state::space as sp; use serde::{Deserialize, Serialize}; use tauri::{Emitter, Manager}; -use crate::inbox::{self, Cargo, SpaceInvite, SpaceKeyCargo}; +use crate::inbox::{self, Cargo, SpaceInvite, SpaceJoin, SpaceKeyCargo}; use crate::ledger; use crate::persona; use crate::slsk_link; @@ -46,6 +46,21 @@ struct SpaceRecord { keys: BTreeMap, #[serde(default)] last_epoch: u64, + /// The key we wear in THIS Space (b58 secret), minted on the invitation. + /// A member is on the roster under this and nothing else, so the roster + /// says nothing about who they are anywhere else. Absent for a Space we + /// own: an owner is their persona there by construction, since the + /// Space's address is derived from it. + #[serde(default)] + room_secret: Option, + /// OWNER ONLY, and never published: room key (b58) → the persona that + /// proved it. This is the mapping the whole scheme exists to keep off the + /// network, so it lives here and travels nowhere. + #[serde(default)] + roster: BTreeMap, + /// OWNER ONLY: personas we have invited and will accept a join from. + #[serde(default)] + invited: BTreeMap, } #[derive(Default, Serialize, Deserialize)] @@ -190,6 +205,46 @@ async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> { .ok_or("create a SoulStream identity first")?; key_of(&info.public_key) } +/// The secret we sign with inside one Space, minted the first time we need +/// it. Custody matches the inbox box key: it sits in the app folder, not in +/// the delegate — a persona restored on another device mints fresh room keys +/// and has to be re-admitted. +fn room_secret_of(rec: &SpaceRecord) -> Option<[u8; 32]> { + rec.room_secret.as_deref().and_then(|k| key_of(k).ok()) +} + +/// The key we wear in this Space: our persona when we own it, our room key +/// otherwise. Every membership and authorship check is against THIS. +fn worn_key(rec: &SpaceRecord, me: &[u8; 32]) -> [u8; 32] { + if rec.my_role == "owner" { + return *me; + } + room_secret_of(rec).map(|s| sp::member_public(&s)).unwrap_or(*me) +} + +/// The identity to use inside one Space: the key we wear, and the room +/// secret behind it when that is not our persona. The proposal board rides a +/// Space's roster, so it has to author under exactly the same key. +pub(crate) fn worn_identity( + app: &tauri::AppHandle, + owner_b58: &str, + space_b58: &str, + me: &[u8; 32], +) -> ([u8; 32], Option<[u8; 32]>) { + let l = load(app); + match l.spaces.get(&record_id(owner_b58, space_b58)) { + Some(rec) if rec.my_role != "owner" => (worn_key(rec, me), room_secret_of(rec)), + _ => (*me, None), + } +} + +/// The persona behind a room key, when this device is the one that seated it. +/// Nobody else can answer this, which is the point. +pub(crate) fn persona_behind(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, room: &str) -> Option { + let l = load(app); + l.spaces.get(&record_id(owner_b58, space_b58))?.roster.get(room).cloned() +} + async fn sign(app: &tauri::AppHandle, payload: Vec) -> Result, String> { let adapter = app.state::().freenet.clone(); persona::sign(&adapter, sp::SIGN_DOMAIN, payload).await @@ -269,7 +324,13 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me sp::Role::Owner => "owner".into(), sp::Role::Member => "member".into(), }, - username: slsk_link::username_for_key(app, &b58(&m.key)), + // Only the owner holds room key → persona, so only the owner + // can put a name to a member. To everyone else they are the + // key they wear here, which is the whole point. + username: slsk_link::username_for_key( + app, + rec.roster.get(&b58(&m.key)).map(String::as_str).unwrap_or(&b58(&m.key)), + ), is_me: &m.key == me, since_epoch: m.since_epoch, }) @@ -288,7 +349,10 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me MessageView { id: b58(&m.msg_id), author: b58(&m.author), - username: slsk_link::username_for_key(app, &b58(&m.author)), + username: slsk_link::username_for_key( + app, + rec.roster.get(&b58(&m.author)).map(String::as_str).unwrap_or(&b58(&m.author)), + ), is_me: &m.author == me, created_at: m.created_at, epoch: m.epoch, @@ -366,13 +430,16 @@ pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, priva joined_at: now_secs(), keys, last_epoch: 1, + room_secret: None, + roster: BTreeMap::new(), + invited: BTreeMap::new(), }; let _ledger = ledger::hold(LEDGER_FILE).await; let mut l = load(app); l.spaces.insert(record_id(&rec.owner, &rec.space_id), rec.clone()); save(app, &l)?; log::info!("spaces: created {:?} ({})", rec.name, if private { "private" } else { "public" }); - Ok(view_of(app, &rec, &state, &me, true)) + Ok(view_of(app, &rec, &state, &worn_key(&rec, &me), true)) } pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result { @@ -403,7 +470,7 @@ pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> R } } let rec = l.spaces.get(&rid).cloned().unwrap_or(rec); - Ok(view_of(app, &rec, &state, &me, reachable)) + Ok(view_of(app, &rec, &state, &worn_key(&rec, &me), reachable)) } pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text: &str) -> Result<(), String> { @@ -419,29 +486,42 @@ pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text } let (state, _) = fetch_state(app, &owner, &space_id).await?; let c = state.checkpoint.as_ref().ok_or("the Space has not reached this node yet")?; - if !c.members.iter().any(|m| m.key == me) { + let (worn, room, epoch_key) = { + let l = load(app); + let rec = l.spaces.get(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?; + let epoch_key = if c.private { + Some( + rec.keys + .get(&c.epoch.to_string()) + .and_then(|k| key_of(k).ok()) + .ok_or("no key for this epoch yet — the owner has to hand it to you")?, + ) + } else { + None + }; + (worn_key(rec, &me), room_secret_of(rec), epoch_key) + }; + if !c.members.iter().any(|m| m.key == worn) { return Err("you are not in this Space's roster".into()); } let mut m = sp::Message { space_id, epoch: c.epoch, msg_id: rand16(), - author: me, + author: worn, created_at: now_secs(), body: text.as_bytes().to_vec(), sig: vec![], }; - if c.private { - let l = load(app); - let rec = l.spaces.get(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?; - let key = rec - .keys - .get(&c.epoch.to_string()) - .and_then(|k| key_of(k).ok()) - .ok_or("no key for this epoch yet — the owner has to hand it to you")?; + if let Some(key) = epoch_key { m.body = sp::seal_body(&key, &m); } - m.sig = sign(app, sp::message_sign_payload(&m)).await?; + // A member signs with the key they wear; only an owner reaches the + // delegate, because only an owner is their persona here. + m.sig = match room { + Some(secret) if worn != me => sp::sign_as_member(&secret, &sp::message_sign_payload(&m)), + _ => sign(app, sp::message_sign_payload(&m)).await?, + }; offer(app, &owner, &space_id, &empty_state(space_id, None, vec![m]), false).await } @@ -473,25 +553,34 @@ pub async fn invite(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pe if !inbox::reachable(app, &their).await { return Err("they have not published an encrypted inbox — the invitation has nowhere to go".into()); } - let c = bump(app, me, owner, space_id, |c| { - if !c.members.iter().any(|m| m.key == their) { - let e = c.epoch; - c.members.push(sp::Member { key: their, role: sp::Role::Member, since_epoch: e }); - } - }) - .await?; + // Nobody joins the roster here any more. We do not yet know the key they + // will wear — they mint it — so the invitation is an offer, and the roster + // moves when their signed answer comes back (on_inbox_join). + let (state, _) = fetch_state(app, &owner, &space_id).await?; + let c = state.checkpoint.ok_or("the Space has not reached this node yet")?; + if c.owner != me { + return Err("only the owner can invite".into()); + } let rid = record_id(owner_b58, space_b58); let ledger = ledger::hold(LEDGER_FILE).await; let mut l = load(app); let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?; let mut key = None; if c.private { - let prev = rec.keys.get(&(c.epoch - 1).to_string()).cloned().or_else(|| rec.keys.values().last().cloned()); - let k = prev.unwrap_or_else(|| b58(&rand32())); + // They need this to read while they answer; adding them does not + // rotate, so the current epoch's key is the right one to hand over. + let k = rec + .keys + .get(&c.epoch.to_string()) + .cloned() + .or_else(|| rec.keys.values().last().cloned()) + .unwrap_or_else(|| b58(&rand32())); rec.keys.insert(c.epoch.to_string(), k.clone()); key = Some(key_of(&k)?); } - rec.last_epoch = c.epoch; + // Only a persona we actually asked can join, or anyone who learned the + // Space's address could put themselves on the roster. + rec.invited.insert(b58(&their), now_secs()); save(app, &l)?; inbox::send( app, @@ -499,7 +588,7 @@ pub async fn invite(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pe &Cargo::SpaceInvite(SpaceInvite { space_id, owner, name: c.name.clone(), private: c.private, epoch: c.epoch, key }), ) .await?; - log::info!("spaces: invited {persona_key} to {:?} (epoch {})", c.name, c.epoch); + log::info!("spaces: invited {persona_key} to {:?} — waiting for the key they will wear", c.name); // open() takes this same ledger; the write is done, so let go first. drop(ledger); open(app, owner_b58, space_b58).await @@ -509,10 +598,22 @@ pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: & let me = my_key(app).await?; let owner = key_of(owner_b58)?; let space_id = id_of(space_b58)?; - let gone = key_of(persona_key)?; - if gone == me { + let asked = key_of(persona_key)?; + if asked == me { return Err("the owner cannot be removed".into()); } + // The caller names a person — a persona key, or the room key the UI shows. + // Only the owner's private map can turn one into the other, which is the + // point; a Space seated before room keys still holds personas directly. + let gone = { + let l = load(app); + let rec = l.spaces.get(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?; + rec.roster + .iter() + .find(|(_, persona)| persona.as_str() == persona_key) + .and_then(|(room, _)| key_of(room).ok()) + .unwrap_or(asked) + }; let c = bump(app, me, owner, space_id, |c| c.members.retain(|m| m.key != gone)).await?; let rid = record_id(owner_b58, space_b58); let ledger = ledger::hold(LEDGER_FILE).await; @@ -569,28 +670,113 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in return; } let rid = record_id(&b58(&inv.owner), &b58(&inv.space_id)); + let room = { + let _ledger = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + let rec = l.spaces.entry(rid).or_insert_with(|| SpaceRecord { + owner: b58(&inv.owner), + space_id: b58(&inv.space_id), + name: cap(&inv.name, sp::MAX_NAME_BYTES), + private: inv.private, + my_role: "member".into(), + joined_at: now_secs(), + keys: BTreeMap::new(), + last_epoch: 0, + room_secret: None, + roster: BTreeMap::new(), + invited: BTreeMap::new(), + }); + rec.name = cap(&inv.name, sp::MAX_NAME_BYTES); + rec.private = inv.private; + if let Some(k) = inv.key { + rec.keys.insert(inv.epoch.to_string(), b58(&k)); + } + // One key per Space, minted once and kept. Re-minting on a second + // invitation would strand the roster entry we already have. + let secret = match room_secret_of(rec) { + Some(s) => s, + None => { + let s = rand32(); + rec.room_secret = Some(b58(&s)); + s + } + }; + if let Err(e) = save(app, &l) { + log::warn!("spaces: could not keep an invitation: {e}"); + return; + } + secret + }; + log::info!("spaces: invited to {:?} by {}", inv.name, b58(&inv.owner)); + // The answer: the key we will wear, and a persona signature so the owner + // knows it is us. Sealed to the owner — this pairing is exactly what must + // never be published. + let room_key = sp::member_public(&room); + let proof = match sign(app, sp::join_proof_payload(&inv.space_id, &inv.owner, &room_key)).await { + Ok(p) => p, + Err(e) => { + log::warn!("spaces: could not sign the join for {:?}: {e}", inv.name); + return; + } + }; + let join = Cargo::SpaceJoin(SpaceJoin { space_id: inv.space_id, owner: inv.owner, room_key, proof }); + if let Err(e) = inbox::send(app, &inv.owner, &join).await { + log::warn!("spaces: could not answer the invitation to {:?}: {e}", inv.name); + } +} + +/// The owner's side of the handshake: someone we invited says which key they +/// will wear. Verify it is really them, then put THAT key — and only that key +/// — on the published roster. +pub(crate) async fn on_inbox_join(app: &tauri::AppHandle, sender: [u8; 32], j: SpaceJoin) { + let rid = record_id(&b58(&j.owner), &b58(&j.space_id)); + let owner_b58 = b58(&j.owner); + let space_b58 = b58(&j.space_id); + { + let _ledger = ledger::hold(LEDGER_FILE).await; + let l = load(app); + let Some(rec) = l.spaces.get(&rid) else { return }; + if rec.my_role != "owner" { + return; + } + if !rec.invited.contains_key(&b58(&sender)) { + log::warn!("spaces: a join arrived from someone this Space never invited"); + return; + } + } + if !sp::join_proof_valid(&j.space_id, &j.owner, &j.room_key, &sender, &j.proof) { + log::warn!("spaces: a join did not prove the room key belongs to its sender"); + return; + } + let me = match my_key(app).await { + Ok(k) => k, + Err(_) => return, + }; + let c = match bump(app, me, j.owner, j.space_id, |c| { + if !c.members.iter().any(|m| m.key == j.room_key) { + let e = c.epoch; + c.members.push(sp::Member { key: j.room_key, role: sp::Role::Member, since_epoch: e }); + } + }) + .await + { + Ok(c) => c, + Err(e) => { + log::warn!("spaces: could not seat a joiner: {e}"); + return; + } + }; let _ledger = ledger::hold(LEDGER_FILE).await; let mut l = load(app); - let rec = l.spaces.entry(rid).or_insert_with(|| SpaceRecord { - owner: b58(&inv.owner), - space_id: b58(&inv.space_id), - name: cap(&inv.name, sp::MAX_NAME_BYTES), - private: inv.private, - my_role: "member".into(), - joined_at: now_secs(), - keys: BTreeMap::new(), - last_epoch: 0, - }); - rec.name = cap(&inv.name, sp::MAX_NAME_BYTES); - rec.private = inv.private; - if let Some(k) = inv.key { - rec.keys.insert(inv.epoch.to_string(), b58(&k)); - } + let Some(rec) = l.spaces.get_mut(&rid) else { return }; + // Who is behind the key, kept here and nowhere else. + rec.roster.insert(b58(&j.room_key), b58(&sender)); + rec.invited.remove(&b58(&sender)); + rec.last_epoch = c.epoch; if let Err(e) = save(app, &l) { - log::warn!("spaces: could not keep an invitation: {e}"); - } else { - log::info!("spaces: invited to {:?} by {}", inv.name, b58(&inv.owner)); + log::warn!("spaces: seated a joiner but could not record it: {e}"); } + log::info!("spaces: seated a member in {:?} at epoch {} ({owner_b58}/{space_b58})", c.name, c.epoch); } pub(crate) async fn on_inbox_space_key(app: &tauri::AppHandle, sender: [u8; 32], sk: SpaceKeyCargo) { @@ -644,6 +830,57 @@ pub async fn space_leave(app: tauri::AppHandle, owner: String, space: String) -> mod tests { use super::*; + fn rec(role: &str, secret: Option<[u8; 32]>) -> SpaceRecord { + SpaceRecord { + owner: b58(&[1u8; 32]), + space_id: b58(&[2u8; 16]), + name: "Late Night Ambient".into(), + private: true, + my_role: role.into(), + joined_at: 0, + keys: BTreeMap::new(), + last_epoch: 1, + room_secret: secret.map(|s| b58(&s)), + roster: BTreeMap::new(), + invited: BTreeMap::new(), + } + } + + /// Which key we wear where. An owner is their persona — the Space's + /// address is derived from it, so there is nothing to hide there — and a + /// member is the key minted for that Space and no other. + #[test] + fn a_member_wears_a_room_key_and_an_owner_wears_their_persona() { + let me = [5u8; 32]; + assert_eq!(worn_key(&rec("owner", None), &me), me, "an owner is their persona here"); + assert_eq!(worn_key(&rec("owner", Some([9u8; 32])), &me), me, "even holding a room key"); + + let worn = worn_key(&rec("member", Some([9u8; 32])), &me); + assert_eq!(worn, sp::member_public(&[9u8; 32])); + assert_ne!(worn, me, "a member must not appear as their persona"); + + // Two Spaces, two keys: one roster cannot be correlated with another. + assert_ne!( + worn_key(&rec("member", Some([9u8; 32])), &me), + worn_key(&rec("member", Some([8u8; 32])), &me), + ); + // Before the mint there is nothing to wear but the persona, and the + // roster check then simply fails rather than posting as one. + assert_eq!(worn_key(&rec("member", None), &me), me); + } + + /// The mapping the design exists to keep off the network stays local, and + /// is only ever answerable by the device that seated the member. + #[test] + fn only_the_seating_device_can_name_a_room_key() { + let mut r = rec("owner", None); + let room = b58(&sp::member_public(&[9u8; 32])); + r.roster.insert(room.clone(), b58(&[7u8; 32])); + assert_eq!(r.roster.get(&room).map(String::as_str), Some(b58(&[7u8; 32]).as_str())); + // A record that never seated them knows nothing. + assert!(rec("member", Some([9u8; 32])).roster.get(&room).is_none()); + } + #[test] fn contract_generation_is_pinned() { let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(&[7u8; 32], &[1u8; 16])); diff --git a/src/views/Community.svelte b/src/views/Community.svelte index a9e553f..299888d 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -1194,7 +1194,7 @@ Create a Space{key ? "on Freenet, under your ID" : "needs a SoulStream identity"} {/if} -

What a Space is

A persistent SoulStream community on Freenet. The owner's signed checkpoint is the roster; a removal is a new epoch, and in a private Space every removal rotates the key. Not a Soulseek Room, and never mirrored to one. Invitations travel sealed through the encrypted inbox — the person you invite needs one.

+

What a Space is

A persistent SoulStream community on Freenet. The owner's signed checkpoint is the roster; a removal is a new epoch, and in a private Space every removal rotates the key. Not a Soulseek Room, and never mirrored to one. Invitations travel sealed through the encrypted inbox — the person you invite needs one. A member joins under a key minted for this Space alone, so its roster says nothing about who they are anywhere else; only the owner can put a name to one.

{#if sv}
@@ -1267,6 +1267,7 @@ +

They appear in Members once they answer with the key they will wear here — an invitation on its own puts nobody on the roster.

{#if community.friends.some((f) => f.state === "friends")}

Friends: {#each community.friends.filter((f) => f.state === "friends") as f (f.id)} {/each}

{/if} {/if}

Members are their SoulStream identities here — a joined Space is a linked identity. Removing someone is a new epoch{#if sv.private}, and in this private Space it also rotates the key: those who stay receive the new one sealed{/if}.