diff --git a/docs/contacts-plan.md b/docs/contacts-plan.md index 7bb49b7..ab93c6f 100644 --- a/docs/contacts-plan.md +++ b/docs/contacts-plan.md @@ -225,6 +225,20 @@ state change; rate-limit pings), tickets from friends (validate defensively, no auto-join), the discovery publish (only when toggled, ideally auto-expiring). `cargo audit` (JSON store → no new deps expected). Field test on dopedart. +**Local hardening DONE 2026-06-27:** inbound friend-presence replies are now +rate-limited per authenticated friend id (`PresenceRateLimiter`: burst 4, refill +1/15s) and wired into the live friends listener before it builds a `Pong`; denied +probes get the same silent no-data close as unauthorized probes. Existing +defensive reply handling still validates room tickets against the authenticated +friend id and never auto-joins. Verified with `cargo test presence`, +`cargo test --lib`, `cargo clippy --all-targets -- -D warnings`, and +`cargo audit --no-fetch --stale` (local DB; reports only the two already-allowed +unmaintained advisories in `deny.toml`). A fresh advisory fetch was blocked in +this sandbox by network restrictions. + +**Remaining:** live 2-machine field test on dopedart, a fresh online +`cargo audit`, and any follow-up findings from that test. + ## The connect flow (the user's scenario, end to end) 1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled "HangOut." diff --git a/src/core/mod.rs b/src/core/mod.rs index 0d254f8..90da4f8 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1074,22 +1074,34 @@ async fn run_core_loop( // Join, cleared on Leave. let current_room: Arc>> = Arc::new(std::sync::Mutex::new(None)); + let presence_rate_limiter = + Arc::new(std::sync::Mutex::new(crate::presence::PresenceRateLimiter::default())); // Reply policy for the idle friends listener (B2): answer friends only, never // while invisible (`should_answer`), and report our current gathering so a friend - // can one-click join. Reads the shared snapshots, so it stays correct as they - // change and survives a network-stack rebuild. Pure-sync (no awaits, no lock held - // across one). Built once and handed to every `build_net_stack`. + // can one-click join. Rate-limits allowed friends before building a reply, so a + // spammy saved peer gets the same silent close as an unauthorized peer. Reads the + // shared snapshots, so it stays correct as they change and survives a network-stack + // rebuild. Pure-sync (no awaits, no lock held across one). Built once and handed + // to every `build_net_stack`. let friends_handler: crate::presence_net::Handler = { let friends = friends.clone(); let presence_mode = presence_mode.clone(); let current_room = current_room.clone(); + let presence_rate_limiter = presence_rate_limiter.clone(); Arc::new(move |from| { let mode = *presence_mode.lock().unwrap(); let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode); if !allowed { return None; } + if !presence_rate_limiter + .lock() + .unwrap() + .allow(from, std::time::Instant::now()) + { + return None; + } let room = current_room.lock().unwrap().clone(); Some(crate::presence::ControlMsg::Pong { room }) }) diff --git a/src/presence.rs b/src/presence.rs index 69ec24c..e9a917e 100644 --- a/src/presence.rs +++ b/src/presence.rs @@ -15,6 +15,16 @@ use crate::friends::FriendStore; use iroh::EndpointId; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// Maximum immediate presence replies to one friend before throttling. Normal +/// presence polling is once per minute, so this only catches repeated/manual or +/// abusive probes while still allowing a short burst after app startup. +pub const PRESENCE_RATE_LIMIT_BURST: u32 = 4; + +/// Refill one presence-reply token per friend at this cadence. +pub const PRESENCE_RATE_LIMIT_REFILL: Duration = Duration::from_secs(15); /// The user's presence posture — how reachable they are to friends while idle. /// Persisted in `AppConfig`; the default keeps you privately reachable to friends @@ -100,6 +110,48 @@ pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMod mode.answers_pings() && friends.contains(from) } +#[derive(Debug, Clone)] +struct RateBucket { + tokens: u32, + last_refill: Instant, +} + +/// Per-friend limiter for inbound presence pings. It is intentionally keyed by +/// the authenticated connection id, not payload data. Callers should only invoke +/// it after [`should_answer`] passes, so strangers do not consume memory here. +#[derive(Debug, Default, Clone)] +pub struct PresenceRateLimiter { + buckets: HashMap, +} + +impl PresenceRateLimiter { + /// Return whether `from` may receive a presence reply at `now`. + /// + /// This is a token bucket: each friend starts with a small burst and regains + /// one token every [`PRESENCE_RATE_LIMIT_REFILL`]. A denied probe should be + /// answered with no data, matching the listener's "reveal nothing" policy. + pub fn allow(&mut self, from: EndpointId, now: Instant) -> bool { + let bucket = self.buckets.entry(from).or_insert(RateBucket { + tokens: PRESENCE_RATE_LIMIT_BURST, + last_refill: now, + }); + + let elapsed = now.saturating_duration_since(bucket.last_refill); + let refill = elapsed.as_secs() / PRESENCE_RATE_LIMIT_REFILL.as_secs(); + if refill > 0 { + let refill = refill.min(u32::MAX as u64) as u32; + bucket.tokens = PRESENCE_RATE_LIMIT_BURST.min(bucket.tokens.saturating_add(refill)); + bucket.last_refill = now; + } + + if bucket.tokens == 0 { + return false; + } + bucket.tokens -= 1; + true + } +} + /// What we learned about a friend from a successful ping reply. #[derive(Debug, Clone, PartialEq, Eq)] pub enum FriendPresence { @@ -177,6 +229,36 @@ mod tests { assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible)); } + #[test] + fn presence_rate_limiter_allows_a_small_burst_then_refills() { + let mut limiter = PresenceRateLimiter::default(); + let friend = id(); + let now = Instant::now(); + + for _ in 0..PRESENCE_RATE_LIMIT_BURST { + assert!(limiter.allow(friend, now)); + } + assert!(!limiter.allow(friend, now)); + assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL - Duration::from_millis(1))); + + assert!(limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL)); + assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL)); + } + + #[test] + fn presence_rate_limiter_is_per_peer() { + let mut limiter = PresenceRateLimiter::default(); + let a = id(); + let b = id(); + let now = Instant::now(); + + for _ in 0..PRESENCE_RATE_LIMIT_BURST { + assert!(limiter.allow(a, now)); + } + assert!(!limiter.allow(a, now)); + assert!(limiter.allow(b, now)); + } + #[test] fn presence_mode_flags() { assert!(PresenceMode::Discoverable.publishes_to_discovery());