//! 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 { /// All postures, default first — the option list for the Settings/home picker. pub const ALL: [PresenceMode; 3] = [PresenceMode::Normal, PresenceMode::Invisible, PresenceMode::Discoverable]; /// 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) } } impl std::fmt::Display for PresenceMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let label = match self { PresenceMode::Normal => "Normal — friends only", PresenceMode::Invisible => "Invisible — appear offline", PresenceMode::Discoverable => "Discoverable — findable after a move", }; f.write_str(label) } } /// 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. `from` must be the connection's /// authenticated remote id, not any value carried in the payload. 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`] and /// points back at the replying friend. A garbage/redirect ticket downgrades the /// friend to plain `Online` rather than offering a dead or attacker-controlled /// Join button. (We still never auto-join; the user clicks.) pub fn interpret_pong(msg: &ControlMsg, from: EndpointId) -> Option { match msg { ControlMsg::Ping => None, ControlMsg::Pong { room: None } => Some(FriendPresence::Online), ControlMsg::Pong { room: Some(r) } => { let Ok(ticket) = r.ticket.parse::() else { // Online, but the advertised room is unusable — don't offer Join. return Some(FriendPresence::Online); }; if ticket.host_addr.id != from { // Online, but the advertised room redirects away from the friend // who authenticated this Pong — don't offer a phishing Join. return Some(FriendPresence::Online); } Some(FriendPresence::InRoom { name: crate::sanitize::sanitize_name(&r.name), ticket: r.ticket.clone(), }) } } } #[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], name: String::new(), } .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, id()), None); } #[test] fn interpret_pong_online_and_inroom() { let friend = id(); // No room -> Online. assert_eq!( interpret_pong(&ControlMsg::Pong { room: None }, friend), Some(FriendPresence::Online) ); // Valid ticket -> InRoom with a sanitized name. let t = valid_ticket(friend); let got = interpret_pong(&ControlMsg::Pong { room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }), }, friend); 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() }), }, id()); assert_eq!(got, Some(FriendPresence::Online)); } #[test] fn interpret_pong_rejects_ticket_for_a_different_host() { let friend = id(); let attacker = id(); let t = valid_ticket(attacker); let got = interpret_pong(&ControlMsg::Pong { room: Some(RoomPresence { name: "Redirect".into(), ticket: t }), }, friend); 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 friend = id(); let t = valid_ticket(friend); let got = interpret_pong(&ControlMsg::Pong { room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }), }, friend); 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:?}"), } } }