spaces: a member wears a key minted for that room alone, so a roster stops naming personas

This commit is contained in:
KeelrLeek committed 2026-09-03 13:06:32 -05:00
1 parent b3c121fa94
commit 67e52697b2
6 files changed
+462 -58

No files matched your search

+21
View File
@@ -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 {
+33 -9
View File
@@ -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
View File
@@ -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]));