From 06baa5a805d1d9e0ff35bd201f02d8bb154dc35f Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:56:39 -0500 Subject: [PATCH] spaces: leaving a room says so, so an owner stops publishing a seat nobody sits in --- crates/persona-state/src/space.rs | 39 +++++++++++++ src-tauri/src/inbox.rs | 21 +++++++ src-tauri/src/spaces.rs | 94 ++++++++++++++++++++++++++++--- 3 files changed, 147 insertions(+), 7 deletions(-) diff --git a/crates/persona-state/src/space.rs b/crates/persona-state/src/space.rs index 7f3df05..8ed7088 100644 --- a/crates/persona-state/src/space.rs +++ b/crates/persona-state/src/space.rs @@ -255,6 +255,24 @@ pub fn join_proof_valid( 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 @@ -453,6 +471,27 @@ mod tests { 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]); diff --git a/src-tauri/src/inbox.rs b/src-tauri/src/inbox.rs index f1b23d1..230c01d 100644 --- a/src-tauri/src/inbox.rs +++ b/src-tauri/src/inbox.rs @@ -54,6 +54,7 @@ pub enum Cargo { SpaceKey(SpaceKeyCargo), ProposalPing(ProposalPing), SpaceJoin(SpaceJoin), + SpaceLeave(SpaceLeave), } /// The answer to an invitation: "here is the key I will wear in your Space." @@ -68,6 +69,18 @@ pub struct SpaceJoin { pub proof: Vec, } +/// "I am giving up my seat." The owner cannot see a member walk away — the +/// roster is theirs to publish — so leaving says so, sealed. `worn` is the key +/// on the roster, and `proof` is signed BY that key: holding the seat is the +/// whole authority to vacate it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceLeave { + pub space_id: [u8; 16], + pub owner: [u8; 32], + pub worn: [u8; 32], + pub proof: Vec, +} + /// "Someone put a proposal on your playlist's board." Carries only enough to /// name it in a toast — the board itself stays the truth, and this is a /// best-effort nudge: the proposal stands whether or not the ping lands. @@ -507,6 +520,7 @@ async fn dispatch(app: &tauri::AppHandle, sender: [u8; 32], cargo: Cargo) { 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, + Cargo::SpaceLeave(l) => crate::spaces::on_inbox_leave(app, sender, l).await, } } @@ -572,8 +586,15 @@ mod tests { room_key: [10u8; 32], proof: vec![1, 2, 3], }); + let leave = Cargo::SpaceLeave(SpaceLeave { + space_id: [11u8; 16], + owner: [12u8; 32], + worn: [13u8; 32], + proof: vec![4, 5, 6], + }); 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"); + assert_eq!(index(&leave), 8, "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/spaces.rs b/src-tauri/src/spaces.rs index d1d172f..1c8c997 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, SpaceJoin, SpaceKeyCargo}; +use crate::inbox::{self, Cargo, SpaceInvite, SpaceJoin, SpaceKeyCargo, SpaceLeave}; use crate::ledger; use crate::persona; use crate::slsk_link; @@ -635,12 +635,32 @@ pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: & .and_then(|(room, _)| key_of(room).ok()) .unwrap_or(asked) }; + unseat(app, owner_b58, space_b58, me, owner, space_id, gone).await?; + open(app, owner_b58, space_b58).await +} + +/// Take one key off the roster and re-key everyone who stays. The owner does +/// this both when they remove someone and when someone leaves of their own +/// accord, and it must look identical from the outside: a member who walks +/// away is only trusted to have DELETED the epoch key, never proven to. +async fn unseat( + app: &tauri::AppHandle, + owner_b58: &str, + space_b58: &str, + me: [u8; 32], + owner: [u8; 32], + space_id: [u8; 16], + gone: [u8; 32], +) -> Result { 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; let mut l = load(app); let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?; rec.last_epoch = c.epoch; + // Nobody sits under that key any more, so neither does the private + // mapping. Keeping it would hold a persona this device no longer needs. + rec.roster.remove(&b58(&gone)); if c.private { // A removal rotates: everyone who stays gets the new epoch's key. let k = rand32(); @@ -665,9 +685,9 @@ pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: & } else { save(app, &l)?; } - // open() takes this same ledger; the write is done, so let go first. + // open() takes this same ledger; callers go straight back into it. drop(ledger); - open(app, owner_b58, space_b58).await + Ok(c) } pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pl_owner: Option, pl_uuid: Option) -> Result { @@ -683,12 +703,34 @@ pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str } pub async fn leave(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> { + let me = my_key(app).await?; + let owner = key_of(owner_b58)?; + let space_id = id_of(space_b58)?; + let rid = record_id(owner_b58, space_b58); + // Signed BEFORE the record goes: the room secret is the only thing that + // can prove this seat asked to be vacated, and leaving destroys it. + let (worn, room) = { + let l = load(app); + let rec = l.spaces.get(&rid).ok_or("not a Space this device knows")?; + if rec.my_role == "owner" { + return Err("an owner keeps the Space; remove members instead".into()); + } + (worn_key(rec, &me), room_secret_of(rec)) + }; + let payload = sp::leave_proof_payload(&space_id, &owner, &worn); + let proof = match room { + Some(secret) => sp::sign_as_member(&secret, &payload), + None => sign(app, payload).await?, + }; + // Best-effort, and deliberately so: the person asked to go, so they go + // whether or not the owner is reachable. A lost notice leaves a seat on + // the roster that can no longer post — the owner can still remove it. + let cargo = Cargo::SpaceLeave(SpaceLeave { space_id, owner, worn, proof }); + if let Err(e) = inbox::send(app, &owner, &cargo).await { + log::warn!("spaces: left {space_b58} but could not tell its owner: {e}"); + } let _ledger = ledger::hold(LEDGER_FILE).await; let mut l = load(app); - let rid = record_id(owner_b58, space_b58); - if l.spaces.get(&rid).is_some_and(|r| r.my_role == "owner") { - return Err("an owner keeps the Space; remove members instead".into()); - } l.spaces.remove(&rid); save(app, &l) } @@ -771,6 +813,44 @@ async fn send_join(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16] } } +/// Sealed cargo: a member gave up their seat. Three things have to hold — +/// the seat exists, its holder signed the request, and the sender is the +/// person this device seated under it. The last one is what stops anyone who +/// merely READ the roster (the keys are published) from unseating a stranger. +pub(crate) async fn on_inbox_leave(app: &tauri::AppHandle, sender: [u8; 32], lv: SpaceLeave) { + let owner_b58 = b58(&lv.owner); + let space_b58 = b58(&lv.space_id); + let rid = record_id(&owner_b58, &space_b58); + { + 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; + } + // Room-local seats map back to a persona here and nowhere else; a + // Space seated before room keys holds the persona on the roster + // itself, and then the sender IS the seat. + let behind = rec.roster.get(&b58(&lv.worn)).map(String::as_str); + if behind.unwrap_or(&b58(&lv.worn)) != b58(&sender) { + log::warn!("spaces: a leave came from someone other than the member seated under that key"); + return; + } + } + if !sp::leave_proof_valid(&lv.space_id, &lv.owner, &lv.worn, &lv.proof) { + log::warn!("spaces: a leave did not prove it came from the seat it gives up"); + return; + } + let me = match my_key(app).await { + Ok(k) => k, + Err(_) => return, + }; + match unseat(app, &owner_b58, &space_b58, me, lv.owner, lv.space_id, lv.worn).await { + Ok(c) => log::info!("spaces: a member left {:?}, roster at epoch {}", c.name, c.epoch), + Err(e) => log::warn!("spaces: could not unseat a member who left: {e}"), + } +} + /// A member who holds a room key but is not on the roster never got seated. /// Say so again, spaced out, rather than leaving them stranded. const JOIN_RETRY_SECS: u64 = 120;