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
+518 -3
View File
@@ -36,7 +36,8 @@ const MAX_GOSSIP_FRAME_BYTES: usize = 128 * 1024;
/// key); `sig` is that key's signature over [`signable_bytes`], so a forged
/// `author` can't validate (the attacker lacks the victim's secret key). `ts`
/// (sender-stamped unix-millis) is covered by the signature and gates replay
/// freshness — distinct from `GossipMessage::Chat.ts`, which is only for display.
/// freshness — distinct from `GossipMessage::Chat.ts`, an unauthenticated
/// duplicate kept only for wire compatibility and ignored on receive.
#[derive(Serialize, Deserialize, Clone)]
pub struct GossipPayload {
pub author: EndpointId,
@@ -254,6 +255,239 @@ impl ClockSkewMonitor {
}
}
/// Chat-hardening Phase 2 policy (docs/chat-hardening-plan.md): exact-replay
/// suppression bounds and the token-bucket rates that stop one admitted member
/// from monopolizing the event channel / UI with chat.
///
/// The replay cache is keyed on the payload's Ed25519 SIGNATURE bytes rather
/// than a separate BLAKE3 digest: ed25519 signing is deterministic (RFC 8032),
/// so the 64-byte signature is itself a collision-resistant fingerprint of the
/// exact signed bytes (topic + author + ts + msg) — same dedup power, zero new
/// dependencies. Entries are stamped with the payload's SIGNED timestamp and
/// pruned once that falls out of the freshness window, because `verify_gossip`
/// already rejects any replay whose signed `ts` is out-of-window — an expired
/// cache entry can no longer correspond to an admissible frame.
const CHAT_REPLAY_CACHE_CAP: usize = 1024;
/// Per-author chat budget: a burst of 8 absorbs a fast typist; 1 msg/s sustained
/// is well above real human chat rate while bounding a flooder to a trickle.
const CHAT_AUTHOR_BURST: f64 = 8.0;
const CHAT_AUTHOR_REFILL_PER_MS: f64 = 1.0 / 1000.0;
/// Room-wide chat budget across ALL authors, so a set of sock-puppet identities
/// can't multiply the per-author budget into unbounded event-channel pressure.
const CHAT_ROOM_BURST: f64 = 32.0;
const CHAT_ROOM_REFILL_PER_MS: f64 = 8.0 / 1000.0;
/// Bound on the per-author bucket map. Authors only enter it after the
/// known-author gate, so it tracks roughly the live roster plus recently
/// disconnected members; idle entries are pruned past this cap.
const CHAT_AUTHOR_BUCKETS_CAP: usize = 64;
/// Cooldown between logged chat rejections for one author (and one shared slot
/// for unknown authors), so a flood of rejected frames can't turn the log into
/// the new unbounded cost.
const CHAT_REJECT_LOG_COOLDOWN_MS: u64 = 10_000;
/// A minimal deterministic token bucket: time is passed in, never read from a
/// clock, so every boundary is unit-testable.
#[derive(Debug, Clone, Copy)]
struct TokenBucket {
tokens: f64,
last_ms: u64,
}
impl TokenBucket {
fn full(burst: f64, now_ms: u64) -> Self {
Self {
tokens: burst,
last_ms: now_ms,
}
}
/// Refill for elapsed time (capped at `burst`), then take one token if
/// available. Returns whether a token was consumed.
fn try_take(&mut self, burst: f64, refill_per_ms: f64, now_ms: u64) -> bool {
let elapsed = now_ms.saturating_sub(self.last_ms) as f64;
self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst);
self.last_ms = now_ms;
if self.tokens >= 1.0 {
self.tokens -= 1.0;
true
} else {
false
}
}
}
/// Why an authenticated chat payload was still refused admission (Phase 2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChatReject {
/// Author is neither a live gossip peer nor one mid-reconnect. The core
/// roster gate re-checks this as the final authority; this early copy just
/// refuses the work before any sanitize/attachment handling.
UnknownAuthor,
/// Exact byte-for-byte replay of an already-admitted signed chat.
Replay,
/// Per-author or room-wide token bucket empty.
RateLimited,
}
/// Per-author rate-limit + log-squelch state (see [`ChatIngressGate`]).
#[derive(Debug)]
struct AuthorGateState {
bucket: TokenBucket,
last_seen_ms: u64,
last_reject_log_ms: Option<u64>,
}
/// Chat admission gate run after `verify_gossip`, before any sanitize work or
/// event-channel send (chat-hardening plan Phase 2): known author → exact-replay
/// dedup → per-author + room token buckets, in that order. Dedup runs BEFORE the
/// buckets so a replayed frame can never consume tokens and starve the author's
/// own legitimate next message. Pure — callers pass `now_ms` — so every branch
/// is unit-testable.
#[derive(Debug)]
struct ChatIngressGate {
seen: HashSet<[u8; 64]>,
/// FIFO of (signature, signed ts) mirroring `seen`, for TTL + cap pruning.
seen_order: std::collections::VecDeque<([u8; 64], u64)>,
room: TokenBucket,
authors: HashMap<EndpointId, AuthorGateState>,
/// Shared squelch slot for unknown-author rejects (they have no map entry).
last_unknown_log_ms: Option<u64>,
}
impl ChatIngressGate {
fn new(now_ms: u64) -> Self {
Self {
seen: HashSet::new(),
seen_order: std::collections::VecDeque::new(),
room: TokenBucket::full(CHAT_ROOM_BURST, now_ms),
authors: HashMap::new(),
last_unknown_log_ms: None,
}
}
/// Admit or reject one verified chat payload. `known_author` is the caller's
/// live-or-reconnecting membership check; `payload_ts` is the SIGNED envelope
/// timestamp (already freshness-checked by `verify_gossip`).
fn admit(
&mut self,
known_author: bool,
author: EndpointId,
sig: &[u8; 64],
payload_ts: u64,
now_ms: u64,
) -> Result<(), ChatReject> {
if !known_author {
return Err(ChatReject::UnknownAuthor);
}
self.prune_replay_cache(now_ms);
if self.seen.contains(sig) {
return Err(ChatReject::Replay);
}
// Room bucket first: it is the cheaper aggregate bound, and consuming
// from it only when the author bucket also admits keeps the two in
// lockstep — so check both, then commit both.
let author_state = self.author_entry(author, now_ms);
let author_ok =
author_state
.bucket
.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, now_ms);
if !author_ok {
return Err(ChatReject::RateLimited);
}
if !self
.room
.try_take(CHAT_ROOM_BURST, CHAT_ROOM_REFILL_PER_MS, now_ms)
{
// Refund the author token so a room-wide squeeze doesn't also debit
// every individual author's future budget.
if let Some(state) = self.authors.get_mut(&author) {
state.bucket.tokens = (state.bucket.tokens + 1.0).min(CHAT_AUTHOR_BURST);
}
return Err(ChatReject::RateLimited);
}
// Fully admitted — only now does the frame enter the replay cache, so a
// rate-limited legitimate message redelivered later is not misread as a
// replay of something that was never displayed.
self.seen.insert(*sig);
self.seen_order.push_back((*sig, payload_ts));
Ok(())
}
/// Whether this rejection should be logged: at most one log line per author
/// (or one shared line for unknown authors) per cooldown window.
fn should_log_reject(&mut self, known_author: bool, author: EndpointId, now_ms: u64) -> bool {
let slot = if known_author {
self.authors
.get_mut(&author)
.map(|state| &mut state.last_reject_log_ms)
} else {
Some(&mut self.last_unknown_log_ms)
};
let Some(slot) = slot else {
return true;
};
let due =
slot.is_none_or(|last| now_ms.saturating_sub(last) >= CHAT_REJECT_LOG_COOLDOWN_MS);
if due {
*slot = Some(now_ms);
}
due
}
/// Drop cache entries whose signed timestamp fell out of the freshness
/// window (they can no longer pass `verify_gossip`), then enforce the hard
/// cap FIFO-oldest-first.
fn prune_replay_cache(&mut self, now_ms: u64) {
let floor = now_ms.saturating_sub(GOSSIP_FRESHNESS_MS);
while let Some((sig, ts)) = self.seen_order.front() {
if *ts >= floor && self.seen_order.len() < CHAT_REPLAY_CACHE_CAP {
break;
}
self.seen.remove(sig);
self.seen_order.pop_front();
}
}
/// Get-or-create the author's bucket state, pruning the map if a flood of
/// short-lived identities has grown it past its cap: idle authors (nothing
/// admitted within the freshness window) go first, then oldest-seen.
fn author_entry(&mut self, author: EndpointId, now_ms: u64) -> &mut AuthorGateState {
if !self.authors.contains_key(&author) && self.authors.len() >= CHAT_AUTHOR_BUCKETS_CAP {
let floor = now_ms.saturating_sub(GOSSIP_FRESHNESS_MS);
self.authors.retain(|_, state| state.last_seen_ms >= floor);
while self.authors.len() >= CHAT_AUTHOR_BUCKETS_CAP {
if let Some(oldest) = self
.authors
.iter()
.min_by_key(|(_, state)| state.last_seen_ms)
.map(|(id, _)| *id)
{
self.authors.remove(&oldest);
} else {
break;
}
}
}
let state = self.authors.entry(author).or_insert(AuthorGateState {
bucket: TokenBucket::full(CHAT_AUTHOR_BURST, now_ms),
last_seen_ms: now_ms,
last_reject_log_ms: None,
});
state.last_seen_ms = now_ms;
state
}
/// Drop an author's limiter state alongside its roster eviction (a signed
/// `Leave`), so the map stays bounded by the roster's own churn.
fn evict_author(&mut self, author: &EndpointId) {
self.authors.remove(author);
}
}
/// Maximum number of distinct peers we hold in a room roster at once.
///
/// Everyone with the room ticket is an authenticated *insider*: a signature only
@@ -524,6 +758,7 @@ impl RoomState for IrohGossipState {
));
let mut state_mutations_seen = HashMap::new();
let mut clock_skew_monitor = ClockSkewMonitor::default();
let mut chat_gate = ChatIngressGate::new(now_millis());
// Broadcast initial state
let initial_payload = {
@@ -793,6 +1028,10 @@ impl RoomState for IrohGossipState {
.lock()
.unwrap()
.remove(&payload.author);
// Roster eviction also drops the author's
// chat-limiter state, keeping that map
// bounded by roster churn (Phase 2).
chat_gate.evict_author(&payload.author);
if removed || was_disconnected {
let _ = event_tx
.send(RoomEvent::PeerLeft(payload.author))
@@ -802,9 +1041,43 @@ impl RoomState for IrohGossipState {
GossipMessage::Chat {
name,
text,
ts,
// The inner ts is an unauthenticated duplicate of the
// signed envelope ts — ignored entirely; the envelope
// value is what RoomEvent carries (Phase 2).
ts: _,
attachment,
} => {
// Phase 2 ingress admission, BEFORE any sanitize or
// attachment work: known author (live or
// mid-reconnect — the core roster gate is the final
// authority) → exact-replay dedup keyed on the
// signature → per-author + room token buckets.
let known_author =
peers.lock().unwrap().contains_key(&payload.author)
|| disconnected_peers
.lock()
.unwrap()
.contains(&payload.author);
if let Err(reject) = chat_gate.admit(
known_author,
payload.author,
&payload.sig.to_bytes(),
payload.ts,
received_now_ms,
) {
if chat_gate.should_log_reject(
known_author,
payload.author,
received_now_ms,
) {
crate::log_msg(&format!(
"Dropped chat from author={}: {:?}",
crate::short_id(&payload.author.to_string()),
reject
));
}
continue;
}
crate::log_msg(&format!(
"Gossip chat from author={:?}",
payload.author
@@ -839,7 +1112,10 @@ impl RoomState for IrohGossipState {
from: payload.author,
name,
text,
ts,
// Only the SIGNED envelope timestamp travels
// downstream (never used for replay/ordering —
// the gate above already handled replay).
ts: payload.ts,
attachment,
})
.await;
@@ -1687,4 +1963,243 @@ mod tests {
5
));
}
// ---- Chat-hardening Phase 2: ingress gate (replay dedup + token buckets) ----
/// Distinct opaque "signature" bytes; the gate never inspects them beyond
/// equality, so a counter-stamped array stands in for a real signature.
fn sig(n: u64) -> [u8; 64] {
let mut bytes = [0u8; 64];
bytes[..8].copy_from_slice(&n.to_le_bytes());
bytes
}
const T0: u64 = 1_000_000_000_000;
#[test]
fn token_bucket_burst_and_refill_boundaries() {
let mut bucket = TokenBucket::full(CHAT_AUTHOR_BURST, T0);
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert!(bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0));
}
// Burst exhausted at the same instant.
assert!(!bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0));
// 999 ms refills just under one token at 1/s…
assert!(!bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0 + 999));
// …a full second refills exactly one (999 ms already banked 0.999 of it,
// so take at the accumulated boundary).
assert!(bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0 + 1_001));
assert!(!bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0 + 1_001));
}
#[test]
fn chat_gate_rejects_unknown_author() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
assert_eq!(
gate.admit(false, author, &sig(1), T0, T0),
Err(ChatReject::UnknownAuthor)
);
// Same frame from a known author is fine — nothing was consumed above.
assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
}
#[test]
fn chat_gate_suppresses_exact_replay_but_admits_distinct_same_ms() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
// Two DISTINCT chats signed in the same millisecond both land…
assert_eq!(gate.admit(true, author, &sig(2), T0, T0), Ok(()));
// …but the byte-identical frame is a replay, from any deliverer.
assert_eq!(
gate.admit(true, author, &sig(1), T0, T0),
Err(ChatReject::Replay)
);
}
#[test]
fn chat_gate_author_burst_then_refill() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
for n in 0..CHAT_AUTHOR_BURST as u64 {
assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
}
assert_eq!(
gate.admit(true, author, &sig(99), T0, T0),
Err(ChatReject::RateLimited)
);
// One second later the author has exactly one more message.
assert_eq!(gate.admit(true, author, &sig(100), T0, T0 + 1_000), Ok(()));
assert_eq!(
gate.admit(true, author, &sig(101), T0, T0 + 1_000),
Err(ChatReject::RateLimited)
);
}
#[test]
fn chat_gate_replay_never_consumes_tokens() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
for n in 0..CHAT_AUTHOR_BURST as u64 {
assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
}
// Replays of an admitted frame while exhausted report Replay (dedup runs
// BEFORE the buckets) and burn no tokens…
for _ in 0..50 {
assert_eq!(
gate.admit(true, author, &sig(0), T0, T0 + 1_000),
Err(ChatReject::Replay)
);
}
// …so the token refilled at +1s is still there for a NEW message.
assert_eq!(gate.admit(true, author, &sig(200), T0, T0 + 1_000), Ok(()));
}
#[test]
fn chat_gate_rate_limited_frame_is_not_marked_replayed() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
for n in 0..CHAT_AUTHOR_BURST as u64 {
assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
}
// Rejected for rate only — NOT entered into the replay cache…
assert_eq!(
gate.admit(true, author, &sig(300), T0, T0),
Err(ChatReject::RateLimited)
);
// …so the same signed frame redelivered after refill is admitted once.
assert_eq!(gate.admit(true, author, &sig(300), T0, T0 + 1_000), Ok(()));
}
#[test]
fn chat_gate_room_bucket_bounds_sock_puppet_authors() {
let mut gate = ChatIngressGate::new(T0);
// 40 distinct authors, one message each, same instant: per-author buckets
// are all full, so only the room-wide burst bounds admission.
let mut admitted = 0;
for n in 0..40u64 {
if gate.admit(true, fresh_id(), &sig(n), T0, T0).is_ok() {
admitted += 1;
}
}
assert_eq!(admitted, CHAT_ROOM_BURST as usize);
// The room refills at 8/s: exactly 8 more land a second later, even from
// fresh authors whose own buckets are full — the room bound decides.
let mut late_admitted = 0;
for n in 100..120u64 {
if gate
.admit(true, fresh_id(), &sig(n), T0, T0 + 1_000)
.is_ok()
{
late_admitted += 1;
}
}
assert_eq!(late_admitted, 8);
// Chat admission being room-bounded is what keeps the event channel
// available for control messages: an Announce is gated independently.
let mut seen = HashMap::new();
assert!(admit_state_mutation(
&mut seen,
fresh_id(),
&GossipMessage::Leave,
T0 + 1_000
));
}
#[test]
fn chat_gate_room_reject_refunds_the_author_token() {
let mut gate = ChatIngressGate::new(T0);
// Author A drains the whole room burst alone? No — its own burst is 8.
// Use 4 authors × 8 to empty the room exactly.
let mut n = 0u64;
for _ in 0..4 {
let author = fresh_id();
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
n += 1;
}
}
// A 5th author is room-rejected 8 times, but its own bucket is refunded
// each time…
let victim = fresh_id();
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert_eq!(
gate.admit(true, victim, &sig(n), T0, T0),
Err(ChatReject::RateLimited)
);
n += 1;
}
// …so when the room refills, the victim still has its FULL burst.
let mut admitted = 0;
for _ in 0..CHAT_AUTHOR_BURST as usize {
if gate.admit(true, victim, &sig(n), T0, T0 + 1_000).is_ok() {
admitted += 1;
}
n += 1;
}
assert_eq!(admitted, CHAT_AUTHOR_BURST as usize);
}
#[test]
fn chat_replay_cache_prunes_by_ttl_and_cap() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
// TTL: an admitted frame's entry is dropped once its SIGNED ts falls out
// of the freshness window (it could no longer pass verify_gossip anyway).
assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
assert_eq!(gate.seen.len(), 1);
gate.prune_replay_cache(T0 + GOSSIP_FRESHNESS_MS + 1);
assert!(gate.seen.is_empty() && gate.seen_order.is_empty());
// Hard cap: stuff the cache directly (admission itself is rate-limited
// far below the cap) and verify FIFO-oldest eviction bounds it.
for n in 0..(CHAT_REPLAY_CACHE_CAP as u64 + 100) {
gate.seen.insert(sig(n));
gate.seen_order.push_back((sig(n), T0));
}
gate.prune_replay_cache(T0);
assert!(gate.seen_order.len() < CHAT_REPLAY_CACHE_CAP);
assert_eq!(gate.seen.len(), gate.seen_order.len());
// The oldest entries went first.
assert!(!gate.seen.contains(&sig(0)));
assert!(gate.seen.contains(&sig(CHAT_REPLAY_CACHE_CAP as u64 + 99)));
}
#[test]
fn chat_gate_author_bucket_map_stays_bounded() {
let mut gate = ChatIngressGate::new(T0);
for n in 0..(CHAT_AUTHOR_BUCKETS_CAP as u64 * 2) {
let _ = gate.admit(true, fresh_id(), &sig(n), T0, T0 + n);
}
assert!(gate.authors.len() <= CHAT_AUTHOR_BUCKETS_CAP);
}
#[test]
fn chat_gate_evict_author_drops_limiter_state() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
assert!(gate.authors.contains_key(&author));
gate.evict_author(&author);
assert!(!gate.authors.contains_key(&author));
}
#[test]
fn chat_gate_reject_logging_is_squelched_per_author() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
// Establish limiter state, then exhaust it.
for n in 0..=CHAT_AUTHOR_BURST as u64 {
let _ = gate.admit(true, author, &sig(n), T0, T0);
}
assert!(gate.should_log_reject(true, author, T0));
assert!(!gate.should_log_reject(true, author, T0 + 1));
assert!(gate.should_log_reject(true, author, T0 + CHAT_REJECT_LOG_COOLDOWN_MS));
// Unknown authors share one squelch slot (they have no map entry).
let stranger = fresh_id();
assert!(gate.should_log_reject(false, stranger, T0));
assert!(!gate.should_log_reject(false, fresh_id(), T0 + 1));
}
}