diff --git a/Cargo.lock b/Cargo.lock index 544d17440..49c9022f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4843,7 +4843,7 @@ dependencies = [ [[package]] name = "river-core" -version = "0.2.0" +version = "0.2.1" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -4870,7 +4870,7 @@ dependencies = [ [[package]] name = "river-ui" -version = "0.2.0" +version = "0.2.1" dependencies = [ "aes-gcm", "blake3", @@ -4910,7 +4910,7 @@ dependencies = [ [[package]] name = "riverctl" -version = "0.2.21" +version = "0.2.22" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 3f051282c..faa2b83df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,7 @@ freenet-scaffold-macro = "0.2.2" freenet-stdlib = { version = "0.8.5", features = ["contract"] } [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2021" [profile.release] diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index ffc2347f4..ea7f2dbd6 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to riverctl will be documented in this file. +## [0.2.22] - 2026-10-16 + +### Changed +- Built against river-core 0.2.1 and the new room contract (freenet/river#796). + Rooms migrate to the new contract automatically. +- When the ban list is over `max_user_bans`, bans that take no effect are + removed first, then the oldest. Owner bans and all other bans are each + guaranteed half the cap, rounded down (so nothing at a cap of 1), and either + can use slots the other leaves free. + Before, a list full of owner bans removed every new deputy ban as soon as it + was made. Now, while owner bans fill more than half the cap, each new ban + lifts the oldest owner ban, and the person it named can rejoin. +- Known limit: a ban's date is set by whoever signs it, so a member who floods + the list with future-dated bans can still push out other members' bans, and + owner bans beyond the newest half of the cap. Banning that member makes their + bans dead, but bans already pushed out are not restored. To protect every + owner ban, set `max_user_bans` to at least twice the number of owner bans. + ## [0.2.21] - 2026-10-15 ### Changed diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 7c2aaa7ae..8dfd3d966 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "riverctl" -version = "0.2.21" +version = "0.2.22" edition = "2021" authors = ["Freenet Project"] description = "Command-line interface for River decentralized chat on Freenet" @@ -50,7 +50,7 @@ dialoguer = "0.11" atty = "0.2" # Internal dependencies -river-core = { version = "=0.2.0", path = "../common", features = ["ecies", "ecies-randomized", "migration", "mentions"] } +river-core = { version = "=0.2.1", path = "../common", features = ["ecies", "ecies-randomized", "migration", "mentions"] } freenet-stdlib = { workspace = true, features = ["net"] } freenet-scaffold = "0.2.2" # Sans-IO backward-probe decision driver (freenet/river#398 phase 2b): drives diff --git a/cli/contracts/room_contract.wasm b/cli/contracts/room_contract.wasm index 9f55fe8b2..9605adb68 100755 Binary files a/cli/contracts/room_contract.wasm and b/cli/contracts/room_contract.wasm differ diff --git a/common/legacy_room_contracts.toml b/common/legacy_room_contracts.toml index c4f8be4d2..33008063f 100644 --- a/common/legacy_room_contracts.toml +++ b/common/legacy_room_contracts.toml @@ -241,3 +241,9 @@ version = "V34" description = "Before bans were resolved from converged state (freenet/river#423, PR #702)" date = "2026-10-15" code_hash = "b98f49cf853c6dc09976197fa00d9f1e4d5a32c0e3d5b06e7e5969f9aa3c4acb" + +[[entry]] +version = "V35" +description = "Before the max_user_bans cap evicted by age within owner and other pools (freenet/river#796)" +date = "2026-10-16" +code_hash = "eb2222c3f584231ee6f010b2802eae10d70f6bb0b6e48e2aa56be1cdef8b5396" diff --git a/common/src/migration.rs b/common/src/migration.rs index 1b57efc8d..22adb2cc6 100644 --- a/common/src/migration.rs +++ b/common/src/migration.rs @@ -123,8 +123,12 @@ mod tests { // converged state (freenet/river#423, PR #702): `BansV1::apply_delta` // rejected a WHOLE delta on the orphaned-ban rule judged against the // pre-update member set, which could fork a room permanently. - assert_eq!(LEGACY_ROOM_CONTRACT_CODE_HASHES.len(), 34); - assert_eq!(&hasher.finalize().to_hex()[..16], "2ae50081f1b74c3a"); + // + // V35 registers the generation before the `max_user_bans` cap + // evicted by age within owner and other pools (freenet/river#796): + // a list full of owner bans evicted every new deputy ban at once. + assert_eq!(LEGACY_ROOM_CONTRACT_CODE_HASHES.len(), 35); + assert_eq!(&hasher.finalize().to_hex()[..16], "9880dcc559424027"); } #[test] diff --git a/common/src/room_state.rs b/common/src/room_state.rs index 6e09eee9b..6c607b86b 100644 --- a/common/src/room_state.rs +++ b/common/src/room_state.rs @@ -182,18 +182,16 @@ impl ChatRoomStateV1 { // not survive in `ban_evidence` on pass 1 and vanish on pass 2 // (`cleanup(S) != cleanup(cleanup(S))`, which permanently // diverges peers that run cleanup a different number of times). - // Eviction drops bans that take no effect before those that do, - // judged by `MembersV1::resolve_bans` over the uncapped set, one per - // (issuer, target) (#410 review round 1, freenet/river#702). This - // bounds a flood of forged, revoked-deputy or banned-issuer bans, - // which `verify` accepts: those are evicted first, so they cannot - // push real moderator bans out of the cap. It does NOT fully defend - // the un-ban DoS: a CURRENT member's ban on an ABSENT target takes - // effect, and `banned_at` is an attacker-signed, future-datable - // field, so a member can mint many newest-dated effective bans that - // outrank and evict genuine ones. - // The substantive fix (an authorization-aware / non-attacker-ordered - // cap) is deferred pending Ian's decision; tracked in + // Eviction drops bans that take no effect first, judged by + // `MembersV1::resolve_bans` over the uncapped set, one per + // (issuer, target) (#410 review round 1, freenet/river#702), then + // evicts the oldest within two pools, owner bans and the rest, each + // guaranteed `max_user_bans / 2` (freenet/river#796). A flood of + // forged, revoked-deputy or banned-issuer bans, which `verify` + // accepts, is dead and goes first. A flood of LIVE far-future-dated + // bans, which any member can mint against its own invitees or + // against ids known nowhere, evicts older non-owner bans and owner + // bans beyond the guaranteed half; see `enforce_user_ban_cap` and // freenet/river#413 (Limitation 2). // // CONVERGENCE HONESTY: the enforcing/inert classification is a @@ -207,7 +205,9 @@ impl ChatRoomStateV1 { // anti-entropy always converges — pinned by // `cap_eviction_reconciles_via_merge`; tracked in freenet/river#413, // Limitation 1). Do NOT claim the eviction is order-independent per - // delta. + // delta. (`enforce_user_ban_cap` is independent of the order of the + // ban list it is handed; the arrival-order dependence is in the + // member set it judges against.) // `verify`'s hard cap ceiling still rejects any stored state left over // the cap. The signature sweep (step 4) still runs AFTER enforcement // to drop bans orphaned by a banner's removal, and only removes bans, @@ -1368,27 +1368,51 @@ mod tests { &owner_sk, ); + // Member D: a current member kept by a message, issuing two NEWER + // bans against ABSENT targets. All three bans are in the non-owner + // pool, which with no owner bans gets the whole cap, so the two newest + // (D's) survive and B's oldest is evicted (freenet/river#796). + let d_sk = SigningKey::generate(rng); + let d_vk = d_sk.verifying_key(); + let d_id = MemberId::from(&d_vk); + let member_d = AuthorizedMember::new( + Member { + owner_member_id: owner_id, + invited_by: owner_id, + member_vk: d_vk, + }, + &owner_sk, + ); + let msg_d = AuthorizedMessageV1::new( + MessageV1 { + room_owner: owner_id, + author: d_id, + time: SystemTime::now(), + content: RoomMessageBody::public("hi".to_string()), + }, + &d_sk, + ); + let base = SystemTime::now(); - // Two owner bans against ABSENT targets, NEWER → survive the cap. let absent1 = MemberId::from(&SigningKey::generate(rng).verifying_key()); let absent2 = MemberId::from(&SigningKey::generate(rng).verifying_key()); - let owner_ban1 = AuthorizedUserBan::new( + let d_ban1 = AuthorizedUserBan::new( UserBan { owner_member_id: owner_id, banned_at: base + std::time::Duration::from_secs(10), banned_user: absent1, }, - owner_id, - &owner_sk, + d_id, + &d_sk, ); - let owner_ban2 = AuthorizedUserBan::new( + let d_ban2 = AuthorizedUserBan::new( UserBan { owner_member_id: owner_id, banned_at: base + std::time::Duration::from_secs(11), banned_user: absent2, }, - owner_id, - &owner_sk, + d_id, + &d_sk, ); // B's ban against an ABSENT target, OLDEST → evicted by the cap. It is // "enforcing" (member banner, absent target) so ONLY the cap removes it. @@ -1406,9 +1430,13 @@ mod tests { let state = ChatRoomStateV1 { configuration: auth_config, members: MembersV1 { - members: vec![member_b], + members: vec![member_b, member_d], + }, + recent_messages: MessagesV1 { + messages: vec![msg_d], + ..Default::default() }, - bans: BansV1(vec![ban_c_by_b, owner_ban1, owner_ban2]), + bans: BansV1(vec![ban_c_by_b, d_ban1, d_ban2]), ..Default::default() }; diff --git a/common/src/room_state/ban.rs b/common/src/room_state/ban.rs index b527e6354..d7c6b0701 100644 --- a/common/src/room_state/ban.rs +++ b/common/src/room_state/ban.rs @@ -31,15 +31,18 @@ use std::time::SystemTime; /// member whom a stored ban still references), and its signature must verify /// against that record's key. [`crate::room_state::ChatRoomStateV1::post_apply_cleanup`] /// sweeps any ban whose issuer is known nowhere. The `max_user_bans` cap is -/// enforced there too, by tier (owner bans, then bans that take effect, then -/// mutual bans, then the rest; see [`Self::enforce_user_ban_cap`]), and +/// enforced there too, by recency within an owner pool and an everyone-else +/// pool, each guaranteed `max_user_bans / 2` (see +/// [`Self::enforce_user_ban_cap`]), and /// [`ComposableState::apply_delta`] bounds a single delta to `max_user_bans` /// new bans so a forged flood cannot make signature verification unbounded. /// -/// KNOWN, ACCEPTED, self-limiting residual (Ian confirmed, #411 round 3 D): a -/// current member can flood bans against ABSENT targets (each such ban takes -/// effect because its issuer is not removed), and `banned_at` is signed by the -/// issuer, so they can evict older effective bans (never owner bans). The +/// KNOWN, ACCEPTED, self-limiting residual (Ian confirmed, #411 round 3 D; +/// widened by #796): a current member can flood bans against ABSENT targets +/// (each such ban takes effect because its issuer is not removed), and +/// `banned_at` is signed by the issuer, so they can evict older non-owner +/// bans and owner bans beyond the newest `max_user_bans / 2` (see +/// [`Self::enforce_user_ban_cap`]). The /// flooder is identifiable on every junk ban, and an OWNER ban makes all of /// their bans inert. The second #411 residual (slot-squatting through the /// banner prune exemption, which #702 keeps for fixpoint stability) remains. @@ -142,33 +145,76 @@ impl BansV1 { /// construction; two copies of an almost-identical predicate is precisely /// what drifted in freenet/river#671 and in #411 round 4. /// - /// Ranks each ban by its tier in [`MembersV1::resolve_bans`] over the - /// uncapped set (freenet/river#702, Ian 2026-09-24), highest kept first: + /// Evicts by RECENCY within two pools (freenet/river#796, Ian + /// 2026-10-16). When the set is over the cap: /// - /// 1. owner bans ([`BanResolution::OWNER`]): a non-owner ban can never - /// evict one; - /// 2. bans that take effect because their issuer is not removed - /// ([`BanResolution::EFFECTIVE`]), and bans whose target's record is - /// held nowhere, by a present member whose signature verifies and whom - /// no ban removes (their authority cannot be judged without the - /// target's record; this is how they ranked before ban evidence); - /// 3. bans that close a mutual-ban cycle ([`BanResolution::MUTUAL`]); - /// 4. everything else: bans that take no effect, by removed issuers, or - /// unverifiable ([`BanResolution::INERT`]). + /// 1. Dead entries go first, oldest first: records whose own signature + /// does not verify against their claimed issuer's key, bans that take + /// no effect, and later duplicates of an `(issuer, target)` pair + /// (below). + /// 2. Then live bans are split into OWNER bans and all OTHER bans (deputy, + /// inviter and mutual bans alike). Each pool is guaranteed + /// `max_bans / 2` slots. Only a pool's bans beyond its guarantee can be + /// evicted, and among those the oldest by `(banned_at, id)` go first. + /// With an odd cap and both pools over their share, the spare slot + /// goes to the newest ban beyond either pool's share; with one pool + /// under its share, the other keeps it. Within a pool + /// nothing ranks above anything else. /// - /// Only one signature-verified ban per `(issuer, target)` pair keeps its - /// tier (the oldest by `(banned_at, id)`); later duplicates, owner bans - /// included, add nothing and rank inert. Within a tier it evicts oldest - /// before newest, then by ban id, and restores the canonical - /// `(banned_at, id)` stored order. A no-op when already within the cap. + /// Owner bans are often rare, so the guarantee is a floor, not an + /// allocation: slots one pool does not use are open to the other, and a + /// room with few owner bans gives other bans nearly the whole cap. /// - /// What this bounds: a member removed by a ban that takes effect, re-added - /// with a burst of bans, or a ring of sybils banning each other in pairs - /// under deputy grants with far-future dates, can only produce mutual or - /// inert bans, so it cannot evict an owner ban or a ban that takes effect. - /// A mutual-ban flood can still evict other mutual bans. The residual - /// #413 describes (a CURRENT member's effective bans on absent targets) - /// is unchanged. + /// Why two pools and not one. #702 ranked owner bans above every other + /// ban, so a list full of owner bans evicted each new deputy ban the + /// moment it was added, and the Official room's deputies could not ban + /// anyone. Pure recency over one pool would fix that but let any member + /// flush every ban, owner bans included: `banned_at` is signed by the + /// issuer and the contract has no clock, so far-future-dated bans always + /// count as newest. + /// + /// What the two pools do NOT bound, stated plainly. ANY current member can + /// mint live, far-future-dated bans without moderator rights: against + /// throwaway members they invited (an inviter may ban its invitees, and + /// sybils it deputizes may ban each other in mutual pairs), or against + /// random ids whose record is held nowhere (counted live, below). Such a + /// flood evicts: + /// + /// - every older OTHER ban, deputies' included, except in slots owner + /// bans leave unused. This was already true under #702, where inviter + /// bans ranked with deputy bans; only owner bans were immune. While the + /// flood stands, a new deputy ban dated now is the oldest in its pool + /// and is evicted at once, which is the #796 symptom on purpose + /// (freenet/river#413); + /// - OWNER bans beyond the newest `max_bans / 2`. This is new: under #702 + /// no non-owner ban could evict an owner ban. + /// + /// An owner ban on the flooder makes all their bans dead, so the next cap + /// pass drops them first; bans already evicted stay evicted, and the + /// members they named can rejoin. A room that + /// wants every owner ban protected sets `max_user_bans` to at least twice + /// its owner-ban count. + /// + /// A ban takes no effect when [`MembersV1::resolve_bans`] over the uncapped + /// set gives it [`BanResolution::INERT`]: its issuer is removed, its + /// deputy grant is revoked, the issuer has no authority over the target, + /// or its signature cannot be checked. A ban whose target's record is held + /// nowhere cannot have its authority judged and removes nobody until the + /// target returns; it counts as live (in the other pool) when its issuer + /// is a present member whose signature verifies and whom no ban removes, + /// as such bans ranked before ban evidence, so bans stored before the + /// upgrade keep their place. Only one signature-verified ban per + /// `(issuer, target)` pair is live (the oldest by `(banned_at, id)`). + /// + /// Liveness is judged once, over the uncapped set. Evicting one half of a + /// mutual pair can make the surviving half take effect in the post-cap + /// resolution, as under #702. + /// + /// The result is in the canonical `(banned_at, id)` stored order and does + /// not depend on the order of `bans` as passed in. (It still depends on + /// `members`, which can differ between peers mid-merge; see the + /// CONVERGENCE HONESTY note at step 0-cap in `post_apply_cleanup`.) A + /// no-op when already within the cap. pub fn enforce_user_ban_cap( bans: &mut Vec, max_bans: usize, @@ -180,14 +226,35 @@ impl BansV1 { if bans.len() <= max_bans { return; } + // Canonical stored order, oldest first; every pass below walks it, and + // the per-record indices below refer to it. Records can share + // `(banned_at, id)` (an id hashes the signature alone), so the record + // digest breaks the tie and the eviction never depends on arrival + // order. + bans.sort_by_cached_key(|b| (b.ban.banned_at, b.id(), crate::util::record_digest(b))); let resolution = members.resolve_bans(evidence, &BansV1(bans.clone()), member_info, parameters); - // Only signature-verified bans compete for their pair, so a forged - // earlier copy cannot displace the real one. + let owner_id = parameters.owner_id(); + let present = members.members_by_member_id(); + let known = evidence.lookup(members, owner_id); + // Each record is judged by its own signature against its claimed + // issuer's key; one that does not verify is dead. `resolution` is + // keyed by `BanId`, so its lookups below are used only for records + // whose own signature verifies. + let own_signature_ok: Vec = bans + .iter() + .map(|b| { + Self::ban_signature_matches_current_key(b, &known, owner_id, ¶meters.owner) + }) + .collect(); + // Only bans whose own signature verifies compete for their pair, so a + // forged earlier copy cannot displace the real one. let mut first_of_pair: HashMap<(MemberId, MemberId), (SystemTime, BanId)> = HashMap::new(); for ban in bans .iter() - .filter(|b| resolution.verified.contains(&b.id())) + .zip(&own_signature_ok) + .filter(|(_, ok)| **ok) + .map(|(b, _)| b) { let key = (ban.banned_by, ban.ban.banned_user); let rank = (ban.ban.banned_at, ban.id()); @@ -200,43 +267,75 @@ impl BansV1 { }) .or_insert(rank); } - let owner_id = parameters.owner_id(); - let present = members.members_by_member_id(); - let known = evidence.lookup(members, owner_id); - let tier = |ban: &AuthorizedUserBan| { + // A dead entry: a record whose own signature does not verify, a later + // duplicate of its (issuer, target) pair, or a ban that takes no + // effect. + let live = |i: usize, ban: &AuthorizedUserBan| { + if !own_signature_ok[i] { + return false; + } let first = first_of_pair .get(&(ban.banned_by, ban.ban.banned_user)) .is_some_and(|(at, id)| *at == ban.ban.banned_at && *id == ban.id()); if !first { - return BanResolution::INERT; + return false; } - let tier = resolution.tier(&ban.id()); - // A ban whose target's record is held nowhere cannot have its - // authority judged, and removes nobody until the target returns. - // It ranks as it did before ban evidence existed (`main`'s - // `ban_is_enforcing`): kept as a ban that takes effect when its - // issuer is a present member whose signature verifies and whom - // no ban removes. Bans stored before the upgrade, whose targets - // left before evidence was kept, keep their place at the cap. - if tier == BanResolution::INERT - && ban.banned_by != owner_id + if resolution.tier(&ban.id()) > BanResolution::INERT { + return true; + } + // A ban whose target's record is held nowhere: live when its + // issuer is a present member whose signature verifies and whom no + // ban removes (see the doc comment). + ban.banned_by != owner_id && ban.ban.banned_user != owner_id && present.contains_key(&ban.banned_by) && !known.contains_key(&ban.ban.banned_user) && !resolution.removed.contains(&ban.banned_by) - { - return BanResolution::EFFECTIVE; - } - tier }; - bans.sort_by_cached_key(|ban| (tier(ban), ban.ban.banned_at, ban.id())); - let to_remove = bans.len() - max_bans; - bans.drain(0..to_remove); - bans.sort_by(|a, b| { - a.ban - .banned_at - .cmp(&b.ban.banned_at) - .then_with(|| a.id().cmp(&b.id())) + + let mut to_remove = bans.len() - max_bans; + let mut evict = vec![false; bans.len()]; + let mut owner_live = Vec::new(); + let mut other_live = Vec::new(); + for (i, ban) in bans.iter().enumerate() { + if !live(i, ban) { + if to_remove > 0 { + evict[i] = true; + to_remove -= 1; + } + } else if resolution.tier(&ban.id()) == BanResolution::OWNER { + owner_live.push(i); + } else { + other_live.push(i); + } + } + if to_remove > 0 { + // Each pool keeps at least `max_bans / 2`; only a pool's bans + // beyond that are candidates, and the oldest candidates go first. + // With an odd cap the remaining slot goes to the newer ban, + // whichever pool it is in. The live total exceeds the cap by + // `to_remove` and the two shares sum to at most the cap, so the + // pools' excesses sum to at least `to_remove`: the candidates + // suffice. + let share = max_bans / 2; + let owner_excess = owner_live.len().saturating_sub(share); + let other_excess = other_live.len().saturating_sub(share); + let mut candidates: Vec = owner_live[..owner_excess] + .iter() + .chain(&other_live[..other_excess]) + .copied() + .collect(); + // Indices follow the canonical order, so sorting them sorts by age. + candidates.sort_unstable(); + for i in candidates.into_iter().take(to_remove) { + evict[i] = true; + } + } + let mut i = 0; + bans.retain(|_| { + let keep = !evict[i]; + i += 1; + keep }); } } diff --git a/common/src/room_state/member.rs b/common/src/room_state/member.rs index 30a78f68f..41065af6b 100644 --- a/common/src/room_state/member.rs +++ b/common/src/room_state/member.rs @@ -823,9 +823,10 @@ pub struct BanResolution { /// invite subtree. Their bans never take effect, and `post_apply_cleanup` /// sweeps them. pub owner_removed: HashSet, - /// How strongly the `max_user_bans` eviction keeps each ban, highest - /// first ([`Self::OWNER`], [`Self::EFFECTIVE`], [`Self::MUTUAL`]). A ban - /// that is absent here ranks [`Self::INERT`]. + /// Why each ban takes effect ([`Self::OWNER`], [`Self::EFFECTIVE`], + /// [`Self::MUTUAL`]). A ban absent here takes no effect ([`Self::INERT`]); + /// the `max_user_bans` eviction drops those first, then evicts by age + /// within an owner pool and an everyone-else pool (freenet/river#796). pub ban_tiers: HashMap, /// The bans whose signature was checked against their issuer's key and /// verified. Bans by owner-removed issuers are not checked. @@ -839,7 +840,7 @@ impl BanResolution { pub const MUTUAL: u8 = 1; /// A ban that takes effect because its issuer is not removed. pub const EFFECTIVE: u8 = 2; - /// A verifiable owner ban. Never evicted in favour of a non-owner ban. + /// A verifiable owner ban. pub const OWNER: u8 = 3; /// The tier of `ban` ([`Self::INERT`] if it has none). diff --git a/common/tests/dm_merge_law_test.rs b/common/tests/dm_merge_law_test.rs index 2cc2eb8b4..321949e1a 100644 --- a/common/tests/dm_merge_law_test.rs +++ b/common/tests/dm_merge_law_test.rs @@ -1100,8 +1100,10 @@ fn dm_ban_derivation_must_apply_the_user_ban_cap() { }, bans: BansV1(vec![ ban_at(q.id, &d, 1_000), // DEPUTY-issued, oldest -> evicted by the cap - ban_at(t1.id, &owner, 2_000), - ban_at(t2.id, &owner, 3_000), + // Also deputy-issued, so all three share the non-owner pool and + // recency alone picks Q's (freenet/river#796). + ban_at(t1.id, &d, 2_000), + ban_at(t2.id, &d, 3_000), ]), direct_messages: DirectMessagesV1 { messages: legit.iter().chain(&q_dms).cloned().collect(), @@ -1152,6 +1154,145 @@ fn dm_ban_derivation_must_apply_the_user_ban_cap() { ); } +/// As `dm_ban_derivation_must_apply_the_user_ban_cap`, with the surviving set +/// decided by the POOLS rather than plain age (freenet/river#796): the oldest +/// ban is an owner ban, kept by the owner pool's share, and the DM path must +/// agree that Q's deputy ban is the one evicted. Pure recency would evict the +/// owner ban instead and keep Q banned. +#[test] +fn dm_ban_derivation_applies_the_cap_pools() { + const CAP: usize = 20; + let owner = Peer::new(1); + let (a, b, q) = (Peer::new(10), Peer::new(11), Peer::new(20)); + let d = Peer::new(13); // deputy + let (t1, t2) = (Peer::new(21), Peer::new(22)); + let p = params(&owner); + + let ban_at = |target: MemberId, banner: &Peer, secs: u64| { + AuthorizedUserBan::new( + UserBan { + owner_member_id: owner.id, + banned_at: std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs), + banned_user: target, + }, + banner.id, + &banner.sk, + ) + }; + + let cfg = AuthorizedConfigurationV1::new( + Configuration { + max_members: 1000, + max_user_bans: 2, + max_direct_messages: Some(CAP), + ..Default::default() + }, + &owner.sk, + ); + + let mut owner_info = MemberInfo::new_public(owner.id, 1, "owner".to_string()); + owner_info.deputies = vec![d.id]; + + let q_dms: Vec = + (0..3u64).map(|i| dm(&a, &q, &owner, 100 + i)).collect(); + let legit: Vec = (0..3u64).map(|i| dm(&a, &b, &owner, i)).collect(); + + let mut state = ChatRoomStateV1 { + configuration: cfg, + members: MembersV1 { + members: vec![ + member(&a, &owner), + member(&b, &owner), + member(&d, &owner), + member(&q, &owner), + member(&t1, &owner), + member(&t2, &owner), + ], + }, + member_info: MemberInfoV1 { + member_info: vec![ + info(&a), + info(&b), + info(&d), + info(&q), + info(&t1), + info(&t2), + AuthorizedMemberInfo::new(owner_info, &owner.sk), + ], + }, + recent_messages: MessagesV1 { + messages: vec![ + join_msg(&a, owner.id), + join_msg(&b, owner.id), + join_msg(&d, owner.id), + join_msg(&q, owner.id), + join_msg(&t1, owner.id), + join_msg(&t2, owner.id), + ], + ..Default::default() + }, + bans: BansV1(vec![ + // OWNER-issued and OLDEST, but within the owner pool's share + // (1 of cap 2), so it is kept. + ban_at(t1.id, &owner, 1_000), + // DEPUTY-issued: the non-owner pool holds two against a share of + // one, so its oldest, Q's, is evicted (freenet/river#796). + ban_at(q.id, &d, 2_000), + ban_at(t2.id, &d, 3_000), + ]), + direct_messages: DirectMessagesV1 { + messages: legit.iter().chain(&q_dms).cloned().collect(), + purges: vec![], + }, + ..Default::default() + }; + + let delta = ChatRoomStateV1Delta { + member_info: Some(vec![info(&a)]), + direct_messages: None, + ..Default::default() + }; + let parent = state.clone(); + state.apply_delta(&parent, &p, &Some(delta)).expect("apply"); + + assert_eq!( + state.bans.0.len(), + 2, + "test premise: the ban cap must actually bite" + ); + assert!( + !state.bans.0.iter().any(|x| x.ban.banned_user == q.id), + "test premise: the cap must be what evicts Q's ban" + ); + assert!( + state.bans.0.iter().any(|x| x.ban.banned_user == t1.id), + "test premise: the older owner ban survives in its pool" + ); + assert!( + member_ids(&state).contains(&q.id), + "test premise: with its ban evicted, Q must still be a member" + ); + let held: HashSet<[u8; 64]> = state + .direct_messages + .messages + .iter() + .map(|m| m.sender_signature.to_bytes()) + .collect(); + let lost: Vec = legit + .iter() + .chain(&q_dms) + .filter(|m| !held.contains(&m.sender_signature.to_bytes())) + .map(|m| m.message.timestamp - BASE_TS) + .collect(); + assert!( + lost.is_empty(), + "DMs at offsets {lost:?} were deleted at apply time against an UNCAPPED ban \ + set, though step 0-cap evicts that ban and step 6 would have kept them; \ + held now = {:?}", + held_offsets(&state) + ); +} + /// PROPOSED GUARD 2 (reviewer-authored): the `delta == None` arm's sweep must be /// BAN-AWARE, not merely membership-aware. /// diff --git a/common/tests/mutual_rejection_test.rs b/common/tests/mutual_rejection_test.rs index 3bf29d8ce..25beac922 100644 --- a/common/tests/mutual_rejection_test.rs +++ b/common/tests/mutual_rejection_test.rs @@ -1808,12 +1808,15 @@ fn a_mutual_ban_removes_only_the_cycle_members() { ); } -/// Round 5 Must Fix 2: an ordinary member X deputizes sybils it invited and -/// has them ban each other in pairs with far-future dates. Every pair is a -/// cycle, but mutual bans rank below the owner's ban and below a moderator's -/// ban that takes effect, so the cap evicts sybil bans instead. -#[test] -fn a_sybil_deputy_cycle_flood_cannot_evict_real_bans() { +/// Round 5 Must Fix 2, revised for freenet/river#796: an ordinary member X +/// deputizes sybils it invited and has them ban each other in pairs with +/// far-future dates. Every pair is a cycle, so the bans are live and compete +/// on recency in the non-owner pool. The owner pool keeps its half of the cap, +/// so the owner's ban survives the flood. A moderator's older ban in the +/// non-owner pool is evicted: that is the accepted future-dating residual +/// (freenet/river#413), and the evicted ban's target is back. +#[test] +fn a_sybil_deputy_cycle_flood_cannot_evict_owner_bans() { let room = capped_room(20, 4); let (v, w, d, x) = (room.person(), room.person(), room.person(), room.person()); let sybils: Vec = (0..4).map(|_| room.person_invited_by(&x)).collect(); @@ -1839,9 +1842,55 @@ fn a_sybil_deputy_cycle_flood_cannot_evict_real_bans() { ]; let after = apply_checked(&s, bans_delta(flood), &room.params); assert!(has_ban(&after, &owner_bans_v), "the owner's ban survives"); - assert!(has_ban(&after, &d_bans_w), "the moderator's ban survives"); - let ids = member_ids(&after); - assert!(!ids.contains(&v.id) && !ids.contains(&w.id)); + assert!( + !member_ids(&after).contains(&v.id), + "the owner's ban still takes effect" + ); + assert!( + !has_ban(&after, &d_bans_w), + "accepted residual (#413): the older non-owner ban is evicted" + ); +} + +/// The owner pool's guarantee holds under a flood even when the owner has more +/// bans than its share: with a cap of 4 and three owner bans, a flood of +/// far-future non-owner bans evicts only the OLDEST owner ban, and the newest +/// two (half the cap) survive. +#[test] +fn a_flood_leaves_the_newest_half_cap_of_owner_bans() { + let room = capped_room(20, 4); + let x = room.person(); + let sybils: Vec = (0..4).map(|_| room.person_invited_by(&x)).collect(); + let mut members: Vec<&Person> = vec![&x]; + members.extend(sybils.iter()); + let mut s = room.modded_state(&members, &[], vec![]); + s.member_info + .member_info + .push(deputies_record(&x, 1, &sybils.iter().collect::>())); + s.verify(&s, &room.params).expect("fixture"); + let gone: Vec = (0..3).map(|_| room.person()).collect(); + let owner_bans: Vec = gone + .iter() + .enumerate() + .map(|(i, p)| room.owner_ban(p, 10 + i as u64)) + .collect(); + let s = apply_checked(&s, bans_delta(owner_bans.clone()), &room.params); + let far = 3_000_000_000; + let flood = vec![ + room.ban(&sybils[0], &sybils[1], far), + room.ban(&sybils[1], &sybils[0], far), + room.ban(&sybils[2], &sybils[3], far + 1), + room.ban(&sybils[3], &sybils[2], far + 1), + ]; + let after = apply_checked(&s, bans_delta(flood), &room.params); + assert!( + !has_ban(&after, &owner_bans[0]), + "the oldest owner ban goes" + ); + assert!( + has_ban(&after, &owner_bans[1]) && has_ban(&after, &owner_bans[2]), + "the newest half-cap of owner bans survives" + ); } /// Round 5 Must Fix 2: a member removed by a moderator's ban re-adds @@ -1978,10 +2027,10 @@ fn empty_ban_evidence_is_not_serialized() { assert_eq!(ser(&back), bytes); } -/// A ban that closes a mutual-ban cycle ranks with the mutual bans at the -/// `max_user_bans` cap even when an earlier ban already removed its issuer -/// (Ian, 2026-09-24: both bans of a mutual ban stand). Under cap pressure the -/// cycle's bans are kept and other bans that take no effect go first. +/// A ban that closes a mutual-ban cycle is live at the `max_user_bans` cap +/// even when an earlier ban already removed its issuer (Ian, 2026-09-24: both +/// bans of a mutual ban stand). Under cap pressure the cycle's bans are kept +/// and bans that take no effect go first. #[test] fn cycle_bans_rank_mutual_after_an_earlier_removal() { let room = capped_room(20, 4); @@ -2115,11 +2164,11 @@ fn a_pruned_moderator_held_as_evidence_keeps_ban_authority() { ); } -/// The owner ranks above everything at the `max_user_bans` cap, and only one -/// ban per (issuer, target) keeps its tier: a later duplicate of an effective -/// ban is evicted before a different effective ban. +/// Only one ban per (issuer, target) is live at the `max_user_bans` cap: a +/// later duplicate of an effective ban is a dead entry, evicted before any +/// live ban however old. #[test] -fn cap_keeps_owner_bans_first_and_one_ban_per_pair() { +fn cap_evicts_a_duplicate_before_any_live_ban() { let room = capped_room(20, 3); let (d, x, y, z) = (room.person(), room.person(), room.person(), room.person()); let s = room.modded_state(&[&d, &x, &y, &z], &[&d], vec![]); @@ -2145,6 +2194,354 @@ fn cap_keeps_owner_bans_first_and_one_ban_per_pair() { ); } +/// freenet/river#796: with the ban list full of OWNER bans, a deputy's new ban +/// used to rank lowest and be evicted the moment it arrived, so the deputy +/// could ban nobody. Owner bans are guaranteed only half the cap, so the +/// oldest owner ban beyond that half goes instead and the deputy's ban takes +/// effect. +#[test] +fn a_full_list_of_owner_bans_does_not_evict_a_new_deputy_ban() { + let room = capped_room(20, 4); + let (d, x) = (room.person(), room.person()); + let s = room.modded_state(&[&d, &x], &[&d], vec![]); + let gone: Vec = (0..4).map(|_| room.person()).collect(); + let owner_bans: Vec = gone + .iter() + .enumerate() + .map(|(i, p)| room.owner_ban(p, 10 + i as u64)) + .collect(); + let s = apply_checked(&s, bans_delta(owner_bans.clone()), &room.params); + assert_eq!(s.bans.0.len(), 4, "the list is full of owner bans"); + + let d_bans_x = room.ban(&d, &x, 100); + let after = apply_checked(&s, bans_delta(vec![d_bans_x.clone()]), &room.params); + assert!(has_ban(&after, &d_bans_x), "the deputy's ban is kept"); + assert!( + !member_ids(&after).contains(&x.id), + "the deputy's ban takes effect" + ); + assert!( + !has_ban(&after, &owner_bans[0]), + "the oldest owner ban is evicted" + ); + for ban in &owner_bans[1..] { + assert!(has_ban(&after, ban), "the newer owner bans are kept"); + } +} + +/// Owner bans are often rare, so their half of the cap is a floor, not an +/// allocation: with no owner bans, the other pool uses the whole cap and +/// evicts strictly by age. +#[test] +fn rare_owner_bans_leave_the_whole_cap_to_other_bans() { + let room = capped_room(20, 4); + let d = room.person(); + let s = room.modded_state(&[&d], &[&d], vec![]); + let gone: Vec = (0..5).map(|_| room.person()).collect(); + let d_bans: Vec = gone + .iter() + .enumerate() + .map(|(i, p)| room.ban(&d, p, 10 + i as u64)) + .collect(); + // A single delta may carry at most `max_user_bans` bans. + let s = apply_checked(&s, bans_delta(d_bans[..4].to_vec()), &room.params); + let after = apply_checked(&s, bans_delta(vec![d_bans[4].clone()]), &room.params); + assert_eq!(after.bans.0.len(), 4, "the whole cap is used"); + assert!(!has_ban(&after, &d_bans[0]), "the oldest is evicted"); + for ban in &d_bans[1..] { + assert!(has_ban(&after, ban)); + } +} + +/// The two pools trade unused slots by age: with one owner ban and a cap of 4, +/// three other bans fit, and a newer owner ban evicts the oldest OTHER ban +/// once the owner pool is within its share, because the other pool is then +/// the one over its share. +#[test] +fn a_new_owner_ban_evicts_the_oldest_ban_of_the_pool_over_its_share() { + let room = capped_room(20, 4); + let d = room.person(); + let s = room.modded_state(&[&d], &[&d], vec![]); + let gone: Vec = (0..5).map(|_| room.person()).collect(); + let old_owner = room.owner_ban(&gone[0], 5); + let d_bans: Vec = gone[1..4] + .iter() + .enumerate() + .map(|(i, p)| room.ban(&d, p, 10 + i as u64)) + .collect(); + let mut first = vec![old_owner.clone()]; + first.extend(d_bans.iter().cloned()); + let s = apply_checked(&s, bans_delta(first), &room.params); + assert_eq!(s.bans.0.len(), 4); + + let new_owner = room.owner_ban(&gone[4], 100); + let after = apply_checked(&s, bans_delta(vec![new_owner.clone()]), &room.params); + assert!( + has_ban(&after, &old_owner) && has_ban(&after, &new_owner), + "both owner bans fit the owner pool's share, so the older one is kept" + ); + assert!( + !has_ban(&after, &d_bans[0]), + "the oldest other ban goes, its pool being over its share" + ); + assert!(has_ban(&after, &d_bans[1]) && has_ban(&after, &d_bans[2])); +} + +/// An odd cap's spare slot goes to the newer ban, whichever pool it is in. +/// Cap 3: each pool is guaranteed one slot and the third is shared by age. +#[test] +fn an_odd_caps_spare_slot_goes_to_the_newer_ban() { + for owner_older in [true, false] { + let room = capped_room(20, 3); + let d = room.person(); + let s = room.modded_state(&[&d], &[&d], vec![]); + let gone: Vec = (0..4).map(|_| room.person()).collect(); + let (o_at, d_at) = if owner_older { (10, 15) } else { (15, 10) }; + let o1 = room.owner_ban(&gone[0], o_at); + let o2 = room.owner_ban(&gone[1], 20); + let d1 = room.ban(&d, &gone[2], d_at); + let d2 = room.ban(&d, &gone[3], 30); + let s = apply_checked( + &s, + bans_delta(vec![o1.clone(), o2.clone(), d1.clone()]), + &room.params, + ); + let after = apply_checked(&s, bans_delta(vec![d2.clone()]), &room.params); + assert_eq!(after.bans.0.len(), 3); + assert!(has_ban(&after, &o2) && has_ban(&after, &d2)); + if owner_older { + assert!(!has_ban(&after, &o1) && has_ban(&after, &d1)); + } else { + assert!(has_ban(&after, &o1) && !has_ban(&after, &d1)); + } + } +} + +/// At cap 1 neither pool is guaranteed a slot, so the newer ban wins. +#[test] +fn cap_one_keeps_the_newer_ban_whatever_its_pool() { + for owner_newer in [true, false] { + let room = capped_room(20, 1); + let d = room.person(); + let s = room.modded_state(&[&d], &[&d], vec![]); + let (x, y) = (room.person(), room.person()); + let (o_at, d_at) = if owner_newer { (20, 10) } else { (10, 20) }; + let o = room.owner_ban(&x, o_at); + let db = room.ban(&d, &y, d_at); + let s = apply_checked(&s, bans_delta(vec![o.clone()]), &room.params); + let after = apply_checked(&s, bans_delta(vec![db.clone()]), &room.params); + assert_eq!(after.bans.0.len(), 1); + assert_eq!(has_ban(&after, &o), owner_newer); + assert_eq!(has_ban(&after, &db), !owner_newer); + } +} + +/// One cap pass that drops a dead entry AND then evicts from a pool: cap 4, +/// three owner bans, a deputy's ban plus a later duplicate of it, and another +/// deputy ban. Six bans, two to go: the duplicate (dead) first, then the +/// oldest owner ban, the owner pool being the only one over its share. The +/// result does not depend on input order, and a second pass changes nothing. +#[test] +fn a_dead_entry_and_a_pool_eviction_in_one_pass() { + let room = capped_room(20, 4); + let (d, x, y) = (room.person(), room.person(), room.person()); + let s = room.modded_state(&[&d, &x, &y], &[&d], vec![]); + let gone: Vec = (0..3).map(|_| room.person()).collect(); + let owner_bans: Vec = gone + .iter() + .enumerate() + .map(|(i, p)| room.owner_ban(p, 10 + i as u64)) + .collect(); + let d_bans_x = room.ban(&d, &x, 20); + let d_bans_x_again = room.ban(&d, &x, 40); + let d_bans_y = room.ban(&d, &y, 21); + let mut input = owner_bans.clone(); + input.extend([d_bans_x.clone(), d_bans_x_again.clone(), d_bans_y.clone()]); + + let cap = |bans: &[AuthorizedUserBan]| { + let mut bans = bans.to_vec(); + BansV1::enforce_user_ban_cap( + &mut bans, + 4, + &s.members, + &s.ban_evidence, + &s.member_info, + &room.params, + ); + bans + }; + let out = cap(&input); + let ids: Vec<_> = out.iter().map(|b| b.id()).collect(); + let want: Vec<_> = [&owner_bans[1], &owner_bans[2], &d_bans_x, &d_bans_y] + .iter() + .map(|b| b.id()) + .collect(); + assert_eq!( + ids, want, + "duplicate and oldest owner ban evicted, in stored order" + ); + + let mut reversed = input.clone(); + reversed.reverse(); + let mut rotated = input.clone(); + rotated.rotate_left(2); + assert_eq!(cap(&reversed), out, "input order does not matter"); + assert_eq!(cap(&rotated), out, "input order does not matter"); + assert_eq!(cap(&out), out, "a second pass changes nothing"); +} + +/// More dead entries than the overflow: only the oldest dead entries go, and +/// no live ban is touched. Cap 5 with four live bans, then three later +/// duplicates of the deputy's pairs (dead): two over, so the two oldest +/// duplicates go and the newest stays. +#[test] +fn surplus_dead_entries_go_oldest_first_and_spare_live_bans() { + let room = capped_room(20, 5); + let (d, x, y) = (room.person(), room.person(), room.person()); + let s = room.modded_state(&[&d, &x, &y], &[&d], vec![]); + let gone: Vec = (0..2).map(|_| room.person()).collect(); + let o1 = room.owner_ban(&gone[0], 10); + let o2 = room.owner_ban(&gone[1], 11); + let dx = room.ban(&d, &x, 12); + let dy = room.ban(&d, &y, 13); + let s = apply_checked( + &s, + bans_delta(vec![o1.clone(), o2.clone(), dx.clone(), dy.clone()]), + &room.params, + ); + // Three duplicates of live pairs: all dead. 7 bans against cap 5, so two + // go: the two OLDEST duplicates. The newest duplicate stays as a dead + // entry, since the set is then within the cap. + let dup1 = room.ban(&d, &x, 20); + let dup2 = room.ban(&d, &y, 21); + let dup3 = room.ban(&d, &x, 22); + let after = apply_checked( + &s, + bans_delta(vec![dup1.clone(), dup2.clone(), dup3.clone()]), + &room.params, + ); + for live in [&o1, &o2, &dx, &dy] { + assert!(has_ban(&after, live), "no live ban is evicted"); + } + assert!(!has_ban(&after, &dup1) && !has_ban(&after, &dup2)); + assert!(has_ban(&after, &dup3), "only the overflow is evicted"); +} + +/// Both pools over their share with several evictions in one pass. Cap 4 +/// (share 2): owner bans at 10, 30, 40, 50 and deputy bans at 20, 60, 70. +/// Seven live, three go: each pool's bans beyond its share, oldest first, so +/// the newest two of each pool survive. (With an even cap and both pools at +/// or over their share, the candidates number exactly the overflow; the age +/// choice between pools is pinned by `an_odd_caps_spare_slot_goes_to_the_newer_ban`.) +#[test] +fn several_evictions_take_each_pools_oldest_beyond_its_share() { + let room = capped_room(20, 4); + let d = room.person(); + let s = room.modded_state(&[&d], &[&d], vec![]); + let gone: Vec = (0..7).map(|_| room.person()).collect(); + let o: Vec = [10, 30, 40, 50] + .iter() + .zip(&gone[..4]) + .map(|(t, p)| room.owner_ban(p, *t)) + .collect(); + let db: Vec = [20, 60, 70] + .iter() + .zip(&gone[4..]) + .map(|(t, p)| room.ban(&d, p, *t)) + .collect(); + let s = apply_checked(&s, bans_delta(o.clone()), &room.params); + let after = apply_checked(&s, bans_delta(db.clone()), &room.params); + assert_eq!(after.bans.0.len(), 4); + assert!(!has_ban(&after, &o[0]) && !has_ban(&after, &o[1])); + assert!(!has_ban(&after, &db[0])); + assert!(has_ban(&after, &o[2]) && has_ban(&after, &o[3])); + assert!(has_ban(&after, &db[1]) && has_ban(&after, &db[2])); +} + +/// A ban that takes no effect is dead and goes before any live ban, however +/// new: a plain member B bans D, whom B has no authority over (D is present, +/// so the ban can be judged, and it is inert). It is the NEWEST ban, yet it is +/// the one evicted at the cap. +#[test] +fn an_inert_ban_goes_before_older_live_bans() { + let room = capped_room(20, 3); + let (b, d, x) = (room.person(), room.person(), room.person()); + let s = room.modded_state(&[&b, &d, &x], &[&d], vec![]); + let gone: Vec = (0..2).map(|_| room.person()).collect(); + let o = room.owner_ban(&gone[0], 10); + let d_ban = room.ban(&d, &gone[1], 11); + let d_bans_x = room.ban(&d, &x, 12); + let s = apply_checked( + &s, + bans_delta(vec![o.clone(), d_ban.clone(), d_bans_x.clone()]), + &room.params, + ); + let inert = room.ban(&b, &d, 100); + let after = apply_checked(&s, bans_delta(vec![inert.clone()]), &room.params); + assert!(!has_ban(&after, &inert), "the inert ban is evicted"); + for live in [&o, &d_ban, &d_bans_x] { + assert!(has_ban(&after, live), "no live ban is evicted"); + } +} + +/// A ban on an id held nowhere by an issuer known nowhere is dead (its +/// signature cannot be checked), and is evicted before older live bans. +#[test] +fn an_absent_target_ban_by_an_absent_issuer_is_dead() { + let room = capped_room(20, 2); + let d = room.person(); + let s = room.modded_state(&[&d], &[&d], vec![]); + let gone: Vec = (0..2).map(|_| room.person()).collect(); + let o = room.owner_ban(&gone[0], 10); + let d_ban = room.ban(&d, &gone[1], 11); + let stranger = room.person(); + let by_stranger = room.ban(&stranger, &room.person(), 100); + let mut bans = vec![by_stranger.clone(), d_ban.clone(), o.clone()]; + BansV1::enforce_user_ban_cap( + &mut bans, + 2, + &s.members, + &s.ban_evidence, + &s.member_info, + &room.params, + ); + let ids: Vec<_> = bans.iter().map(|b| b.id()).collect(); + assert_eq!(ids, vec![o.id(), d_ban.id()]); +} + +/// The widened residual of freenet/river#796, pinned so a future fix flips it +/// deliberately: a plain member with no deputy grant and no invitees floods +/// far-future bans against random ids whose record is held nowhere (each +/// counts as live). With three owner bans at cap 4 the oldest owner ban is +/// evicted; the newest two (the owner pool's share) survive. +#[test] +fn a_plain_members_random_target_flood_evicts_owner_bans_down_to_the_share() { + let room = capped_room(20, 4); + let m = room.person(); + let s = room.modded_state(&[&m], &[], vec![]); + let gone: Vec = (0..3).map(|_| room.person()).collect(); + let owner_bans: Vec = gone + .iter() + .enumerate() + .map(|(i, p)| room.owner_ban(p, 10 + i as u64)) + .collect(); + let s = apply_checked(&s, bans_delta(owner_bans.clone()), &room.params); + let far = 3_000_000_000; + let flood: Vec = (0..4) + .map(|i| room.ban(&m, &room.person(), far + i)) + .collect(); + let after = apply_checked(&s, bans_delta(flood.clone()), &room.params); + assert!( + !has_ban(&after, &owner_bans[0]), + "the oldest owner ban goes" + ); + assert!(has_ban(&after, &owner_bans[1]) && has_ban(&after, &owner_bans[2])); + assert!( + has_ban(&after, &flood[2]) && has_ban(&after, &flood[3]), + "the newest flood bans fill the other pool" + ); +} + /// An evidence record whose inviter leaves `members` at the `max_members` trim /// (which runs before cleanup) no longer reaches the owner, so every reader /// leaves it out and cleanup drops it. The result verifies and cleanup is a diff --git a/common/tests/room_message_retention_test.rs b/common/tests/room_message_retention_test.rs index a46e6ac43..fd4fc9667 100644 --- a/common/tests/room_message_retention_test.rs +++ b/common/tests/room_message_retention_test.rs @@ -455,8 +455,9 @@ fn ban_evicted_case(privacy: PrivacyMode) { state.post_apply_cleanup(&room.params).unwrap(); assert_eq!(state.bans.0.len(), 1, "the ban on x fits the cap"); - // A newer, enforcing owner ban on an absent id: the cap keeps it and - // evicts the older ban on `x`. + // A newer, enforcing ban on an absent id by the same deputy: both share + // the non-owner pool, so the cap keeps the newer and evicts the older ban + // on `x` (freenet/river#796). let other = Peer::new(); let mut delta = room.delta_with_x(); delta.bans = Some(vec![AuthorizedUserBan::new( @@ -465,8 +466,8 @@ fn ban_evicted_case(privacy: PrivacyMode) { banned_at: SystemTime::now() + Duration::from_secs(60), banned_user: other.id, }, - room.owner.id, - &room.owner.sk, + room.d.id, + &room.d.sk, )]); apply(&mut state, &room.params, delta); state diff --git a/legacy_delegates.toml b/legacy_delegates.toml index 330529183..5afdc5919 100644 --- a/legacy_delegates.toml +++ b/legacy_delegates.toml @@ -251,3 +251,10 @@ description = "Last chat-delegate generation before the freenet/river#423 ban-re date = "2026-10-15" delegate_key = "e3c99e6897a1daef51370d0d00bc74132eec374d4267a0badf600e1a24cda5a4" code_hash = "cea989c88db1a0ebc3558f2c33f90ab491473388e3f80319d8a09450b13a5d81" + +[[entry]] +version = "V35" +description = "Last chat-delegate generation before the freenet/river#796 ban-cap eviction change, which the delegate links through river-core's room-state code; river-core 0.2.0 -> 0.2.1" +date = "2026-10-16" +delegate_key = "107923229a7cf8508968557478eda7c89c8252f36a4488619bfc6d8d5ce86892" +code_hash = "49a3122b2de64a62d56a2254518277d6ed8247faf3abb5325e722587a03b2ee5" diff --git a/pointer-records.toml b/pointer-records.toml index f535271b0..f92c699d3 100644 --- a/pointer-records.toml +++ b/pointer-records.toml @@ -68,17 +68,17 @@ wasm_path = "ui/public/contracts/room_contract.wasm" # (author_verifying_key ‖ app_id); recorded here so a human can check it and # so the gate can catch a derivation that has drifted. pointer_key = "Ai4VLoC2jGdhpcB2UU8VPo3efUoxjm1Ju9VKXqRC63Az" -version = 4 -code_hash = "eb2222c3f584231ee6f010b2802eae10d70f6bb0b6e48e2aa56be1cdef8b5396" -state = "00000004eb2222c3f584231ee6f010b2802eae10d70f6bb0b6e48e2aa56be1cdef8b539643276b28bdb5617fe3c20ecfc901e1244b97140a66d823cb91c1d90442d9b81c929bdb939e1d3e14fab5fdfd3fe6cc78ae9bb1aff7f91bce86617b6963da5402" +version = 5 +code_hash = "057ec3ae165f54f043157e1c60fdf7a621ff86d526d80cf8e1a15f91bd271ad7" +state = "00000005057ec3ae165f54f043157e1c60fdf7a621ff86d526d80cf8e1a15f91bd271ad7c454e7b6e4c2d3dfac8a9a5760f216bc36f55f381d9dd118863ac2be65acf60abf01636f6ff13f8488fc3e891d3e397403f6e71ada97e9d62e9046f607bc1106" [[record]] app_id = "river.chat-delegate" wasm_path = "ui/public/contracts/chat_delegate.wasm" pointer_key = "6qF2H5JRPBxbKC45UtPnzdDzyfsejYFW1UwDLGDU66mu" -version = 5 -code_hash = "49a3122b2de64a62d56a2254518277d6ed8247faf3abb5325e722587a03b2ee5" -state = "0000000549a3122b2de64a62d56a2254518277d6ed8247faf3abb5325e722587a03b2ee5285a94a7cd301e8d99d09fd671dcb5d255facfbc71c66f31c934ef488d5a23379d2b32596232da883fdba72972a4c515b648eb74e02e252c0d1eebcd3d32ce02" +version = 6 +code_hash = "eca06ace26857393b0c0ef283b560e06839b2c97b29d40d64b3909e9f9ead0aa" +state = "00000006eca06ace26857393b0c0ef283b560e06839b2c97b29d40d64b3909e9f9ead0aaa6433dba677c3984954498f5065bccce325968d53108c79c77ef67b186acbcba06d7b87a2a981ae9d8a163abd26f525fa062ebe2ac9303543c36d52bc16ea207" # SCOPE, and it matters most here. Resolving this pointer correctly tells you # the delegate's current key. It does NOT tell you that the user's secrets came # with it: delegate secrets only move when River's own UI has run on that user's diff --git a/ui/public/contracts/chat_delegate.wasm b/ui/public/contracts/chat_delegate.wasm index 4beac0974..be165f7ca 100755 Binary files a/ui/public/contracts/chat_delegate.wasm and b/ui/public/contracts/chat_delegate.wasm differ diff --git a/ui/public/contracts/room_contract.wasm b/ui/public/contracts/room_contract.wasm index 9f55fe8b2..9605adb68 100755 Binary files a/ui/public/contracts/room_contract.wasm and b/ui/public/contracts/room_contract.wasm differ diff --git a/ui/src/components/app/chat_delegate.rs b/ui/src/components/app/chat_delegate.rs index 66e4e1887..4a3639b68 100644 --- a/ui/src/components/app/chat_delegate.rs +++ b/ui/src/components/app/chat_delegate.rs @@ -6230,12 +6230,17 @@ mod tests { /// delegate links the room-state code that change rewrites, and the /// mandatory `river-core` 0.1.22 -> 0.2.0 bump would move it regardless. /// V34 (`cea989c8…`, from freenet/river#768) is the outgoing generation. + /// + /// Updated for V35 (freenet/river#796, PR #797): the delegate links the + /// room-state code that change touches, and the river-core 0.2.0 -> 0.2.1 + /// bump moves it regardless. V35 (`49a3122b…`, from PR #702) is the + /// outgoing generation. #[test] fn chat_delegate_wasm_is_byte_identical() { let bytes = include_bytes!("../../../public/contracts/chat_delegate.wasm"); assert_eq!( blake3::hash(bytes).to_hex().as_str(), - "49a3122b2de64a62d56a2254518277d6ed8247faf3abb5325e722587a03b2ee5", + "eca06ace26857393b0c0ef283b560e06839b2c97b29d40d64b3909e9f9ead0aa", "chat_delegate.wasm changed, which re-keys the delegate; if the change is \ intentional, follow .claude/rules/delegate-migration.md \ (add-migration BEFORE rebuilding) and update this pin in the same commit"