Phase 9: authentication — pairing, device tokens, a per-launch token for the desktop app #17
Loading…
Reference in a new issue
No description provided.
Delete branch "refactor/phase-9-auth"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Phase 9 of the DDD/CQRS refactor: authentication. A device pairs with a one-time code, every request proves who it is, and the desktop app protects itself with a token made at each launch. The change was security-reviewed adversarially, and every finding was fixed or documented as an accepted limit before this PR. The review also surfaced a real race in workflow runs, fixed here too. Stacked on #16.
What changes
Who may ask what (the rule is
decideAccess;server/middleware/auth.tsruns before every page and route):kanbot_device, an HttpOnly SameSite=Strict cookie, or Bearer). Pages redirect to/pair?redirect=; the API answers 401.ps -Ewould show to any of your processes, agents included). The window and its cookie use a per-installkanbot-<hex>.localhostname, so the cookie never reaches other servers on 127.0.0.1.node .output/…)Hoston the listening port (no DNS rebinding), a same-originOrigin/Sec-Fetch-Site(no cross-site posts), and no proxy headers. A request through a proxy counts as another machine.nuxt devX-Forwarded-Forthrough, so that header is never believed./pair,POST /api/auth/pair(body capped at 4 KB, 10 tries per 10 minutes per address), static assets, icons, the browser handoff link.Pairing. Settings › Devices offers a six-digit code:
crypto.randomInt, five minutes, single use, burnt after five wrong tries, compared in constant time. Every failure gives the same answer. The pairing window shows the code with a countdown, a QR code (uqr, inline SVG) and the hub's certificate fingerprint./pairworks with no token, reads no data, and follows the browser's language. A device's 32-byte token is returned once and stored only as its sha256.Roles. Only this machine's own app (or a tokenless loopback session) may offer codes or revoke other devices; a paired device may only revoke itself. Revoking closes the device's open streams.
The hub's certificate. An ECDSA P-256 certificate is generated once into the data dir with pkijs (pure JS); its common name is the machine id. Its fingerprint is the hub's identity. HTTPS is switched on with hub mode in Phase 11.
Hardening from the security review:
X-Frame-Options,nosniffandReferrer-Policy.http://127.0.0.1:PORT@evil.comused to load evil.com inside it).Bugs fixed along the way
crypto.subtledigest on libuv's thread pool, so runs reached the engine late and out of order. That is where the suite's intermittent failures came from, and under load a wake at a gate could be lost. Runs now start under the id the task already records.Checks
Host/Origin, cookie and Bearer, pairing (wrong code 401, right code returns a token), roles;/pair;/settingswith its data;utilityProcess.evil.localhostgot 403.ps -Eshows no token.Accepted limits (documented in CLAUDE.md › Auth)
/pairproves nothing yet.npm run dev,npm start), any local process is trusted.Not exercised
The real desktop window was not opened: its cookie on the
kanbot-…name, the tray, notifications, and "Open in browser" in Safari. That Safari resolves*.localhostis inferred from the macOS resolver. Not run on Windows. No Forgejo runner.🤖 Generated with Claude Code
Cookies are not scoped by port. The packaged app set its per-launch cookie (kanbot_local) on 127.0.0.1, and after the tray's "Open in browser" the system browser had it too: it then sent it to every server on 127.0.0.1, whatever the port, for instance a dev server an agent started from a cloned repository's code, which could log it (and drive Kanbot with it) or overwrite it with a Set-Cookie of its own. The shell now makes a name for this machine once per install, kanbot-<16 hex>.localhost (kept 0600 in the user-data folder), and the window, its cookie and the browser handoff all use it; the shell's own REST calls and boot poll stay on 127.0.0.1 with the Bearer header. The name goes to the server with the token over the message port (it is not secret, but must be known), and hostIsLoopback accepts exactly that name besides the loopback names; the server takes no name the shell could not have made (/^kanbot-[a-z0-9]{12,32}\.localhost$/). Nothing changes in how the server listens: it still binds 127.0.0.1. Browsers resolve *.localhost to this machine by themselves (Chromium, Firefox), and Safari asks macOS's resolver, which does too (getaddrinfo answers 127.0.0.1 and ::1 here), so the handoff needs no fallback to 127.0.0.1. Checked in headless Chrome against a server bound to 127.0.0.1: the name resolves, and a cookie set under it is not sent to 127.0.0.1 or localhost on another port. Remaining: the cookie still goes to the same name on another port, so a program that reads the name from the user-data folder and gets the browser to open it on its own port could still read the cookie; it is one more step than before, not a wall. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>vue-i18n reads @ as a linked message, so plugins.problem.noMetadata and undecorated failed to compile and took the dev page down with them. They are reworded rather than escaped as {'@'}, which the server's translate() would print verbatim. A test compiles every message, the plugins' included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Any code from Settings › Devices could pair a node, so a code offered for someone's phone could become a node the hub sends prompts to. An offer is now for a node or, by default, for a browser or a script (Devices.offerPairing takes an optional { kind }; no payload still works), and redeeming it as the other kind fails like a wrong code (it counts towards burning it; every failure is the same PairingRefused / 401). Settings › Devices' pairing card has a "For a node" switch: it asks for a node's code and shows the kanbot-node pair command to run there instead of the address and QR code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>A code offered for a desktop (Devices.offerPairing({ kind: 'desktop' })) pairs two devices in one transaction: the window's browser device (its token is the kanbot_device cookie, Secure over HTTPS) and the node it runs (its token in the answer: { deviceId, nodeDeviceId, nodeToken }). devices.companion_id (migration 0022) points each at the other. Revoking either revokes both and cuts both off. A code for another kind fails like a wrong one. Settings › Devices offers the three kinds and shows a desktop as one entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Over the utility process's parent port ({ type: 'kanbot-shell', action: 'open-connect' }), only from the desktop app's own window (a Local caller) and only when the shell started the server to talk over that port; anything else is ShellUnavailable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>