spaces: a rotated key goes to the person behind the room key, and the epoch it belongs to is found by number

This commit is contained in:
KeelrLeek committed 2026-09-03 16:01:40 -05:00
1 parent 7b7f48ebc6
commit d6102101af
1 file changed
+48 -9
+48 -9
View File
@@ -209,6 +209,17 @@ async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
.ok_or("create a SoulStream identity first")?;
key_of(&info.public_key)
}
/// The key from the newest epoch below `epoch`. The map is keyed by a
/// STRING, so it iterates lexicographically — `.last()` picks "9" over "10"
/// and hands back a key from six epochs ago the moment a Space gets busy.
fn key_below(rec: &SpaceRecord, epoch: u64) -> Option<String> {
rec.keys
.iter()
.filter_map(|(e, k)| e.parse::<u64>().ok().filter(|n| *n < epoch).map(|n| (n, k.clone())))
.max_by_key(|(n, _)| *n)
.map(|(_, k)| k)
}
/// 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
@@ -458,19 +469,21 @@ pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> R
let (state, reachable) = fetch_state(app, &owner, &space_id).await?;
if let Some(c) = &state.checkpoint {
let r = l.spaces.get_mut(&rid).unwrap();
let changed = r.name != c.name || r.last_epoch != c.epoch || r.private != c.private;
let mut dirty = r.name != c.name || r.last_epoch != c.epoch || r.private != c.private;
r.name = c.name.clone();
r.last_epoch = c.epoch;
r.private = c.private;
// Adding members never rotates the key: the previous epoch's key
// carries forward until a removal issues a new one.
// carries forward until a removal issues a new one. A removal DOES
// rotate, and the real key arrives sealed — `on_inbox_space_key`
// overwrites this one when it lands.
if c.private && !r.keys.contains_key(&c.epoch.to_string()) {
if let Some((_, k)) = r.keys.iter().filter(|(e, _)| e.parse::<u64>().unwrap_or(0) < c.epoch).last() {
let k = k.clone();
if let Some(k) = key_below(r, c.epoch) {
r.keys.insert(c.epoch.to_string(), k);
dirty = true;
}
}
if changed {
if dirty {
let _ = save(app, &l);
}
}
@@ -581,7 +594,7 @@ pub async fn invite(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pe
.keys
.get(&c.epoch.to_string())
.cloned()
.or_else(|| rec.keys.values().last().cloned())
.or_else(|| key_below(rec, c.epoch + 1))
.unwrap_or_else(|| b58(&rand32()));
rec.keys.insert(c.epoch.to_string(), k.clone());
key = Some(key_of(&k)?);
@@ -632,11 +645,21 @@ pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: &
// A removal rotates: everyone who stays gets the new epoch's key.
let k = rand32();
rec.keys.insert(c.epoch.to_string(), b58(&k));
// An inbox is addressed by PERSONA, and since members became
// room-local the roster no longer holds one. Only this device can
// bridge the two; sealing to the roster key reaches nobody, and every
// member who stayed silently keeps a stale key.
let targets: Vec<[u8; 32]> = c
.members
.iter()
.filter(|m| m.key != me)
.map(|m| rec.roster.get(&b58(&m.key)).and_then(|p| key_of(p).ok()).unwrap_or(m.key))
.collect();
save(app, &l)?;
for m in c.members.iter().filter(|m| m.key != me) {
for to in targets {
let cargo = Cargo::SpaceKey(SpaceKeyCargo { space_id, owner, epoch: c.epoch, key: k });
if let Err(e) = inbox::send(app, &m.key, &cargo).await {
log::warn!("spaces: could not hand the epoch-{} key to {}: {e}", c.epoch, b58(&m.key));
if let Err(e) = inbox::send(app, &to, &cargo).await {
log::warn!("spaces: could not hand the epoch-{} key to {}: {e}", c.epoch, b58(&to));
}
}
} else {
@@ -923,6 +946,22 @@ mod tests {
assert!(rec("member", Some([9u8; 32])).roster.get(&room).is_none());
}
/// Epoch keys are keyed by a STRING, and a BTreeMap orders those
/// lexicographically — "9" sorts after "10". A Space only has to reach its
/// tenth epoch for the wrong key to carry forward, and the symptom is
/// messages that simply will not open.
#[test]
fn the_newest_epoch_key_is_found_by_number_not_by_string() {
let mut r = rec("member", None);
for (e, k) in [(1u64, "one"), (9, "nine"), (10, "ten"), (11, "eleven")] {
r.keys.insert(e.to_string(), k.into());
}
assert_eq!(key_below(&r, 12).as_deref(), Some("eleven"));
assert_eq!(key_below(&r, 11).as_deref(), Some("ten"), "lexicographic order would say \"nine\"");
assert_eq!(key_below(&r, 10).as_deref(), Some("nine"));
assert_eq!(key_below(&r, 1), None, "nothing precedes the first epoch");
}
#[test]
fn contract_generation_is_pinned() {
let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(&[7u8; 32], &[1u8; 16]));