chat: roster-bound authorship, replay dedup, and rate limits (Phase 2)
CI / check (push) Successful in 2m59s
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:
@@ -201,3 +201,39 @@ async fn rejoin_after_grace_eviction_dials_cleanly() {
|
||||
"an initial dial after a grace eviction must not be treated as a reconnect"
|
||||
);
|
||||
}
|
||||
|
||||
/// Chat-hardening Phase 2: a peer mid-reconnect-grace keeps its roster-bound
|
||||
/// chat name (its chat stays admitted), but a TERMINAL grace-expiry eviction
|
||||
/// revokes it — after that, only a fresh authenticated Announce (PeerJoined)
|
||||
/// restores chat authority.
|
||||
#[tokio::test]
|
||||
async fn grace_eviction_revokes_chat_roster_entry() {
|
||||
use peerspeak::core::chatroster::ChatRoster;
|
||||
|
||||
let (ui_tx, mut ui_rx) = mpsc::channel(100);
|
||||
let roster = ChatRoster::default();
|
||||
let h = make_handler(ui_tx, make_transport().await, GRACE).with_chat_roster(roster.clone());
|
||||
let peer = fake_peer();
|
||||
roster.upsert(peer, "Victim");
|
||||
|
||||
// Link up, then drop: DURING the grace window the peer is still a member —
|
||||
// its chat must keep rendering under its roster name.
|
||||
h.handle(ConnEvent::Connected(peer)).await;
|
||||
h.handle(ConnEvent::Connecting(peer)).await;
|
||||
assert_eq!(
|
||||
roster.name_of(&peer),
|
||||
Some("Victim".to_string()),
|
||||
"reconnect grace must NOT revoke chat authority"
|
||||
);
|
||||
|
||||
// Once the grace expires and the eviction fires, chat authority goes too.
|
||||
assert!(
|
||||
evicted_within(&mut ui_rx, peer, GRACE * 4).await,
|
||||
"the outage should evict once the grace window elapses"
|
||||
);
|
||||
assert_eq!(
|
||||
roster.name_of(&peer),
|
||||
None,
|
||||
"terminal eviction must revoke the roster-bound chat name"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user