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>
133 lines
4.7 KiB
Rust
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()));
|
|
}
|
|
}
|