feat(presence): pure friends presence protocol + auth gate (W7 P4 core)
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<RoomPresence> } } — 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+232
@@ -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<RoomPresence> },
|
||||
}
|
||||
|
||||
/// 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<FriendPresence> {
|
||||
match msg {
|
||||
ControlMsg::Ping => None,
|
||||
ControlMsg::Pong { room: None } => Some(FriendPresence::Online),
|
||||
ControlMsg::Pong { room: Some(r) } => {
|
||||
if r.ticket.parse::<crate::network::PeerSpeakTicket>().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::<ControlMsg>(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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user