
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.
BROWSENTIC_PORTS replaces that list for the daemon and the CLI: comma-separated, with 0 for
whatever port the OS hands out. The test suite sets it so a test daemon never takes a port from the
one you are running. The extension cannot read it and always walks the three, so no browser will find
a daemon moved off them.
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 the session key minted for that browser's install id |
| Any other value | (none) | 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,
which requires a token they could only have read off the local filesystem.
So there are two independent gates: 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 token.
Pairing and sessions
The extension connects to nothing until you pair it.
browsentic 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 carries a fresh nonce, never the secret itself. - The daemon answers
challengewith a nonce of its own. Both sides now share a transcript: the protocol version thehelloclaimed, the extension version, the manifest hash, and the two nonces. The manifest hash names the tool list this build offers: a Chromium build's, or a Firefox build's, which leaves off the nine tools that need Chrome's debugger. The daemon knows both lists and serves the one the hash names; an unknown hash is a drifted build, which is asked for its list and served that. Either way the list belongs to that browser alone: a run is offered its own browser's tools, and a caller outside any run is offered those of the browser its next call would reach. - 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 the install id the
hellocarried: a UUID the extension mints once per browser profile. The origin cannot name a browser, because every browser installing from the same store, or loading the same unpacked folder, presents the same one. A session paired before install ids existed is found by its origin and claimed by whichever browser proves its key. The daemon returns the key 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 youbrowsentic revoke.
Why the daemon proves itself too
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.
So 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. It never deletes the stored key; only pairing again or disconnect replaces it.
Protocol version
Both sides compile in SOCKET_PROTOCOL_VERSION (currently 22). They used to have to match
exactly. A store copy updates when its browser decides to, not when the daemon does, so the
extension and the daemon are routinely a version apart, and the daemon accepts a window:
- It lets in any extension whose
helloclaimsMIN_EXTENSION_PROTOCOL(22) or more, newer than itself included, and builds the transcript from the number thehelloclaimed, so the proofs verify across versions. Older ones are refused withprotocol version mismatch: …, the words an extension before 0.8 already reads as a refusal not worth retrying. - A
hellomay carryminDaemonProtocol, the oldest daemon that extension works with. A daemon below it refuses withdaemon too old: …. welcomecarries the daemon's ownprotocolVersion, so a newer extension knows what not to send. Awelcomewithout one comes from a daemon before 0.8, which speaks 22 or less.
The rule for changing it. A change a peer can do without (a new frame, a new optional field)
bumps SOCKET_PROTOCOL_VERSION, and the new frame is sent only to a peer whose hello (or
welcome) says it speaks that number. Anything the daemon does not know, it leaves unanswered. Only
a change neither side can do without raises a minimum. A refusal must never strand a browser: the
v0.8.0 store build unpairs on any refusal and does not retry, so no daemon may refuse protocol 22
until a store build that retries has replaced it.
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.