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:
@@ -0,0 +1,132 @@
|
||||
//! Roster-bound chat identity (chat-hardening plan, Phase 2).
|
||||
//!
|
||||
//! The wire `GossipMessage::Chat` carries a sender-CLAIMED display name, which
|
||||
//! any insider could set to another member's name. This map is the antidote:
|
||||
//! the core event task records each authenticated member's latest sanitized
|
||||
//! presence name here (from `PeerJoined`/`PeerUpdated`, the events that only
|
||||
//! fire for a verified signed `Announce`), and chat renders under THAT name —
|
||||
//! the embedded wire name is never displayed.
|
||||
//!
|
||||
//! Shared (`Arc<Mutex<…>>`) because eviction happens in two places: the event
|
||||
//! task itself (graceful `PeerLeft`) and the detached reconnect-grace timer
|
||||
//! (terminal eviction). A peer mid-reconnect-grace keeps its entry, so its
|
||||
//! chat stays admitted until the grace actually expires.
|
||||
|
||||
use iroh::EndpointId;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Bound on tracked names. Mirrors the gossip roster cap (`MAX_ACTIVE_PEERS`):
|
||||
/// insertions only follow cap-gated roster admissions, so this is pure defense
|
||||
/// in depth against that invariant breaking.
|
||||
const CHAT_ROSTER_CAP: usize = 32;
|
||||
|
||||
/// The authoritative id → display-name map for the current room. Cheap to
|
||||
/// clone; all clones share one map.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ChatRoster {
|
||||
names: Arc<Mutex<HashMap<EndpointId, String>>>,
|
||||
}
|
||||
|
||||
impl ChatRoster {
|
||||
/// Record (or refresh) a member's display name. The name is re-sanitized
|
||||
/// here (idempotent — gossip ingress already did) and an empty result falls
|
||||
/// back to the short node id so a chat line is never label-less. A NEW id
|
||||
/// is refused past the cap; updates to a present id always land.
|
||||
pub fn upsert(&self, id: EndpointId, name: &str) {
|
||||
let clean = crate::sanitize::sanitize_name(name);
|
||||
let label = if clean.is_empty() {
|
||||
crate::short_id(&id.to_string())
|
||||
} else {
|
||||
clean
|
||||
};
|
||||
let mut names = self.names.lock().unwrap();
|
||||
if names.contains_key(&id) || names.len() < CHAT_ROSTER_CAP {
|
||||
names.insert(id, label);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a member on graceful leave or terminal (grace-expired) eviction.
|
||||
pub fn remove(&self, id: &EndpointId) {
|
||||
self.names.lock().unwrap().remove(id);
|
||||
}
|
||||
|
||||
/// The roster-bound name for an id, or `None` if the author is not a
|
||||
/// current member — the caller must then drop the chat entirely.
|
||||
pub fn name_of(&self, id: &EndpointId) -> Option<String> {
|
||||
self.names.lock().unwrap().get(id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iroh::SecretKey;
|
||||
|
||||
fn fresh_id() -> EndpointId {
|
||||
SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_then_lookup_returns_sanitized_name() {
|
||||
let roster = ChatRoster::default();
|
||||
let a = fresh_id();
|
||||
roster.upsert(a, "Alice");
|
||||
assert_eq!(roster.name_of(&a), Some("Alice".to_string()));
|
||||
// Bidi override / zero-width spoofing characters are stripped.
|
||||
roster.upsert(a, "Al\u{202E}ice\u{200B}");
|
||||
assert_eq!(roster.name_of(&a), Some("Alice".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_update_affects_future_lookups() {
|
||||
let roster = ChatRoster::default();
|
||||
let a = fresh_id();
|
||||
roster.upsert(a, "Alice");
|
||||
roster.upsert(a, "Alice2");
|
||||
assert_eq!(roster.name_of(&a), Some("Alice2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_author_has_no_name() {
|
||||
let roster = ChatRoster::default();
|
||||
roster.upsert(fresh_id(), "Alice");
|
||||
assert_eq!(roster.name_of(&fresh_id()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_author_is_no_longer_a_member() {
|
||||
let roster = ChatRoster::default();
|
||||
let a = fresh_id();
|
||||
roster.upsert(a, "Alice");
|
||||
roster.remove(&a);
|
||||
assert_eq!(roster.name_of(&a), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sanitized_name_falls_back_to_short_id() {
|
||||
let roster = ChatRoster::default();
|
||||
let a = fresh_id();
|
||||
roster.upsert(a, "\u{0}\r\n\t ");
|
||||
let label = roster.name_of(&a).unwrap();
|
||||
assert!(!label.is_empty());
|
||||
assert_eq!(label, crate::short_id(&a.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_ids_are_refused_past_the_cap_but_updates_land() {
|
||||
let roster = ChatRoster::default();
|
||||
let first = fresh_id();
|
||||
roster.upsert(first, "member");
|
||||
for _ in 1..CHAT_ROSTER_CAP {
|
||||
roster.upsert(fresh_id(), "member");
|
||||
}
|
||||
// A brand-new 33rd id is refused...
|
||||
let overflow = fresh_id();
|
||||
roster.upsert(overflow, "overflow");
|
||||
assert_eq!(roster.name_of(&overflow), None);
|
||||
// ...but an update to a present id still lands at the cap.
|
||||
roster.upsert(first, "renamed");
|
||||
assert_eq!(roster.name_of(&first), Some("renamed".to_string()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user