river_core/room_state/ban.rs
1use crate::room_state::member::{AuthorizedMember, MemberId, MembersV1};
2use crate::room_state::member_info::MemberInfoV1;
3use crate::room_state::ChatRoomParametersV1;
4use crate::util::{sign_struct, verify_struct};
5use crate::ChatRoomStateV1;
6use ed25519_dalek::{Signature, SigningKey, VerifyingKey};
7use freenet_scaffold::util::{fast_hash, FastHash};
8use freenet_scaffold::ComposableState;
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeSet, HashMap, HashSet};
11use std::fmt;
12use std::hash::{Hash, Hasher};
13use std::time::SystemTime;
14
15/// Represents a collection of user bans in a chat room
16///
17/// This structure maintains a list of authorized bans and provides methods
18/// to verify, summarize, and apply changes to the ban list while ensuring
19/// all bans are valid according to room rules.
20///
21/// # Ban validity & the un-ban DoS (#411)
22///
23/// A ban is only honored while its banner is the OWNER or a CURRENT,
24/// signature-validated member: [`Self::ban_is_enforcing`] gates on that, and
25/// [`crate::room_state::ChatRoomStateV1::post_apply_cleanup`] sweeps any ban
26/// whose banner is neither. A member who is a banner is exempt from
27/// inactivity-pruning while they hold a retained ban, so a moderator's bans do
28/// not vanish. The `max_user_bans` FIFO is enforced there too, evicting inert
29/// (currently-unauthorized) bans before enforcing ones, and
30/// [`ComposableState::apply_delta`] bounds a single delta to `max_user_bans`
31/// new bans so a forged flood cannot make signature verification unbounded.
32///
33/// KNOWN, ACCEPTED, self-limiting residuals (Ian confirmed, #411 round 3 D):
34/// 1. A current member can flood bans against ABSENT targets (each such ban is
35/// "enforcing" only because its banner is a member).
36/// 2. Slot-squatting: because of the item-B pruning exemption, a current member
37/// can make themselves PERMANENTLY exempt from inactivity-pruning with a
38/// single junk ban against an absent target — that ban is "enforcing", so it
39/// survives both the non-member-banner sweep and the `max_user_bans`
40/// eviction, and inactivity-prune can no longer reclaim their member slot.
41/// Both are pre-existing/emergent and self-limiting the same way: the flooder /
42/// squatter is a current member, identifiable on every junk ban, and only an
43/// explicit OWNER ban reclaims the slot — banning them makes their bans inert
44/// and sweeps them (freeing the cap and making them prunable again).
45/// Inactivity-prune alone cannot. No code fix; documented deliberately.
46#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
47pub struct BansV1(pub Vec<AuthorizedUserBan>);
48
49/// Validation errors that can occur with bans.
50///
51/// Since #410, ban ENFORCEMENT authority (owner / ancestor / deputy) is no
52/// longer decided in `verify` — it is recomputed from converged state in
53/// `ChatRoomStateV1::post_apply_cleanup`. The former invite-chain /
54/// excess-count validation variants were removed with that change; the only
55/// remaining `verify`-time rejection is an orphaned ban whose banner was
56/// themselves banned.
57#[derive(Debug, Clone, PartialEq)]
58pub enum BanValidationError {
59 /// The banning member is not in the current member list AND was themselves
60 /// banned — an orphaned ban.
61 BannerNotFound(MemberId),
62}
63
64impl fmt::Display for BanValidationError {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 match self {
67 BanValidationError::BannerNotFound(id) => {
68 write!(f, "Banning member not found in member list: {:?}", id)
69 }
70 }
71 }
72}
73
74impl BansV1 {
75 /// Validates the per-ban orphan constraints and returns a map of invalid
76 /// bans with errors. Does NOT enforce the `max_user_bans` ceiling — that is
77 /// a whole-collection concern applied as a hard count check in `verify` and
78 /// enforced (inert-first) in `ChatRoomStateV1::post_apply_cleanup` (#410).
79 fn get_invalid_bans(
80 &self,
81 parent_state: &ChatRoomStateV1,
82 parameters: &ChatRoomParametersV1,
83 ) -> HashMap<BanId, BanValidationError> {
84 let member_map = parent_state.members.members_by_member_id();
85 let mut invalid_bans = HashMap::new();
86 let banned_user_ids: HashSet<MemberId> = self.0.iter().map(|b| b.ban.banned_user).collect();
87
88 // Validate each ban
89 for ban in &self.0 {
90 self.validate_single_ban(
91 ban,
92 &member_map,
93 parameters,
94 &mut invalid_bans,
95 &banned_user_ids,
96 );
97 }
98
99 invalid_bans
100 }
101
102 /// Validates a single ban and adds any validation errors to the invalid_bans map.
103 ///
104 /// Note (#410): this does NOT reject a ban merely because the banner is not
105 /// a current ancestor of the target. Authority to ENFORCE a ban (owner /
106 /// ancestor / deputy, including retroactive deputy revocation) is evaluated
107 /// at enforcement time in [`crate::room_state::member::MembersV1::banned_member_ids`]
108 /// (run from `post_apply_cleanup`), NOT here. A ban whose banner has no
109 /// current authority (for example a revoked deputy) is INERT — it removes
110 /// nobody — but must still pass `verify`: a legitimately converged state (a
111 /// previously-banned user who rejoined after their deputy was revoked)
112 /// would otherwise fail validation and break convergence. Keeping ban
113 /// authority out of `verify` is exactly what makes `verify` stable across
114 /// deputy-state changes. Ban SIGNATURES are still verified in `verify`, and
115 /// the orphaned-ban check (banner was themselves banned) is retained.
116 fn validate_single_ban(
117 &self,
118 ban: &AuthorizedUserBan,
119 member_map: &HashMap<MemberId, &AuthorizedMember>,
120 parameters: &ChatRoomParametersV1,
121 invalid_bans: &mut HashMap<BanId, BanValidationError>,
122 banned_user_ids: &HashSet<MemberId>,
123 ) {
124 // If the banned member is no longer present they were already removed
125 // (e.g. by this ban, a cascade, or an inactivity prune); nothing left
126 // to enforce, so the ban is valid.
127 if !member_map.contains_key(&ban.ban.banned_user) {
128 return;
129 }
130
131 // Owner bans are always valid.
132 if ban.banned_by == parameters.owner_id() {
133 return;
134 }
135
136 // If the banner is not a current member, distinguish an orphaned ban
137 // (the banner was themselves banned) from a still-valid ban by a member
138 // who was merely pruned for inactivity. If the banner IS a current
139 // member the ban is accepted regardless of the banner's current
140 // ancestor/deputy authority (see the doc comment above) — enforcement
141 // decides who is actually removed.
142 if !member_map.contains_key(&ban.banned_by) && banned_user_ids.contains(&ban.banned_by) {
143 // Banner was banned — this ban is orphaned.
144 invalid_bans.insert(ban.id(), BanValidationError::BannerNotFound(ban.banned_by));
145 }
146 }
147
148 /// Per-ban validity + signature checks, EXCLUDING the `max_user_bans`
149 /// ceiling. `apply_delta` uses this because it defers cap enforcement to
150 /// `ChatRoomStateV1::post_apply_cleanup` (where the converged member_info is
151 /// available to evict inert bans before enforcing ones); `verify` layers the
152 /// hard count ceiling on top. See #410 review round 1.
153 fn verify_excluding_cap(
154 &self,
155 parent_state: &ChatRoomStateV1,
156 parameters: &ChatRoomParametersV1,
157 ) -> Result<(), String> {
158 let invalid_bans = self.get_invalid_bans(parent_state, parameters);
159 if !invalid_bans.is_empty() {
160 let error_messages: Vec<String> = invalid_bans
161 .iter()
162 .map(|(id, error)| format!("{:?}: {}", id, error))
163 .collect();
164 return Err(format!("Invalid bans: {}", error_messages.join(", ")));
165 }
166
167 let members_by_id = parent_state.members.members_by_member_id();
168 let owner_vk = parameters.owner;
169 let owner_id = parameters.owner_id();
170
171 // Verify signatures for all bans.
172 for ban in &self.0 {
173 if ban.banned_by == owner_id {
174 ban.verify_signature(&owner_vk)
175 .map_err(|e| format!("Invalid ban signature: {}", e))?;
176 } else if let Some(banning_member) = members_by_id.get(&ban.banned_by) {
177 ban.verify_signature(&banning_member.member.member_vk)
178 .map_err(|e| format!("Invalid ban signature: {}", e))?;
179 } else {
180 // Banning member not in current members list. This can happen when:
181 // 1. During merge when bans are applied before the members delta
182 // 2. The banner was pruned for inactivity (no recent messages)
183 // In both cases, skip signature verification — we can't verify
184 // without the banner's key, and the signature was verified when
185 // the ban was first created. post_apply_cleanup will remove
186 // truly orphaned bans (where the banner was banned, not pruned).
187 }
188 }
189
190 Ok(())
191 }
192
193 /// Re-verify `ban`'s signature against the banner's CURRENT converged key
194 /// (#411 round 4 item A). Returns `true` iff the banner is the owner or a
195 /// current member AND the stored signature verifies against that banner's
196 /// current verifying key; `false` for a non-member banner (unverifiable) or a
197 /// signature that does not match — a forged/replayed ban.
198 ///
199 /// This closes the SAME-DELTA replay bypass. Field order applies `bans`
200 /// before `members`, so `verify` SKIPS the signature check for a banner that
201 /// is absent from the parent state at bans-apply time (see
202 /// `verify_excluding_cap`). A single delta that re-adds a pruned deputy via
203 /// their PUBLIC, replayable `AuthorizedMember` AND carries a garbage-signature
204 /// ban attributed to that deputy would otherwise have the ban ENFORCED by
205 /// `post_apply_cleanup` once the deputy is a current member (the retained
206 /// deputy grant authorizes it) — a ban forged without the deputy's private
207 /// key. ENFORCEMENT re-checks the signature against the converged key, so the
208 /// apply-time skip no longer matters. It can never false-reject a genuine
209 /// ban: a `MemberId` is the hash of its verifying key, so a current member's
210 /// key is exactly the one that produced their id, and a ban legitimately
211 /// signed by that member always verifies.
212 ///
213 /// `verify` itself is deliberately NOT tightened (that would strand the
214 /// Official room's migration PUT, which carries legitimately sig-skipped
215 /// non-member-banner bans); the check lives only in enforcement.
216 pub fn ban_signature_matches_current_key(
217 ban: &AuthorizedUserBan,
218 members_by_id: &HashMap<MemberId, &AuthorizedMember>,
219 owner_id: MemberId,
220 owner_vk: &VerifyingKey,
221 ) -> bool {
222 let banner = ban.banned_by;
223 let vk = if banner == owner_id {
224 *owner_vk
225 } else if let Some(member) = members_by_id.get(&banner) {
226 member.member.member_vk
227 } else {
228 // Non-member banner: unverifiable here (key unavailable). Treated as
229 // not-matching so callers classify it inert / sweep it.
230 return false;
231 };
232 ban.verify_signature(&vk).is_ok()
233 }
234
235 /// THE single definition of the `max_user_bans` eviction (#411 round 7).
236 ///
237 /// Two sites need to know which bans survive the cap: `post_apply_cleanup`
238 /// step 0-cap, which stores the result, and
239 /// `DirectMessagesV1::apply_delta`, which needs the resulting
240 /// enforced-ban set to decide which held DMs the caps may rank
241 /// (freenet/river#675). They MUST agree exactly — a DM swept at apply time
242 /// against a different surviving ban set than step 6 uses is data loss.
243 /// One function rather than two matching copies is what makes that hold by
244 /// construction; two copies of an almost-identical predicate is precisely
245 /// what drifted in freenet/river#671 and in #411 round 4.
246 ///
247 /// Evicts inert-before-enforcing, then oldest-before-newest, then by ban
248 /// id, and restores the canonical `(banned_at, id)` stored order. A no-op
249 /// when already within the cap. `sort_by_cached_key` computes
250 /// `ban_is_enforcing` at most once per ban (#411 round 3 C).
251 pub fn enforce_user_ban_cap(
252 bans: &mut Vec<AuthorizedUserBan>,
253 max_bans: usize,
254 members_by_id: &HashMap<MemberId, &AuthorizedMember>,
255 member_info: &MemberInfoV1,
256 owner_id: MemberId,
257 owner_vk: &VerifyingKey,
258 ) {
259 if bans.len() <= max_bans {
260 return;
261 }
262 bans.sort_by_cached_key(|ban| {
263 (
264 Self::ban_is_enforcing(ban, members_by_id, member_info, owner_id, owner_vk),
265 ban.ban.banned_at,
266 ban.id(),
267 )
268 });
269 let to_remove = bans.len() - max_bans;
270 bans.drain(0..to_remove);
271 bans.sort_by(|a, b| {
272 a.ban
273 .banned_at
274 .cmp(&b.ban.banned_at)
275 .then_with(|| a.id().cmp(&b.id()))
276 });
277 }
278
279 /// Whether `ban` is currently ENFORCING (worth keeping under `max_user_bans`
280 /// pressure) rather than INERT (evicted first). Pure function of the
281 /// converged `(members + member_info)` state (#410 review round 1).
282 ///
283 /// - The banner must be the owner or a current member AND the ban's signature
284 /// must verify against that banner's CURRENT key
285 /// ([`Self::ban_signature_matches_current_key`], #411 round 4 A). A
286 /// non-member banner, or a forged/replayed ban whose signature does not
287 /// match the converged key, is inert.
288 /// - If the target is a **current member**, the ban is enforcing iff its
289 /// banner is currently authorized to ban it
290 /// ([`MembersV1::is_ban_authorized`]). A forged ban on a present member,
291 /// or a revoked-deputy ban whose target rejoined, is inert.
292 /// - If the target is **absent** (already removed/pruned), keep the ban (its
293 /// banner is a signature-verified owner/member): a real enforcing ban.
294 pub fn ban_is_enforcing(
295 ban: &AuthorizedUserBan,
296 members_by_id: &HashMap<MemberId, &AuthorizedMember>,
297 member_info: &MemberInfoV1,
298 owner_id: MemberId,
299 owner_vk: &VerifyingKey,
300 ) -> bool {
301 let banner = ban.banned_by;
302 // A ban can only enforce while its banner is the owner or a current
303 // member (#411 round 3). A non-member banner ID — a stale/pruned deputy,
304 // or a forged one — must NOT make a ban enforcing, and such bans are
305 // swept from state entirely in `post_apply_cleanup`.
306 if banner != owner_id && !members_by_id.contains_key(&banner) {
307 return false;
308 }
309 // Re-verify the signature against the banner's CURRENT converged key
310 // (#411 round 4 A). Closes the same-delta pruned-deputy-replay bypass:
311 // `verify` skipped this at apply time because the banner was absent then.
312 if !Self::ban_signature_matches_current_key(ban, members_by_id, owner_id, owner_vk) {
313 return false;
314 }
315 let target = ban.ban.banned_user;
316 if members_by_id.contains_key(&target) {
317 MembersV1::is_ban_authorized(banner, target, members_by_id, member_info, owner_id)
318 } else {
319 // Target already removed and the banner is a signature-verified owner
320 // or current member (checked above): a real enforcing ban worth keeping.
321 true
322 }
323 }
324}
325
326impl ComposableState for BansV1 {
327 type ParentState = ChatRoomStateV1;
328 // BTreeSet (not HashSet) so the ciborium-serialized summary bytes are
329 // deterministic: freenet-core byte-compares `summarize_state` output for
330 // staleness, and a HashSet iterates in a per-process-random order, making
331 // two identical ban sets summarize to different bytes → spurious
332 // anti-entropy heals. See `.claude/rules/contract-summary-determinism.md`
333 // and freenet/freenet-core#4857.
334 type Summary = BTreeSet<BanId>;
335 type Delta = Vec<AuthorizedUserBan>;
336 type Parameters = ChatRoomParametersV1;
337
338 /// Verifies that all bans in the collection are valid:
339 /// - per-ban orphan constraints hold and all signatures are valid
340 /// (`verify_excluding_cap`), AND
341 /// - the number of bans does not exceed `max_user_bans` (the hard ceiling
342 /// on stored state; a legitimately-produced state is already ≤ the cap
343 /// because `post_apply_cleanup` evicts inert-first down to it).
344 fn verify(
345 &self,
346 parent_state: &Self::ParentState,
347 parameters: &Self::Parameters,
348 ) -> Result<(), String> {
349 self.verify_excluding_cap(parent_state, parameters)?;
350
351 if self.0.len() > parent_state.configuration.configuration.max_user_bans {
352 return Err(format!(
353 "Number of bans ({}) exceeds the maximum allowed ({})",
354 self.0.len(),
355 parent_state.configuration.configuration.max_user_bans
356 ));
357 }
358
359 Ok(())
360 }
361
362 /// Creates a summary of the current ban state
363 ///
364 /// Returns a set of all ban IDs currently in the collection
365 fn summarize(
366 &self,
367 _parent_state: &Self::ParentState,
368 _parameters: &Self::Parameters,
369 ) -> Self::Summary {
370 self.0.iter().map(|ban| ban.id()).collect()
371 }
372
373 /// Computes the difference between current ban state and old state
374 ///
375 /// Returns a vector of bans that exist in the current state but not in the old state,
376 /// or None if there are no differences
377 fn delta(
378 &self,
379 _parent_state: &Self::ParentState,
380 _parameters: &Self::Parameters,
381 old_state_summary: &Self::Summary,
382 ) -> Option<Self::Delta> {
383 // Identify bans in self.0 that are not in old_state_summary
384 let delta = self
385 .0
386 .iter()
387 .filter(|ban| !old_state_summary.contains(&ban.id()))
388 .cloned()
389 .collect::<Vec<_>>();
390 if delta.is_empty() {
391 None
392 } else {
393 Some(delta)
394 }
395 }
396
397 /// Applies changes from a delta to the current ban state.
398 ///
399 /// This method:
400 /// - Checks for duplicate bans
401 /// - Verifies all new bans are valid (per-ban + signatures), EXCLUDING the
402 /// `max_user_bans` ceiling
403 /// - Adds the new bans to the collection
404 ///
405 /// It deliberately does NOT enforce `max_user_bans` here. The cap is
406 /// enforced in `ChatRoomStateV1::post_apply_cleanup`, which evicts INERT
407 /// (currently-unauthorized) bans before enforcing ones — using the converged
408 /// `member_info`, which is not yet available at this point in the field-apply
409 /// order. Capping here (by oldest, as before) would let a flood of
410 /// forged/inert bans evict the real moderator bans and un-ban spammers
411 /// (#410 review round 1). The transient over-cap set is capped by
412 /// post_apply_cleanup at the end of the same whole-state apply; `verify`
413 /// still rejects any stored state that is over the cap.
414 ///
415 /// Returns an error if any ban in the delta is invalid or already exists.
416 fn apply_delta(
417 &mut self,
418 parent_state: &Self::ParentState,
419 parameters: &Self::Parameters,
420 delta: &Option<Self::Delta>,
421 ) -> Result<(), String> {
422 if let Some(delta) = delta {
423 // Bound the number of NEW bans a single delta may carry (#411 round
424 // 3 item C). Since post_apply_cleanup caps stored state at
425 // `max_user_bans`, ANY legitimate peer's ban list — and therefore
426 // any legitimate delta (a summary-diff against another peer) — holds
427 // at most `max_user_bans` bans. A larger delta can only be a forged
428 // flood; rejecting it bounds the O(N) signature-verification work
429 // below (round 2 removed the pre-verify cap that used to do this).
430 // Deterministic across peers, so it does not affect convergence: a
431 // legitimate delta is never over the bound, and a flood is rejected
432 // identically everywhere.
433 //
434 // Transient skew during a `max_user_bans` REDUCTION: config applies
435 // before bans in field order, so a peer that has already applied the
436 // lower (monotonic) cap may briefly reject a ban-only delta from a
437 // peer still lagging on the old higher cap. Self-heals once the
438 // config converges (the lagging peer re-derives a within-bound delta).
439 let max_bans = parent_state.configuration.configuration.max_user_bans;
440 if delta.len() > max_bans {
441 return Err(format!(
442 "Ban delta of {} exceeds max_user_bans ({}); refusing to process a flood",
443 delta.len(),
444 max_bans
445 ));
446 }
447
448 // Check for duplicate bans
449 let existing_ban_ids: std::collections::HashSet<_> =
450 self.0.iter().map(|ban| ban.id()).collect();
451 for new_ban in delta {
452 if existing_ban_ids.contains(&new_ban.id()) {
453 return Err(format!("Duplicate ban detected: {:?}", new_ban.id()));
454 }
455 }
456
457 // Create a temporary BansV1 with the new bans and validate WITHOUT
458 // the max-cap ceiling (deferred to post_apply_cleanup).
459 let mut temp_bans = self.clone();
460 temp_bans.0.extend(delta.iter().cloned());
461 if let Err(e) = temp_bans.verify_excluding_cap(parent_state, parameters) {
462 return Err(format!("Invalid delta: {}", e));
463 }
464 self.0 = temp_bans.0;
465 }
466
467 // Sort for deterministic ordering (CRDT convergence requirement)
468 self.0.sort_by(|a, b| {
469 a.ban
470 .banned_at
471 .cmp(&b.ban.banned_at)
472 .then_with(|| a.id().cmp(&b.id()))
473 });
474
475 Ok(())
476 }
477}
478
479/// A user ban with authorization proof
480///
481/// Contains the ban details, the ID of the member who created the ban,
482/// and a cryptographic signature proving the ban's authenticity
483#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
484pub struct AuthorizedUserBan {
485 pub ban: UserBan,
486 pub banned_by: MemberId,
487 pub signature: Signature,
488}
489
490impl Eq for AuthorizedUserBan {}
491
492impl Hash for AuthorizedUserBan {
493 fn hash<H: Hasher>(&self, state: &mut H) {
494 self.signature.to_bytes().hash(state);
495 }
496}
497
498impl AuthorizedUserBan {
499 /// Creates a new authorized ban
500 ///
501 /// Signs the ban with the provided signing key and verifies that the
502 /// banned_by ID matches the public key derived from the signing key
503 pub fn new(ban: UserBan, banned_by: MemberId, banner_signing_key: &SigningKey) -> Self {
504 assert_eq!(
505 MemberId::from(banner_signing_key.verifying_key()),
506 banned_by
507 );
508
509 let signature = sign_struct(&ban, banner_signing_key);
510
511 Self {
512 ban,
513 banned_by,
514 signature,
515 }
516 }
517
518 /// Create an AuthorizedUserBan with a pre-computed signature.
519 /// Use this when signing is done externally (e.g., via delegate).
520 pub fn with_signature(ban: UserBan, banned_by: MemberId, signature: Signature) -> Self {
521 Self {
522 ban,
523 banned_by,
524 signature,
525 }
526 }
527
528 /// Verifies that the ban's signature is valid
529 ///
530 /// Checks that the signature was created by the key corresponding to the provided verifying key
531 pub fn verify_signature(&self, banner_verifying_key: &VerifyingKey) -> Result<(), String> {
532 verify_struct(&self.ban, &self.signature, banner_verifying_key)
533 .map_err(|e| format!("Invalid ban signature: {}", e))
534 }
535
536 /// Generates a unique identifier for this ban based on its signature
537 pub fn id(&self) -> BanId {
538 BanId(fast_hash(&self.signature.to_bytes()))
539 }
540}
541
542/// Contains the core information about a user ban
543///
544/// Includes the room owner's ID, the time of the ban, and the ID of the banned user
545#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
546pub struct UserBan {
547 pub owner_member_id: MemberId,
548 pub banned_at: SystemTime,
549 pub banned_user: MemberId,
550}
551
552/// A unique identifier for a ban
553///
554/// Created from a hash of the ban's signature to ensure uniqueness
555#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Hash, Debug, Ord, PartialOrd)]
556pub struct BanId(pub FastHash);
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use crate::room_state::configuration::AuthorizedConfigurationV1;
562 use crate::room_state::member::{AuthorizedMember, Member, MembersV1};
563 use ed25519_dalek::SigningKey;
564 use std::time::Duration;
565
566 fn create_test_chat_room_state() -> ChatRoomStateV1 {
567 // Create a minimal ChatRoomStateV1 for testing
568 ChatRoomStateV1 {
569 configuration: AuthorizedConfigurationV1::default(),
570 bans: Default::default(),
571 members: MembersV1::default(),
572 member_info: Default::default(),
573 secrets: Default::default(),
574 recent_messages: Default::default(),
575 upgrade: Default::default(),
576 ..Default::default()
577 }
578 }
579
580 fn create_test_parameters() -> ChatRoomParametersV1 {
581 // Create minimal ChatRoomParametersV1 for testing
582 let owner_key = SigningKey::generate(&mut rand::thread_rng());
583 ChatRoomParametersV1 {
584 owner: owner_key.verifying_key(),
585 }
586 }
587
588 #[test]
589 fn test_bans_verify() {
590 let mut state = create_test_chat_room_state();
591 let params = create_test_parameters();
592
593 // Create some test members
594 let owner_key = SigningKey::generate(&mut rand::thread_rng());
595 let owner_id: MemberId = owner_key.verifying_key().into();
596 let member1_key = SigningKey::generate(&mut rand::thread_rng());
597 let member1_id: MemberId = member1_key.verifying_key().into();
598 let member2_key = SigningKey::generate(&mut rand::thread_rng());
599 let member2_id: MemberId = member2_key.verifying_key().into();
600
601 // Add members to the room_state
602 state.members.members.push(AuthorizedMember::new(
603 Member {
604 owner_member_id: owner_id,
605 invited_by: owner_id,
606 member_vk: owner_key.verifying_key(),
607 },
608 &owner_key,
609 ));
610 state.members.members.push(AuthorizedMember::new(
611 Member {
612 owner_member_id: owner_id,
613 invited_by: owner_id,
614 member_vk: member1_key.verifying_key(),
615 },
616 &owner_key,
617 ));
618 state.members.members.push(AuthorizedMember::new(
619 Member {
620 owner_member_id: owner_id,
621 invited_by: member1_id,
622 member_vk: member2_key.verifying_key(),
623 },
624 &member1_key,
625 ));
626
627 // Update the configuration to allow bans
628 state.configuration.configuration.max_user_bans = 5;
629
630 // Test 1: Valid ban by owner
631 let ban1 = AuthorizedUserBan::new(
632 UserBan {
633 owner_member_id: owner_id,
634 banned_at: SystemTime::now(),
635 banned_user: member1_id,
636 },
637 owner_id,
638 &owner_key,
639 );
640
641 let bans = BansV1(vec![ban1]);
642 assert!(
643 bans.verify(&state, ¶ms).is_ok(),
644 "Valid ban should be verified successfully: {:?}",
645 bans.verify(&state, ¶ms).err()
646 );
647
648 // Test 2: Exceeding max_user_bans
649 let mut many_bans = Vec::new();
650 for _ in 0..6 {
651 many_bans.push(AuthorizedUserBan::new(
652 UserBan {
653 owner_member_id: owner_id,
654 banned_at: SystemTime::now(),
655 banned_user: member1_id,
656 },
657 owner_id,
658 &owner_key,
659 ));
660 }
661 let too_many_bans = BansV1(many_bans);
662 assert!(
663 too_many_bans.verify(&state, ¶ms).is_err(),
664 "Exceeding max_user_bans should fail verification"
665 );
666
667 // Test 3: Ban by pruned member (not in member list, not banned) — valid
668 // With message-based lifecycle, banners not in the members list who
669 // are not themselves banned are considered pruned for inactivity.
670 let pruned_key = SigningKey::generate(&mut rand::thread_rng());
671 let pruned_id: MemberId = pruned_key.verifying_key().into();
672 let pruned_ban = AuthorizedUserBan::new(
673 UserBan {
674 owner_member_id: owner_id,
675 banned_at: SystemTime::now(),
676 banned_user: member2_id,
677 },
678 pruned_id,
679 &pruned_key,
680 );
681
682 let pruned_bans = BansV1(vec![pruned_ban]);
683 assert!(
684 pruned_bans.verify(&state, ¶ms).is_ok(),
685 "Ban by pruned (non-banned) member should pass verification: {:?}",
686 pruned_bans.verify(&state, ¶ms).err()
687 );
688
689 // Test 3b: Orphaned ban (banner was banned) — invalid
690 let orphaned_key = SigningKey::generate(&mut rand::thread_rng());
691 let orphaned_id: MemberId = orphaned_key.verifying_key().into();
692 let orphaned_ban = AuthorizedUserBan::new(
693 UserBan {
694 owner_member_id: owner_id,
695 banned_at: SystemTime::now(),
696 banned_user: member2_id,
697 },
698 orphaned_id,
699 &orphaned_key,
700 );
701 // A ban targeting the orphaned member makes them "banned" (not just pruned)
702 let ban_of_orphaned = AuthorizedUserBan::new(
703 UserBan {
704 owner_member_id: owner_id,
705 banned_at: SystemTime::now(),
706 banned_user: orphaned_id,
707 },
708 owner_id,
709 &owner_key,
710 );
711 let orphaned_bans = BansV1(vec![orphaned_ban, ban_of_orphaned]);
712 assert!(
713 orphaned_bans.verify(&state, ¶ms).is_err(),
714 "Orphaned ban (banner was banned) should fail verification"
715 );
716
717 // Test 4: Valid ban by non-owner member
718 let ban_by_member = AuthorizedUserBan::new(
719 UserBan {
720 owner_member_id: owner_id,
721 banned_at: SystemTime::now(),
722 banned_user: member2_id,
723 },
724 member1_id,
725 &member1_key,
726 );
727
728 let member_bans = BansV1(vec![ban_by_member]);
729 assert!(
730 member_bans.verify(&state, ¶ms).is_ok(),
731 "Valid ban by non-owner member should pass verification"
732 );
733 }
734
735 #[test]
736 fn test_bans_summarize() {
737 let state = create_test_chat_room_state();
738 let params = create_test_parameters();
739
740 let key = SigningKey::generate(&mut rand::thread_rng());
741 let id: MemberId = key.verifying_key().into();
742
743 let ban1 = AuthorizedUserBan::new(
744 UserBan {
745 owner_member_id: id,
746 banned_at: SystemTime::now(),
747 banned_user: id,
748 },
749 id,
750 &key,
751 );
752
753 let ban2 = AuthorizedUserBan::new(
754 UserBan {
755 owner_member_id: id,
756 banned_at: SystemTime::now() + Duration::from_secs(1),
757 banned_user: id,
758 },
759 id,
760 &key,
761 );
762
763 let bans = BansV1(vec![ban1.clone(), ban2.clone()]);
764 let summary = bans.summarize(&state, ¶ms);
765
766 assert_eq!(summary.len(), 2);
767 assert!(summary.contains(&ban1.id()));
768 assert!(summary.contains(&ban2.id()));
769 }
770
771 #[test]
772 fn test_bans_delta() {
773 let state = create_test_chat_room_state();
774 let params = create_test_parameters();
775
776 let key = SigningKey::generate(&mut rand::thread_rng());
777 let id: MemberId = key.verifying_key().into();
778
779 let ban1 = AuthorizedUserBan::new(
780 UserBan {
781 owner_member_id: id,
782 banned_at: SystemTime::now(),
783 banned_user: id,
784 },
785 id,
786 &key,
787 );
788
789 let ban2 = AuthorizedUserBan::new(
790 UserBan {
791 owner_member_id: id,
792 banned_at: SystemTime::now() + Duration::from_secs(1),
793 banned_user: id,
794 },
795 id,
796 &key,
797 );
798
799 let bans = BansV1(vec![ban1.clone(), ban2.clone()]);
800
801 // Test 1: Empty old summary
802 let empty_summary = BTreeSet::new();
803 let delta = bans.delta(&state, ¶ms, &empty_summary);
804 assert_eq!(delta, Some(vec![ban1.clone(), ban2.clone()]));
805
806 // Test 2: Partial old summary
807 let partial_summary: BTreeSet<BanId> = vec![ban1.id()].into_iter().collect();
808 let delta = bans.delta(&state, ¶ms, &partial_summary);
809 assert_eq!(delta, Some(vec![ban2.clone()]));
810
811 // Test 3: Full old summary
812 let full_summary: BTreeSet<BanId> = vec![ban1.id(), ban2.id()].into_iter().collect();
813 let delta = bans.delta(&state, ¶ms, &full_summary);
814 assert_eq!(delta, None);
815 }
816
817 #[test]
818 fn test_bans_apply_delta() {
819 let mut state = create_test_chat_room_state();
820 let params = create_test_parameters();
821
822 let owner_key = SigningKey::generate(&mut rand::thread_rng());
823 let owner_id: MemberId = owner_key.verifying_key().into();
824 let member_key = SigningKey::generate(&mut rand::thread_rng());
825 let member_id: MemberId = member_key.verifying_key().into();
826
827 // Add members to the room_state
828 state.members.members.push(AuthorizedMember::new(
829 Member {
830 owner_member_id: owner_id,
831 invited_by: owner_id,
832 member_vk: owner_key.verifying_key(),
833 },
834 &owner_key,
835 ));
836 state.members.members.push(AuthorizedMember::new(
837 Member {
838 owner_member_id: owner_id,
839 invited_by: owner_id,
840 member_vk: member_key.verifying_key(),
841 },
842 &owner_key,
843 ));
844
845 // Update the configuration to allow bans
846 state.configuration.configuration.max_user_bans = 5;
847
848 let mut bans = BansV1::default();
849
850 let new_ban = AuthorizedUserBan::new(
851 UserBan {
852 owner_member_id: owner_id,
853 banned_at: SystemTime::now(),
854 banned_user: member_id,
855 },
856 owner_id,
857 &owner_key,
858 );
859
860 // Test 1: Apply valid delta
861 let delta = vec![new_ban.clone()];
862 assert!(
863 bans.apply_delta(&state, ¶ms, &Some(delta.clone()))
864 .is_ok(),
865 "Valid delta should be applied successfully: {:?}",
866 bans.apply_delta(&state, ¶ms, &Some(delta)).err()
867 );
868 assert_eq!(
869 bans.0.len(),
870 1,
871 "Bans should contain one ban after applying delta"
872 );
873 assert_eq!(bans.0[0], new_ban, "Applied ban should match the new ban");
874
875 // Test 2: A delta that pushes past max_user_bans NO LONGER caps here.
876 // The cap moved to `ChatRoomStateV1::post_apply_cleanup` so it can evict
877 // INERT-before-enforcing bans using the converged member_info (#410
878 // review round 1). `apply_delta` accumulates all valid bans; the
879 // whole-state cleanup enforces the ceiling. (State-level capping +
880 // inert-first eviction are covered by the DoS test in
881 // `common/tests/deputy_ban_test.rs`.)
882 let mut many_bans = Vec::new();
883 for i in 0..5 {
884 many_bans.push(AuthorizedUserBan::new(
885 UserBan {
886 owner_member_id: owner_id,
887 banned_at: SystemTime::now() + Duration::from_secs(i as u64 + 10),
888 banned_user: member_id,
889 },
890 owner_id,
891 &owner_key,
892 ));
893 }
894 let delta_exceeding_max = Some(many_bans.clone());
895 assert!(
896 bans.apply_delta(&state, ¶ms, &delta_exceeding_max)
897 .is_ok(),
898 "Applying more bans should succeed: {:?}",
899 bans.apply_delta(&state, ¶ms, &delta_exceeding_max)
900 .err()
901 );
902 assert_eq!(
903 bans.0.len(),
904 6,
905 "apply_delta accumulates without capping (cap is enforced in post_apply_cleanup)"
906 );
907 assert!(
908 bans.0.contains(&new_ban),
909 "apply_delta must NOT drop the oldest ban — evicting is post_apply_cleanup's job"
910 );
911
912 // Test 3: Apply invalid delta (duplicate ban) - use one of the bans still in the list
913 let existing_ban = many_bans.last().unwrap().clone();
914 let invalid_delta = Some(vec![existing_ban]);
915 assert!(
916 bans.apply_delta(&state, ¶ms, &invalid_delta).is_err(),
917 "Applying duplicate ban should fail: {:?}",
918 bans.apply_delta(&state, ¶ms, &invalid_delta).ok()
919 );
920 assert_eq!(
921 bans.0.len(),
922 6,
923 "State should not change after applying duplicate ban"
924 );
925
926 // Test 4: More valid bans keep accumulating (still no cap at this level).
927 let mut additional_bans = Vec::new();
928 for i in 0..2 {
929 additional_bans.push(AuthorizedUserBan::new(
930 UserBan {
931 owner_member_id: owner_id,
932 banned_at: SystemTime::now() + Duration::from_secs(i as u64 + 100),
933 banned_user: member_id,
934 },
935 owner_id,
936 &owner_key,
937 ));
938 }
939 assert!(
940 bans.apply_delta(&state, ¶ms, &Some(additional_bans))
941 .is_ok(),
942 "Applying more bans should succeed: {:?}",
943 bans.apply_delta(&state, ¶ms, &Some(Vec::new())).err()
944 );
945 assert_eq!(
946 bans.0.len(),
947 8,
948 "apply_delta keeps accumulating; the cap is enforced by post_apply_cleanup"
949 );
950 }
951
952 #[test]
953 fn test_authorized_user_ban() {
954 let owner_key = SigningKey::generate(&mut rand::thread_rng());
955 let owner_id: MemberId = owner_key.verifying_key().into();
956 let member_key = SigningKey::generate(&mut rand::thread_rng());
957 let member_id: MemberId = member_key.verifying_key().into();
958
959 let ban = UserBan {
960 owner_member_id: owner_id,
961 banned_at: SystemTime::now(),
962 banned_user: member_id,
963 };
964
965 let authorized_ban = AuthorizedUserBan::new(ban.clone(), owner_id, &owner_key);
966
967 // Test 1: Verify signature
968 assert!(authorized_ban
969 .verify_signature(&owner_key.verifying_key())
970 .is_ok());
971
972 // Test 2: Verify signature with wrong key
973 let wrong_key = SigningKey::generate(&mut rand::thread_rng());
974 assert!(authorized_ban
975 .verify_signature(&wrong_key.verifying_key())
976 .is_err());
977
978 // Test 3: Check ban ID
979 let id1 = authorized_ban.id();
980 let id2 = authorized_ban.id();
981 assert_eq!(id1, id2);
982
983 // Test 4: Different bans should have different IDs
984 let another_ban = AuthorizedUserBan::new(
985 UserBan {
986 owner_member_id: owner_id,
987 banned_at: SystemTime::now() + Duration::from_secs(1),
988 banned_user: member_id,
989 },
990 owner_id,
991 &owner_key,
992 );
993 assert_ne!(authorized_ban.id(), another_ban.id());
994 }
995}