forked from Keelrleek/SoulStream
spaces: leaving a room says so, so an owner stops publishing a seat nobody sits in
This commit is contained in:
1 parent
d6102101af
commit
06baa5a805
3 files changed
+146
-6
No files matched your search
@@ -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)
|
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<u8> {
|
||||||
|
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 {
|
pub fn checkpoint_valid(owner: &[u8; 32], space_id: &[u8; 16], c: &Checkpoint) -> bool {
|
||||||
c.schema_version == SCHEMA_VERSION
|
c.schema_version == SCHEMA_VERSION
|
||||||
&& &c.space_id == space_id
|
&& &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");
|
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]
|
#[test]
|
||||||
fn roster_is_the_authority_and_removal_is_an_epoch() {
|
fn roster_is_the_authority_and_removal_is_an_epoch() {
|
||||||
let owner = SigningKey::from_bytes(&[1; 32]);
|
let owner = SigningKey::from_bytes(&[1; 32]);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ pub enum Cargo {
|
|||||||
SpaceKey(SpaceKeyCargo),
|
SpaceKey(SpaceKeyCargo),
|
||||||
ProposalPing(ProposalPing),
|
ProposalPing(ProposalPing),
|
||||||
SpaceJoin(SpaceJoin),
|
SpaceJoin(SpaceJoin),
|
||||||
|
SpaceLeave(SpaceLeave),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The answer to an invitation: "here is the key I will wear in your Space."
|
/// 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<u8>,
|
pub proof: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
/// "Someone put a proposal on your playlist's board." Carries only enough to
|
/// "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
|
/// 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.
|
/// 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::SpaceKey(k) => crate::spaces::on_inbox_space_key(app, sender, k).await,
|
||||||
Cargo::ProposalPing(p) => crate::proposals::on_inbox_proposal(app, sender, p),
|
Cargo::ProposalPing(p) => crate::proposals::on_inbox_proposal(app, sender, p),
|
||||||
Cargo::SpaceJoin(j) => crate::spaces::on_inbox_join(app, sender, j).await,
|
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],
|
room_key: [10u8; 32],
|
||||||
proof: vec![1, 2, 3],
|
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(&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(&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();
|
let back: Cargo = bincode::deserialize(&bincode::serialize(&ping).unwrap()).unwrap();
|
||||||
match back {
|
match back {
|
||||||
|
|||||||
+86
-6
@@ -24,7 +24,7 @@ use persona_state::space as sp;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::{Emitter, Manager};
|
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::ledger;
|
||||||
use crate::persona;
|
use crate::persona;
|
||||||
use crate::slsk_link;
|
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())
|
.and_then(|(room, _)| key_of(room).ok())
|
||||||
.unwrap_or(asked)
|
.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<sp::Checkpoint, String> {
|
||||||
let c = bump(app, me, owner, space_id, |c| c.members.retain(|m| m.key != gone)).await?;
|
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 rid = record_id(owner_b58, space_b58);
|
||||||
let ledger = ledger::hold(LEDGER_FILE).await;
|
let ledger = ledger::hold(LEDGER_FILE).await;
|
||||||
let mut l = load(app);
|
let mut l = load(app);
|
||||||
let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?;
|
let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?;
|
||||||
rec.last_epoch = c.epoch;
|
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 {
|
if c.private {
|
||||||
// A removal rotates: everyone who stays gets the new epoch's key.
|
// A removal rotates: everyone who stays gets the new epoch's key.
|
||||||
let k = rand32();
|
let k = rand32();
|
||||||
@@ -665,9 +685,9 @@ pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: &
|
|||||||
} else {
|
} else {
|
||||||
save(app, &l)?;
|
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);
|
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<String>, pl_uuid: Option<String>) -> Result<SpaceView, String> {
|
pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pl_owner: Option<String>, pl_uuid: Option<String>) -> Result<SpaceView, String> {
|
||||||
@@ -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> {
|
pub async fn leave(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> {
|
||||||
let _ledger = ledger::hold(LEDGER_FILE).await;
|
let me = my_key(app).await?;
|
||||||
let mut l = load(app);
|
let owner = key_of(owner_b58)?;
|
||||||
|
let space_id = id_of(space_b58)?;
|
||||||
let rid = record_id(owner_b58, space_b58);
|
let rid = record_id(owner_b58, space_b58);
|
||||||
if l.spaces.get(&rid).is_some_and(|r| r.my_role == "owner") {
|
// 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());
|
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);
|
||||||
l.spaces.remove(&rid);
|
l.spaces.remove(&rid);
|
||||||
save(app, &l)
|
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.
|
/// 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.
|
/// Say so again, spaced out, rather than leaving them stranded.
|
||||||
const JOIN_RETRY_SECS: u64 = 120;
|
const JOIN_RETRY_SECS: u64 = 120;
|
||||||
|
|||||||
Reference in new issue
Block a user