Repository navigation
Inbox mobile: Reach from this Wi-Fi, the same-Wi-Fi listener (P2) - #1780
Merged
Merged
Conversation
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.
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
opensslat first use, its SHA-256 in the pairing QR (lan,fp) and under the switch, and a_plannotator-inbox._tcpBonjour record throughdns-sd -Roravahi-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.mdsection 3 (edited first in this PR, below).What changed
packages/server/inbox-lan.ts(new): the certificate (ensureInboxCertificate, twoopensslchild 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 existingdoorOnlyhandler; the offer and the redemption carryaddresses.lanandaddresses.fingerprint; the QR link gainslan=andfp=in the contract's order; new window routeGET/POST /api/inbox/lanbehind the window's guards (409 lan_unavailable).packages/server/inbox.ts:startLan()besidestartTailnet()at start,stopLan()besidestopTailnet()in the clean stop (quit, the stop route, a restart before the new run starts);previous.lancarried into the new registry entry.packages/shared/inbox/registry.ts:lan?: { port }ininbox.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 passespreviousPort: null..github/workflows/inbox-e2e.yml: runsinbox-lan.test.tson the compiled binary besideinbox-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)
GET /api/inbox/lananswers{ lan: { on, address, fingerprint, bonjour, error } };POST{ serverSession?, on };409 lan_unavailable(added to the error table) whenopensslcannot make the certificate./api/inbox/lanjoins the "never on the door" list.opensslon this Mac is known: two steps (ecparam -genkey, thenreq -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.lancarries: 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,lanis null and the window says the computer is not on a network.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 clearslanand keeps the certificate; a clean stop keeps the switch.Record sections this makes real
addresses.lan,addresses.fingerprintand the link'slan/fp; section 1's "Pair a phone offers the Reach from this Wi-Fi switch in place"; exchange 7.2 replayed over the LAN.fpin 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.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):inbox/tls, nolanin the QR;ip:port, the fingerprint equals the SHA-256 ofinbox/tls/cert.pemand of the certificate the socket presents;CN=Plannotator Inbox, P-256, self-signed; modes 0700/0600;inbox.jsonlan: { port }; the link's keys in orderv, name, lan, fp, secret, code;addressescarrylanandfingerprint), the same secret again is 410, health and threads with the token, a command replays by its key;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 withX-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 one404 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;dns-sd -Lreads the record under the computer's name with the listener's port,v=1andfp=<fingerprint>;dns-sd -Blists it;lanleavesinbox.json; on again keeps the fingerprint;opensslthat fails answers409 lan_unavailable, the switch stays off, nothing is left ininbox/tls; a foreign Origin and a stale tab are refused on the switch's route.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 withlanandfp(the QR's path equalsqrModules(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 onlyopenssldraws the typed-address note; anopensslthat fails draws the error and the switch stays off. Runs in the paths-gatedinbox-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).research/operational/mobile-proofs/P2-2026-10-08/lan-transcript.txt(its script beside it;lan-spoofs.txtis every spoof the bun test sent and its answer) (switch,openssl x509of the made certificate, the offer,dns-sd -L, the pinned pair and health, the wrong pin, a sample of spoofs,dns-sd -Bacross 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).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.yml) on the changed files: 0 findings. Onenosemgrep: plannotator.network.tls-verification-disabledintests/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)
NSLocalNetworkUsageDescriptionandNSBonjourServices=_plannotator-inbox._tcpin the app's Info.plist, and the first browse or connect raises the system prompt.URLSessionDelegatethat, for the LAN address only, takes the server trust's leaf certificate, compares its SHA-256 (of the DER,SecCertificateCopyData) with thefpit holds, and answers.useCredentialon a match and.cancelAuthenticationChallengeotherwise. No host name, chain or date check (section 3). The address is an IP, which ATS does not cover.NWBrowseron_plannotator-inbox._tcp, matched to a paired computer by the TXTfp; 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 hasdns-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 anydast.ymltarget 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
opensslsteps, the address rule, the publisher's lifetime).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-sdandavahi-publishare child processes, found on PATH.