Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
18 changes: 18 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down
Binary file modified cli/contracts/room_contract.wasm
Binary file not shown.
6 changes: 6 additions & 0 deletions common/legacy_room_contracts.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
8 changes: 6 additions & 2 deletions common/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
72 changes: 50 additions & 22 deletions common/src/room_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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()
};

Expand Down
Loading
Loading