diff --git a/CHANGELOG.md b/CHANGELOG.md index 8109736d..b7cfaed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,17 @@ archived by series under [docs/changelog/](docs/changelog/); see the ### Added +- **Browsers on one network find each other.** `NearbyRoom` meets the + browsers behind one public address in a Nostr room derived from that + address and the application id, lists them by the label each application + chose, and links over WebRTC to the one a person picks; the link runs on + the local network. The room key is public by construction, so every + connection code is sealed to one device's key, every presence is signed + by the key it announces, and a host answers only what its `admit` + callback accepts, asking once per session. Relays and STUN servers are the + application's ([W13](docs/bridges/web.md#w13-a-nearby-room-is-derived-from-the-networks-address), + [ADR 0034](docs/adr/0034-browsers-find-each-other-through-their-network-address.md)). + - **The browser reaches devices it has no link to, over Nostr.** `OfflineNode.start({ nostr: { relays } })` adds the Nostr transport every binding shares, driven from the worker over the relays the application diff --git a/bindings/web/scripts/test-browser.sh b/bindings/web/scripts/test-browser.sh index 274de119..de4d98f3 100755 --- a/bindings/web/scripts/test-browser.sh +++ b/bindings/web/scripts/test-browser.sh @@ -15,6 +15,8 @@ cd "$(dirname "$0")/.." PORT="${PORT:-8765}" # The local Nostr relay the signalling page publishes through. RELAY_PORT="${RELAY_PORT:-8766}" +# The local STUN server the nearby page learns its network's address from. +STUN_PORT="${STUN_PORT:-8767}" [ -f pkg/offline_protocol_web.js ] || bash scripts/build.sh [ -d node_modules/playwright ] || npm ci @@ -23,9 +25,11 @@ python3 -m http.server "$PORT" --bind 127.0.0.1 >/dev/null 2>&1 & server=$! node test/nostr-relay.mjs "$RELAY_PORT" >/dev/null 2>&1 & relay=$! +node test/stun-server.mjs "$STUN_PORT" >/dev/null 2>&1 & +stun=$! cleanup() { - kill "$server" "$relay" 2>/dev/null || true - wait "$server" "$relay" 2>/dev/null || true + kill "$server" "$relay" "$stun" 2>/dev/null || true + wait "$server" "$relay" "$stun" 2>/dev/null || true } trap cleanup EXIT @@ -36,4 +40,5 @@ done pages=() for page in test/*.html; do pages+=("$page"); done -node test/run.mjs "http://127.0.0.1:$PORT" "ws://127.0.0.1:$RELAY_PORT" 90000 "${pages[@]}" +STUN_URL="stun:127.0.0.1:$STUN_PORT" \ + node test/run.mjs "http://127.0.0.1:$PORT" "ws://127.0.0.1:$RELAY_PORT" 90000 "${pages[@]}" diff --git a/bindings/web/src/index.d.ts b/bindings/web/src/index.d.ts index 74e164a3..3a3d970f 100644 --- a/bindings/web/src/index.d.ts +++ b/bindings/web/src/index.d.ts @@ -157,6 +157,25 @@ export class OfflineNode extends EventTarget { room: string, message: string, ): Promise<{ role: "offer" | "answer"; session: string; code: string; freshUntilMs: number } | null>; + /** NearbyRoom's half in the worker (W13). */ + nearbyOpen(network: string, label: string): Promise<{ id: number; key: string }>; + nearbySubscription(id: number, subscriptionId: string): Promise; + nearbyPresence(id: number): Promise; + nearbyOffer(id: number, to: string, session: string, code: string): Promise; + nearbyAnswer(id: number, to: string, session: string, code: string): Promise; + nearbyOpenMessage( + id: number, + message: string, + ): Promise<{ + kind: "presence" | "offer" | "answer"; + peer: string; + label: string; + session: string | null; + code: string | null; + createdAtMs: number; + freshUntilMs: number; + } | null>; + nearbyClose(id: number): Promise; /** Registers a carrier the page opened; resolves to its link id. */ openLink(carrier: PageCarrier): Promise; /** Bytes the carrier delivered; resolves to the reason it was closed, if it was. */ @@ -251,3 +270,69 @@ export class NostrSignaller { /** Joins the device hosting `room`; rejects when no answer comes in time. */ join(room: string, carrier: WebRtcCarrier, options?: { timeoutMs?: number }): Promise; } + +/** A device announced in a nearby room. */ +export interface NearbyPeer { + /** Its key in this room; what `connect` takes. Not an identity. */ + peer: string; + /** The label its application chose. A claim, not a name anyone checked. */ + label: string; + /** When this device last heard it, in milliseconds since the epoch. */ + seenAt: number; +} + +export interface NearbyRoomOptions { + /** The relays to use, as ws:// or wss:// URLs. Required: the SDK names none. */ + relays: string[]; + /** + * Servers to learn the network's public address from, with at least one + * STUN server. Required unless `network` is given. The SDK names none. + */ + iceServers?: RTCIceServer[]; + /** The network's public address, when the application already knows it. */ + network?: string; + /** What this device is shown as to the others. At most 64 characters. */ + label?: string; + /** + * Decides every offer this device receives. Anyone who can guess the + * network's address can offer, so ask the person; only `true` answers. + * `signal` aborts when the offer stops being fresh or the room closes: + * take the question down then, as nobody is waiting for the answer. A + * session is asked about once; its repeats are not asked again. + */ + admit(offer: { peer: string; label: string; signal: AbortSignal }): boolean | Promise; +} + +/** + * The devices behind this network's public address for one application, + * found through Nostr relays (W13). Each one announces a room key and a + * label, sees the others in `peers`, and links over WebRTC to the one its + * person picks. The room is weak by construction: anyone who can guess the + * address can read who is present and offer, and none can read a + * connection code, which is sealed to one device's key. A `peers` event + * carries the list whenever it changes. A `full` event carries `{ peer, + * label }` for an offer dropped because sixteen were already waiting on + * `admit`. + */ +export class NearbyRoom extends EventTarget { + constructor(node: OfflineNode, carrier: WebRtcCarrier, options: NearbyRoomOptions); + /** The devices present now, newest first. */ + readonly peers: NearbyPeer[]; + /** This device's key in the room, once open. */ + readonly key: string | null; + /** Learns the address, subscribes, and starts announcing. A room opens once. */ + open(): Promise; + /** Links to `peer` once it admits this device; rejects when it does not answer. */ + connect(peer: string, options?: { timeoutMs?: number }): Promise; + /** Stops announcing and answering; links already made stay up. */ + close(): void; +} + +/** + * The network's public address, from a server-reflexive candidate: IPv4 when + * one arrives, else IPv6 after waiting `settleMs` for an IPv4 one. + */ +export function publicAddress( + iceServers: RTCIceServer[], + options?: { timeoutMs?: number; settleMs?: number }, +): Promise; diff --git a/bindings/web/src/index.js b/bindings/web/src/index.js index 832d0e2a..94b7024e 100644 --- a/bindings/web/src/index.js +++ b/bindings/web/src/index.js @@ -2,6 +2,7 @@ export { WebRtcCarrier } from "./webrtc.js"; export { NostrSignaller } from "./nostr-signal.js"; +export { NearbyRoom, publicAddress } from "./nearby.js"; import { checkRelays } from "./nostr-relays.js"; export class OfflineNode extends EventTarget { @@ -184,6 +185,38 @@ export class OfflineNode extends EventTarget { return this.#call("signalOpen", { room, message }); } + /// NearbyRoom's half in the worker (W13): open a room for this network's + /// address under a label (`{ id, key }`), its REQ text, sealed EVENT texts, + /// and what a relay message carries (`{ kind, peer, label, session, code, + /// createdAtMs, freshUntilMs }`, or null). + nearbyOpen(network, label) { + return this.#call("nearbyOpen", { network, label }); + } + + nearbySubscription(id, subscriptionId) { + return this.#call("nearbySubscription", { id, subscriptionId }); + } + + nearbyPresence(id) { + return this.#call("nearbyPresence", { id }); + } + + nearbyOffer(id, to, session, code) { + return this.#call("nearbyOffer", { id, to, session, code }); + } + + nearbyAnswer(id, to, session, code) { + return this.#call("nearbyAnswer", { id, to, session, code }); + } + + nearbyOpenMessage(id, message) { + return this.#call("nearbyOpenMessage", { id, message }); + } + + nearbyClose(id) { + return this.#call("nearbyClose", { id }); + } + /// What the durable store has cost since the node started (flushes, log /// and live bytes, compactions per log), or null for a node in memory. stats() { diff --git a/bindings/web/src/nearby.js b/bindings/web/src/nearby.js new file mode 100644 index 00000000..9058fc3d --- /dev/null +++ b/bindings/web/src/nearby.js @@ -0,0 +1,390 @@ +// Finding devices on the same network through Nostr relays (docs/bridges/web.md, W13). +// +// Invariants: +// - This file moves text. The room's topic and key, every seal and every +// check on what a relay sent are in Rust (nearby.rs, in the node's +// worker). The page learns the network's address, holds the relay sockets +// and decides when to publish. +// - The relays and the STUN servers are the application's. Nothing here +// names one: a NearbyRoom without relays, or without a STUN server and +// without a `network`, refuses to open. +// - The room is weak: anyone who can guess a network's public address can +// derive its key. So a presence carries only a key used for this room and +// the label the application chose, and every connection code is sealed to +// one presence key. A holder of the room key sees who is present and can +// offer, and reads no code. +// - Nothing is answered without the application's say. Every offer goes to +// `admit({ peer, label, signal })` first, and only `true` opens a +// connection. `signal` aborts when the offer stops being fresh or the room +// closes, so a prompt nobody answered can be taken down. The label is a +// claim by whoever sent it; who is on the link is what the engine's +// preamble and MLS session prove (W3). +// - A refused offer never takes the place of one still to be decided. The +// room is public, so anyone can offer. A refused session used to stay among +// the MAX_SESSIONS the host decides on, kept by every repeat, and sixteen +// refused sessions republished by a stranger anywhere on the internet +// locked the host out of admitting anyone, silently. Refused sessions are +// kept apart (at most MAX_REFUSED, oldest forgotten first) only so their +// repeats are not asked about again. An offer dropped because MAX_SESSIONS +// are waiting on the application is reported as a `full` event. +// - A presence is republished every PRESENCE_MS and forgotten when unseen for +// PRESENCE_STALE_MS, so the list is the devices present now. A full list +// forgets the device heard from longest ago: refusing newcomers let +// whoever filled it first keep everyone else off it. +// - A host answers a session once, and again only for that session's +// repeats, for as long as its newest offer is fresh (W11). +// - A room opens once. One that failed to open, or was closed, holds no +// socket and no room in the worker, and refuses to open or connect again. + +import { RoomChannel } from "./nostr-signal.js"; +import { checkRelays } from "./nostr-relays.js"; + +const PRESENCE_MS = 5000; +const PRESENCE_STALE_MS = 15000; +const REPUBLISH_MS = 2000; +const CONNECT_TIMEOUT_MS = 30000; +const ADDRESS_TIMEOUT_MS = 5000; +// After an IPv6 server-reflexive candidate, how long to wait for an IPv4 one. +const ADDRESS_SETTLE_MS = 500; +const KEEP_ALIVE_MS = 60000; +const MAX_PEERS = 64; +const MAX_SESSIONS = 16; +const MAX_REFUSED = 256; + +function randomHex(bytes) { + return Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); +} + +/// The address the internet sees this device from, as the application's +/// STUN servers report it: a server-reflexive candidate a throwaway +/// connection gathers, IPv4 when there is one. Two devices on a dual-stack +/// network must pick the same family or they derive different rooms, and the +/// first candidate's family depends on which STUN answer arrived first, so +/// an IPv6 one waits `settleMs` for an IPv4 one. Rejects when none arrives +/// within `timeoutMs`, which is what a network that blocks STUN, or a list +/// with no STUN server, does. +export async function publicAddress( + iceServers, + { timeoutMs = ADDRESS_TIMEOUT_MS, settleMs = ADDRESS_SETTLE_MS } = {}, +) { + const stun = (iceServers ?? []).some((server) => + [server?.urls].flat().some((url) => typeof url === "string" && /^(stuns?|turns?):/.test(url)), + ); + if (!stun) throw new Error("iceServers must name a STUN server to learn the network's address"); + const pc = new RTCPeerConnection({ iceServers }); + try { + return await new Promise((resolve, reject) => { + const found = []; + let settle = null; + const done = () => { + clearTimeout(timer); + clearTimeout(settle); + if (found.length === 0) reject(new Error("no STUN server reported this network's address")); + else resolve(found.find(isIpv4) ?? found[0]); + }; + const timer = setTimeout(done, timeoutMs); + pc.onicecandidate = ({ candidate }) => { + // The end of gathering: nothing more will come. + if (!candidate) return done(); + const address = reflexive(candidate); + if (!address) return; + found.push(address); + if (isIpv4(address)) return done(); + settle ??= setTimeout(done, settleMs); + }; + pc.createDataChannel("nearby"); + pc.createOffer() + .then((offer) => pc.setLocalDescription(offer)) + .catch((error) => { + clearTimeout(timer); + reject(error); + }); + }); + } finally { + pc.close(); + } +} + +// The address of a server-reflexive candidate, or null for any other kind. +// An engine may leave any parsed field out, so each falls back to the +// candidate line: `candidate:

