forked from Keelrleek/SoulStream
spaces: a member wears a key minted for that room alone, so a roster stops naming personas
This commit is contained in:
1 parent
b3c121fa94
commit
67e52697b2
6 files changed
+462
-58
No files matched your search
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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]);
|
||||
|
||||
@@ -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<u8>,
|
||||
}
|
||||
|
||||
/// "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 {
|
||||
|
||||
@@ -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<String> {
|
||||
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<BoardEntry> = 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<String, [u8; 16]> = 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());
|
||||
}
|
||||
|
||||
+284
-47
@@ -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<String, String>,
|
||||
#[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<String>,
|
||||
/// 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<String, String>,
|
||||
/// OWNER ONLY: personas we have invited and will accept a join from.
|
||||
#[serde(default)]
|
||||
invited: BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
#[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<String> {
|
||||
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<u8>) -> Result<Vec<u8>, String> {
|
||||
let adapter = app.state::<AppState>().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<SpaceView, String> {
|
||||
@@ -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]));
|
||||
|
||||
@@ -1194,7 +1194,7 @@
|
||||
<span class="tx"><span class="l1"><span class="nm">Create a Space</span></span><span class="l2">{key ? "on Freenet, under your ID" : "needs a SoulStream identity"}</span></span>
|
||||
</button>
|
||||
{/if}
|
||||
<div class="panel" style="margin-top:14px"><h3>What a Space is</h3><p class="hint">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.</p></div>
|
||||
<div class="panel" style="margin-top:14px"><h3>What a Space is</h3><p class="hint">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.</p></div>
|
||||
</div>
|
||||
{#if sv}
|
||||
<div>
|
||||
@@ -1267,6 +1267,7 @@
|
||||
<input placeholder="Invite by SoulStream ID (they need an encrypted inbox)" bind:value={spaceInviteKey} />
|
||||
<button class="ax-fill sm" type="submit" disabled={!spaceInviteKey.trim() || !nodeUp || !!community.busy["space:invite"]}>{community.busy["space:invite"] ? "Inviting…" : "Invite"}</button>
|
||||
</form>
|
||||
<p class="hint" style="margin-top:8px">They appear in Members once they answer with the key they will wear here — an invitation on its own puts nobody on the roster.</p>
|
||||
{#if community.friends.some((f) => f.state === "friends")}<p class="hint" style="margin-top:8px">Friends: {#each community.friends.filter((f) => f.state === "friends") as f (f.id)}<button class="lnk" onclick={() => (spaceInviteKey = f.persona_key)}>{f.username || fpShort(f.persona_key)}</button> {/each}</p>{/if}
|
||||
{/if}
|
||||
<p class="hint" style="margin-top:12px">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}.</p>
|
||||
|
||||
Reference in new issue
Block a user