diff --git a/src-tauri/src/spaces.rs b/src-tauri/src/spaces.rs index 59eaa40..d1d172f 100644 --- a/src-tauri/src/spaces.rs +++ b/src-tauri/src/spaces.rs @@ -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 { + rec.keys + .iter() + .filter_map(|(e, k)| e.parse::().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::().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]));