typ srflx …`. +function reflexive(candidate) { + const parts = (candidate.candidate ?? "").split(" "); + if ((candidate.type ?? parts[7]) !== "srflx") return null; + return candidate.address || parts[4] || null; +} + +const isIpv4 = (address) => !address.includes(":"); + +/// The devices behind this network's address for one application, through +/// relays: each NearbyRoom announces itself, lists the others, and links +/// over `carrier` (a WebRtcCarrier) to one its person picks. +/// +/// `admit({ peer, label, signal })` decides every offer this device +/// receives; it is required, so a page that opens a room has said who it +/// lets in. A `full` event reports an offer dropped because MAX_SESSIONS +/// were already waiting on `admit`. +export class NearbyRoom extends EventTarget { + #node; + #carrier; + #relays; + #iceServers; + #network; + #label; + #admit; + #room = null; + #channel = null; + #timer = null; + #opened = false; + #live = false; + #closed = false; + // key -> { label, seenAt } + #peers = new Map(); + // session -> { answer, connection, freshUntilMs, timer, abort }: offers the + // application is deciding on or admitted. At most MAX_SESSIONS. + #sessions = new Map(); + // session -> { freshUntilMs, timer }: offers the application refused, + // ignored while fresh. At most MAX_REFUSED, oldest first out. + #refused = new Map(); + // session -> (opened) => void, for this device's own offers. + #joins = new Map(); + + constructor(node, carrier, { relays, iceServers, network, label = "", admit } = {}) { + super(); + this.#relays = checkRelays(relays); + if (typeof admit !== "function") { + throw new Error("NearbyRoom needs admit({ peer, label }): who this device lets in is the application's call"); + } + if (network === undefined && !Array.isArray(iceServers)) { + throw new Error("NearbyRoom needs iceServers with a STUN server, or the network's address"); + } + this.#node = node; + this.#carrier = carrier; + this.#iceServers = iceServers; + this.#network = network; + this.#label = label; + this.#admit = admit; + } + + /// The devices present now: `[{ peer, label, seenAt }]`, newest first. + get peers() { + return [...this.#peers] + .map(([peer, { label, seenAt }]) => ({ peer, label, seenAt })) + .sort((a, b) => b.seenAt - a.seenAt); + } + + /// This device's key in the room, once open. + get key() { + return this.#room?.key ?? null; + } + + /// Learns the network's address (unless given one), subscribes on every + /// relay and starts announcing this device. Rejects when no STUN server + /// answers or no relay confirms the room, with everything it opened + /// closed. A room opens once. + async open() { + if (this.#closed) throw new Error("this nearby room is closed"); + if (this.#opened) throw new Error("this nearby room is already open"); + this.#opened = true; + try { + const network = this.#network ?? (await publicAddress(this.#iceServers)); + if (this.#closed) throw new Error("the nearby room was closed while it opened"); + this.#room = await this.#node.nearbyOpen(network, this.#label); + const id = this.#room.id; + if (this.#closed) throw new Error("the nearby room was closed while it opened"); + this.#channel = new RoomChannel( + { + subscription: (sub) => this.#node.nearbySubscription(id, sub), + open: (text) => this.#node.nearbyOpenMessage(id, text), + }, + this.#relays, + (opened) => this.#heard(opened), + { keepAliveMs: KEEP_ALIVE_MS }, + ); + await this.#channel.open(); + await this.#announce(); + if (this.#closed) throw new Error("the nearby room was closed while it opened"); + } catch (error) { + this.close(); + throw error; + } + this.#live = true; + this.#timer = setInterval(() => { + this.#announce().catch(() => {}); + this.#sweep(); + }, PRESENCE_MS); + return this; + } + + /// Links to the device that announced `peer`. Resolves to the + /// connection's `{ close() }` once that device admitted this one and its + /// answer was accepted; rejects when no answer comes within `timeoutMs`. + async connect(peer, { timeoutMs = CONNECT_TIMEOUT_MS } = {}) { + if (this.#closed) throw new Error("this nearby room is closed"); + if (!this.#live) throw new Error("open the room first"); + const session = randomHex(16); + const offer = await this.#carrier.offer(); + let republish; + let deadline; + try { + const answered = new Promise((resolve, reject) => { + this.#joins.set(session, (opened) => { + if (opened.peer !== peer) return; + this.#joins.delete(session); + offer.accept(opened.code).then(resolve, reject); + }); + deadline = setTimeout(() => reject(new Error("the device did not answer")), timeoutMs); + }); + const publish = async () => { + if (!this.#room) throw new Error("this nearby room is closed"); + this.#channel.publish(await this.#node.nearbyOffer(this.#room.id, peer, session, offer.code)); + }; + await publish(); + republish = setInterval(() => publish().catch(() => {}), REPUBLISH_MS); + await answered; + return { close: () => offer.close() }; + } catch (error) { + offer.close(); + throw error; + } finally { + this.#joins.delete(session); + clearInterval(republish); + clearTimeout(deadline); + } + } + + /// Stops announcing and answering. Links already made stay up. Safe to + /// call at any point, an open() still running included. + close() { + this.#closed = true; + this.#live = false; + clearInterval(this.#timer); + this.#timer = null; + this.#channel?.close(); + for (const map of [this.#sessions, this.#refused]) { + for (const [session, entry] of [...map]) this.#forget(map, session, entry); + } + if (this.#room) { + const { id } = this.#room; + this.#room = null; + this.#node.nearbyClose(id).catch(() => {}); + } + } + + async #announce() { + if (this.#closed) return; + this.#channel.publish(await this.#node.nearbyPresence(this.#room.id)); + } + + #sweep() { + const stale = Date.now() - PRESENCE_STALE_MS; + let changed = false; + for (const [peer, { seenAt }] of this.#peers) { + if (seenAt < stale) { + this.#peers.delete(peer); + changed = true; + } + } + if (changed) this.#changed(); + } + + #heard(opened) { + if (this.#closed) return; + if (opened.kind === "presence") return this.#present(opened); + if (opened.kind === "answer") return this.#joins.get(opened.session)?.(opened); + if (opened.kind === "offer") return this.#offered(opened); + } + + #present({ peer, label }) { + const known = this.#peers.get(peer); + if (!known && this.#peers.size >= MAX_PEERS) { + let oldest = null; + for (const [key, { seenAt }] of this.#peers) { + if (oldest === null || seenAt < oldest.seenAt) oldest = { key, seenAt }; + } + this.#peers.delete(oldest.key); + } + this.#peers.set(peer, { label, seenAt: Date.now() }); + if (!known || known.label !== label) this.#changed(); + } + + async #offered({ peer, label, session, code, freshUntilMs }) { + const refused = this.#refused.get(session); + if (refused) return this.#hold(this.#refused, session, refused, freshUntilMs); + const known = this.#sessions.get(session); + if (known) { + this.#hold(this.#sessions, session, known, freshUntilMs); + if (known.answer) this.#channel.publish(known.answer); + return; + } + if (this.#sessions.size >= MAX_SESSIONS) { + this.dispatchEvent(new CustomEvent("full", { detail: { peer, label } })); + return; + } + const entry = { + answer: null, + connection: null, + freshUntilMs: -Infinity, + timer: undefined, + abort: new AbortController(), + }; + this.#sessions.set(session, entry); + this.#hold(this.#sessions, session, entry, freshUntilMs); + let admitted = false; + try { + admitted = (await this.#admit({ peer, label, signal: entry.abort.signal })) === true; + } catch { + // An application that throws has not said yes. + } + // Forgotten while the application decided: the joiner stopped asking, or + // the room closed. Nobody is waiting for this answer now. + if (this.#sessions.get(session) !== entry) return; + if (!admitted) { + this.#forget(this.#sessions, session, entry); + return this.#refuse(session, entry.freshUntilMs); + } + try { + entry.connection = await this.#carrier.answer(code); + if (this.#sessions.get(session) !== entry) return entry.connection.close(); + entry.answer = await this.#node.nearbyAnswer(this.#room.id, peer, session, entry.connection.code); + this.#channel.publish(entry.answer); + } catch { + entry.connection?.close(); + this.#forget(this.#sessions, session, entry); + } + } + + #refuse(session, freshUntilMs) { + if (this.#refused.size >= MAX_REFUSED) { + const [oldest, entry] = this.#refused.entries().next().value; + this.#forget(this.#refused, oldest, entry); + } + const entry = { freshUntilMs: -Infinity, timer: undefined }; + this.#refused.set(session, entry); + this.#hold(this.#refused, session, entry, freshUntilMs); + } + + // Keeps `entry` in `map` until its newest offer is no longer fresh, so a + // relay's late repeat reaches the session it belongs to (W11). + #hold(map, session, entry, freshUntilMs) { + if (freshUntilMs <= entry.freshUntilMs) return; + entry.freshUntilMs = freshUntilMs; + clearTimeout(entry.timer); + entry.timer = setTimeout( + () => this.#forget(map, session, entry), + Math.max(0, freshUntilMs - Date.now()), + ); + } + + #forget(map, session, entry) { + clearTimeout(entry.timer); + entry.abort?.abort(); + if (map.get(session) === entry) map.delete(session); + } + + #changed() { + this.dispatchEvent(new CustomEvent("peers", { detail: this.peers })); + } +} diff --git a/bindings/web/src/nostr-signal.js b/bindings/web/src/nostr-signal.js index 84c801fd..f9ad846c 100644 --- a/bindings/web/src/nostr-signal.js +++ b/bindings/web/src/nostr-signal.js @@ -66,14 +66,23 @@ function randomHex(bytes) { ).join(""); } +/// What a room channel subscribes with and opens through: `subscription(id)` +/// resolves to the REQ text, and `open(text)` to what a relay message +/// carries for the room, or null. Both run in the worker. +export function signalRoom(node, room) { + return { + subscription: (id) => node.signalSubscription(room, id), + open: (text) => node.signalOpen(room, text), + }; +} + /// One subscription to a room on every relay. `onSignal` gets each signal a /// relay delivers that opens for this room. A channel that `stays` (a hosted /// room) keeps every relay subscribed for as long as it is open; one that /// does not (a join, which lasts seconds) lets a relay that drops go. -class RoomChannel { - #node; +export class RoomChannel { + #source; #relays; - #room; #onSignal; #keepAliveMs; // One per relay: { url, socket, request, live, confirm, retry, backoff, refused }. @@ -88,10 +97,9 @@ class RoomChannel { #shown = () => document.visibilityState === "visible" && this.#probe({ now: true }); /// `keepAliveMs` makes the channel stay, probing each relay that often. - constructor(node, relays, room, onSignal, { keepAliveMs = null } = {}) { - this.#node = node; + constructor(source, relays, onSignal, { keepAliveMs = null } = {}) { + this.#source = source; this.#relays = relays; - this.#room = room; this.#onSignal = onSignal; this.#keepAliveMs = keepAliveMs === null ? null : Math.min(keepAliveMs, MAX_TIMER_MS); } @@ -143,7 +151,7 @@ class RoomChannel { async #connect(slot) { try { // Built again for every socket, so a resubscription asks from now on. - slot.request = await this.#node.signalSubscription(this.#room, this.#subscriptionId); + slot.request = await this.#source.subscription(this.#subscriptionId); } catch (error) { // The node stopped: nothing this room hears could be answered. return this.#failed(error); @@ -177,8 +185,8 @@ class RoomChannel { } return; } - this.#node - .signalOpen(this.#room, data) + this.#source + .open(data) .then((signal) => signal && !this.#closed && this.#onSignal(signal)) .catch(() => {}); }; @@ -355,7 +363,7 @@ export class NostrSignaller { clearTimeout(entry.timer); entry.timer = setTimeout(() => forget(session, entry), Math.max(0, freshUntilMs - Date.now())); }; - const channel = new RoomChannel(this.#node, this.#relays, room, async (signal) => { + const channel = new RoomChannel(signalRoom(this.#node, room), this.#relays, async (signal) => { if (signal.role !== "offer") return; const known = sessions.get(signal.session); if (known) { @@ -396,7 +404,7 @@ export class NostrSignaller { let settle; const answered = new Promise((resolve, reject) => (settle = { resolve, reject })); let accepted = false; - const channel = new RoomChannel(this.#node, this.#relays, room, async (signal) => { + const channel = new RoomChannel(signalRoom(this.#node, room), this.#relays, async (signal) => { if (accepted || signal.role !== "answer" || signal.session !== session) return; accepted = true; try { diff --git a/bindings/web/src/worker.js b/bindings/web/src/worker.js index 4edb3fea..97976698 100644 --- a/bindings/web/src/worker.js +++ b/bindings/web/src/worker.js @@ -33,6 +33,7 @@ import init, { signalEvent, signalOpen, parseInvite, + NearbyRoomKeys, } from "../pkg/offline_protocol_web.js"; import { openDurableStore } from "./store.js"; import { TabCarrier } from "./tab-carrier.js"; @@ -44,6 +45,11 @@ let engine = null; let carrier = null; let nostr = null; let store = null; +let startedAppId = null; +// Open nearby rooms, by id (W13). A page holds a few at most. +const nearby = new Map(); +let nextNearby = 1; +const MAX_NEARBY_ROOMS = 4; let timer = null; let dead = null; // Links whose carrier is on the page. @@ -151,6 +157,12 @@ function post(message) { self.postMessage(message); } +function nearbyRoom(id) { + const room = nearby.get(id); + if (!room) throw new Error("that nearby room is closed"); + return room; +} + const handlers = { async start({ appId, profile, storage = "durable", tabs = true, instance, wasm, nostr: relays }) { if (engine) throw new Error("this worker already runs a node"); @@ -191,6 +203,7 @@ const handlers = { throw error; } engine = next; + startedAppId = appId; if (tabs) { carrier = new TabCarrier( appId, @@ -280,6 +293,35 @@ const handlers = { parseInvite({ blob }) { return JSON.parse(parseInvite(blob)); }, + // A nearby room's half in the worker (W13). The page holds the relay + // sockets and the STUN lookup; these derive, seal and open. + nearbyOpen({ network, label = "" }) { + if (nearby.size >= MAX_NEARBY_ROOMS) throw new Error(`${MAX_NEARBY_ROOMS} nearby rooms are already open`); + const room = new NearbyRoomKeys(startedAppId, network, label); + const id = nextNearby++; + nearby.set(id, room); + return { id, key: room.key }; + }, + nearbySubscription({ id, subscriptionId }) { + return nearbyRoom(id).subscription(subscriptionId); + }, + nearbyPresence({ id }) { + return nearbyRoom(id).presence(); + }, + nearbyOffer({ id, to, session, code }) { + return nearbyRoom(id).offer(to, session, code); + }, + nearbyAnswer({ id, to, session, code }) { + return nearbyRoom(id).answer(to, session, code); + }, + nearbyOpenMessage({ id, message }) { + const opened = nearby.get(id)?.open(message); + return opened === undefined ? null : JSON.parse(opened); + }, + nearbyClose({ id }) { + nearby.get(id)?.free(); + nearby.delete(id); + }, sendGroupMessage({ groupId, content, ephemeral = false }) { return JSON.parse(engine.sendGroupMessage(groupId, content, Boolean(ephemeral))); }, @@ -289,6 +331,8 @@ const handlers = { carrier = null; nostr?.close(); nostr = null; + for (const room of nearby.values()) room.free(); + nearby.clear(); engine.stop(); flush(); pageLinks.clear(); diff --git a/bindings/web/test/nearby.html b/bindings/web/test/nearby.html new file mode 100644 index 00000000..dbf1b4c2 --- /dev/null +++ b/bindings/web/test/nearby.html @@ -0,0 +1,266 @@ + + +running +


+
diff --git a/bindings/web/test/run.mjs b/bindings/web/test/run.mjs
index 3086a9f4..678921e0 100644
--- a/bindings/web/test/run.mjs
+++ b/bindings/web/test/run.mjs
@@ -3,7 +3,8 @@
 // one API, so a page runs unchanged in all three.
 //
 // Usage: node test/run.mjs    ...
-// BROWSERS narrows the engines (default: chromium firefox webkit).
+// BROWSERS narrows the engines (default: chromium firefox webkit). STUN_URL,
+// when set, is passed to every page as `stun`.
 
 import { mkdtempSync, rmSync } from "node:fs";
 import { tmpdir } from "node:os";
@@ -31,6 +32,9 @@ const options = {
   firefox: {
     firefoxUserPrefs: {
       "media.peerconnection.ice.obfuscate_host_addresses": false,
+      // The nearby page's STUN server listens on loopback, which Firefox
+      // leaves out of ICE gathering unless told otherwise.
+      "media.peerconnection.ice.loopback": true,
     },
   },
   webkit: {},
@@ -53,7 +57,8 @@ for (const name of names) {
       page.on("pageerror", (error) => console.log("[page exception]", error.message));
       let title = "TIMEOUT";
       try {
-        await page.goto(`${base}/${path}?relay=${encodeURIComponent(relay)}`);
+        const stun = process.env.STUN_URL ? `&stun=${encodeURIComponent(process.env.STUN_URL)}` : "";
+        await page.goto(`${base}/${path}?relay=${encodeURIComponent(relay)}${stun}`);
         await page.waitForFunction(
           () => document.title === "PASS" || document.title.startsWith("FAIL"),
           null,
diff --git a/bindings/web/test/stun-server.mjs b/bindings/web/test/stun-server.mjs
new file mode 100644
index 00000000..59c20a3a
--- /dev/null
+++ b/bindings/web/test/stun-server.mjs
@@ -0,0 +1,35 @@
+// A minimal STUN server for the browser tests (RFC 8489 Binding only): it
+// answers every Binding request with a XOR-MAPPED-ADDRESS naming the
+// address it was told to report, not the one the request came from, so a
+// page on one machine gathers a server-reflexive candidate distinct from its
+// host candidates, as it would behind a real network's NAT. Node's standard
+// library only.
+//
+// Usage: node test/stun-server.mjs  
+
+import { createSocket } from "node:dgram";
+
+const port = Number(process.argv[2] ?? 8767);
+const reported = (process.argv[3] ?? "203.0.113.50").split(".").map(Number);
+const MAGIC = 0x2112a442;
+
+const socket = createSocket("udp4");
+socket.on("message", (request, from) => {
+  if (request.length < 20 || request.readUInt16BE(0) !== 0x0001) return;
+  if (request.readUInt32BE(4) !== MAGIC) return;
+  const transaction = request.subarray(8, 20);
+  const attribute = Buffer.alloc(12);
+  attribute.writeUInt16BE(0x0020, 0); // XOR-MAPPED-ADDRESS
+  attribute.writeUInt16BE(8, 2);
+  attribute.writeUInt8(0x01, 5); // IPv4
+  attribute.writeUInt16BE(from.port ^ (MAGIC >>> 16), 6);
+  const address = Buffer.from(reported);
+  attribute.writeUInt32BE((address.readUInt32BE(0) ^ MAGIC) >>> 0, 8);
+  const header = Buffer.alloc(20);
+  header.writeUInt16BE(0x0101, 0); // Binding success response
+  header.writeUInt16BE(attribute.length, 2);
+  header.writeUInt32BE(MAGIC, 4);
+  transaction.copy(header, 8);
+  socket.send(Buffer.concat([header, attribute]), from.port, from.address);
+});
+socket.bind(port, "127.0.0.1", () => console.log(`stun test server on ${port}`));
diff --git a/crates/offline-protocol-transport/src/nostr_crypto.rs b/crates/offline-protocol-transport/src/nostr_crypto.rs
index d0cef969..b4233c52 100644
--- a/crates/offline-protocol-transport/src/nostr_crypto.rs
+++ b/crates/offline-protocol-transport/src/nostr_crypto.rs
@@ -910,6 +910,37 @@ pub(crate) fn unwrap_gift_wrap(
     nip44::decrypt_bytes(sealed, &conversation_key)
 }
 
+/// Seals `plaintext` from `sender` to whoever holds the secret behind
+/// `recipient_pubkey_hex`, with NIP-44 v2, and returns the base64 payload.
+///
+/// For a page that has met another only through a public channel and wants
+/// what it says next readable by that one key alone: the browser's nearby
+/// room seals each connection code to the presence key its host announced,
+/// so a holder of the room's weak key reads presences and nothing else.
+/// Nothing here names or authenticates the sender; the recipient learns only
+/// that the payload was sealed by the holder of the key it opens it with.
+pub fn seal_to_key(
+    sender: &NostrKeypair,
+    recipient_pubkey_hex: &str,
+    plaintext: &[u8],
+) -> Result {
+    let peer = hex::decode(recipient_pubkey_hex)
+        .map_err(|e| Error::CryptoError(format!("Invalid recipient pubkey: {}", e)))?;
+    let conversation_key = ConversationKey::derive(&sender.signing_key, &peer)?;
+    nip44::encrypt(plaintext, &conversation_key)
+}
+
+/// Opens what the holder of `sender_pubkey_hex` sealed to `recipient` with
+/// [`seal_to_key`]. `payload` is the base64-decoded payload. Fails on any
+/// other key, a forged MAC or bad padding.
+pub fn open_from_key(
+    recipient: &NostrKeypair,
+    sender_pubkey_hex: &str,
+    payload: &[u8],
+) -> Result> {
+    unwrap_gift_wrap(recipient, sender_pubkey_hex, payload)
+}
+
 /// Opens a peer's published key-package record.
 ///
 /// Mechanically identical to [`unwrap_gift_wrap`] — NIP-44's conversation key
@@ -1171,6 +1202,23 @@ mod tests {
     use super::*;
     use base64::Engine;
 
+    #[test]
+    fn a_payload_sealed_to_a_key_opens_with_that_key_only() {
+        let sender = NostrKeypair::generate_ephemeral().unwrap();
+        let recipient = NostrKeypair::generate_ephemeral().unwrap();
+        let stranger = NostrKeypair::generate_ephemeral().unwrap();
+        let sealed = seal_to_key(&sender, recipient.public_key_hex(), b"op1.code").unwrap();
+        let bytes = base64::engine::general_purpose::STANDARD
+            .decode(&sealed)
+            .unwrap();
+        assert_eq!(
+            open_from_key(&recipient, sender.public_key_hex(), &bytes).unwrap(),
+            b"op1.code"
+        );
+        assert!(open_from_key(&stranger, sender.public_key_hex(), &bytes).is_err());
+        assert!(open_from_key(&recipient, stranger.public_key_hex(), &bytes).is_err());
+    }
+
     /// The address a test label stands for.
     ///
     /// Both derivations here take an address, and an address is not a name —
diff --git a/crates/offline-protocol-web/src/bindings.rs b/crates/offline-protocol-web/src/bindings.rs
index 73303ee3..ca76fa78 100644
--- a/crates/offline-protocol-web/src/bindings.rs
+++ b/crates/offline-protocol-web/src/bindings.rs
@@ -80,6 +80,68 @@ pub fn parse_invite(blob: &str) -> Result {
     .to_string())
 }
 
+/// One device's place in a nearby room (W13): the room behind one network
+/// address for one application, and the key this device announces there.
+#[wasm_bindgen(js_name = NearbyRoomKeys)]
+pub struct WebNearby {
+    inner: crate::nearby::Nearby,
+}
+
+#[wasm_bindgen(js_class = NearbyRoomKeys)]
+impl WebNearby {
+    /// Joins the room for `app_id` behind `network` (the address a STUN
+    /// server reports: IPv4, or IPv6 taken to its /64), under `label`.
+    #[wasm_bindgen(constructor)]
+    pub fn new(app_id: &str, network: &str, label: &str) -> Result {
+        crate::nearby::Nearby::new(app_id, network, label)
+            .map(|inner| Self { inner })
+            .map_err(|e| JsError::new(&e))
+    }
+
+    /// The key this device announces.
+    #[wasm_bindgen(getter)]
+    pub fn key(&self) -> String {
+        self.inner.key().to_string()
+    }
+
+    /// The `REQ` text receiving the room, from as far back as an event is
+    /// accepted.
+    pub fn subscription(&self, subscription_id: &str) -> Result {
+        let since = signal::now_unix_secs().saturating_sub(signal::MAX_EVENT_AGE_SECS);
+        self.inner
+            .subscription(subscription_id, since)
+            .map_err(|e| JsError::new(&e))
+    }
+
+    /// The `EVENT` text announcing this device.
+    pub fn presence(&self) -> Result {
+        self.inner.presence().map_err(|e| JsError::new(&e))
+    }
+
+    /// The `EVENT` text offering `code` to the device that announced `to`.
+    pub fn offer(&self, to: &str, session: &str, code: &str) -> Result {
+        self.inner
+            .offer(to, session, code)
+            .map_err(|e| JsError::new(&e))
+    }
+
+    /// The `EVENT` text answering the joiner whose presence key is `to`.
+    pub fn answer(&self, to: &str, session: &str, code: &str) -> Result {
+        self.inner
+            .answer(to, session, code)
+            .map_err(|e| JsError::new(&e))
+    }
+
+    /// Opens a relay message: `{kind, peer, label, session, code,
+    /// createdAtMs, freshUntilMs}` as JSON for a presence from another
+    /// device or an offer or answer sealed to this one, else `undefined`.
+    pub fn open(&self, relay_message: &str) -> Option {
+        self.inner
+            .open(relay_message, signal::now_unix_secs())
+            .map(|opened| opened.to_json())
+    }
+}
+
 type DurableStores = (Arc>, Arc>);
 
 fn js(error: NodeError) -> JsError {
diff --git a/crates/offline-protocol-web/src/lib.rs b/crates/offline-protocol-web/src/lib.rs
index 646f7d2c..e5d5211b 100644
--- a/crates/offline-protocol-web/src/lib.rs
+++ b/crates/offline-protocol-web/src/lib.rs
@@ -12,7 +12,8 @@
 //! [`signal`] lets two pages that share a room code exchange their WebRTC
 //! connection codes through Nostr relays instead of by hand, and
 //! [`nostr_host`] drives the Nostr transport itself from the worker, with
-//! relays the application names.
+//! relays the application names. [`nearby`] lets the browsers behind one
+//! network address find each other there.
 //!
 //! The crate also builds on every host, so the workspace's lint, docs and test
 //! runs cover it: everything below the bindings is plain Rust, and the tests
@@ -24,6 +25,7 @@
 pub mod bindings;
 pub mod log_store;
 pub mod memory_store;
+pub mod nearby;
 pub mod node;
 pub mod nostr_host;
 pub mod opfs;
diff --git a/crates/offline-protocol-web/src/nearby.rs b/crates/offline-protocol-web/src/nearby.rs
new file mode 100644
index 00000000..9a4185b8
--- /dev/null
+++ b/crates/offline-protocol-web/src/nearby.rs
@@ -0,0 +1,620 @@
+//! Finding devices on the same network, through Nostr relays (W13).
+//!
+//! A page cannot browse DNS-SD, advertise over Bluetooth or join a Wi-Fi
+//! Direct group, so two browsers on one network have no way to notice each
+//! other. What they do share is the address the internet sees them from. A
+//! nearby room is a Nostr topic derived from that address and the
+//! application id: every device behind the same address lands in the same
+//! room, announces itself there, and sets up a WebRTC link with whichever
+//! announced device its person picks. The link then runs on the local
+//! network; the relays carry only the setup.
+//!
+//! # Invariants
+//!
+//! - **The room is weak, by construction.** Its key is derived from an
+//!   address anyone can enumerate, so anyone may compute a room's topic and
+//!   key and read what is sealed under it. Only presences are sealed under it
+//!   alone: a presence announces a key used for this room only and a label
+//!   the application chose, and nothing that identifies the device or its
+//!   local addresses.
+//! - **A presence is signed by the key it announces.** The room key is
+//!   public, so it authenticates nothing: a presence sealed under it alone
+//!   let anyone list a key they do not hold, relabel a device that is there,
+//!   or keep one listed after it left. A presence opens only when its event's
+//!   author is the key it names.
+//! - **A connection code, and the label beside it, are sealed to one presence
+//!   key.** An offer is sealed with NIP-44 from the joiner's room key to the
+//!   presence key it picked, and the answer back to the joiner's, so a holder
+//!   of the room key reads neither, and cannot read the local addresses a
+//!   code names (R27). The label rides inside that seal: under the room key
+//!   alone, anyone could re-wrap a real joiner's offer under a label of their
+//!   choosing before the host was asked about it. Both layers bind their
+//!   role, so an answer is not replayed as an offer.
+//! - **A host admits by its application's decision.** Anyone holding the
+//!   room key can offer, so the page asks the application before it answers
+//!   one, and nothing here answers on its own. Who is on a link that forms is
+//!   still what the engine's preamble and MLS session prove (W3).
+//! - **IPv6 rooms are per prefix.** Behind IPv4 the devices of one network
+//!   share one public address; with IPv6 each has its own, from the
+//!   network's /64. The room is derived from the address for IPv4 and from
+//!   the /64 for IPv6.
+//! - **Events are ephemeral and fresh.** The signalling kind (25912),
+//!   refused when more than [`signal::MAX_EVENT_AGE_SECS`] from this clock,
+//!   as in a signalling room. Offers and answers are signed by a key used
+//!   once. Every signature is checked, because it binds `created_at`: without
+//!   it a relay could re-stamp an old offer and replay it fresh. The room key
+//!   is checked first, as it is the cheaper of the two and refuses everything
+//!   not sealed for this room.
+
+use std::net::IpAddr;
+
+use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
+use chacha20poly1305::aead::{Aead, KeyInit, Payload};
+use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
+use hkdf::Hkdf;
+use offline_protocol_transport::nostr_crypto::{
+    create_signal_subscription, open_from_key, seal_to_key, verify_event, NOSTR_SIGNAL_KIND,
+};
+use offline_protocol_transport::{NostrEvent, NostrKeypair};
+use rand_core::{OsRng, RngCore};
+use sha2::Sha256;
+use zeroize::Zeroizing;
+
+use crate::signal::{self, check_code, check_session, hex_encode, MAX_EVENT_AGE_SECS};
+
+const HKDF_SALT: &[u8] = b"offline-protocol web nearby v1";
+const NONCE_BYTES: usize = 12;
+const VERSION: u64 = 1;
+
+/// The longest label a presence carries, in characters.
+pub const MAX_LABEL_CHARS: usize = 64;
+
+/// What an event in a nearby room is.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Kind {
+    /// "This device is here", sealed under the room key.
+    Presence,
+    /// A joiner's connection code, sealed to one presence key.
+    Offer,
+    /// The host's connection code, sealed back to the joiner.
+    Answer,
+}
+
+impl Kind {
+    fn as_str(self) -> &'static str {
+        match self {
+            Self::Presence => "presence",
+            Self::Offer => "offer",
+            Self::Answer => "answer",
+        }
+    }
+
+    fn parse(kind: &str) -> Option {
+        match kind {
+            "presence" => Some(Self::Presence),
+            "offer" => Some(Self::Offer),
+            "answer" => Some(Self::Answer),
+            _ => None,
+        }
+    }
+}
+
+/// One event a relay delivered, opened.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Opened {
+    /// What it is.
+    pub kind: Kind,
+    /// The sender's presence key: who to offer to, or who offered.
+    pub peer: String,
+    /// The label the sender's application chose. Empty on an answer.
+    pub label: String,
+    /// The joiner's session, on an offer or an answer.
+    pub session: Option,
+    /// The connection code, on an offer or an answer.
+    pub code: Option,
+    /// When the sender created it, in Unix seconds.
+    pub created_at: i64,
+}
+
+impl Opened {
+    /// As JSON for the page, times in milliseconds. `freshUntilMs` is when
+    /// this clock stops accepting the event, as for a signal.
+    pub fn to_json(&self) -> String {
+        serde_json::json!({
+            "kind": self.kind.as_str(),
+            "peer": self.peer,
+            "label": self.label,
+            "session": self.session,
+            "code": self.code,
+            "createdAtMs": self.created_at.saturating_mul(1000),
+            "freshUntilMs": self.created_at.saturating_add(MAX_EVENT_AGE_SECS).saturating_mul(1000),
+        })
+        .to_string()
+    }
+}
+
+/// One device's place in one nearby room: the room's topic and key, and the
+/// key this device announces there.
+pub struct Nearby {
+    topic: String,
+    cipher: ChaCha20Poly1305,
+    keys: NostrKeypair,
+    label: String,
+}
+
+impl Nearby {
+    /// Joins the room for `app_id` behind `network`, the address the
+    /// internet sees this device from (as a STUN server reports it), under
+    /// `label`. Refuses an address that does not parse, a label over
+    /// [`MAX_LABEL_CHARS`], and one with control characters.
+    pub fn new(app_id: &str, network: &str, label: &str) -> Result {
+        if app_id.is_empty() {
+            return Err("a nearby room needs the application id".to_string());
+        }
+        check_label(label)?;
+        let scope = network_scope(network)?;
+        let mut ikm = app_id.as_bytes().to_vec();
+        ikm.push(0);
+        ikm.extend_from_slice(scope.as_bytes());
+        let hkdf = Hkdf::::new(Some(HKDF_SALT), &ikm);
+        let mut topic = [0u8; 16];
+        hkdf.expand(b"topic", &mut topic)
+            .map_err(|_| "room derivation failed".to_string())?;
+        let mut key = Zeroizing::new([0u8; 32]);
+        hkdf.expand(b"key", key.as_mut())
+            .map_err(|_| "room derivation failed".to_string())?;
+        Ok(Self {
+            topic: hex_encode(&topic),
+            cipher: ChaCha20Poly1305::new(Key::from_slice(key.as_ref())),
+            keys: NostrKeypair::generate_ephemeral().map_err(|e| e.to_string())?,
+            label: label.to_string(),
+        })
+    }
+
+    /// The room's public topic: what a relay sees.
+    pub fn topic(&self) -> &str {
+        &self.topic
+    }
+
+    /// The key this device announces in the room.
+    pub fn key(&self) -> &str {
+        self.keys.public_key_hex()
+    }
+
+    /// The relay `REQ` that receives the room from `since` on.
+    pub fn subscription(&self, subscription_id: &str, since: i64) -> Result {
+        create_signal_subscription(&self.topic, subscription_id, since).map_err(|e| e.to_string())
+    }
+
+    /// The relay `EVENT` announcing this device.
+    pub fn presence(&self) -> Result {
+        self.publish(
+            Kind::Presence,
+            serde_json::json!({ "v": VERSION, "key": self.key(), "label": self.label }),
+            &self.keys,
+        )
+    }
+
+    /// The relay `EVENT` carrying a joiner's connection code to the device
+    /// that announced `to`.
+    pub fn offer(&self, to: &str, session: &str, code: &str) -> Result {
+        self.addressed(Kind::Offer, to, session, code)
+    }
+
+    /// The relay `EVENT` carrying the host's connection code back to the
+    /// joiner whose presence key is `to`.
+    pub fn answer(&self, to: &str, session: &str, code: &str) -> Result {
+        self.addressed(Kind::Answer, to, session, code)
+    }
+
+    /// Opens a relay message: a presence from another device, or an offer or
+    /// answer addressed to this one. `None` for anything else, this device's
+    /// own events included.
+    pub fn open(&self, relay_message: &str, now: i64) -> Option {
+        let message: serde_json::Value = serde_json::from_str(relay_message).ok()?;
+        let parts = message.as_array()?;
+        if parts.len() != 3 || parts[0].as_str()? != "EVENT" {
+            return None;
+        }
+        let event = &parts[2];
+        if event.get("kind")?.as_u64()? != u64::from(NOSTR_SIGNAL_KIND) {
+            return None;
+        }
+        let created_at = event.get("created_at")?.as_i64()?;
+        if (now - created_at).abs() > MAX_EVENT_AGE_SECS {
+            return None;
+        }
+        let on_topic = event.get("tags")?.as_array()?.iter().any(|tag| {
+            tag.as_array().is_some_and(|tag| {
+                tag.len() >= 2 && tag[0] == "t" && tag[1].as_str() == Some(self.topic.as_str())
+            })
+        });
+        if !on_topic {
+            return None;
+        }
+        let (kind, body) = event.get("content")?.as_str()?.split_once(':')?;
+        let kind = Kind::parse(kind)?;
+        let body = BASE64.decode(body).ok()?;
+        if body.len() < NONCE_BYTES {
+            return None;
+        }
+        let aad = self.aad(kind);
+        let plain = self
+            .cipher
+            .decrypt(
+                Nonce::from_slice(&body[..NONCE_BYTES]),
+                Payload {
+                    msg: &body[NONCE_BYTES..],
+                    aad: &aad,
+                },
+            )
+            .ok()?;
+        let plain: serde_json::Value = serde_json::from_slice(&plain).ok()?;
+        if plain.get("v")?.as_u64()? != VERSION || !verify_event(event) {
+            return None;
+        }
+        match kind {
+            Kind::Presence => {
+                let peer = plain.get("key")?.as_str()?.to_string();
+                let label = plain.get("label")?.as_str()?.to_string();
+                if peer == self.key()
+                    || !is_key(&peer)
+                    || event.get("pubkey")?.as_str()? != peer
+                    || check_label(&label).is_err()
+                {
+                    return None;
+                }
+                Some(Opened {
+                    kind,
+                    peer,
+                    label,
+                    session: None,
+                    code: None,
+                    created_at,
+                })
+            }
+            Kind::Offer | Kind::Answer => {
+                if plain.get("to")?.as_str()? != self.key() {
+                    return None;
+                }
+                let peer = plain.get("from")?.as_str()?.to_string();
+                if !is_key(&peer) {
+                    return None;
+                }
+                let sealed = BASE64.decode(plain.get("sealed")?.as_str()?).ok()?;
+                let inner = open_from_key(&self.keys, &peer, &sealed).ok()?;
+                let inner: serde_json::Value = serde_json::from_slice(&inner).ok()?;
+                if inner.get("v")?.as_u64()? != VERSION
+                    || inner.get("kind")?.as_str()? != kind.as_str()
+                {
+                    return None;
+                }
+                let session = inner.get("session")?.as_str()?.to_string();
+                let code = inner.get("code")?.as_str()?.to_string();
+                let label = inner.get("label")?.as_str()?.to_string();
+                check_session(&session).ok()?;
+                check_code(&code).ok()?;
+                check_label(&label).ok()?;
+                Some(Opened {
+                    kind,
+                    peer,
+                    label,
+                    session: Some(session),
+                    code: Some(code),
+                    created_at,
+                })
+            }
+        }
+    }
+
+    fn addressed(&self, kind: Kind, to: &str, session: &str, code: &str) -> Result {
+        if !is_key(to) {
+            return Err("not a presence key".to_string());
+        }
+        check_session(session)?;
+        check_code(code)?;
+        let inner = Zeroizing::new(
+            serde_json::json!({
+                "v": VERSION,
+                "kind": kind.as_str(),
+                "session": session,
+                "code": code,
+                "label": self.label,
+            })
+            .to_string(),
+        );
+        let sealed = seal_to_key(&self.keys, to, inner.as_bytes()).map_err(|e| e.to_string())?;
+        self.publish(
+            kind,
+            serde_json::json!({
+                "v": VERSION,
+                "from": self.key(),
+                "to": to,
+                "sealed": sealed,
+            }),
+            &NostrKeypair::generate_ephemeral().map_err(|e| e.to_string())?,
+        )
+    }
+
+    /// Seals `body` under the room key for `kind` and signs the event as
+    /// `author`: the presence key for a presence, a key used once otherwise.
+    fn publish(
+        &self,
+        kind: Kind,
+        body: serde_json::Value,
+        author: &NostrKeypair,
+    ) -> Result {
+        let plain = Zeroizing::new(body.to_string());
+        let mut nonce = [0u8; NONCE_BYTES];
+        OsRng.fill_bytes(&mut nonce);
+        let aad = self.aad(kind);
+        let sealed = self
+            .cipher
+            .encrypt(
+                Nonce::from_slice(&nonce),
+                Payload {
+                    msg: plain.as_bytes(),
+                    aad: &aad,
+                },
+            )
+            .map_err(|_| "sealing the event failed".to_string())?;
+        let mut content = nonce.to_vec();
+        content.extend_from_slice(&sealed);
+        let content = format!("{}:{}", kind.as_str(), BASE64.encode(content));
+        NostrEvent::create_signal(author, &self.topic, &content)
+            .and_then(|event| event.to_relay_message())
+            .map_err(|e| e.to_string())
+    }
+
+    fn aad(&self, kind: Kind) -> Vec {
+        let mut aad = b"offline-protocol web nearby/".to_vec();
+        aad.extend_from_slice(self.topic.as_bytes());
+        aad.push(b'/');
+        aad.extend_from_slice(kind.as_str().as_bytes());
+        aad
+    }
+}
+
+/// What a room is derived from: the IPv4 address, or the IPv6 /64.
+fn network_scope(network: &str) -> Result {
+    let address: IpAddr = network
+        .trim()
+        .trim_start_matches('[')
+        .trim_end_matches(']')
+        .parse()
+        .map_err(|_| format!("{network} is not an IP address"))?;
+    Ok(match address {
+        IpAddr::V4(v4) => format!("v4/{v4}"),
+        IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
+            Some(v4) => format!("v4/{v4}"),
+            None => {
+                let segments = v6.segments();
+                format!(
+                    "v6/{:x}:{:x}:{:x}:{:x}::/64",
+                    segments[0], segments[1], segments[2], segments[3]
+                )
+            }
+        },
+    })
+}
+
+fn is_key(key: &str) -> bool {
+    key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit())
+}
+
+fn check_label(label: &str) -> Result<(), String> {
+    if label.chars().count() > MAX_LABEL_CHARS {
+        return Err(format!(
+            "a nearby label is at most {MAX_LABEL_CHARS} characters"
+        ));
+    }
+    if label.chars().any(char::is_control) {
+        return Err("a nearby label cannot hold control characters".to_string());
+    }
+    if label.chars().any(is_bidi_control) {
+        return Err("a nearby label cannot hold bidirectional controls".to_string());
+    }
+    Ok(())
+}
+
+/// The Unicode bidirectional formatting characters. They are not control
+/// characters to `char::is_control`, and a label is shown in a prompt the
+/// host answers: one holding an override could make "Bob" out of another
+/// string, or reorder the words around it.
+fn is_bidi_control(c: char) -> bool {
+    matches!(
+        c,
+        '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
+    )
+}
+
+/// This clock in Unix seconds.
+pub fn now_unix_secs() -> i64 {
+    signal::now_unix_secs()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const APP: &str = "pong";
+    const SESSION: &str = "0123456789abcdef0123456789abcdef";
+
+    fn room(network: &str, label: &str) -> Nearby {
+        Nearby::new(APP, network, label).expect("room")
+    }
+
+    fn now() -> i64 {
+        now_unix_secs()
+    }
+
+    /// What a relay delivers for a published `["EVENT", {...}]`.
+    fn relayed(published: Result) -> String {
+        let sent: serde_json::Value = serde_json::from_str(&published.unwrap()).unwrap();
+        serde_json::json!(["EVENT", "sub", sent[1]]).to_string()
+    }
+
+    #[test]
+    fn devices_behind_one_address_share_a_room_and_see_each_other() {
+        let (a, b) = (room("203.0.113.7", "Alice"), room("203.0.113.7", "Bob"));
+        assert_eq!(a.topic(), b.topic());
+        let seen = b.open(&relayed(a.presence()), now()).expect("presence");
+        assert_eq!(seen.kind, Kind::Presence);
+        assert_eq!(seen.peer, a.key());
+        assert_eq!(seen.label, "Alice");
+        assert!(
+            a.open(&relayed(a.presence()), now()).is_none(),
+            "own presence"
+        );
+    }
+
+    #[test]
+    fn another_network_or_application_is_another_room() {
+        let a = room("203.0.113.7", "");
+        assert_ne!(a.topic(), room("203.0.113.8", "").topic());
+        assert_ne!(
+            a.topic(),
+            Nearby::new("chess", "203.0.113.7", "").unwrap().topic()
+        );
+        assert!(room("198.51.100.1", "")
+            .open(&relayed(a.presence()), now())
+            .is_none());
+    }
+
+    #[test]
+    fn an_ipv6_room_is_the_networks_prefix() {
+        assert_eq!(
+            room("2001:db8:1:2:aaaa::1", "").topic(),
+            room("2001:db8:1:2:bbbb::9", "").topic()
+        );
+        assert_ne!(
+            room("2001:db8:1:2::1", "").topic(),
+            room("2001:db8:1:3::1", "").topic()
+        );
+        assert_eq!(
+            room("::ffff:203.0.113.7", "").topic(),
+            room("203.0.113.7", "").topic()
+        );
+        assert!(Nearby::new(APP, "not an address", "").is_err());
+    }
+
+    #[test]
+    fn an_offer_opens_for_the_key_it_was_sealed_to_and_nobody_else() {
+        let (host, joiner) = (room("203.0.113.7", "Host"), room("203.0.113.7", "Joiner"));
+        let eavesdropper = room("203.0.113.7", "Eve");
+        let offer = relayed(joiner.offer(host.key(), SESSION, "op1.offer"));
+        let opened = host.open(&offer, now()).expect("offer");
+        assert_eq!(opened.kind, Kind::Offer);
+        assert_eq!(opened.peer, joiner.key());
+        assert_eq!(opened.label, "Joiner");
+        assert_eq!(opened.code.as_deref(), Some("op1.offer"));
+        assert!(
+            eavesdropper.open(&offer, now()).is_none(),
+            "a holder of the room key read an offer sealed to another"
+        );
+        let answer = relayed(host.answer(joiner.key(), SESSION, "op1.answer"));
+        let opened = joiner.open(&answer, now()).expect("answer");
+        assert_eq!(opened.kind, Kind::Answer);
+        assert_eq!(opened.code.as_deref(), Some("op1.answer"));
+        assert!(host.open(&answer, now()).is_none());
+    }
+
+    #[test]
+    fn an_event_out_of_its_window_is_refused() {
+        let a = room("203.0.113.7", "");
+        let b = room("203.0.113.7", "");
+        let presence = relayed(a.presence());
+        assert!(b.open(&presence, now() + MAX_EVENT_AGE_SECS + 5).is_none());
+        assert!(b.open(&presence, now() - MAX_EVENT_AGE_SECS - 5).is_none());
+    }
+
+    #[test]
+    fn a_label_is_bounded_and_printable() {
+        assert!(Nearby::new(APP, "203.0.113.7", &"x".repeat(MAX_LABEL_CHARS)).is_ok());
+        assert!(Nearby::new(APP, "203.0.113.7", &"x".repeat(MAX_LABEL_CHARS + 1)).is_err());
+        assert!(Nearby::new(APP, "203.0.113.7", "a\nb").is_err());
+        assert!(Nearby::new(APP, "203.0.113.7", "Bob\u{202E}evil").is_err());
+        assert!(Nearby::new(APP, "203.0.113.7", "\u{2067}Bob").is_err());
+        assert!(Nearby::new(APP, "203.0.113.7", "Zoë's phone").is_ok());
+    }
+
+    #[test]
+    fn a_presence_opens_only_from_the_key_it_names() {
+        let (victim, eve, watcher) = (
+            room("203.0.113.7", "Bob"),
+            room("203.0.113.7", "Eve"),
+            room("203.0.113.7", ""),
+        );
+        // Eve holds the room key and lists Bob's key under her own label,
+        // signed by a key of hers: the relabel and ghost a public room key
+        // allowed.
+        let forged = eve.publish(
+            Kind::Presence,
+            serde_json::json!({ "v": VERSION, "key": victim.key(), "label": "Mallory" }),
+            &NostrKeypair::generate_ephemeral().unwrap(),
+        );
+        assert!(watcher.open(&relayed(forged), now()).is_none());
+        let genuine = watcher.open(&relayed(victim.presence()), now()).unwrap();
+        assert_eq!(
+            (genuine.peer.as_str(), genuine.label.as_str()),
+            (victim.key(), "Bob")
+        );
+    }
+
+    #[test]
+    fn a_label_re_wrapped_around_a_real_offer_is_not_what_the_host_is_shown() {
+        let (host, joiner, eve) = (
+            room("203.0.113.7", "Host"),
+            room("203.0.113.7", "Bob"),
+            room("203.0.113.7", "Eve"),
+        );
+        let offer: serde_json::Value =
+            serde_json::from_str(&joiner.offer(host.key(), SESSION, "op1.offer").unwrap()).unwrap();
+        // Eve opens the room-key layer of Bob's offer, which anyone can, and
+        // seals it again with her label beside the code.
+        let content = offer[1]["content"].as_str().unwrap();
+        let body = BASE64.decode(content.split_once(':').unwrap().1).unwrap();
+        let aad = eve.aad(Kind::Offer);
+        let plain = eve
+            .cipher
+            .decrypt(
+                Nonce::from_slice(&body[..NONCE_BYTES]),
+                Payload {
+                    msg: &body[NONCE_BYTES..],
+                    aad: &aad,
+                },
+            )
+            .unwrap();
+        let mut plain: serde_json::Value = serde_json::from_slice(&plain).unwrap();
+        plain["label"] = "Mallory".into();
+        let rewrapped = eve.publish(
+            Kind::Offer,
+            plain,
+            &NostrKeypair::generate_ephemeral().unwrap(),
+        );
+        let opened = host
+            .open(&relayed(rewrapped), now())
+            .expect("still Bob's offer");
+        assert_eq!(opened.peer, joiner.key());
+        assert_eq!(
+            opened.label, "Bob",
+            "the host was shown a label Bob never sent"
+        );
+    }
+
+    #[test]
+    fn a_signalling_room_and_a_nearby_room_do_not_read_each_other() {
+        let a = room("203.0.113.7", "");
+        let code = crate::signal::new_room();
+        let offer = relayed(crate::signal::event(
+            &code,
+            &crate::signal::Signal {
+                role: crate::signal::Role::Offer,
+                session: SESSION.into(),
+                code: "op1.x".into(),
+            },
+        ));
+        assert!(a.open(&offer, now()).is_none());
+        assert!(crate::signal::open(&code, &relayed(a.presence()), now()).is_none());
+    }
+}
diff --git a/crates/offline-protocol-web/src/signal.rs b/crates/offline-protocol-web/src/signal.rs
index 7ddd2965..c9dfa811 100644
--- a/crates/offline-protocol-web/src/signal.rs
+++ b/crates/offline-protocol-web/src/signal.rs
@@ -294,7 +294,7 @@ fn normalise(code: &str) -> Result {
     Ok(code)
 }
 
