Skip to content

feat(bindings): let browsers on one network find each other in a nearby room - #635

Merged
bahdotsh merged 2 commits into
mainfrom
feat/web-nearby-room
Oct 25, 2026
Merged

bahdotsh merged 2 commits into
mainfrom
feat/web-nearby-room

Conversation

@bahdotsh

Copy link
Copy Markdown
Member

Stacked on #634. This branch contains #634's three commits. Review only the last commit, 59ae4095. Merge #634 first, then merge main into this branch.

What this does

A page cannot scan for Bluetooth, browse DNS-SD or join Wi-Fi Direct, so two browsers on one network had no way to notice each other. NearbyRoom meets the browsers behind one public address in a Nostr room derived from that address and the application id. It lists them by the label each application chose and links over WebRTC to the one a person picks. The relays carry only the setup, and the link runs on the local network.

  • The room key is public by construction. Anyone can derive it from an address. It seals only presences, which hold a key used for this room and the application's label.
  • Every connection code is sealed to one presence key. It uses NIP-44, under the room key as a second layer, with the role bound in. A holder of the room key reads no code and no local address.
  • The application admits. admit({ peer, label }) is required and decides every offer. A refused session is ignored while its offer stays fresh, so republished offers do not ask again.
  • The relays and STUN servers are the application's. A room without them refuses to open. The network address comes from the first server-reflexive candidate the application's STUN server produces.
  • IPv6 rooms are per /64, because each IPv6 device has its own public address.

Documented as W13 in docs/bridges/web.md, residual risk R30, and ADR 0034. ADR 0034 records that same-network discovery for browsers needs the internet and is shared with everyone behind the address.

Supporting changes

  • The transport crate gains seal_to_key and open_from_key over its existing NIP-44 code, with a round-trip and wrong-key test.
  • RoomChannel in nostr-signal.js now takes its subscription and opener as a source. The nearby room reuses the signaller's relay handling, with redial, probe and keep-alive unchanged.
  • test/stun-server.mjs is a STUN Binding responder in Node's standard library. It reports a fixed documentation address, so a page on one machine gets a server-reflexive candidate. test-browser.sh starts it. Firefox runs with media.peerconnection.ice.loopback on so it gathers toward loopback, the same kind of test-host setting as the existing obfuscation pref.
  • The lobby example gains a nearby section. It has empty relay and STUN fields, and an inline Allow or Deny for every request.

Testing

  • cargo test -p offline-protocol-web -p offline-protocol-transport --lib: 58 and 315 pass. Seven new nearby tests check:
    • Devices behind one address share a room.
    • A different address or application id gives a different room.
    • IPv6 rooms follow the /64.
    • An offer opens only for its key: an eavesdropper holding the room key reads nothing.
    • Events outside their freshness window are refused.
    • Labels are bounded.
    • Signalling rooms and nearby rooms do not read each other.
  • bash bindings/web/scripts/test-browser.sh: 21 of 21 pass, seven pages in Chromium, Firefox and WebKit. On nearby.html:
    • The STUN step learns the fixed address.
    • Three nodes see each other's labels.
    • An admitted device links and a message crosses.
    • A device the host refuses gets no answer, after the host was asked.
    • A room behind another address sees nobody.
  • The lobby's nearby flow, driven headlessly in two Chromium contexts against the local relay and STUN server: list, Connect, Allow, then "1 device connected" on both pages.
  • Clippy is clean on the host and for wasm32, and docs build under -D warnings.

Not done

  • No run on two physical devices. It is step 8 of docs/bridges/web-devices.md, which also covers the phone-on-cellular case where the two must stop listing each other.
  • Devices that leave by different routes land in different rooms. A VPN, or one device reporting IPv4 and the other IPv6, has that effect. Nothing detects it, and ADR 0034 states it as a consequence.
  • Nearby is browser to browser only. Native bindings have no WebRTC carrier.

…by room

A page cannot scan for Bluetooth, browse DNS-SD or join Wi-Fi Direct, so two
browsers on one network had to carry WebRTC codes or a room code between
them. What they share is the address the internet sees them from. A
NearbyRoom is a Nostr topic and key derived from the application id and that
address (IPv4, or the IPv6 /64, since each IPv6 device has its own), learned
from a server-reflexive candidate of the application's STUN server. Each
device announces a presence every 5 s, lists the others it heard within
15 s, and links over WebRTC to one its person picks; the link runs on the
local network.

The room key is public by construction, so it seals only presences, which
hold a key used for this room and the application's label. Every connection
code is sealed with NIP-44 to one presence key, under the room key as a
second layer with the role bound in, so a holder of the room key reads no
code and no local address. admit({ peer, label }) is required and decides
every offer; a refused session is ignored while its offer stays fresh. The
relays and STUN servers are the application's, and a room without them
refuses to open.

The transport crate gains seal_to_key and open_from_key over its NIP-44
code. RoomChannel now takes its subscription and opener as a source, so the
nearby room reuses the signaller's relay handling unchanged. The test
runner starts a STUN responder that reports a fixed address, and Firefox
runs with loopback ICE gathering so it can reach it. The lobby example
gains a nearby section that asks before it admits. Documented as W13,
R30 and ADR 0034.
@bahdotsh
bahdotsh force-pushed the feat/web-nearby-room branch from 59ae409 to 83246d4 Compare October 25, 2026 12:21
The nearby room borrowed its limits from the signalling room, where
only a holder of an eighty-bit code can offer. Here the room key is
derived from an IP address, so *anyone on the internet* can offer.
Same caps, very different room.

It turns out that a refused session stayed among the sixteen the
host decides on, and every repeat kept it alive. Sixteen refused
sessions republished by a stranger, and the host never admitted
anyone again, with no event and no log. The device list had the
same disease: full at sixty-four, it refused newcomers, so whoever
filled it first owned it. Presences were sealed under the public
key alone, so filling it, relabelling a real device or keeping a
departed one listed took nothing but a guess at the address. And
the label on an offer sat outside the NIP-44 seal, so anyone could
re-wrap a real joiner's offer under a name of their choosing before
the host was asked.

This is not great.

Refused sessions now live apart, bounded, only so their repeats are
not asked about again; an offer dropped while sixteen wait on the
application is reported as a `full` event, and `admit` gets an
AbortSignal so a question nobody answered comes down. A presence is
signed by the key it announces, the label rides inside the seal, and
a full list forgets the device heard from longest ago. The signature
is now checked after the cheap AEAD, but still checked: it binds
created_at, and without it a relay could re-stamp an old offer.

While at it: open() closes what it opened when the first announce
fails, a closed room refuses to open or connect again, and
publicAddress prefers IPv4, because on a dual-stack network the
family that answers first varies and two devices that picked
differently landed in different rooms. The docs stop citing sixteen
sessions as a protection and say what the SDK does not bound.
@bahdotsh
bahdotsh merged commit de1c768 into main Oct 25, 2026
42 checks passed
@bahdotsh
bahdotsh deleted the feat/web-nearby-room branch October 25, 2026 13:30
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant