Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e1740d3c |
@@ -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).
|
auto-join), the discovery publish (only when toggled, ideally auto-expiring).
|
||||||
`cargo audit` (JSON store → no new deps expected). Field test on dopedart.
|
`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)
|
## The connect flow (the user's scenario, end to end)
|
||||||
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
|
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
|
||||||
"HangOut."
|
"HangOut."
|
||||||
|
|||||||
+15
-3
@@ -1074,22 +1074,34 @@ async fn run_core_loop(
|
|||||||
// Join, cleared on Leave.
|
// Join, cleared on Leave.
|
||||||
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
|
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
|
||||||
Arc::new(std::sync::Mutex::new(None));
|
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
|
// Reply policy for the idle friends listener (B2): answer friends only, never
|
||||||
// while invisible (`should_answer`), and report our current gathering so a friend
|
// 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
|
// can one-click join. Rate-limits allowed friends before building a reply, so a
|
||||||
// change and survives a network-stack rebuild. Pure-sync (no awaits, no lock held
|
// spammy saved peer gets the same silent close as an unauthorized peer. Reads the
|
||||||
// across one). Built once and handed to every `build_net_stack`.
|
// 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_handler: crate::presence_net::Handler = {
|
||||||
let friends = friends.clone();
|
let friends = friends.clone();
|
||||||
let presence_mode = presence_mode.clone();
|
let presence_mode = presence_mode.clone();
|
||||||
let current_room = current_room.clone();
|
let current_room = current_room.clone();
|
||||||
|
let presence_rate_limiter = presence_rate_limiter.clone();
|
||||||
Arc::new(move |from| {
|
Arc::new(move |from| {
|
||||||
let mode = *presence_mode.lock().unwrap();
|
let mode = *presence_mode.lock().unwrap();
|
||||||
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
|
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
|
||||||
if !allowed {
|
if !allowed {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if !presence_rate_limiter
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.allow(from, std::time::Instant::now())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let room = current_room.lock().unwrap().clone();
|
let room = current_room.lock().unwrap().clone();
|
||||||
Some(crate::presence::ControlMsg::Pong { room })
|
Some(crate::presence::ControlMsg::Pong { room })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
use crate::friends::FriendStore;
|
use crate::friends::FriendStore;
|
||||||
use iroh::EndpointId;
|
use iroh::EndpointId;
|
||||||
use serde::{Deserialize, Serialize};
|
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.
|
/// The user's presence posture — how reachable they are to friends while idle.
|
||||||
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends
|
/// 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)
|
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<EndpointId, RateBucket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
/// What we learned about a friend from a successful ping reply.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum FriendPresence {
|
pub enum FriendPresence {
|
||||||
@@ -177,6 +229,36 @@ mod tests {
|
|||||||
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
|
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]
|
#[test]
|
||||||
fn presence_mode_flags() {
|
fn presence_mode_flags() {
|
||||||
assert!(PresenceMode::Discoverable.publishes_to_discovery());
|
assert!(PresenceMode::Discoverable.publishes_to_discovery());
|
||||||
|
|||||||
Reference in New Issue
Block a user