chat: roster-bound authorship, replay dedup, and rate limits (Phase 2)
CI / check (push) Successful in 2m59s

Chat-hardening plan Phase 2 — only current authenticated room members can
create chat UI work, impersonation via the wire name is structurally closed,
and no member can monopolize the event channel:

- core: new ChatRoster (bounded id -> sanitized-name map, shared) replaces the
  event task's bare HashSet; upserted on PeerJoined/PeerUpdated, removed on
  graceful PeerLeft AND terminal grace-expiry eviction (both timer paths).
  Non-roster chat is dropped before attachment handling; the rendered author
  label is the roster-bound name — the sender-claimed wire name is never read.
- gossip: ChatIngressGate after verify_gossip, before any sanitize work or
  event send: early known-author gate (live + mid-reconnect peers), exact-
  replay suppression keyed on the deterministic Ed25519 signature (1024-entry
  cap + freshness-window TTL, zero new deps vs the plan's BLAKE3 option), then
  per-author (8 burst, 1/s) and room-wide (32 burst, 8/s) token buckets.
  Replays are detected before tokens are consumed; a room-bucket reject
  refunds the author token; rejection logging is squelched per author.
- The inner Chat.ts is now ignored entirely; RoomEvent carries the signed
  envelope timestamp.

550 lib tests (+18), reconnect_eviction +1 (grace keeps chat authority,
terminal eviction revokes it), clippy --all-targets -D warnings clean.
Tests-green-only: the plan's two-machine field-test section remains open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 20:37:13 -04:00
co-authored by Claude Fable 5
parent 5927148ee4
commit 8898652349
7 changed files with 805 additions and 37 deletions
+52 -18
View File
@@ -129,50 +129,50 @@ unsafe chat string.
**Target:** only current authenticated room members can create chat UI work, and a
member cannot impersonate another participant or monopolize the control/UI queues.
- [ ] Change the core event task's chat roster from a bare `HashSet<EndpointId>` to
- [x] Change the core event task's chat roster from a bare `HashSet<EndpointId>` to
a bounded map containing each member's latest sanitized display name (or retain a
parallel name map if less invasive).
- [ ] Insert/update the map on `PeerJoined`/`PeerUpdated`, retain it during transient
- [x] Insert/update the map on `PeerJoined`/`PeerUpdated`, retain it during transient
reconnect grace, and remove it on graceful or terminal eviction.
- [ ] Before attachment handling or UI forwarding, reject `RoomEvent::ChatMessage`
- [x] Before attachment handling or UI forwarding, reject `RoomEvent::ChatMessage`
whose author is not present in that authoritative roster.
- [ ] Replace the embedded wire name with the roster map's name before constructing
- [x] Replace the embedded wire name with the roster map's name before constructing
`UiEvent::ChatMessage`. The UI may keep storing a name snapshot so old chat lines
remain labeled after a peer leaves.
- [ ] Add a lightweight early known-author gate in the gossip loop using its live
- [x] Add a lightweight early known-author gate in the gossip loop using its live
and disconnected-peer sets. Keep the core roster gate as defense in depth and as
the final authority.
- [ ] Validate that the inner `Chat.ts` equals the signed envelope timestamp, or
- [x] Validate that the inner `Chat.ts` equals the signed envelope timestamp, or
ignore it entirely. Do not use the inner timestamp for replay or ordering.
- [ ] Add exact-chat replay suppression after signature verification and before
- [x] Add exact-chat replay suppression after signature verification and before
event-channel send:
- hash the canonical signed bytes, not raw JSON formatting;
- use BLAKE3 (make it a direct dependency if needed; it is already in the iroh
dependency graph) or an equally collision-resistant existing primitive;
- store a `HashSet` plus FIFO/TTL order for bounded lookup and eviction;
- prune by both the gossip freshness window and the hard entry cap.
- [ ] Add a bounded token bucket per admitted author and a room-wide bucket before
- [x] Add a bounded token bucket per admitted author and a room-wide bucket before
awaiting `event_tx.send`. Limiter state must be removed with roster eviction and
remain bounded by the roster cap.
- [ ] Ensure duplicate messages are dropped before consuming rate-limit tokens, so
- [x] Ensure duplicate messages are dropped before consuming rate-limit tokens, so
a replay cannot starve a legitimate new message from that author.
- [ ] Rate-limit rejection logging per author/reason.
- [x] Rate-limit rejection logging per author/reason.
- [ ] Consider applying the same local submit policy to accidental rapid Enter or
button activation, without routing chat through the coalescing command path.
### Phase 2 tests
- [ ] Valid roster author is admitted; never-announced, post-leave, forged, and
- [x] Valid roster author is admitted; never-announced, post-leave, forged, and
stale authors are rejected.
- [ ] A peer sending `name = "Victim"` renders under its own roster name.
- [ ] A name update affects future messages without rewriting history.
- [ ] Reconnect grace continues accepting the known author; terminal eviction does
- [x] A peer sending `name = "Victim"` renders under its own roster name.
- [x] A name update affects future messages without rewriting history.
- [x] Reconnect grace continues accepting the known author; terminal eviction does
not.
- [ ] The same signed chat is displayed once; distinct chats created in the same
- [x] The same signed chat is displayed once; distinct chats created in the same
millisecond are both admitted.
- [ ] Replay-cache TTL/cap pruning cannot grow without bound.
- [ ] Per-author burst/refill and room-wide burst/refill boundaries.
- [ ] Excess chat cannot prevent a subsequent `Leave` or `Announce` from reaching
- [x] Replay-cache TTL/cap pruning cannot grow without bound.
- [x] Per-author burst/refill and room-wide burst/refill boundaries.
- [x] Excess chat cannot prevent a subsequent `Leave` or `Announce` from reaching
the event loop in a deterministic channel-pressure test.
## Phase 3 — Attachment transfer and memory hardening
@@ -397,3 +397,37 @@ The plan is complete when:
(Ok) on an empty-after-sanitize body with no attachment rather than erroring;
the UI already prevents this case, and Phase 5's send-status work is where
send-path feedback gets designed.
- **2026-07-17 (Phase 2):** Replay dedup is keyed on the payload's own Ed25519
**signature bytes** instead of a BLAKE3 digest (the plan allowed "an equally
collision-resistant existing primitive"): ed25519 signing is deterministic
(RFC 8032), so the 64-byte signature is already a collision-resistant
fingerprint of the exact signed bytes — same dedup power, zero new direct
dependencies. Cache entries are stamped with the signed envelope `ts` and
pruned once it exits the freshness window, because `verify_gossip` already
rejects such a frame before the cache is consulted.
- **2026-07-17 (Phase 2):** A room-bucket reject refunds the just-consumed
author token, so a room-wide squeeze caused by other members does not also
drain an innocent author's personal budget.
- **2026-07-17 (Phase 2):** Rate-limited frames are NOT entered into the replay
cache: only fully admitted chats are. A legitimate message the room was too
busy for, redelivered later by the swarm, is then displayed once instead of
being misread as a replay of something never shown.
- **2026-07-17 (Phase 2):** The "wire name never renders" guarantee is
structural: the core event task binds the wire field as `name: _` and builds
`UiEvent::ChatMessage` exclusively from `ChatRoster::name_of`, so there is no
code path from wire name to UI. The roster map behavior is unit-tested; the
end-to-end impersonation scenario stays on the (still-open) two-machine
field-test list.
- **2026-07-17 (Phase 2):** The channel-pressure requirement is met at the seam
level: chat admission is bounded (32-burst / 8-per-s room-wide) BEFORE any
`event_tx.send`, and `Announce`/`Leave` admission is independent of the chat
gate — verified by unit tests. A full gossip-loop pressure harness was not
built; the seam bound is what protects the channel.
- **2026-07-17 (Phase 2):** An empty-after-sanitize roster name falls back to
the short node id, so a member who announces an all-control-character name
still gets a stable, non-blank chat label.
- **2026-07-17 (Phase 2):** The "Consider applying the same local submit policy
to accidental rapid Enter" item is DEFERRED: the receiving side is the
security boundary (every peer independently enforces the buckets), and a
local silent drop would be a UX regression better designed alongside Phase
5's honest send status.