diff --git a/EXTENSIONS.md b/EXTENSIONS.md index e065952..480b1ec 100644 --- a/EXTENSIONS.md +++ b/EXTENSIONS.md @@ -379,3 +379,109 @@ clients receive the full reaction state on reconnect. - Incoming `reaction` wire frames are dispatched as `reaction` IPC events. - `BrowserAdapter.send()` handles `send_reaction` commands and both broadcasts to all peers and emits a local `reaction` event. - `sendChat` includes the `mid` in the wire frame so reactions can reference it correctly across peers. + +--- + +## EXT-009 — Scoped Presence Query + +**Status:** proposed +**Affects:** anchor WS protocol only (new request/response pair); no DataChannel or peer-to-peer wire changes + +### Motivation + +YAW/2 §5.2 presence (`peer-join` / `peer-leave`) is push-only and scoped to peers +who are simultaneously joined to the same network — you only learn someone is +online by already being in the room with them. That's fine for chat, but it's +the wrong shape for flit's "known devices" list: each paired device is its own +isolated network (`net = NetHash(PairRoomName(idA, idB))`), and the app doesn't +hold a persistent anchor connection per pairing while idle. Today the only way +to find out if a known device is reachable is to attempt a full connect. + +This extension adds a lightweight, stateless query: "is peer X currently +online in network Y?" — answerable from the anchor's existing in-memory +`clients` registry with an O(1) lookup, no new server-side state. + +**Deliberately not** a global "is this pubkey online anywhere" query. The +anchor's `clients` map is keyed globally by peer id, so an unscoped query +would let anyone who knows a pubkey probe its online status across every +network on the anchor, with no relationship required — a cross-identity +presence oracle. Scoping the query to `(net, id)` keeps the access model +identical to today's: knowing a network's hash is already the bar for +joining it and observing presence the slow way (§5.1 `joined`, §5.2 +`peer-join`); this extension only removes the need to actually join and wait. + +### Wire messages + +Sent over the anchor WS connection. Does **not** require a prior `join` — +the query is anchor-local and stateless, so a client may ask before joining, +after leaving, or without ever joining any network on this connection. + +#### `presence_query` + +```json +{ "type": "presence_query", "net": "<64-hex net hash>", "id": "<64-hex peer id>" } +``` + +| Field | Type | Description | +|-------|--------|--------------| +| `net` | string | Network hash, computed the same way as `join` (§5.1). | +| `id` | string | Peer id being queried. | + +#### `presence` + +```json +{ "type": "presence", "net": "<64-hex net hash>", "id": "<64-hex peer id>", "online": true } +``` + +`online` is `true` iff a client is currently registered with that exact +`(net, id)` pair. Unknown `net`/`id` combinations return `online: false`, +identical in shape to a peer who simply isn't connected — the anchor does +not distinguish "never seen this net" from "peer not currently in it." + +### Anchor implementation notes + +No new registry. `a.clients` is already keyed globally by peer id +(`cmd/anchor/main.go`); the query handler does: + +```go +a.mu.RLock() +c, ok := a.clients[id] +online := ok && c.net == net +a.mu.RUnlock() +``` + +Same lock, same map, no writes. YAW/2-only anchors that don't implement this +extension simply never emit a `presence` reply — per §8, unknown request +types are ignored by clients, so callers should treat "no reply within a +short timeout" the same as `online: false` rather than blocking indefinitely. + +### Security considerations + +- **No new information beyond what §5.2 already permits** — the requester + must already know both `net` and `id` to ask, exactly the knowledge + required to join that network and observe presence natively. This + extension is a latency/statefulness shortcut, not a new capability. +- **Does not enable identity-wide presence enumeration.** Because the query + is scoped to a specific `net`, checking whether pubkey X is online + requires already knowing at least one network X is a member of. An anchor + operator or a client cannot use this to ask "where is X online" across + all networks it serves. +- **Rate limiting recommended** at the anchor (e.g. per-connection token + bucket) to prevent a connection from being used to hammer many `(net, + id)` guesses cheaply. This mirrors the existing `history_request` + rate-limit precedent (EXT-007, one request per peer/room per 60s) even + though the abuse shape here is different — presence queries are free of + disk I/O but still worth bounding. +- **No change to what the anchor can already infer.** An anchor could + already tell that two ids are members of the same net by virtue of + relaying between them; this extension doesn't let the anchor learn new + relationships, only lets *clients* ask a question the anchor already had + the answer to. + +### Client usage (flit) + +Each known device already has a deterministic `net` (`PairRoomName(idA, +idB)`, hashed). A "known devices" list can send one `presence_query` per +entry — no persistent connection required per pairing — and render +online/offline before the user taps Connect. A short client-side cache +(a few seconds) is recommended to avoid re-querying on every render. diff --git a/cmd/anchor/main.go b/cmd/anchor/main.go index 9136d2a..2505378 100644 --- a/cmd/anchor/main.go +++ b/cmd/anchor/main.go @@ -69,8 +69,30 @@ type client struct { net string // hashed network name, set after join send chan proto.AnchorMessage conn *websocket.Conn + + // EXT-009 presence_query rate limiting. Only ever touched from this + // connection's own read-loop goroutine, so no lock needed. + presenceQueryCount int + presenceWindowStart time.Time } +// allowPresenceQuery implements a simple per-connection token bucket for +// EXT-009 presence_query: presenceQueryLimit requests per presenceQueryWindow. +func (c *client) allowPresenceQuery() bool { + now := time.Now() + if now.Sub(c.presenceWindowStart) > presenceQueryWindow { + c.presenceWindowStart = now + c.presenceQueryCount = 0 + } + c.presenceQueryCount++ + return c.presenceQueryCount <= presenceQueryLimit +} + +const ( + presenceQueryLimit = 20 + presenceQueryWindow = 10 * time.Second +) + type anchor struct { mu sync.RWMutex clients map[string]*client // keyed by hex peer id @@ -109,6 +131,16 @@ func (a *anchor) unregister(c *client) { log.Printf("anchor: peer left: %s", c.id[:min(8, len(c.id))]) } +// isOnline reports whether a client is currently registered with the exact +// (net, id) pair — EXT-009. O(1): reuses the existing global clients map, +// no new state. +func (a *anchor) isOnline(net, id string) bool { + a.mu.RLock() + defer a.mu.RUnlock() + c, ok := a.clients[id] + return ok && c.net == net +} + // networkPeerIDs returns the hex ids of all peers in the same network as netHash. func (a *anchor) networkPeerIDs(netHash, excludeID string) []string { a.mu.RLock() @@ -225,6 +257,24 @@ func (a *anchor) handleWS(w http.ResponseWriter, r *http.Request) { } log.Printf("anchor: peer joined: %s net=%s peers=%d", c.id[:min(8, len(c.id))], msg.Net[:8], len(peers)) + case proto.AnchorPresenceQuery: + // EXT-009. Stateless and anchor-local: does not require the + // querying connection to have joined any network. Scoped to + // (net, id) — never a global "is this pubkey online anywhere" + // lookup, to avoid a cross-network presence oracle. + if msg.Net == "" || msg.ID == "" { + continue + } + if !c.allowPresenceQuery() { + continue + } + online := a.isOnline(msg.Net, msg.ID) + resp := proto.AnchorMessage{Type: proto.AnchorPresence, Net: msg.Net, ID: msg.ID, Online: &online} + select { + case c.send <- resp: + default: + } + case proto.AnchorTo: if c.id == "" { continue // not joined yet diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 8302bc6..f0fb273 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -38,20 +38,20 @@ type PeerInfo struct { type MsgType string const ( - MsgChat MsgType = "chat" - MsgPm MsgType = "pm" // private message, §8 - MsgPeerGossip MsgType = "peer_gossip" - MsgFileListReq MsgType = "file_list_req" - MsgFileListResp MsgType = "file_list_resp" - MsgFileOffer MsgType = "file-offer" // §9, hyphenated per spec - MsgFileAccept MsgType = "file-accept" - MsgFileCancel MsgType = "file-cancel" - MsgFileDone MsgType = "file-done" - MsgPing MsgType = "ping" - MsgPong MsgType = "pong" - MsgHistoryRequest MsgType = "history_request" - MsgHistoryChunk MsgType = "history_chunk" - MsgReaction MsgType = "reaction" + MsgChat MsgType = "chat" + MsgPm MsgType = "pm" // private message, §8 + MsgPeerGossip MsgType = "peer_gossip" + MsgFileListReq MsgType = "file_list_req" + MsgFileListResp MsgType = "file_list_resp" + MsgFileOffer MsgType = "file-offer" // §9, hyphenated per spec + MsgFileAccept MsgType = "file-accept" + MsgFileCancel MsgType = "file-cancel" + MsgFileDone MsgType = "file-done" + MsgPing MsgType = "ping" + MsgPong MsgType = "pong" + MsgHistoryRequest MsgType = "history_request" + MsgHistoryChunk MsgType = "history_chunk" + MsgReaction MsgType = "reaction" ) // PmMessage is a private message sent directly over a single peer link (§8 "pm"). @@ -108,9 +108,9 @@ type PeerMessage struct { type ResumableFile struct { Name string `json:"name"` SHA256 string `json:"sha256"` - From string `json:"from"` // peer ID hex + From string `json:"from"` // peer ID hex Size int64 `json:"size"` - Offset int64 `json:"offset"` // bytes already received + Offset int64 `json:"offset"` // bytes already received } // HistoryEntry is one message in a history_chunk response. @@ -125,10 +125,10 @@ type HistoryEntry struct { // ChatMessage is a group chat message (wire type "chat", §8). // Also used internally for persisting PMs after they are received. type ChatMessage struct { - Mid string `json:"mid,omitempty"` // optional dedup id (required when relay hops > 0) + Mid string `json:"mid,omitempty"` // optional dedup id (required when relay hops > 0) MsgID string `json:"msg_id,omitempty"` // EXT-007: content-addressed gossip ID - From PeerID `json:"from,omitempty"` // set by receiver from DC context; not on wire for pm - To *PeerID `json:"to,omitempty"` // internal only — not transmitted; set for DMs + From PeerID `json:"from,omitempty"` // set by receiver from DC context; not on wire for pm + To *PeerID `json:"to,omitempty"` // internal only — not transmitted; set for DMs Room string `json:"room"` Text string `json:"text"` Ts int64 `json:"ts"` // Unix milliseconds @@ -240,27 +240,30 @@ type SignalingPayload struct { type AnchorMsgType string const ( - AnchorChallenge AnchorMsgType = "challenge" - AnchorJoin AnchorMsgType = "join" - AnchorJoined AnchorMsgType = "joined" - AnchorPeerJoin AnchorMsgType = "peer-join" - AnchorPeerLeave AnchorMsgType = "peer-leave" - AnchorTo AnchorMsgType = "to" - AnchorFrom AnchorMsgType = "from" - AnchorNoPeer AnchorMsgType = "no-peer" + AnchorChallenge AnchorMsgType = "challenge" + AnchorJoin AnchorMsgType = "join" + AnchorJoined AnchorMsgType = "joined" + AnchorPeerJoin AnchorMsgType = "peer-join" + AnchorPeerLeave AnchorMsgType = "peer-leave" + AnchorTo AnchorMsgType = "to" + AnchorFrom AnchorMsgType = "from" + AnchorNoPeer AnchorMsgType = "no-peer" + AnchorPresenceQuery AnchorMsgType = "presence_query" // waste-go ext EXT-009 + AnchorPresence AnchorMsgType = "presence" // waste-go ext EXT-009 ) // AnchorMessage covers all WebSocket frames to/from the anchor. type AnchorMessage struct { - Type AnchorMsgType `json:"type"` - Nonce string `json:"nonce,omitempty"` // challenge nonce, hex - ID string `json:"id,omitempty"` // peer hex id - Net string `json:"net,omitempty"` // hashed network name - Sig string `json:"sig,omitempty"` // ed25519 sig over (nonce||net), hex - Peers []string `json:"peers,omitempty"` // joined: list of peer hex ids in network - To string `json:"to,omitempty"` // target peer hex id - From string `json:"from,omitempty"` // sender peer hex id - Box string `json:"box,omitempty"` // base64 nacl/box sealed payload + Type AnchorMsgType `json:"type"` + Nonce string `json:"nonce,omitempty"` // challenge nonce, hex + ID string `json:"id,omitempty"` // peer hex id + Net string `json:"net,omitempty"` // hashed network name + Sig string `json:"sig,omitempty"` // ed25519 sig over (nonce||net), hex + Peers []string `json:"peers,omitempty"` // joined: list of peer hex ids in network + To string `json:"to,omitempty"` // target peer hex id + From string `json:"from,omitempty"` // sender peer hex id + Box string `json:"box,omitempty"` // base64 nacl/box sealed payload + Online *bool `json:"online,omitempty"` // presence: true iff (net, id) is currently connected } // ── IPC protocol (daemon ↔ local UI) ───────────────────────────────────────── @@ -270,45 +273,45 @@ type IpcMsgType string const ( // Commands (UI → daemon) - CmdSendMessage IpcMsgType = "send_message" - CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext) - CmdLeaveNetwork IpcMsgType = "leave_network" - CmdGetState IpcMsgType = "get_state" + CmdSendMessage IpcMsgType = "send_message" + CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext) + CmdLeaveNetwork IpcMsgType = "leave_network" + CmdGetState IpcMsgType = "get_state" CmdSendFile IpcMsgType = "send_file" - CmdSetShareDir IpcMsgType = "set_share_dir" // set per-network share directory at runtime + CmdSetShareDir IpcMsgType = "set_share_dir" // set per-network share directory at runtime CmdGenerateInvite IpcMsgType = "generate_invite" CmdGetFileList IpcMsgType = "get_file_list" CmdExportIdentity IpcMsgType = "export_identity" // returns encrypted backup blob CmdImportIdentity IpcMsgType = "import_identity" // replaces identity from backup blob CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks - CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path - CmdListShares IpcMsgType = "list_shares" // returns shares_list event - CmdCreateRoom IpcMsgType = "create_room" // field: room (name) - CmdSetDownloadDir IpcMsgType = "set_download_dir" // set per-network download directory at runtime; fields: network_id, path - CmdSendReaction IpcMsgType = "send_reaction" // fields: network_id, reaction_mid, reaction_emoji + CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path + CmdListShares IpcMsgType = "list_shares" // returns shares_list event + CmdCreateRoom IpcMsgType = "create_room" // field: room (name) + CmdSetDownloadDir IpcMsgType = "set_download_dir" // set per-network download directory at runtime; fields: network_id, path + CmdSendReaction IpcMsgType = "send_reaction" // fields: network_id, reaction_mid, reaction_emoji // Events (daemon → UI) - EvtMessageReceived IpcMsgType = "message_received" - EvtPeerConnected IpcMsgType = "peer_connected" - EvtPeerDisconnected IpcMsgType = "peer_disconnected" - EvtSessionReady IpcMsgType = "session_ready" // DataChannel open + hello verified - EvtPeerStatus IpcMsgType = "peer_status" // ICE connection state + candidate type - EvtIncomingFile IpcMsgType = "incoming_file" - EvtFileProgress IpcMsgType = "file_progress" - EvtStateSnapshot IpcMsgType = "state_snapshot" - EvtError IpcMsgType = "error" - EvtInviteGenerated IpcMsgType = "invite_generated" - EvtFileList IpcMsgType = "file_list" - EvtFileComplete IpcMsgType = "file_complete" - EvtNetworkJoined IpcMsgType = "network_joined" - EvtNetworkLeft IpcMsgType = "network_left" - EvtIdentityExported IpcMsgType = "identity_exported" - EvtIdentityImported IpcMsgType = "identity_imported" - EvtSharesList IpcMsgType = "shares_list" - EvtRoomCreated IpcMsgType = "room_created" // field: room (name) - EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages - EvtResumableTransfers IpcMsgType = "resumable_transfers" // field: resumable_files - EvtReaction IpcMsgType = "reaction" // fields: reaction_mid, reaction_emoji, peer_id + EvtMessageReceived IpcMsgType = "message_received" + EvtPeerConnected IpcMsgType = "peer_connected" + EvtPeerDisconnected IpcMsgType = "peer_disconnected" + EvtSessionReady IpcMsgType = "session_ready" // DataChannel open + hello verified + EvtPeerStatus IpcMsgType = "peer_status" // ICE connection state + candidate type + EvtIncomingFile IpcMsgType = "incoming_file" + EvtFileProgress IpcMsgType = "file_progress" + EvtStateSnapshot IpcMsgType = "state_snapshot" + EvtError IpcMsgType = "error" + EvtInviteGenerated IpcMsgType = "invite_generated" + EvtFileList IpcMsgType = "file_list" + EvtFileComplete IpcMsgType = "file_complete" + EvtNetworkJoined IpcMsgType = "network_joined" + EvtNetworkLeft IpcMsgType = "network_left" + EvtIdentityExported IpcMsgType = "identity_exported" + EvtIdentityImported IpcMsgType = "identity_imported" + EvtSharesList IpcMsgType = "shares_list" + EvtRoomCreated IpcMsgType = "room_created" // field: room (name) + EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages + EvtResumableTransfers IpcMsgType = "resumable_transfers" // field: resumable_files + EvtReaction IpcMsgType = "reaction" // fields: reaction_mid, reaction_emoji, peer_id ) // NetworkInfo summarises one joined network for state_snapshot and network_joined events. @@ -335,44 +338,44 @@ type IpcMessage struct { // join_network / leave_network NetworkName string `json:"network_name,omitempty"` - NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name - ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory - RequireInvite bool `json:"require_invite,omitempty"` // waste-go ext: reject peers without valid signed invite - InviteString string `json:"invite_string,omitempty"` // waste-go ext: the invite used to join (stored in mesh) + NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name + ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory + RequireInvite bool `json:"require_invite,omitempty"` // waste-go ext: reject peers without valid signed invite + InviteString string `json:"invite_string,omitempty"` // waste-go ext: the invite used to join (stored in mesh) // send_file / set_share_dir / file_complete path Path string `json:"path,omitempty"` // events - Peer *PeerInfo `json:"peer,omitempty"` - PeerID *PeerID `json:"peer_id,omitempty"` - Nick string `json:"nick,omitempty"` - Message *ChatMessage `json:"message,omitempty"` - Offer *FileOffer `json:"offer,omitempty"` - TransferID string `json:"transfer_id,omitempty"` - BytesReceived int64 `json:"bytes_received,omitempty"` - TotalBytes int64 `json:"total_bytes,omitempty"` + Peer *PeerInfo `json:"peer,omitempty"` + PeerID *PeerID `json:"peer_id,omitempty"` + Nick string `json:"nick,omitempty"` + Message *ChatMessage `json:"message,omitempty"` + Offer *FileOffer `json:"offer,omitempty"` + TransferID string `json:"transfer_id,omitempty"` + BytesReceived int64 `json:"bytes_received,omitempty"` + TotalBytes int64 `json:"total_bytes,omitempty"` // state_snapshot fields (existing shape preserved for backward compat) - MasterAlias string `json:"master_alias,omitempty"` // daemon's alias (available before any network join) - MasterID string `json:"master_id,omitempty"` // daemon's master public key hex - LocalPeer *PeerInfo `json:"local_peer,omitempty"` - ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"` - KnownPeers []PeerInfo `json:"known_peers,omitempty"` // historically seen, not currently connected - Rooms []string `json:"rooms,omitempty"` + MasterAlias string `json:"master_alias,omitempty"` // daemon's alias (available before any network join) + MasterID string `json:"master_id,omitempty"` // daemon's master public key hex + LocalPeer *PeerInfo `json:"local_peer,omitempty"` + ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"` + KnownPeers []PeerInfo `json:"known_peers,omitempty"` // historically seen, not currently connected + Rooms []string `json:"rooms,omitempty"` // multi-network: all joined networks (additive) - Networks []NetworkInfo `json:"networks,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - InviteGenerated string `json:"invite,omitempty"` - Files []FileEntry `json:"files,omitempty"` - Messages []ChatMessage `json:"messages,omitempty"` // history_loaded - ResumableFiles []ResumableFile `json:"resumable_files,omitempty"` // resumable_transfers - ReactionMID string `json:"reaction_mid,omitempty"` // reaction - ReactionEmoji string `json:"reaction_emoji,omitempty"` // reaction - Shares []ShareEntry `json:"shares,omitempty"` - ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global + Networks []NetworkInfo `json:"networks,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + InviteGenerated string `json:"invite,omitempty"` + Files []FileEntry `json:"files,omitempty"` + Messages []ChatMessage `json:"messages,omitempty"` // history_loaded + ResumableFiles []ResumableFile `json:"resumable_files,omitempty"` // resumable_transfers + ReactionMID string `json:"reaction_mid,omitempty"` // reaction + ReactionEmoji string `json:"reaction_emoji,omitempty"` // reaction + Shares []ShareEntry `json:"shares,omitempty"` + ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global // export_identity / import_identity - Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back - Backup string `json:"backup,omitempty"` // JSON backup blob + Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back + Backup string `json:"backup,omitempty"` // JSON backup blob // peer_status — ICE connection quality ConnState string `json:"conn_state,omitempty"` // pion PeerConnectionState string CandidateType string `json:"candidate_type,omitempty"` // host | srflx | relay | unknown