Skip to content

Inbox mobile: Reach from this Wi-Fi, the same-Wi-Fi listener (P2) - #1780

Merged
backnotprop merged 2 commits into
mainfrom
feat/mobile-p2-wifi
Oct 8, 2026
Merged

backnotprop merged 2 commits into
mainfrom
feat/mobile-p2-wifi

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

The story

"Reach from this Wi-Fi" is a second switch in the Inbox's Phones settings. On, it opens a TLS listener on every interface that serves the device door and nothing else (the same door-only handler the tailnet's listener uses), with a self-signed certificate made by openssl at first use, its SHA-256 in the pairing QR (lan, fp) and under the switch, and a _plannotator-inbox._tcp Bonjour record through dns-sd -R or avahi-publish. Off, a clean stop, a restart and even a kill -9 take the listener and the record down; the next start opens the same port with the same certificate, so a paired phone's pin holds.

Mobile plan step P2. Contract: adr/implementation/inbox-mobile.md section 3 (edited first in this PR, below).

What changed

  • packages/server/inbox-lan.ts (new): the certificate (ensureInboxCertificate, two openssl child processes, inbox/tls/ 0700 with both files 0600), its fingerprint (SHA-256 of the DER), the address for the QR (lanAddress), the Bonjour publisher, and the listener's open, start, stop and switch.
  • packages/server/inbox-devices.ts: mounts the LAN listener on the existing doorOnly handler; the offer and the redemption carry addresses.lan and addresses.fingerprint; the QR link gains lan= and fp= in the contract's order; new window route GET/POST /api/inbox/lan behind the window's guards (409 lan_unavailable).
  • packages/server/inbox.ts: startLan() beside startTailnet() at start, stopLan() beside stopTailnet() in the clean stop (quit, the stop route, a restart before the new run starts); previous.lan carried into the new registry entry.
  • packages/shared/inbox/registry.ts: lan?: { port } in inbox.json.
  • packages/inbox/components/Phones.tsx, api.ts, inbox.css: the "Reach from this Wi-Fi" row (same row style as the tailnet switch), the address, the certificate's SHA-256 in groups of four under it, the typed-address note where nothing can publish Bonjour, the error line; the pairing panel's no-path note now offers "Turn on Reach from this Wi-Fi" in place (contract section 1). The countdown restarts from 10:00 when a switch remakes the code (it read 10:01 for a tick).
  • tests/helpers/pinned-phone.ts (new): the scripted phone. One HTTP/1.1 request over a TLS socket whose certificate SHA-256 must equal the pin before a byte is written; the request line goes out byte for byte, so the spoof tests can send any Host, Origin or encoded path.
  • packages/server/inbox-lan.test.ts (new), tests/e2e/inbox-phones.spec.ts: the proofs below.
  • packages/server/inbox-devices.test.ts: the PR 1779 nit, the race test no longer passes previousPort: null.
  • .github/workflows/inbox-e2e.yml: runs inbox-lan.test.ts on the compiled binary beside inbox-devices.test.ts; paths gain the two test helpers.
  • AGENTS.md: "Local only by default" (the Wi-Fi path), the registry shape, "On a phone" (P2 built), "Phones", "Security", the route table, the test list.

Contract edits (section 3, made before the code)

  • The switch's window route, which the first draft named but did not write: GET /api/inbox/lan answers { lan: { on, address, fingerprint, bonjour, error } }; POST { serverSession?, on }; 409 lan_unavailable (added to the error table) when openssl cannot make the certificate. /api/inbox/lan joins the "never on the door" list.
  • How the certificate is made, now that openssl on this Mac is known: two steps (ecparam -genkey, then req -new -x509 -days 36500), which macOS's LibreSSL 3.3 and OpenSSL 3 both accept. LibreSSL writes the files 0644, so they are made inside the 0700 folder and set to 0600 after. Valid 100 years: the phone reads no dates.
  • Which address the QR's lan carries: the first IPv4 that is up, not loopback, link-local or Tailscale's, private ranges first, read when the offer is made. With none, the listener keeps running, lan is null and the window says the computer is not on a network.
  • Why all interfaces (as the contract already said) rather than the brief's "bound to the Mac's LAN addresses": a per-address bind breaks when DHCP moves the address, and section 3 promises that a phone follows a new address through Bonjour without pairing again. Every interface reaches the same door-only handler and nothing else.
  • The publisher's lifetime: it runs under sh, which ends it when its stdin closes, so no record outlives the Inbox (a clean stop closes the pipe; a kill -9 lets the kernel close it). Off and stops are written out: off clears lan and keeps the certificate; a clean stop keeps the switch.