-fn check_session(session: &str) -> Result<(), String> {
+pub(crate) fn check_session(session: &str) -> Result<(), String> {
     if session.len() == 32 && session.bytes().all(|b| b.is_ascii_hexdigit()) {
         Ok(())
     } else {
@@ -302,7 +302,7 @@ fn check_session(session: &str) -> Result<(), String> {
     }
 }
 
-fn check_code(code: &str) -> Result<(), String> {
+pub(crate) fn check_code(code: &str) -> Result<(), String> {
     if code.starts_with("op1.") && code.len() <= MAX_CONNECTION_CODE_BYTES {
         Ok(())
     } else {
@@ -310,7 +310,7 @@ fn check_code(code: &str) -> Result<(), String> {
     }
 }
 
-fn hex_encode(bytes: &[u8]) -> String {
+pub(crate) fn hex_encode(bytes: &[u8]) -> String {
     bytes.iter().map(|b| format!("{b:02x}")).collect()
 }
 
diff --git a/docs/adr/0034-browsers-find-each-other-through-their-network-address.md b/docs/adr/0034-browsers-find-each-other-through-their-network-address.md
new file mode 100644
index 00000000..a7b21382
--- /dev/null
+++ b/docs/adr/0034-browsers-find-each-other-through-their-network-address.md
@@ -0,0 +1,77 @@
+# 0034. Browsers on one network find each other through its address
+
+**Status:** Accepted
+
+## Context
+
+Every native binding discovers neighbours on its own: a Bluetooth LE scan,
+DNS-SD on the local network, Wi-Fi Direct service discovery. A page can do
+none of these. It cannot advertise over Bluetooth, no browser exposes
+multicast DNS, and Wi-Fi Direct has no web API. The Local Peer-to-Peer API
+that would cover the local network is a Web Platform Incubator Community
+Group draft (dated 27 May 2025) that no browser ships.
+
+So two browsers in one room had to carry two WebRTC codes between them, or
+share a room code a person reads out (W11). Nothing let them notice each
+other.
+
+What two devices on one network do share is the address the internet sees
+them from: one public IPv4 address behind a home or office NAT, or one IPv6
+/64. A STUN server reports it to a page as a server-reflexive ICE candidate.
+
+## Decision
+
+A nearby room is a Nostr topic and key derived from the application id and
+that address (the IPv4 address, or the IPv6 /64). Every device in the room
+publishes a presence, lists the others, and links over WebRTC to one its
+person picks. The link runs on the local network; the relays carry only the
+setup.
+
+The room key is derived from something anyone can enumerate, so the design
+treats it as public:
+
+- **A presence holds only a key used for this room and the application's
+  label, and is signed by that key.** Nothing identifies the device or its
+  local addresses, and nobody can list a key they do not hold.
+- **Every connection code, and the label beside it, is sealed to one
+  presence key** with NIP-44, under the room key as a second layer. A holder
+  of the room key reads presences and nothing else, and cannot relabel an
+  offer.
+- **No limit copied from a secret room becomes a lock.** A signalling room
+  (W11) can hold refused sessions among the ones it decides on, because only
+  a code holder can offer. Here anyone can, so a refused session is kept
+  apart and a full list forgets its oldest entry: otherwise a stranger
+  anywhere could fill either for good.
+- **A host answers only what its application admits.** `admit` is required:
+  a page that opens a room has said who it lets in. The label is a claim; the
+  engine's preamble and MLS session are what identify the device on a link.
+- **The relays and the STUN servers are the application's.** The SDK names
+  neither, and a room without them does not open.
+
+## Consequences
+
+Same-network discovery for browsers needs the internet, to reach a relay and
+a STUN server. Two devices on a network with no route out cannot use it,
+and fall back to codes (W9). That is a real difference from the native
+bindings, and it is permanent until browsers expose local discovery.
+
+The room is shared with everyone behind the same address. On a carrier-grade
+NAT, a hotel or a campus that can be thousands of strangers, who all see each
+other's labels and can each offer. Admission is what keeps that from being a
+way in, which is why it is the application's explicit decision and not a
+default. An application should show the label, ask, and keep the address the
+person accepts.
+
+Devices whose traffic leaves by different routes (a phone on cellular, a
+laptop on a VPN, a dual-stack network where one device reports IPv4 and the
+other IPv6) land in different rooms and do not see each other. Nothing
+detects that; the room is simply empty.
+
+## What would undo this
+
+A browser shipping local discovery (the Local Peer-to-Peer API, or an mDNS
+API for pages) makes the address-derived room unnecessary on networks that
+allow it, and that would be the better path where it exists. Until then,
+the thing to guard against is someone treating the room key as a secret and
+sealing a connection code under it alone: anyone who can guess the address
+would then read the local addresses every code names.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 8272568d..3c1a0c97 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -44,6 +44,7 @@ silently undo it. Decisions that follow from the obvious default do not need one
 | [0031](0031-telemetry-trusts-only-the-ingests-issuers.md) | The telemetry uploader trusts only the ingest's issuers | Accepted |
 | [0032](0032-ephemeral-sends-go-once-over-a-live-link.md) | Ephemeral sends go once, over a live link | Accepted |
 | [0033](0033-a-session-is-confirmed-only-by-proof-of-the-same-group.md) | A session is confirmed only by proof of the same group | Accepted |
+| [0034](0034-browsers-find-each-other-through-their-network-address.md) | Browsers on one network find each other through its address | Accepted |
 
 ## Format
 
diff --git a/docs/bridges/web-devices.md b/docs/bridges/web-devices.md
index b9b27ccf..643e065a 100644
--- a/docs/bridges/web-devices.md
+++ b/docs/bridges/web-devices.md
@@ -121,6 +121,16 @@ Open the page on both devices. Each step names what to record.
    with the same URL. The second tab must not start a second engine over the
    same account (W8): it stays at "starting…", and its console shows
    "another node already runs this account in this browser".
+8. **Nearby, with no codes.** Reload both pages. On each, fill in the same
+   Nostr relays and a STUN server you choose, a name, and press **Look for
+   devices nearby** (W13). Each lists the other by its name within a few
+   seconds; press **Connect** beside A on B, and A asks whether to let B in.
+   **Allow** links them ("1 device connected" on both), and **Deny** leaves B
+   waiting until its request times out. Then put the phone on cellular data:
+   it is behind another address now, and the two must no longer list each
+   other. Fails as: an empty list on one network (the STUN server unreachable,
+   or the two leaving by different routes, which the page cannot tell), or a
+   link that forms without A being asked.
 
 ## What this does not show
 
diff --git a/docs/bridges/web.md b/docs/bridges/web.md
index 79e6b1c1..b1b38cec 100644
--- a/docs/bridges/web.md
+++ b/docs/bridges/web.md
@@ -7,7 +7,8 @@ origin-private files; tabs of one origin link over a `BroadcastChannel`, and
 devices on one network over WebRTC, set up with two codes and no server. A
 node the application gives relays also carries the Nostr transport, which
 reaches any device by its address and resolves usernames, mobile ones
-included (W12).
+included (W12), and browsers behind one network address find each other in
+a nearby room (W13).
 
 Read [the shared contract](README.md) first. This document covers what is
 specific to a page that runs the engine.
@@ -440,6 +441,74 @@ answer is one `username_resolved` event carrying every claim. A name is a
 claim, not an identity: show the whole set and keep the address the person
 chooses.
 
+### W13. A nearby room is derived from the network's address
+
+A page cannot scan for Bluetooth, browse DNS-SD or join Wi-Fi Direct, so a
+browser has no local discovery of its own
+([ADR 0034](../adr/0034-browsers-find-each-other-through-their-network-address.md)).
+`NearbyRoom` gives the browsers behind one network address a place to meet:
+a Nostr topic and key derived from the application id and the address a
+STUN server reports (the IPv4 address, or the IPv6 /64, since each IPv6
+device has its own address). Each device publishes a presence every 5 s,
+lists the others it has heard within 15 s, and links over WebRTC to one its
+person picks. The relays carry only the setup; the link runs on the local
+network.
+
+**The room key is public.** Anyone can derive it from an address, so it
+seals only presences, and a presence holds a key used for this room and the
+application's label. Because the room key authenticates nothing, a presence
+is also signed by the key it announces: one sealed under the room key alone
+let anyone list a key they do not hold, relabel a device that is there, or
+keep one listed after it left. A connection code and the joiner's label are
+sealed with NIP-44 from the joiner's room key to the presence key it picked,
+and the answer back, under the room key as a second layer, with the role
+bound into both. A holder of the room key sees who is present and can offer,
+and reads no code
+([R30](../security/threat-model.md#r30-a-nearby-room-is-readable-by-anyone-who-can-guess-the-networks-address)).
+Sealing a code under the room key alone would hand every guesser the local
+addresses the code names (R27), and a label under it alone would let anyone
+re-wrap a real joiner's offer under a name of their choosing.
+
+**The application admits.** Anyone who can guess the address can offer, so
+`admit({ peer, label, signal })` is required and every offer goes to it;
+only `true` answers. Each session is asked about once: a refused session is
+ignored for as long as its offer stays fresh, so a joiner's republished
+offers do not ask again, and `signal` aborts when the offer stops being
+fresh or the room closes, so a question nobody answered comes down. The
+label is a claim: who is on a link that forms is what the preamble and MLS
+session prove (W3).
+
+**A cap never becomes a lock.** The limits a signalling room keeps (W11)
+assume only a holder of an eighty-bit code can offer; here anyone can. So a
+refused session is kept apart from the sixteen the host is deciding on or
+has admitted, and only so its repeats are not asked about again (at most
+256, oldest forgotten first). Kept among the sixteen, as it first was,
+sixteen refused sessions that a stranger kept fresh from anywhere on the
+internet stopped the host admitting anyone, with nothing to say so. An offer
+that arrives while sixteen are still waiting on the application is dropped
+and reported as a `full` event. A list of devices that reaches sixty-four
+forgets the one heard from longest ago, rather than refusing newcomers to
+whoever filled it first.
+
+**The relays and the STUN servers are the application's.** A room without
+relays, or without a STUN server and without a `network` the application
+already knows, refuses to open. `publicAddress` takes a server-reflexive
+candidate a throwaway connection gathers and gives up after 5 s. It prefers
+IPv4, and waits half a second for an IPv4 candidate after an IPv6 one: on a
+dual-stack network the family that answers first varies, and two devices
+that took different families would derive different rooms.
+
+**A room opens once.** A room that fails to open, or is closed (during
+`open()` included), holds no relay socket and no room in the worker, and
+refuses to open or connect again.
+
+**The room's events are signalling events.** Kind 25912, refused more than
+two minutes from this clock, on a topic derived under a different salt from
+a signalling room's, so the two never read each other. Offers and answers
+are signed by a key used once, a presence by its own key. The signature is
+checked on every event, after the room key, because it binds `created_at`:
+without it a relay could re-stamp an old offer and replay it fresh.
+
 ## Which shared rules apply
 
 | Rule | How it reaches the web binding |
@@ -467,7 +536,8 @@ chooses.
   shown to the people involved before anything is shared with it (R27, R28).
 - Its own STUN or TURN servers, if links must cross networks (W9).
 - Its own Nostr relays, if the node is to reach devices it has no link to
-  (W12). The SDK names none.
+  (W12), and its own STUN server and an `admit` decision for a nearby room
+  (W13). The SDK names no relay and no STUN server.
 - The engine module where the worker can fetch it: beside `worker.js`, or
   handed to `OfflineNode.start({ wasm })` by a host whose worker starts from
   a Blob URL (an editor plugin, an extension), where a relative fetch has
@@ -547,6 +617,20 @@ start there, because Firefox answers it by asking the user.
   redialled and never counts as live, and closing the driver reports it down
   before it ends a query (W12).
 
+- `nearby.html`: four nodes in nearby rooms, three behind one address and
+  one behind another. The address comes from the runner's STUN server, which
+  reports a fixed one (`test/stun-server.mjs`); Firefox runs with
+  `media.peerconnection.ice.loopback` on so it gathers toward a server on
+  loopback. The three see each other's labels. One links to another that
+  admits it and a message crosses, while a stranger keeps sixteen refused
+  sessions fresh against the host. One the host does not admit gets no
+  answer and is asked about once. A host whose person never answers is asked
+  sixteen times, reports the seventeenth offer as `full`, and withdraws every
+  question when closed. The fourth sees nobody. A room with no relays, no
+  `admit` or no STUN server is refused, as is a closed one, and
+  `publicAddress` over a stubbed connection takes IPv4 on a dual-stack
+  network (W13).
+
   The relay is `test/nostr-relay.mjs`, a NIP-01 relay in Node's standard
   library. It keeps what is not ephemeral, replaces an addressable event by
   kind, author and `d` tag, and answers a subscription with what it holds,
diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md
index 02ae011b..4ba5c79b 100644
--- a/docs/security/threat-model.md
+++ b/docs/security/threat-model.md
@@ -1303,6 +1303,46 @@ other neighbours, and every frame is still authenticated above this
 layer. An application must not treat `peer_rssi`
 as evidence of who is physically near.
 
+### R30. A nearby room is readable by anyone who can guess the network's address
+
+A browser's nearby room is a Nostr topic and key derived from the
+application id and the network's public address
+([web bridge W13](../bridges/web.md#w13-a-nearby-room-is-derived-from-the-networks-address),
+[ADR 0034](../adr/0034-browsers-find-each-other-through-their-network-address.md)).
+An address is not a secret: anyone can derive every room for an application
+and read who is present in each, by key and label, and can offer a
+connection to any of them. Everyone behind the same address is in the same
+room, which on a carrier-grade NAT, a hotel or a campus is many strangers.
+The relays the application names see the topic, the times and sizes of the
+events, and the network address of every client.
+
+**Why it stands:** the address is the one thing two browsers on a network
+share without a person carrying something between them, and no browser
+exposes local discovery.
+
+**What bounds it:** a presence carries a key used for this room only and the
+label its sender chose, and nothing that identifies the device; it is signed
+by the key it names, so nobody lists a key they do not hold or relabels one
+that is there. Every connection code, and the joiner's label with it, is
+sealed with NIP-44 to one presence key, so a holder of the room key reads no
+code, learns no local address (R27), and cannot put another label on a real
+joiner's offer. A host answers only offers its application admits, and the
+binding refuses a room without an `admit` decision; an application should
+show the label and ask. A link that forms proves nothing by itself: the
+engine's preamble names the device, and MLS admits it to nothing until it is
+invited.
+
+**What it does not bound:** how often strangers ask. Anyone can mint presence
+keys and sessions, so the binding bounds what it holds and not who asks: at
+most sixteen offers wait on the application at once, a seventeenth is
+dropped and reported as `full`, each session is asked about once, and a
+refused one never takes a waiting one's place. Sixteen questions a stranger
+keeps coming can still crowd out a real joiner until the person answers
+them, and a list of sixty-four keys a stranger keeps announcing can push a
+real device off the list between its announcements. The application's own
+policy (who it asks about, how fast it lets questions come) is the limit
+here, not a count in the SDK.
+
 ## Network egress
 
 Until 0.26 the Rust crates opened no socket: every byte that left a device
diff --git a/examples/web-lobby/README.md b/examples/web-lobby/README.md
index 0f9f2957..93364e55 100644
--- a/examples/web-lobby/README.md
+++ b/examples/web-lobby/README.md
@@ -10,6 +10,8 @@ python3 -m http.server 8000 --bind 0.0.0.0                   # from the reposito
 
 **Two devices on one network.** Open `http://:8000/examples/web-lobby/` on both. A browser gives WebRTC and OPFS only to a secure context, and a LAN address over plain HTTP is not one, so serve the page over HTTPS, or on a phone use a tunnel to `localhost`. On one device press **Host a game**, then **Connect a device: make a code**, and get the code to the other device (paste it, or show it as a QR code). The other device presses **I have a code**, pastes it, and sends its answer back the same way. The devices connect on the local network: no ICE server is configured, so nothing leaves it.
 
+**Devices nearby, without codes.** Fill in the Nostr relays and the STUN server you want to use (the page names none, and remembers yours in this browser), a name to show, and press **Look for devices nearby** on each device. Every device behind the same public address appears in the list; press **Connect** beside one, and that device is asked whether to let it in. The relays carry only the setup, and the link runs on the local network. Anyone who can guess the network's address can see who is listed and ask to connect, which is why every request is asked about, and why the players list shows the address that actually joined ([docs/bridges/web.md](../../docs/bridges/web.md), W13).
+
 **Two tabs of one browser.** Open  and . A durable node is one per account per origin, so the second tab needs its own profile. The tabs find each other over a `BroadcastChannel` with no codes.
 
 The identity survives a reload: the same profile is the same `off1…` address, and still a member of the group.
diff --git a/examples/web-lobby/index.html b/examples/web-lobby/index.html
index e348b36c..e8707adc 100644
--- a/examples/web-lobby/index.html
+++ b/examples/web-lobby/index.html
@@ -17,6 +17,8 @@
   code { font-size: 13px; word-break: break-all; }
   section { border-top: 1px solid var(--line); padding: 14px 0; }
   .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
+  input { width: 100%; font: 13px/1.4 ui-monospace, monospace; padding: 6px 8px; margin-bottom: 6px;
+          border: 1px solid var(--line); border-radius: 6px; background: transparent; color: inherit; }
   textarea { width: 100%; min-height: 64px; font: 12px/1.4 ui-monospace, monospace; padding: 8px;
              border: 1px solid var(--line); border-radius: 6px; background: transparent; color: inherit; }
   button { font: inherit; padding: 6px 12px; border-radius: 6px; cursor: pointer;
@@ -28,7 +30,7 @@
   [hidden] { display: none !important; }
 
 

Pong Lobby

-

Each browser runs a full Offline Protocol node in a Web Worker. The host makes an MLS group, every device that connects is invited, and the game state travels end-to-end encrypted through the group, ten times a second, with no server. Devices connect over WebRTC on the local network with two codes; tabs of one browser find each other on their own.

+

Each browser runs a full Offline Protocol node in a Web Worker. The host makes an MLS group, every device that connects is invited, and the game state travels end-to-end encrypted through the group, ten times a second, with no server. Devices connect over WebRTC on the local network with two codes, or find each other through relays you name; tabs of one browser find each other on their own.

This device
@@ -60,6 +62,16 @@

Pong Lobby

+
+
Devices on this network: through Nostr relays and a STUN server you choose. Nothing here names either.
+ + + +
+
+
    +
    +
    Players in the game, by address. A connection code proves nothing on its own: check that every address here is a device you meant to let in.
      @@ -73,7 +85,7 @@

      Pong Lobby

      no game yet