use crate::network::{NetError, PeerSpeakTicket, PeerState, RoomEvent, RoomState}; use async_trait::async_trait; use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature, TransportAddr}; use iroh_gossip::net::Gossip; use iroh_gossip::proto::TopicId; use serde::{Deserialize, Serialize}; use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; use tokio::sync::mpsc::Receiver; use tokio_stream::StreamExt; /// Domain-separation tag mixed into every signed gossip payload so a signature /// can never be lifted out of this protocol/version into another context. use crate::protocol::GOSSIP_SIG_DOMAIN; /// How far a payload's sender-stamped timestamp may differ from local time /// before it's rejected as stale (replayed) or implausibly future. Bounds the /// window in which a captured *genuine* signed payload could be replayed; within /// it, a replay is byte-identical (the signature fixes the bytes) so the gossip /// swarm's own in-flight de-duplication suppresses it. 2 minutes tolerates /// reasonable cross-peer clock skew without leaving a wide replay window. const GOSSIP_FRESHNESS_MS: u64 = 120_000; /// Hard cap on an inbound gossip frame before it is deserialized. The largest /// legitimate payload is an `Announce` carrying a full custom avatar (≤48 KB /// base64, [`crate::avatar::CUSTOM_MAX_B64`]) plus the small presence/signature /// fields — about 49 KB on the wire. This cap sits comfortably above that while /// bounding the work/allocation a hostile peer can force: `serde_json::from_slice` /// allocates while parsing, so post-deserialize string caps do NOT prevent abuse — /// the size must be checked *before* parsing (security hardening, Codex find). const MAX_GOSSIP_FRAME_BYTES: usize = 128 * 1024; /// A gossip message plus the authentication envelope that proves who sent it. /// `author` is the claimed sender (an `EndpointId`, which *is* an ed25519 public /// 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`, an unauthenticated /// duplicate kept only for wire compatibility and ignored on receive. #[derive(Serialize, Deserialize, Clone)] pub struct GossipPayload { pub author: EndpointId, pub ts: u64, pub msg: GossipMessage, pub sig: Signature, } // `iroh::Signature` doesn't implement `Debug`, so derive everything else and // hand-write a `Debug` that omits the (noisy, non-Debug) signature. impl std::fmt::Debug for GossipPayload { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("GossipPayload") .field("author", &self.author) .field("ts", &self.ts) .field("msg_kind", &gossip_message_kind(&self.msg)) .finish_non_exhaustive() } } /// Current unix time in milliseconds (0 on the impossible pre-epoch error). fn now_millis() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0) } /// The exact bytes an ed25519 gossip signature covers: domain tag + room topic + /// claimed author + timestamp + message. Binding the **topic** prevents replay /// into another room; binding the **timestamp** bounds temporal replay; binding /// the **author** means a signature only validates for the key it claims to come /// from. Deterministic (same inputs → same bytes on both ends). fn signable_bytes(topic: &[u8; 32], author: &EndpointId, ts: u64, msg: &GossipMessage) -> Vec { serde_json::to_vec(&(GOSSIP_SIG_DOMAIN, topic, author, ts, msg)).unwrap_or_default() } /// Build a signed payload from our secret key for the given room topic. fn sign_gossip(secret: &SecretKey, topic: &[u8; 32], ts: u64, msg: GossipMessage) -> GossipPayload { let author = secret.public(); let sig = secret.sign(&signable_bytes(topic, &author, ts, &msg)); GossipPayload { author, ts, msg, sig, } } /// Why a received gossip payload was rejected (logging + tests). #[derive(Debug, PartialEq, Eq)] enum GossipReject { /// Signature didn't validate for the claimed `author` — a forgery/spoof. BadSignature, /// Timestamp outside the freshness window — stale (replay) or implausibly future. OutOfWindow, /// A signed Announce advertised an address for a different node id. AnnounceAddressMismatch, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum StateMutationKind { Announce, Leave, } fn gossip_message_kind(msg: &GossipMessage) -> &'static str { match msg { GossipMessage::Announce(_) => "Announce", GossipMessage::Leave => "Leave", GossipMessage::Chat { .. } => "Chat", } } fn state_mutation_kind(msg: &GossipMessage) -> Option { match msg { GossipMessage::Announce(_) => Some(StateMutationKind::Announce), GossipMessage::Leave => Some(StateMutationKind::Leave), GossipMessage::Chat { .. } => None, } } fn admit_state_mutation( seen: &mut HashMap<(EndpointId, StateMutationKind), u64>, author: EndpointId, msg: &GossipMessage, ts: u64, ) -> bool { let Some(kind) = state_mutation_kind(msg) else { return true; }; let key = (author, kind); if seen.get(&key).is_some_and(|last_ts| ts <= *last_ts) { return false; } seen.insert(key, ts); true } /// Size at which we prune stale entries from the replay-tracking map (Tier C /// F-01 audit). `admit_state_mutation` records `(author, kind)` for every signed /// mutation, so an insider sending validly signed `Leave`s from unlimited /// generated keys would otherwise grow it for the room's lifetime. A mutation /// older than the freshness window can never be the deciding `last_ts` for an /// in-window message — `verify_gossip`'s timestamp check rejects such a replay /// first — so dropping those entries cannot weaken replay protection; it bounds /// the map to roughly the authors seen within one freshness window. const STATE_MUTATIONS_SOFT_CAP: usize = 256; /// Drop replay-tracking entries whose timestamp is older than `window_ms` before /// `now_ms` (see [`STATE_MUTATIONS_SOFT_CAP`]). Pure → unit-testable. fn prune_stale_mutations( seen: &mut HashMap<(EndpointId, StateMutationKind), u64>, now_ms: u64, window_ms: u64, ) { let floor = now_ms.saturating_sub(window_ms); seen.retain(|_, last_ts| *last_ts >= floor); } /// Three signed, out-of-window payloads inside one minute is enough to distinguish /// a persistently skewed clock from a single delayed gossip frame without making /// the user wait long. Repeats are suppressed for five minutes per author. const CLOCK_SKEW_OBSERVATION_WINDOW_MS: u64 = 60_000; const CLOCK_SKEW_WARNING_THRESHOLD: usize = 3; const CLOCK_SKEW_COOLDOWN_MS: u64 = 5 * 60_000; const CLOCK_SKEW_AUTHORS_SOFT_CAP: usize = 256; const CLOCK_SKEW_AUTHORS_HARD_CAP: usize = 512; const CLOCK_SKEW_AUTHOR_TTL_MS: u64 = CLOCK_SKEW_COOLDOWN_MS; #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ClockSkewWarning { author: EndpointId, /// Positive means the peer's sender-stamped clock is ahead of ours. skew_ms: i64, } #[derive(Debug, Default)] struct ClockSkewMonitor { authors: HashMap, } #[derive(Debug, Default)] struct ClockSkewAuthorState { observed_at: Vec, last_seen_ms: u64, last_warned_ms: Option, } impl ClockSkewMonitor { fn observe( &mut self, author: EndpointId, skew_ms: i64, now_ms: u64, ) -> Option { if self.authors.len() > CLOCK_SKEW_AUTHORS_SOFT_CAP { self.prune_stale_authors(now_ms); } let warning = { let state = self.authors.entry(author).or_default(); state.last_seen_ms = now_ms; let floor = now_ms.saturating_sub(CLOCK_SKEW_OBSERVATION_WINDOW_MS); state.observed_at.retain(|ts| *ts >= floor); state.observed_at.push(now_ms); if state.observed_at.len() > CLOCK_SKEW_WARNING_THRESHOLD { let excess = state.observed_at.len() - CLOCK_SKEW_WARNING_THRESHOLD; state.observed_at.drain(0..excess); } let threshold_met = state.observed_at.len() >= CLOCK_SKEW_WARNING_THRESHOLD; let in_cooldown = state .last_warned_ms .is_some_and(|last| now_ms.saturating_sub(last) < CLOCK_SKEW_COOLDOWN_MS); if threshold_met && !in_cooldown { state.last_warned_ms = Some(now_ms); Some(ClockSkewWarning { author, skew_ms }) } else { None } }; if self.authors.len() > CLOCK_SKEW_AUTHORS_HARD_CAP { self.drop_oldest_authors(); } warning } fn prune_stale_authors(&mut self, now_ms: u64) { let stale_before = now_ms.saturating_sub(CLOCK_SKEW_AUTHOR_TTL_MS); self.authors.retain(|_, state| { let last_warning_live = state .last_warned_ms .is_some_and(|last| now_ms.saturating_sub(last) < CLOCK_SKEW_COOLDOWN_MS); last_warning_live || state.last_seen_ms >= stale_before }); } fn drop_oldest_authors(&mut self) { let remove_count = self .authors .len() .saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP); let mut by_age: Vec<_> = self .authors .iter() .map(|(author, state)| (*author, state.last_seen_ms)) .collect(); by_age.sort_by_key(|(_, last_seen_ms)| *last_seen_ms); for (author, _) in by_age.into_iter().take(remove_count) { self.authors.remove(&author); } } } /// 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. /// `pub(crate)` because the sender-side pacer (chat-hardening Phase 5) mirrors /// this exact policy — one definition, so the two sides can never drift apart. pub(crate) const CHAT_AUTHOR_BURST: f64 = 8.0; pub(crate) 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. Shared with the sender-side chat /// pacer (`app::sendqueue`) so both sides of the rate policy use one mechanism. #[derive(Debug, Clone, Copy)] pub(crate) struct TokenBucket { tokens: f64, last_ms: u64, } impl TokenBucket { pub(crate) 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. pub(crate) 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, } /// 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, /// Shared squelch slot for unknown-author rejects (they have no map entry). last_unknown_log_ms: Option, } 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 /// proves ownership of the generated keypair it was made with, not that the /// author is a distinct human. A malicious member can therefore mint many valid /// signed identities. Voice is full-mesh (each peer dials every other), so a real /// room is realistically well under this bound; the cap exists purely so a flood /// of sock-puppet `Announce`s can't grow our peer map / audio supervisors / dials /// without limit (Tier C F-01). const MAX_ACTIVE_PEERS: usize = 32; /// Maximum transport addresses we retain from a single peer announce. iroh /// normally advertises a handful (a few LAN/WAN IP candidates plus one home /// relay); the cap stops an insider stuffing a large unique address set into each /// announce to inflate the address lookup and the dialer's candidate list. const MAX_PEER_ADDRS: usize = 8; /// Maximum byte length of a relay URL we accept inside a peer address. A relay /// URL is normal-length; anything longer is dropped rather than retained. const MAX_RELAY_URL_LEN: usize = 256; /// Bound an untrusted peer's advertised address set before we retain it / hand it /// to the address lookup and dialer (Tier C F-01). Drops transport kinds we never /// use (`Custom`) and over-long relay URLs, then truncates to at most /// [`MAX_PEER_ADDRS`] addresses. `BTreeSet` iteration is deterministic, so the /// kept subset is stable. Pure → unit-testable. fn sanitize_endpoint_addr(addr: &EndpointAddr) -> EndpointAddr { let addrs: BTreeSet = addr .addrs .iter() .filter(|a| match a { TransportAddr::Relay(url) => url.as_str().len() <= MAX_RELAY_URL_LEN, TransportAddr::Ip(_) => true, // `TransportAddr` is #[non_exhaustive]; we only speak IP + relay, so // anything else (Custom / future kinds) is dropped, not retained. _ => false, }) .take(MAX_PEER_ADDRS) .cloned() .collect(); EndpointAddr { id: addr.id, addrs } } /// Whether an `Announce` may enter the roster. Only a brand-new author /// (`subject_to_cap`) is gated by [`MAX_ACTIVE_PEERS`]; updates to an /// already-present peer AND re-announces from a peer mid-reconnect (which /// already held a slot) always pass — exempting reconnects keeps a full room /// from rejecting a legitimately reconnecting member and orphaning its recovery /// state (Tier C F-01 audit). Pure → unit-testable. fn admit_into_roster(roster_len: usize, subject_to_cap: bool, max_peers: usize) -> bool { !subject_to_cap || roster_len < max_peers } /// Whether a received `Announce`'s author is gated by the roster cap. A peer /// already in the roster (`is_new == false`, an ordinary update) or one /// mid-reconnect (`is_reconnecting`, it already held a slot) is exempt; only a /// brand-new author counts against [`MAX_ACTIVE_PEERS`] (Tier C F-01 audit). /// Pure → unit-testable. fn announce_subject_to_cap(is_new: bool, is_reconnecting: bool) -> bool { is_new && !is_reconnecting } fn peer_state_for_log(state: &PeerState) -> String { format!( "name={:?}, muted={}, addr_id={}, addrs={}, sharing={}, game={:?}", state.name, state.is_muted, crate::short_id(&state.addr.id.to_string()), state.addr.addrs.len(), state.sharing.is_some(), state.game ) } /// Authenticate a received payload against the room topic and local clock. The /// signature must validate for the claimed `author` (closing the author-spoofing /// vector, security S2), and the timestamp must be within `window_ms` of /// `now_ms`. Pure — no I/O — so it's directly unit-testable. fn verify_gossip( payload: &GossipPayload, topic: &[u8; 32], now_ms: u64, window_ms: u64, ) -> Result<(), GossipReject> { let bytes = signable_bytes(topic, &payload.author, payload.ts, &payload.msg); payload .author .verify(&bytes, &payload.sig) .map_err(|_| GossipReject::BadSignature)?; if now_ms.abs_diff(payload.ts) > window_ms { return Err(GossipReject::OutOfWindow); } if let GossipMessage::Announce(state) = &payload.msg && state.addr.id != payload.author { return Err(GossipReject::AnnounceAddressMismatch); } Ok(()) } /// Compute the gossip bootstrap peer set for a (re)join: the ticket's host plus /// every peer we already knew about (`extra`), with self removed and ids /// de-duplicated. Without the retained `extra` peers, the ROOM CREATOR rejoining /// their own room would have an empty list (their ticket names only themselves as /// host) and so never re-enter the swarm — that was bug A8. Pure → unit-testable. fn compute_bootstrap( self_id: EndpointId, ticket_host: EndpointId, extra: &[EndpointAddr], ) -> Vec { let mut out: Vec = Vec::new(); if ticket_host != self_id { out.push(ticket_host); } for addr in extra { if addr.id != self_id && !out.contains(&addr.id) { out.push(addr.id); } } out } #[derive(Serialize, Deserialize, Clone, Debug)] pub enum GossipMessage { Announce(PeerState), Leave, /// A room text-chat message: the author's display name, the text, a /// sender-stamped millisecond timestamp, and an optional file attachment /// descriptor (the bytes are fetched off-gossip on the file plane). Chat { name: String, text: String, ts: u64, #[serde(default)] attachment: Option, }, } pub struct IrohGossipState { _endpoint: Endpoint, gossip: Gossip, address_lookup: iroh::address_lookup::memory::MemoryLookup, /// Our node secret key, used to sign every outgoing gossip payload so peers /// can authenticate us (security S2). Same key the endpoint binds with. secret_key: SecretKey, self_state: Arc>>, peers: Arc>>, /// Previously verified peers whose live roster entry was removed by a /// transient disconnect. Retained only so a later authenticated `Leave` /// still reaches core and cancels background recovery. disconnected_peers: Arc>>, event_tx: mpsc::Sender, event_rx: Mutex>>, active_topic: Mutex>>, active_topic_id: Mutex>, /// Raw 32-byte id of the joined topic, retained so the out-of-task signers /// (`update_self_state`/`send_chat`/`leave`) can bind signatures to the room. active_topic_bytes: Mutex>, active_sender: Mutex>, } impl IrohGossipState { pub fn new( endpoint: Endpoint, gossip: Gossip, address_lookup: iroh::address_lookup::memory::MemoryLookup, secret_key: SecretKey, ) -> Self { let (event_tx, event_rx) = mpsc::channel(100); Self { _endpoint: endpoint, gossip, address_lookup, secret_key, self_state: Arc::new(Mutex::new(None)), peers: Arc::new(Mutex::new(HashMap::new())), disconnected_peers: Arc::new(Mutex::new(HashSet::new())), event_tx, event_rx: Mutex::new(Some(event_rx)), active_topic: Mutex::new(None), active_topic_id: Mutex::new(None), active_topic_bytes: Mutex::new(None), active_sender: Mutex::new(None), } } } #[async_trait] impl RoomState for IrohGossipState { async fn join( &self, ticket_str: &str, self_state: PeerState, extra_bootstrap: Vec, ) -> Result<(), NetError> { crate::log_msg(&format!( "RoomState::join: self_id={}, self_name={:?}, ticket={}", crate::short_id(&self_state.addr.id.to_string()), self_state.name, crate::redact_for_log(ticket_str) )); let ticket = ticket_str.parse::()?; // Version-namespace the subscribed topic (VERSIONING.md): peers on a // different gossip protocol version derive a different topic from the same // ticket and never share a swarm. The raw ticket.topic_id stays the room // identity (and what signatures bind, below). let topic_id = TopicId::from_bytes(crate::protocol::versioned_topic(ticket.topic_id)); crate::log_msg(&format!( "Parsed ticket. host_id={}, host_addrs={}, topic={}", crate::short_id(&ticket.host_addr.id.to_string()), ticket.host_addr.addrs.len(), crate::short_bytes_hex(&ticket.topic_id) )); // Stop any currently running topic let _ = self.leave().await; // Make every dial target resolvable: the ticket host plus any retained // peers handed in (their addresses may have aged out of a fresh endpoint's // book even though the persistent lookup usually still holds them). self.address_lookup .add_endpoint_info(ticket.host_addr.clone()); for addr in &extra_bootstrap { self.address_lookup.add_endpoint_info(addr.clone()); } // Bootstrap to the ticket host AND every retained peer (minus self). The // retained peers are what let the room creator rejoin a room they left — // their own ticket names only themselves as host (bug A8). let bootstrap_peers = compute_bootstrap(self_state.addr.id, ticket.host_addr.id, &extra_bootstrap); crate::log_msg(&format!("Bootstrap peers for join: {:?}", bootstrap_peers)); let gossip_topic = self .gossip .subscribe(topic_id, bootstrap_peers) .await .map_err(|e| { let err = format!("Failed to join gossip topic: {}", e); crate::log_msg(&err); NetError::Gossip(err) })?; let (gossip_sender, mut gossip_receiver) = gossip_topic.split(); *self.self_state.lock().unwrap() = Some(self_state.clone()); *self.active_topic_id.lock().unwrap() = Some(topic_id); *self.active_topic_bytes.lock().unwrap() = Some(ticket.topic_id); *self.active_sender.lock().unwrap() = Some(gossip_sender.clone()); let event_tx = self.event_tx.clone(); let peers = self.peers.clone(); let disconnected_peers = self.disconnected_peers.clone(); let address_lookup = self.address_lookup.clone(); let self_state_clone = self.self_state.clone(); let gossip_sender_clone = gossip_sender.clone(); let self_id = self_state.addr.id; // The signing key + room topic the spawned loop needs to sign our own // broadcasts and authenticate every inbound payload (security S2). let secret_key = self.secret_key.clone(); let topic_bytes = ticket.topic_id; let handle = tokio::spawn(async move { crate::log_msg(&format!( "Spawned gossip topic loop for self_id={:?}", self_id )); 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 = { let guard = self_state_clone.lock().unwrap(); guard.as_ref().map(|s| { sign_gossip( &secret_key, &topic_bytes, now_millis(), GossipMessage::Announce(s.clone()), ) }) }; if let Some(payload) = initial_payload && let Ok(bytes) = serde_json::to_vec(&payload) { crate::log_msg(&format!( "Broadcasting initial state from self_id={:?}", self_id )); let _ = gossip_sender_clone.broadcast(bytes.into()).await; } // Stream topic messages while let Some(res) = gossip_receiver.next().await { match res { Ok(iroh_gossip::api::Event::Received(msg)) => { crate::log_msg(&format!( "Gossip received Event::Received from delivery={:?}", msg.delivered_from )); // Reject oversized frames BEFORE deserializing: parsing // allocates, so a size check has to precede `from_slice` to // bound the memory a hostile peer can make us hold. if msg.content.len() > MAX_GOSSIP_FRAME_BYTES { crate::log_msg(&format!( "Gossip dropped oversized frame: {} bytes > {} cap", msg.content.len(), MAX_GOSSIP_FRAME_BYTES )); continue; } match serde_json::from_slice::(&msg.content) { Ok(payload) => { // Authenticate before trusting `author` for ANY // action: a forged/stale payload is dropped here // so it can't impersonate, evict, or poison // presence/address-book (security S2). let received_now_ms = now_millis(); if let Err(reason) = verify_gossip( &payload, &topic_bytes, received_now_ms, GOSSIP_FRESHNESS_MS, ) { if reason == GossipReject::OutOfWindow { let skew_ms = payload.ts as i64 - received_now_ms as i64; if let Some(warning) = clock_skew_monitor.observe( payload.author, skew_ms, received_now_ms, ) { let _ = event_tx .send(RoomEvent::ClockSkewSuspected { author: warning.author, skew_ms: warning.skew_ms, }) .await; } } crate::log_msg(&format!( "Gossip dropped unauthenticated/stale payload claiming author={:?}: {:?}", payload.author, reason )); continue; } let our_id = { self_state_clone.lock().unwrap().as_ref().map(|s| s.addr.id) }; if Some(payload.author) == our_id { crate::log_msg( "Gossip Event::Received from ourselves; ignoring", ); continue; } // Keep the replay-tracking map bounded: prune entries // older than the freshness window once it grows past the // soft cap (Tier C F-01 audit). Stale entries can't gate // an in-window message, so this never weakens replay // protection. if state_mutations_seen.len() > STATE_MUTATIONS_SOFT_CAP { prune_stale_mutations( &mut state_mutations_seen, now_millis(), GOSSIP_FRESHNESS_MS, ); } if !admit_state_mutation( &mut state_mutations_seen, payload.author, &payload.msg, payload.ts, ) { crate::log_msg(&format!( "Gossip dropped replayed state mutation author={}, kind={}, ts={}", crate::short_id(&payload.author.to_string()), gossip_message_kind(&payload.msg), payload.ts )); continue; } crate::log_msg(&format!( "Gossip Event::Received author={}, kind={}", crate::short_id(&payload.author.to_string()), gossip_message_kind(&payload.msg) )); match payload.msg { GossipMessage::Announce(mut state) => { // Presence names are untrusted (and author- // spoofable): sanitize at ingest so every // consumer gets a safe value (security S4). state.name = crate::sanitize::sanitize_name(&state.name); // A peer's avatar is equally untrusted: a // custom image is validated (size + safe // decode within bounds) or downgraded to a // monogram, so a malformed/oversized/bomb // image can't crash or exhaust us (W4). state.avatar = state.avatar.sanitize_incoming(); // Screen-share tickets are capabilities and // peer-supplied: cap/validate once at ingest // so invalid offers never render a Watch button. state.sharing = state .sharing .and_then(crate::screenshare::sanitize_ticket); // The game-presence label is untrusted // peer text like the name: sanitize + // length-cap at ingest (strip bidi/control, // 64-char/256-byte cap). An empty result // means "no game" rather than a blank label. state.game = state.game.and_then(|g| { let cleaned = crate::sanitize::sanitize_game_label(&g); (!cleaned.is_empty()).then_some(cleaned) }); // Music presence is untrusted peer data: // the track name is display text (sanitize // + cap like the game label) and the size // bounds a future fetch (reject anything // outside the attachment cap). state.music = state.music.and_then(|mut m| { let name = crate::sanitize::sanitize_game_label(&m.name); if name.is_empty() || !crate::files::size_within_cap(m.size) { return None; } m.name = name; if m.next_id.is_some() { let ok = m .next_size .map(crate::files::size_within_cap) .unwrap_or(false); if !ok { m.next_id = None; m.next_size = None; } } Some(m) }); // Bound an insider's advertised address set // before we retain it / hand it to the dialer // (Tier C F-01). state.addr = sanitize_endpoint_addr(&state.addr); // A peer reconnecting from a transient drop sits // in `disconnected_peers` (not the live roster); // it already held a slot, so it must be re-admitted // regardless of the cap, and its disconnect marker // cleared ONLY once re-admitted — clearing it before // a possible reject would orphan its recovery state // (Tier C F-01 audit). let is_reconnecting = disconnected_peers .lock() .unwrap() .contains(&payload.author); let admitted = { let mut peer_map = peers.lock().unwrap(); let is_new = !peer_map.contains_key(&payload.author); // Cap the roster so a flood of signed // sock-puppet identities can't grow our // memory/tasks/dials without bound (Tier C // F-01). Existing-peer updates and reconnects // are exempt; only brand-new authors are gated. let subject_to_cap = announce_subject_to_cap(is_new, is_reconnecting); if !admit_into_roster( peer_map.len(), subject_to_cap, MAX_ACTIVE_PEERS, ) { None } else { let state_changed = peer_map.get(&payload.author) != Some(&state); if is_new || state_changed { peer_map.insert(payload.author, state.clone()); } Some((is_new, state_changed)) } }; let Some((is_new, state_changed)) = admitted else { crate::log_msg(&format!( "Gossip roster full ({MAX_ACTIVE_PEERS}); rejecting new peer {}", crate::short_id(&payload.author.to_string()) )); continue; }; // Admitted — now it is safe to clear any reconnect // marker (a rejected announce above leaves it intact // so a later signed Leave still cleans up). disconnected_peers.lock().unwrap().remove(&payload.author); if is_new { crate::log_msg(&format!( "Gossip new peer joined: {}, state: {}", crate::short_id(&payload.author.to_string()), peer_state_for_log(&state) )); // Replace (not union) the lookup's record for // this id with the authenticated, sanitized // address set, so leave/re-announce cycles // can't accumulate attacker-supplied history // (Tier C F-01). let _ = address_lookup .set_endpoint_info(state.addr.clone()); let _ = event_tx .send(RoomEvent::PeerJoined(payload.author, state)) .await; } else if state_changed { crate::log_msg(&format!( "Gossip peer state updated: {}, state: {}", crate::short_id(&payload.author.to_string()), peer_state_for_log(&state) )); let _ = event_tx .send(RoomEvent::PeerUpdated(payload.author, state)) .await; } } GossipMessage::Leave => { crate::log_msg(&format!( "Gossip peer leave request from author={:?}", payload.author )); // Drop this id's address-lookup entry so cycling // distinct identities through Announce→Leave can't // grow the lookup for the room's lifetime (Tier C // F-01 audit). Re-announce re-populates it. let _ = address_lookup.remove_endpoint_info(payload.author); let removed = peers.lock().unwrap().remove(&payload.author).is_some(); let was_disconnected = disconnected_peers .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)) .await; } } GossipMessage::Chat { name, text, // 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 )); // Defensively normalize an untrusted attachment // descriptor: sanitize the filename and drop it // entirely if it declares an out-of-cap size. let attachment = attachment.and_then(|mut a| { if !crate::files::size_within_cap(a.size) { return None; } a.name = crate::files::sanitize_filename(&a.name); Some(a) }); // Chat text policy at INGRESS: reject raw text // over the byte ceiling before spending any // sanitize work on it (a compliant sender // sanitizes before signing), and drop a message // with neither visible text nor an attachment. let Some(text) = crate::sanitize::admit_chat_text( &text, attachment.is_some(), ) else { crate::log_msg(&format!( "Dropped out-of-policy chat from author={:?} (oversized or empty)", payload.author )); continue; }; let _ = event_tx .send(RoomEvent::ChatMessage { from: payload.author, name, text, // Only the SIGNED envelope timestamp travels // downstream (never used for replay/ordering — // the gate above already handled replay). ts: payload.ts, attachment, }) .await; } } } Err(e) => { crate::log_msg(&format!( "Gossip failed to deserialize payload: {:?}", e )); } } } Ok(iroh_gossip::api::Event::NeighborUp(peer_id)) => { crate::log_msg(&format!("Gossip event: NeighborUp={:?}", peer_id)); // Resend state on new neighbor connection to guarantee synchronization let payload_opt = { let guard = self_state_clone.lock().unwrap(); guard.as_ref().map(|state| { sign_gossip( &secret_key, &topic_bytes, now_millis(), GossipMessage::Announce(state.clone()), ) }) }; if let Some(payload) = payload_opt && let Ok(bytes) = serde_json::to_vec(&payload) { crate::log_msg(&format!( "Broadcasting state to new neighbor={:?}", peer_id )); let _ = gossip_sender_clone.broadcast(bytes.into()).await; } } Ok(iroh_gossip::api::Event::NeighborDown(peer_id)) => { crate::log_msg(&format!("Gossip event: NeighborDown={:?}", peer_id)); // A NeighborDown is a *transient* loss, not a graceful // leave: emit PeerConnectionLost so the core marks the peer // "reconnecting" and keeps its audio supervisor redialing, // rather than tearing everything down. (Treating this as a // PeerLeft is exactly what defeated reconnect in the field — // it aborted the supervisor ~30s in.) We still drop our // cached presence entry; a rejoin re-announces as new. let removed = peers.lock().unwrap().remove(&peer_id).is_some(); if removed { disconnected_peers.lock().unwrap().insert(peer_id); crate::log_msg(&format!( "Peer connection lost (NeighborDown): {:?}", peer_id )); let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await; } } Ok(other) => { crate::log_msg(&format!("Gossip other event: {:?}", other)); } Err(e) => { crate::log_msg(&format!("Gossip error event: {:?}", e)); } } } crate::log_msg("Gossip topic loop terminated"); }); *self.active_topic.lock().unwrap() = Some(handle); Ok(()) } async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError> { crate::log_msg(&format!( "RoomState::update_self_state: state: {}", peer_state_for_log(&self_state) )); *self.self_state.lock().unwrap() = Some(self_state.clone()); let sender_opt = self.active_sender.lock().unwrap().clone(); let topic_opt = *self.active_topic_bytes.lock().unwrap(); if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) { let payload = sign_gossip( &self.secret_key, &topic, now_millis(), GossipMessage::Announce(self_state), ); if let Ok(bytes) = serde_json::to_vec(&payload) { crate::log_msg("Broadcasting updated self state to gossip"); sender .broadcast(bytes.into()) .await .map_err(|e| NetError::Gossip(e.to_string()))?; } } Ok(()) } async fn rebootstrap_peers(&self, peers: Vec) -> Result<(), NetError> { let self_id = self._endpoint.id(); let mut peer_ids = Vec::new(); for addr in peers { if addr.id == self_id || peer_ids.contains(&addr.id) { continue; } self.address_lookup.add_endpoint_info(addr.clone()); peer_ids.push(addr.id); } if peer_ids.is_empty() { return Ok(()); } // Clone the sender before awaiting: active_sender is a standard mutex and // must never be held across an async gossip operation. let sender = self .active_sender .lock() .unwrap() .clone() .ok_or_else(|| NetError::Other("Not in a room".to_string()))?; crate::log_msg(&format!("Rebootstrapping gossip peers: {:?}", peer_ids)); sender .join_peers(peer_ids) .await .map_err(|e| NetError::Gossip(e.to_string())) } fn mark_peer_disconnected(&self, peer_id: EndpointId) { if self.peers.lock().unwrap().remove(&peer_id).is_some() { self.disconnected_peers.lock().unwrap().insert(peer_id); } } async fn send_chat( &self, text: String, attachment: Option, ) -> Result<(), NetError> { // Enforce the chat text policy at the SIGN point, not only in the UI, so // a future non-UI caller can't sign an out-of-policy body (chat-hardening // plan Phase 1). Idempotent over the UI's own sanitize pass. let text = crate::sanitize::sanitize_chat(&text); if text.is_empty() && attachment.is_none() { // Nothing visible to send — not an error, just nothing to do. return Ok(()); } let name = { let guard = self.self_state.lock().unwrap(); match guard.as_ref() { Some(s) => s.name.clone(), None => return Err(NetError::Other("Not in a room".to_string())), } }; let ts = now_millis(); let sender_opt = self.active_sender.lock().unwrap().clone(); let topic_opt = *self.active_topic_bytes.lock().unwrap(); // A missing sender/topic or an encode failure is a real send failure the // caller must see (chat-hardening Phase 5) — silently returning Ok here // would let the UI present an unsent message as broadcast. let (Some(sender), Some(topic)) = (sender_opt, topic_opt) else { return Err(NetError::Other("Not in a room".to_string())); }; let payload = sign_gossip( &self.secret_key, &topic, ts, GossipMessage::Chat { name, text, ts, attachment, }, ); let bytes = serde_json::to_vec(&payload) .map_err(|e| NetError::Other(format!("Failed to encode chat: {e}")))?; sender .broadcast(bytes.into()) .await .map_err(|e| NetError::Gossip(e.to_string())) } async fn leave(&self) -> Result<(), NetError> { crate::log_msg("RoomState::leave called"); { let mut handle_guard = self.active_topic.lock().unwrap(); if let Some(handle) = handle_guard.take() { crate::log_msg("Aborting active gossip topic background task"); handle.abort(); } } *self.active_topic_id.lock().unwrap() = None; let topic_opt = self.active_topic_bytes.lock().unwrap().take(); let sender_opt = self.active_sender.lock().unwrap().take(); if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) { let payload = sign_gossip(&self.secret_key, &topic, now_millis(), GossipMessage::Leave); if let Ok(bytes) = serde_json::to_vec(&payload) { crate::log_msg("Broadcasting Leave message to gossip"); let _ = sender.broadcast(bytes.into()).await; } } self.peers.lock().unwrap().clear(); self.disconnected_peers.lock().unwrap().clear(); Ok(()) } fn active_peers(&self) -> Vec<(EndpointId, PeerState)> { let guard = self.peers.lock().unwrap(); guard.iter().map(|(k, v)| (*k, v.clone())).collect() } async fn subscribe_events(&self) -> Result, NetError> { let mut rx_guard = self.event_rx.lock().unwrap(); if let Some(rx) = rx_guard.take() { Ok(rx) } else { Err(NetError::Other("Events already subscribed".to_string())) } } } #[cfg(test)] mod tests { use super::*; use crate::network::PeerState; use iroh::SecretKey; use std::collections::HashMap; fn sample_peer_state_for(id: EndpointId) -> PeerState { let addr = iroh::EndpointAddr::from(id); PeerState { name: "TestPeerGossip".to_string(), is_muted: true, addr, sharing: None, avatar: crate::avatar::Avatar::default(), game: None, music: None, } } fn fresh_id() -> EndpointId { SecretKey::generate().public() } fn addr_of(id: EndpointId) -> EndpointAddr { EndpointAddr::from(id) } #[test] fn peer_state_log_includes_game() { let mut state = sample_peer_state_for(fresh_id()); state.game = Some("Half-Life 2".to_string()); assert!(peer_state_for_log(&state).contains("game=Some(\"Half-Life 2\")")); state.game = None; assert!(peer_state_for_log(&state).contains("game=None")); } #[test] fn bootstrap_client_dials_host() { // A non-host (client) with no retained peers dials just the ticket host. let me = fresh_id(); let host = fresh_id(); assert_eq!(compute_bootstrap(me, host, &[]), vec![host]); } #[test] fn bootstrap_host_rejoin_dials_retained_peers() { // The A8 regression: the room creator's ticket names themselves as host, // so the ticket-host contributes nothing — the retained peer is the only // (and essential) dial target. An empty result here would reproduce A8. let me = fresh_id(); let peer = fresh_id(); let bootstrap = compute_bootstrap(me, /* ticket_host = */ me, &[addr_of(peer)]); assert_eq!(bootstrap, vec![peer]); } #[test] fn bootstrap_host_rejoin_with_no_retained_peers_is_empty() { // Host rejoin before anyone was ever seen: nothing to dial (unavoidable), // but it must not contain self. let me = fresh_id(); assert!(compute_bootstrap(me, me, &[]).is_empty()); } #[test] fn bootstrap_excludes_self_and_dedups_host() { // Self is never dialed (even if it shows up among retained peers), and a // peer that equals the ticket host isn't listed twice. let me = fresh_id(); let host = fresh_id(); let other = fresh_id(); let extra = [addr_of(me), addr_of(host), addr_of(other)]; let bootstrap = compute_bootstrap(me, host, &extra); assert_eq!(bootstrap, vec![host, other]); assert!(!bootstrap.contains(&me)); } #[test] fn admit_into_roster_caps_new_authors_but_not_updates() { // New authors are admitted while there's room... assert!(admit_into_roster(0, true, 3)); assert!(admit_into_roster(2, true, 3)); // ...rejected once the roster is full... assert!(!admit_into_roster(3, true, 3)); assert!(!admit_into_roster(10, true, 3)); // ...but an existing peer's update always passes, even at/over the cap. assert!(admit_into_roster(3, false, 3)); assert!(admit_into_roster(99, false, 3)); } #[test] fn reconnecting_and_existing_peers_are_exempt_from_the_cap() { // A brand-new author counts against the cap... assert!(announce_subject_to_cap( /* is_new */ true, /* is_reconnecting */ false )); // ...but an ordinary update from an in-roster peer does not... assert!(!announce_subject_to_cap(false, false)); // ...and neither does a re-announce from a peer mid-reconnect, even // though it was removed from the live roster (the F-01-audit fix: a full // room must not reject a legitimately reconnecting member). assert!(!announce_subject_to_cap(true, true)); // Combined with admit_into_roster: a reconnecting author passes at a full // roster, a brand-new one does not. assert!(admit_into_roster(3, announce_subject_to_cap(true, true), 3)); assert!(!admit_into_roster( 3, announce_subject_to_cap(true, false), 3 )); } #[test] fn prune_stale_mutations_drops_only_out_of_window_entries() { let a = fresh_id(); let b = fresh_id(); let mut seen = HashMap::new(); seen.insert((a, StateMutationKind::Announce), 10_000u64); seen.insert((b, StateMutationKind::Leave), 250_000u64); // now = 300_000, window = 120_000 → floor 180_000. The 10_000 entry is // stale (and could never gate an in-window message), the 250_000 is live. prune_stale_mutations(&mut seen, 300_000, GOSSIP_FRESHNESS_MS); assert_eq!(seen.len(), 1); assert!(seen.contains_key(&(b, StateMutationKind::Leave))); assert!(!seen.contains_key(&(a, StateMutationKind::Announce))); } #[test] fn clock_skew_monitor_single_drop_does_not_warn() { let author = fresh_id(); let mut monitor = ClockSkewMonitor::default(); assert_eq!(monitor.observe(author, -121_000, 10_000), None); } #[test] fn clock_skew_monitor_three_drops_in_window_warn_once() { let author = fresh_id(); let mut monitor = ClockSkewMonitor::default(); assert_eq!(monitor.observe(author, -121_000, 10_000), None); assert_eq!(monitor.observe(author, -122_000, 40_000), None); assert_eq!( monitor.observe(author, -123_000, 69_999), Some(ClockSkewWarning { author, skew_ms: -123_000 }) ); assert_eq!(monitor.observe(author, -124_000, 70_000), None); } #[test] fn clock_skew_monitor_cooldown_suppresses_repeats() { let author = fresh_id(); let mut monitor = ClockSkewMonitor::default(); assert_eq!(monitor.observe(author, 121_000, 0), None); assert_eq!(monitor.observe(author, 122_000, 10_000), None); assert!(monitor.observe(author, 123_000, 20_000).is_some()); assert_eq!(monitor.observe(author, 124_000, 30_000), None); assert_eq!(monitor.observe(author, 125_000, 310_000), None); assert_eq!(monitor.observe(author, 126_000, 319_000), None); assert_eq!(monitor.observe(author, 127_000, 319_999), None); assert_eq!( monitor.observe(author, 128_000, 320_000), Some(ClockSkewWarning { author, skew_ms: 128_000 }) ); } #[test] fn clock_skew_monitor_tracks_distinct_authors_independently() { let a = fresh_id(); let b = fresh_id(); let mut monitor = ClockSkewMonitor::default(); assert_eq!(monitor.observe(a, -121_000, 0), None); assert_eq!(monitor.observe(a, -121_000, 1_000), None); assert_eq!(monitor.observe(b, 121_000, 0), None); assert_eq!(monitor.observe(b, 121_000, 1_000), None); assert_eq!( monitor.observe(b, 121_000, 2_000), Some(ClockSkewWarning { author: b, skew_ms: 121_000 }) ); assert_eq!( monitor.observe(a, -121_000, 2_000), Some(ClockSkewWarning { author: a, skew_ms: -121_000 }) ); } #[test] fn clock_skew_monitor_prunes_stale_authors_when_over_cap() { let mut monitor = ClockSkewMonitor::default(); for _ in 0..=CLOCK_SKEW_AUTHORS_SOFT_CAP { assert_eq!(monitor.observe(fresh_id(), -121_000, 1), None); } assert!(monitor.authors.len() > CLOCK_SKEW_AUTHORS_SOFT_CAP); let current = fresh_id(); assert_eq!( monitor.observe(current, -121_000, CLOCK_SKEW_AUTHOR_TTL_MS + 2), None ); assert_eq!(monitor.authors.len(), 1); assert!(monitor.authors.contains_key(¤t)); } #[test] fn clock_skew_monitor_hard_cap_bounds_fresh_author_growth() { let mut monitor = ClockSkewMonitor::default(); for now_ms in 0..(CLOCK_SKEW_AUTHORS_HARD_CAP as u64 + 10) { let _ = monitor.observe(fresh_id(), -121_000, now_ms); assert!(monitor.authors.len() <= CLOCK_SKEW_AUTHORS_HARD_CAP); } } #[test] fn sanitize_endpoint_addr_caps_address_count() { use std::net::SocketAddr; let id = fresh_id(); // An insider stuffs far more addresses than MAX_PEER_ADDRS into one announce. let many: Vec = (0..(MAX_PEER_ADDRS as u16 + 50)) .map(|i| TransportAddr::Ip(SocketAddr::from(([127, 0, 0, 1], 1000 + i)))) .collect(); let addr = EndpointAddr::from_parts(id, many); let out = sanitize_endpoint_addr(&addr); assert_eq!(out.id, id); assert_eq!(out.addrs.len(), MAX_PEER_ADDRS); } #[test] fn sanitize_endpoint_addr_drops_overlong_relay_url() { use std::str::FromStr; let id = fresh_id(); let short = iroh::RelayUrl::from_str("https://relay.example/").unwrap(); let long = iroh::RelayUrl::from_str(&format!( "https://relay.example/{}", "a".repeat(MAX_RELAY_URL_LEN) )) .unwrap(); assert!(long.as_str().len() > MAX_RELAY_URL_LEN); let addr = EndpointAddr::from_parts( id, [ TransportAddr::Relay(short.clone()), TransportAddr::Relay(long), ], ); let out = sanitize_endpoint_addr(&addr); let relays: Vec<_> = out.relay_urls().cloned().collect(); assert_eq!(relays, vec![short], "over-long relay URL must be dropped"); } #[test] fn test_gossip_message_leave_round_trip() { let original = GossipMessage::Leave; let serialized = serde_json::to_string(&original).unwrap(); let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap(); assert!(matches!(deserialized, GossipMessage::Leave)); } #[test] fn test_gossip_payload_announce_round_trip() { let secret = SecretKey::generate(); let topic = [9u8; 32]; let peer_state = sample_peer_state_for(secret.public()); let payload = sign_gossip( &secret, &topic, 1000, GossipMessage::Announce(peer_state.clone()), ); let serialized = serde_json::to_string(&payload).unwrap(); let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap(); // Author is derived from the signing key, and the signed payload still // verifies after a serde round-trip. assert_eq!(deserialized.author, secret.public()); assert!(verify_gossip(&deserialized, &topic, 1000, GOSSIP_FRESHNESS_MS).is_ok()); match deserialized.msg { GossipMessage::Announce(state) => { assert_eq!(state, peer_state); } GossipMessage::Leave | GossipMessage::Chat { .. } => { panic!("Expected GossipMessage::Announce"); } } } #[test] fn test_gossip_message_chat_round_trip() { // Test normal chat message let original = GossipMessage::Chat { name: "Alice".to_string(), text: "Hello".to_string(), ts: 123456789, attachment: None, }; let serialized = serde_json::to_string(&original).unwrap(); let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap(); if let GossipMessage::Chat { name, text, ts, attachment, } = deserialized { assert_eq!(name, "Alice"); assert_eq!(text, "Hello"); assert_eq!(ts, 123456789); assert_eq!(attachment, None); } else { panic!("Expected GossipMessage::Chat"); } // Test empty strings and large timestamp let original_empty = GossipMessage::Chat { name: "".to_string(), text: "".to_string(), ts: u64::MAX, attachment: None, }; let serialized_empty = serde_json::to_string(&original_empty).unwrap(); let deserialized_empty: GossipMessage = serde_json::from_str(&serialized_empty).unwrap(); if let GossipMessage::Chat { name, text, ts, .. } = deserialized_empty { assert_eq!(name, ""); assert_eq!(text, ""); assert_eq!(ts, u64::MAX); } else { panic!("Expected GossipMessage::Chat"); } } #[test] fn test_gossip_chat_attachment_round_trip_and_back_compat() { let att = crate::files::ChatAttachment { name: "photo.png".to_string(), size: 4096, kind: crate::files::AttachmentKind::Image, id: [42u8; 32], }; let original = GossipMessage::Chat { name: "Alice".to_string(), text: "look at this".to_string(), ts: 1, attachment: Some(att.clone()), }; let serialized = serde_json::to_string(&original).unwrap(); let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap(); if let GossipMessage::Chat { attachment, .. } = deserialized { assert_eq!(attachment, Some(att)); } else { panic!("Expected GossipMessage::Chat"); } // A pre-v2 chat payload (no `attachment` field) must still deserialize, // defaulting the attachment to None (serde(default)). let legacy = r#"{"Chat":{"name":"Old","text":"hi","ts":7}}"#; let parsed: GossipMessage = serde_json::from_str(legacy).unwrap(); if let GossipMessage::Chat { name, attachment, .. } = parsed { assert_eq!(name, "Old"); assert_eq!(attachment, None); } else { panic!("Expected GossipMessage::Chat"); } } #[test] fn test_gossip_payload_chat_round_trip() { let secret = SecretKey::generate(); let topic = [3u8; 32]; let payload = sign_gossip( &secret, &topic, 987654321, GossipMessage::Chat { name: "Bob".to_string(), text: "Hi there".to_string(), ts: 987654321, attachment: None, }, ); let serialized = serde_json::to_string(&payload).unwrap(); let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap(); assert_eq!(deserialized.author, secret.public()); if let GossipMessage::Chat { name, text, ts, .. } = deserialized.msg { assert_eq!(name, "Bob"); assert_eq!(text, "Hi there"); assert_eq!(ts, 987654321); } else { panic!("Expected GossipMessage::Chat"); } } #[test] fn test_gossip_chat_unicode_round_trip() { let original = GossipMessage::Chat { name: "🎙 User".to_string(), text: "héllo 🎙 世界".to_string(), ts: 1717171717, attachment: None, }; let serialized = serde_json::to_string(&original).unwrap(); let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap(); if let GossipMessage::Chat { name, text, ts, .. } = deserialized { assert_eq!(name, "🎙 User"); assert_eq!(text, "héllo 🎙 世界"); assert_eq!(ts, 1717171717); } else { panic!("Expected GossipMessage::Chat"); } } // --- S2 authentication (verify_gossip) --- #[test] fn verify_accepts_a_genuine_signed_payload() { let secret = SecretKey::generate(); let topic = [1u8; 32]; let p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave); assert_eq!( verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), Ok(()) ); } #[test] fn verify_rejects_a_forged_author() { // Attacker signs with their OWN key but claims to be the victim — the // classic S2 spoof. The signature can't validate against the victim key. let attacker = SecretKey::generate(); let victim = SecretKey::generate(); let topic = [2u8; 32]; let mut p = sign_gossip(&attacker, &topic, 5_000, GossipMessage::Leave); p.author = victim.public(); // forge the claimed author assert_eq!( verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), Err(GossipReject::BadSignature) ); } #[test] fn verify_rejects_a_tampered_message() { // Flipping the message after signing must invalidate the signature. let secret = SecretKey::generate(); let topic = [4u8; 32]; let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave); p.msg = GossipMessage::Chat { name: "x".into(), text: "y".into(), ts: 5_000, attachment: None, }; assert_eq!( verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), Err(GossipReject::BadSignature) ); } #[test] fn verify_rejects_cross_room_replay() { // A payload signed for one topic must not validate against another room // (the topic is bound into the signed bytes). let secret = SecretKey::generate(); let p = sign_gossip(&secret, &[7u8; 32], 5_000, GossipMessage::Leave); assert_eq!( verify_gossip(&p, &[8u8; 32], 5_000, GOSSIP_FRESHNESS_MS), Err(GossipReject::BadSignature) ); } #[test] fn verify_rejects_stale_and_future_timestamps() { let secret = SecretKey::generate(); let topic = [5u8; 32]; let p = sign_gossip(&secret, &topic, 1_000_000, GossipMessage::Leave); // Far in the past relative to "now" → stale (replay). assert_eq!( verify_gossip( &p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS + 1, GOSSIP_FRESHNESS_MS ), Err(GossipReject::OutOfWindow) ); // Implausibly future. assert_eq!( verify_gossip( &p, &topic, 1_000_000 - GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS ), Err(GossipReject::OutOfWindow) ); // Within the window (clock skew tolerance) → accepted. assert!( verify_gossip( &p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS ) .is_ok() ); } #[test] fn verify_rejects_announce_with_address_for_another_identity() { let signer = SecretKey::generate(); let advertised = SecretKey::generate(); let topic = [6u8; 32]; let state = sample_peer_state_for(advertised.public()); let p = sign_gossip(&signer, &topic, 5_000, GossipMessage::Announce(state)); assert_eq!( verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), Err(GossipReject::AnnounceAddressMismatch) ); } #[test] fn state_mutation_replay_gate_drops_replayed_leave_and_announce() { let author = fresh_id(); let mut seen = HashMap::new(); assert!(admit_state_mutation( &mut seen, author, &GossipMessage::Leave, 10 )); assert!(!admit_state_mutation( &mut seen, author, &GossipMessage::Leave, 10 )); assert!(!admit_state_mutation( &mut seen, author, &GossipMessage::Leave, 9 )); assert!(admit_state_mutation( &mut seen, author, &GossipMessage::Leave, 11 )); let announce = GossipMessage::Announce(sample_peer_state_for(author)); assert!(admit_state_mutation(&mut seen, author, &announce, 10)); assert!(!admit_state_mutation(&mut seen, author, &announce, 10)); assert!(!admit_state_mutation(&mut seen, author, &announce, 9)); assert!(admit_state_mutation(&mut seen, author, &announce, 12)); } #[test] fn state_mutation_replay_gate_leaves_chat_ordering_untouched() { let author = fresh_id(); let mut seen = HashMap::new(); let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200, attachment: None, }; let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100, attachment: None, }; assert!(admit_state_mutation(&mut seen, author, &later_chat, 200)); assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100)); assert!(admit_state_mutation(&mut seen, author, &later_chat, 200)); assert!( seen.is_empty(), "chat must not populate the state-mutation replay map" ); } #[test] fn state_mutation_replay_gate_is_per_author_and_kind() { let author = fresh_id(); let other = fresh_id(); let mut seen = HashMap::new(); let announce = GossipMessage::Announce(sample_peer_state_for(author)); assert!(admit_state_mutation( &mut seen, author, &GossipMessage::Leave, 5 )); assert!(admit_state_mutation(&mut seen, author, &announce, 5)); assert!(admit_state_mutation( &mut seen, other, &GossipMessage::Leave, 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)); } }