Record sections this makes real

  • Contract section 3 in full; section 1's addresses.lan, addresses.fingerprint and the link's lan/fp; section 1's "Pair a phone offers the Reach from this Wi-Fi switch in place"; exchange 7.2 replayed over the LAN.
  • The iPhone record's 1.3 ("On this Wi-Fi") and 9.2 ("Same Wi-Fi") have their server side: the Bonjour record with fp in its TXT, and the address and pin the app connects with.

Proofs and the commands that run them

Binary: bun run --cwd apps/review build && bun run build:hook && bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --define '__CLI_VERSION__="0.0.0-dev"' --outfile .local/plannotator.

  1. PLANNOTATOR_DATA_DIR=$(mktemp -d) PLANNOTATOR_BROWSER=none PLANNOTATOR_INBOX_TEST_BINARY=$PWD/.local/plannotator bun test packages/server/inbox-lan.test.ts packages/server/inbox-devices.test.ts (29 pass on this Mac, on the binary):
    • off by default: no listener, no inbox/tls, no lan in the QR;
    • on: ip:port, the fingerprint equals the SHA-256 of inbox/tls/cert.pem and of the certificate the socket presents; CN=Plannotator Inbox, P-256, self-signed; modes 0700/0600; inbox.json lan: { port }; the link's keys in order v, name, lan, fp, secret, code;
    • 7.2 over the LAN: the pinned phone redeems the QR secret (201, addresses carry lan and fingerprint), the same secret again is 410, health and threads with the token, a command replays by its key;
    • a wrong fingerprint is refused by the phone before it writes a byte, and the offer is still open for the right phone afterwards;
    • the door review's spoofs, on the LAN listener, for each Host of localhost, 127.0.0.1, 127.0.0.1:<window port>, localhost:<window port> and the LAN address, with no token, with a valid token and with X-Forwarded-Host: localhost: /, /favicon.png, /api/inbox/threads, /api/inbox/health, /mcp, the bridge, control/stop, settings (GET and POST), restart, pairing, devices, tailnet, lan, raw attachment bytes, and the encoded and dotted paths /api/inbox/device/../threads, %2e%2e, %2E%2E/%2E%2E/settings, ./../../../mcp, //api/inbox/threads, /api/inbox/device%2fthreads, /API/INBOX/DEVICE/threads: every one 404 device_route_not_found. Door paths off the allowlist with a token (..%2fsettings, settings, restart, pairing, devices, bridge/poll): 404 device_route_not_found. An encoded id on an allowed route (threads/..%2f..%2fsettings) reaches only the thread handler, which finds nothing. A spoofed Origin (the window's own, null): 403 origin_not_allowed. Plain HTTP to the TLS port: no answer;
    • Bonjour (macOS): dns-sd -L reads the record under the computer's name with the listener's port, v=1 and fp=<fingerprint>; dns-sd -B lists it;
    • off: the socket refuses, the record is gone, lan leaves inbox.json; on again keeps the fingerprint;
    • a clean stop (SIGTERM): socket and record gone, the switch kept; the next start answers on the same address with the same fingerprint, and the phone's pin and token work;
    • kill -9: no record left; the next start reopens;
    • in process: an openssl that fails answers 409 lan_unavailable, the switch stays off, nothing is left in inbox/tls; a foreign Origin and a stale tab are refused on the switch's route.
  2. bun run test:e2e:inbox (Playwright against the binary; inbox-phones.spec.ts, 4 pass, 1 skipped tailnet-only): the empty panel's "Turn on Reach from this Wi-Fi" turns the listener on and remakes the code with lan and fp (the QR's path equals qrModules(link)); the row shows the address and the grouped SHA-256; a pinned phone pairs over the LAN and the panel closes; a wrong pin is refused; off closes the socket. A second Inbox whose PATH holds only openssl draws the typed-address note; an openssl that fails draws the error and the switch stays off. Runs in the paths-gated inbox-e2e.yml (on the Linux runner there is no Bonjour publisher, so the main world shows the note too; the spec reads that from the runner).
  3. Transcript on the binary, secrets and tokens redacted, in the Workspaces meta repo: research/operational/mobile-proofs/P2-2026-10-08/lan-transcript.txt (its script beside it; lan-spoofs.txt is every spoof the bun test sent and its answer) (switch, openssl x509 of the made certificate, the offer, dns-sd -L, the pinned pair and health, the wrong pin, a sample of spoofs, dns-sd -B across on, off, clean stop, restart and kill -9). Frames light and dark beside it.

