
One server, three ports
The daemon runs one HTTP server that answers GET /health and upgrades everything else to a
WebSocket. It binds the first free port of 8765, 8766, 8767. If all three are taken it will not
start.
The origin gate
Every upgrade is classified by the handshake Origin header before anything else happens:
Origin |
Role | Requirement |
|---|---|---|
chrome-extension://…, moz-extension://…, safari-web-extension://… |
extension |
Proof of a pairing code, or of a session key bound to that same origin |
| Any other value | — | Refused. This is what keeps web pages out |
| Absent | control |
Authorization: Bearer <token> matching the lockfile, compared with timingSafeEqual |
Every request — /health included — must also carry a loopback Host. A page whose own DNS points
at 127.0.0.1 still arrives with the attacker's hostname, so DNS rebinding gets a 403 before the
Origin check even runs.
The split matters because any web page can open a WebSocket to loopback. Browsers set Origin
themselves and page JavaScript cannot forge it, so a page reaching the daemon is classified as a web
origin and rejected outright. Native clients send no Origin, so they land in the control lane —
where a token they could only have read off the local filesystem is required.
Two independent gates, then: the origin says what kind of peer this is, and the credential says whether this particular peer is allowed.
The control token
24 random bytes, base64url, minted fresh by each daemon and written to
~/.browsentic/daemon.json at mode 0600. It dies with the daemon that issued it, so a token that
leaked once does not open every future daemon.
Clients re-read the lockfile before every connection, and probeExisting() matches the pid in
/health against the lockfile so it never offers a token to a daemon that never issued it.
Read it with browsentic-mcp token.
Pairing and sessions
The extension connects to nothing until you pair it.
browsentic-mcp pairasks the daemon for a code: 8 characters from an alphabet with the ambiguous glyphs removed, valid 10 minutes, single use.- You paste it into the popup. The extension dials
ws://127.0.0.1:<port>/extension, walking the three ports, and sendshello— which names which secret it holds and a fresh nonce, never the secret itself. - The daemon answers
challengewith a nonce of its own. Both sides now share a transcript: protocol version, extension version, manifest hash, and the two nonces. - The extension replies
provewithHMAC(secret, "browsentic/client" ‖ transcript). For a pairing code the key is not the code butPBKDF2(code, nonces, 250 000), so recording one handshake does not let anyone grind an 8-character code offline. - The daemon verifies, then proves itself:
welcomecarriesHMAC(secret, "browsentic/server" ‖ transcript ‖ the rest of the welcome). The two labels are distinct, so an impostor cannot reflect the extension's own proof back at it. - On pairing, the daemon mints a session key (32 random bytes) bound to that extension origin
and returns it XORed with a keystream derived from the same secret, so the long-lived credential
never crosses the wire in the clear. It survives browser and daemon restarts and dies only when
you
browsentic-mcp revoke.
Why the mutual half matters
The three ports are well known and any local process can bind one first, so an extension that trusted whatever answered could be driven by a squatter.
Instead, a socket that closes without a verified welcome — a squatter, a daemon from an older
protocol, an unauthorized frame, five seconds of silence — is abandoned and the walk moves to the
next port. Only a peer that proves it holds the same secret ever gets to send an invoke.
Reconnection
Exponential backoff from 1 s to 30 s with jitter, plus a one-minute browser.alarms tick that
re-dials if the service worker was torn down in between.
A rejected hello comes back as an unauthorized frame carrying a retryable flag. Nothing has
proved itself at that point, so the extension treats it as a claim rather than a verdict: it
notes the reason, tries the remaining ports, and if none work it reports the error and stops
dialling — but it never deletes the stored key. Only pairing again or disconnect replaces it.
Protocol version
Both sides compile in SOCKET_PROTOCOL_VERSION (currently 14). A mismatch closes the socket
with an explicit reason instead of letting two incompatible frame vocabularies talk past each other.
What this does not protect against
Pairing controls which browser, not which local process. Anything running as your user can read the lockfile and drive an already-paired browser through the control port. Browsentic assumes your user account is the trust boundary — see guide/limits.md.
Next
The action registry → — what can be sent once a connection exists.