From a897f5c5d7867acd3cc266bd5aa1facaffe996f8 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Mon, 15 Jun 2026 04:28:08 -0400 Subject: [PATCH] feat(presence): pure friends presence protocol + auth gate (W7 P4 core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The logic half of the friends-only idle listener, built as pure unit-tested seams so the security-critical decisions are provable without live networking. src/presence.rs: - ControlMsg { Ping, Pong { room: Option } } — self-describing tagged JSON; unknown tags rejected (forward-compat). - should_answer(from, friends, mode): the authorization gate — answer pings from FRIENDS ONLY and never while invisible. This whitelist is what keeps the always-on-while-open endpoint from being a stranger-facing spam/DoS surface; must be the authenticated remote_id, never payload data. - PresenceMode { Invisible, Normal(default), Discoverable } + helpers; persisted in AppConfig (backward-compat default = Normal = friends-only, no beacon). - interpret_pong: defensive reply handling — sanitizes the peer-supplied room name and only surfaces a joinable room if its ticket actually parses, else downgrades to plain Online (no dead/hostile Join button). Never auto-joins. Deferred to a 2-machine session (the I/O edges): binding the live control endpoint, its accept loop, and the ping scheduler. +8 presence tests, 256 lib tests green, clippy clean, release builds. Co-Authored-By: Claude Opus 4.8 --- src/config.rs | 7 ++ src/lib.rs | 1 + src/presence.rs | 232 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 src/presence.rs diff --git a/src/config.rs b/src/config.rs index fb394cf..124a966 100644 --- a/src/config.rs +++ b/src/config.rs @@ -153,6 +153,10 @@ pub struct AppConfig { pub output_volume: f32, #[serde(default)] pub network_mode: NetworkMode, + /// Presence posture for the friends idle listener (W7): invisible / normal / + /// discoverable. Default `Normal` = answer friends only, no DNS beacon. + #[serde(default)] + pub presence_mode: crate::presence::PresenceMode, /// Route audio through PipeWire's echo-cancel module (AEC + noise suppression). /// Takes effect on the next room join. Off by default. #[serde(default)] @@ -249,6 +253,7 @@ impl Default for AppConfig { input_volume: 1.0, output_volume: 1.0, network_mode: NetworkMode::default(), + presence_mode: crate::presence::PresenceMode::default(), echo_cancellation_enabled: false, notifications_enabled: true, participants_width: default_participants_width(), @@ -361,6 +366,8 @@ mod tests { let deserialized: AppConfig = serde_json::from_str(minimal_json).unwrap(); assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery); + // Configs predating the presence posture load as friends-only (no beacon). + assert_eq!(deserialized.presence_mode, crate::presence::PresenceMode::Normal); assert!(!deserialized.echo_cancellation_enabled); assert!(deserialized.notifications_enabled); // Configs predating the volume sliders must load at unity gain. diff --git a/src/lib.rs b/src/lib.rs index 0c9d30f..74bd579 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod app; pub mod config; pub mod identity; pub mod friends; +pub mod presence; pub mod theme; pub mod notify; pub mod screenshare; diff --git a/src/presence.rs b/src/presence.rs new file mode 100644 index 0000000..f5dce11 --- /dev/null +++ b/src/presence.rs @@ -0,0 +1,232 @@ +//! Friends presence/control plane — pure protocol + policy (W7 P4 core). +//! +//! This is the logic half of the "friends-only idle listener" from +//! `docs/contacts-plan.md`: while the app is open it answers presence pings from +//! *friends only* (no stranger surface, no DNS beacon) and, if we're currently in +//! a gathering, hands back a one-click-join member ticket. Here we define the +//! wire messages, the authorization gate, the user's presence posture, and the +//! defensive interpretation of replies — all pure and unit-tested. +//! +//! **Deliberately NOT here (deferred to a 2-machine session):** binding the live +//! control endpoint, its accept loop, and the outbound ping scheduler. Those are +//! the I/O edges; keeping them out means the security-critical decisions (who we +//! answer, what we trust from a peer) are testable in isolation. + +use crate::friends::FriendStore; +use iroh::EndpointId; +use serde::{Deserialize, Serialize}; + +/// The user's presence posture — how reachable they are to friends while idle. +/// Persisted in `AppConfig`; the default keeps you privately reachable to friends +/// with no presence beacon. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PresenceMode { + /// Don't answer pings at all — appear offline to everyone, even friends. + Invisible, + /// Answer pings from friends only, from our saved address. No DNS beacon. + /// The default. + #[default] + Normal, + /// Like `Normal`, and additionally publish to discovery so friends can still + /// find us after a network change (opt-in; the publish itself is wired with + /// the live endpoint later). Asymmetric: only the mover needs this on. + Discoverable, +} + +impl PresenceMode { + /// Whether this posture publishes to discovery (the only mode that does). + pub fn publishes_to_discovery(self) -> bool { + matches!(self, PresenceMode::Discoverable) + } + + /// Whether this posture answers friend pings at all. + pub fn answers_pings(self) -> bool { + !matches!(self, PresenceMode::Invisible) + } +} + +/// What a peer advertises about the gathering they're currently in. Both fields +/// are peer-supplied and therefore untrusted — see [`interpret_pong`], which +/// sanitizes the name and validates the ticket before surfacing them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoomPresence { + /// The cosmetic room label ("HangOut"). + pub name: String, + /// A member-issued ticket the recipient can one-click join (points at the + /// sender's own address — see [`crate::network::PeerSpeakTicket::restamp`]). + pub ticket: String, +} + +/// A message on the friends presence/control plane. +/// +/// `#[serde(tag = "type")]` keeps the JSON self-describing and lets us add +/// variants without breaking older peers (an unknown tag fails to parse and is +/// dropped, rather than being misread as another variant). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ControlMsg { + /// "Are you there?" — a presence probe. + Ping, + /// Reply to a ping: I'm online; `room` is `Some` when I'm in a gathering you + /// could join. + Pong { room: Option }, +} + +/// The authorization gate for the idle listener: whether to answer an inbound +/// ping from `from`. **Friends-only, and never while invisible.** This is the +/// whitelist that keeps the always-on-while-open endpoint from being a +/// stranger-facing spam/DoS surface — the single most security-critical decision +/// in the friends-first model, so it lives in one pure, tested function. +/// +/// `from` must be the connection's *authenticated* remote id (`remote_id()`), +/// never a value carried in the payload. +pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMode) -> bool { + mode.answers_pings() && friends.contains(from) +} + +/// What we learned about a friend from a successful ping reply. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FriendPresence { + /// Online, but not in a gathering we can join. + Online, + /// Online and in a joinable gathering (name already sanitized, ticket already + /// validated as parseable). + InRoom { name: String, ticket: String }, +} + +/// Interpret a peer's reply defensively. Only a `Pong` is a reply (a `Ping` is +/// not, so it yields `None`). When the peer reports a room, we **sanitize the +/// peer-supplied name** and **only surface it as joinable if the ticket actually +/// parses** as a [`crate::network::PeerSpeakTicket`] — a garbage or hostile +/// ticket downgrades the friend to plain `Online` rather than offering a dead / +/// dangerous Join button. (We still never auto-join; the user clicks.) +pub fn interpret_pong(msg: &ControlMsg) -> Option { + match msg { + ControlMsg::Ping => None, + ControlMsg::Pong { room: None } => Some(FriendPresence::Online), + ControlMsg::Pong { room: Some(r) } => { + if r.ticket.parse::().is_ok() { + Some(FriendPresence::InRoom { + name: crate::sanitize::sanitize_name(&r.name), + ticket: r.ticket.clone(), + }) + } else { + // Online, but the advertised room is unusable — don't offer Join. + Some(FriendPresence::Online) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::network::PeerSpeakTicket; + use iroh::{EndpointAddr, SecretKey}; + + fn id() -> EndpointId { + SecretKey::generate().public() + } + + fn valid_ticket(for_id: EndpointId) -> String { + PeerSpeakTicket { host_addr: EndpointAddr::from(for_id), topic_id: [9u8; 32] }.to_string() + } + + #[test] + fn should_answer_is_friends_only_and_respects_mode() { + let mut friends = FriendStore::default(); + let friend = id(); + let stranger = id(); + friends.add(friend, "Pal".into(), None); + + // Friend + a ping-answering mode -> answer. + assert!(should_answer(&friend, &friends, PresenceMode::Normal)); + assert!(should_answer(&friend, &friends, PresenceMode::Discoverable)); + // Friend but invisible -> silent. + assert!(!should_answer(&friend, &friends, PresenceMode::Invisible)); + // Stranger is NEVER answered, in any mode. + assert!(!should_answer(&stranger, &friends, PresenceMode::Normal)); + assert!(!should_answer(&stranger, &friends, PresenceMode::Discoverable)); + assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible)); + } + + #[test] + fn presence_mode_flags() { + assert!(PresenceMode::Discoverable.publishes_to_discovery()); + assert!(!PresenceMode::Normal.publishes_to_discovery()); + assert!(!PresenceMode::Invisible.publishes_to_discovery()); + assert!(PresenceMode::Normal.answers_pings()); + assert!(PresenceMode::Discoverable.answers_pings()); + assert!(!PresenceMode::Invisible.answers_pings()); + assert_eq!(PresenceMode::default(), PresenceMode::Normal); + } + + #[test] + fn control_msg_round_trips() { + let cases = [ + ControlMsg::Ping, + ControlMsg::Pong { room: None }, + ControlMsg::Pong { + room: Some(RoomPresence { name: "HangOut".into(), ticket: "abc".into() }), + }, + ]; + for msg in cases { + let bytes = serde_json::to_vec(&msg).unwrap(); + let back: ControlMsg = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(back, msg); + } + } + + #[test] + fn unknown_message_tag_is_rejected() { + // Forward-compat: an unknown variant fails to parse (dropped, not misread). + assert!(serde_json::from_str::(r#"{"type":"nonsense"}"#).is_err()); + } + + #[test] + fn interpret_ping_is_not_a_reply() { + assert_eq!(interpret_pong(&ControlMsg::Ping), None); + } + + #[test] + fn interpret_pong_online_and_inroom() { + // No room -> Online. + assert_eq!( + interpret_pong(&ControlMsg::Pong { room: None }), + Some(FriendPresence::Online) + ); + // Valid ticket -> InRoom with a sanitized name. + let t = valid_ticket(id()); + let got = interpret_pong(&ControlMsg::Pong { + room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }), + }); + assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t })); + } + + #[test] + fn interpret_pong_downgrades_a_garbage_ticket_to_online() { + // A friend reporting a room with an unparseable ticket is treated as just + // Online — no dead/hostile Join button is surfaced. + let got = interpret_pong(&ControlMsg::Pong { + room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }), + }); + assert_eq!(got, Some(FriendPresence::Online)); + } + + #[test] + fn interpret_pong_sanitizes_a_hostile_room_name() { + // Control/bidi characters in a peer-supplied name are stripped. + let t = valid_ticket(id()); + let got = interpret_pong(&ControlMsg::Pong { + room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }), + }); + match got { + Some(FriendPresence::InRoom { name, .. }) => { + assert!(!name.contains('\u{202e}'), "bidi override must be stripped"); + assert!(!name.contains('\u{0007}'), "control char must be stripped"); + } + other => panic!("expected InRoom, got {other:?}"), + } + } +}