Gates run

  • bun run typecheck: pass.
  • PLANNOTATOR_DATA_DIR=$(mktemp -d) bun test packages apps/hook tests scripts: 5852 pass, 1459 skip, 0 fail (628 files).
  • The DOM-gated steps of test.yml (DOM_TESTS=1 bun test ..., the three lists): the three DOM_TESTS=1 lists: 18, 1550 and 6 pass, 0 fail. No DOM test added (no new DOM-gated file, so the allowlist is unchanged).
  • bun run check:release-version: consistent (v0.28.8); no version file touched.
  • gitleaks git . --config .gitleaks.toml --log-opts="origin/main..HEAD": no leaks found (1 commit). The certificate and key in every test are made at run time in a temp data dir; none is committed.
  • Semgrep 1.172.0 (.semgrep.yml) on the changed files: 0 findings. One nosemgrep: plannotator.network.tls-verification-disabled in tests/helpers/pinned-phone.ts, with its reason on the line above: the scripted phone holds a fingerprint, not a CA, and the pin replaces the chain check, as the app does.

M-side (what the app must do; not in this PR)

  • The Local Network permission: NSLocalNetworkUsageDescription and NSBonjourServices = _plannotator-inbox._tcp in the app's Info.plist, and the first browse or connect raises the system prompt.
  • The pinned session: a URLSessionDelegate that, for the LAN address only, takes the server trust's leaf certificate, compares its SHA-256 (of the DER, SecCertificateCopyData) with the fp it holds, and answers .useCredential on a match and .cancelAuthenticationChallenge otherwise. No host name, chain or date check (section 3). The address is an IP, which ATS does not cover.
  • 1.3's "On this Wi-Fi" list from NWBrowser on _plannotator-inbox._tcp, matched to a paired computer by the TXT fp; 9.2's "Same Wi-Fi" row and its order (LAN first, then tailnet).

What it leaves out

Certificate rotation, a CA, IP allowlists, extra prompts, a Linux or Windows proof of avahi-publish (written to the contract, not run here: this Mac has dns-sd, the CI runner has neither), a SIGHUP handler for the LAN (none is needed: the listener dies with the process and the publisher ends when its pipe closes), and any dast.yml target for the LAN listener (it is off by default and serves the same door DAST already scans on loopback). If the reviewer wants DAST over TLS on the LAN port, that is a follow-up.

Deviations from the record or the contract

  • The contract edits above, each with its reason (the route the draft did not write, the openssl steps, the address rule, the publisher's lifetime).
  • The brief said "bound to the Mac's LAN addresses"; the contract said all interfaces. The contract is followed, with the reason written into section 3.
  • The window's Phones block is not in the window's design record (OWNER-ITEMS item 23, as in P1). The Wi-Fi row is placed ABOVE the tailnet row, the order the iPhone's 9.2 lists them and the order the phone tries them, not below it. The certificate's SHA-256 shows on its own line under the row, in groups of four, because 64 hex characters do not fit the row's description slot.
  • The no-path note in the pairing panel became a sentence plus a "Turn on Reach from this Wi-Fi" button (contract section 1 said the panel offers the switch in place; P1 drew only text pointing at the tailnet).

Limits

The two pairing limits already exist (an offer lives 10 minutes; its six digits close after 5 wrong tries). This PR adds none. The certificate's 100-year validity is not a limit (the phone reads no dates).

Dependencies

None new. openssl, dns-sd and avahi-publish are child processes, found on PATH.

Settings' Phones gains "Reach from this Wi-Fi": a TLS listener on every
interface that serves the device door and nothing else (the tailnet's
door-only handler), with a self-signed P-256 certificate made by openssl at
first use (inbox/tls/, 0600 in a 0700 folder), its SHA-256 in the pairing QR
(lan, fp) and under the switch, and a _plannotator-inbox._tcp Bonjour record
through dns-sd -R or avahi-publish. Off, a clean stop and a kill -9 take the
listener and the record down; the next start opens the same port with the
same certificate. Where nothing can publish Bonjour the listener works by
typed address and Settings says so.

Contract: adr/implementation/inbox-mobile.md section 3 gains the switch's
window route (GET/POST /api/inbox/lan, 409 lan_unavailable), the openssl
steps, the address rule and the publisher's lifetime.

Also: the race test no longer passes previousPort (PR 1779 nit).
The LAN listener's pair route takes the QR's 256-bit secret only and answers
six digits with 400 code_not_accepted_here (the offer untouched); the window's
port and the tailnet's listener take both. A Bonjour record anyone on the
Wi-Fi can publish must never become the pin: its fp is an identity hint for a
computer the phone already paired by QR. Contract sections 1, 2 and 3 and
exchange 7.3 say so with the reason (plannotator-ops's review of PR 1780);
Settings' copy no longer points at typing over the Wi-Fi.
@backnotprop
backnotprop merged commit d7b3d06 into main Oct 8, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant