Files
peerspeak/src/core/chatroster.rs
T
molluskandClaude Fable 5 8898652349
CI / check (push) Successful in 2m59s
chat: roster-bound authorship, replay dedup, and rate limits (Phase 2)
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>
2026-07-17 20:37:13 -04:00

133 lines
4.7 KiB
Rust

//! 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()));
}
}