Documentation menu

Remote access: protocol and threat model

Status: round A implemented, experimental (protocol, connector, nid cloud CLI behind --experimental, open relay core with Node and Bun adapters, device reference client and a development console; see Round A implementation and self-review). Round R (/rc with nid.dev accounts, hosted relay and console; replaces round B, see section 11) is in progress; round C (native apps) is design only. This document is the public specification of how a phone or a browser anywhere reaches a nid daemon through a relay: what the daemon sends, what the relay can and cannot see, and what the user has to do. The hosted relay's accounts, metering and hosting are not part of this repository; everything a client or a self-hosted relay needs to interoperate is here. Round A implements the open-source routing core and Node/Bun adapters in packages/nid/src/relay-core/; hosted account services and platform adapters remain separate.

Decisions this design starts from (made before the design, not up for revision here):

  • End-to-end encrypted, with the console as a trusted party. The relay routes sealed frames. It never sees code, prompts, agent output, approvals, questions, answers or notification text, and it cannot forge any of them. A compromised relay, Cloudflare account, database, log pipeline or network path can deny service and observe metadata; it cannot read or steer a team. The console origin (the hosted web app at app.nid.dev, or wherever you serve it from) is different: like any vendor's app, whoever can change what it serves could in principle read and steer the devices that use it. We treat that origin as a trusted party, run it under the operator controls in section 7, and offer a self-hosted console and native apps for people who want not to trust it. Section 7 says exactly what is and is not promised.
  • The daemon dials out. Nothing new listens on your machine. Remote access is opt-in (nid cloud login, then nid cloud pair per device) and off by default.
  • Hosted on Cloudflare, portable by construction. The hosted relay (relay.nid.dev, next to the console at app.nid.dev) runs on Workers and Durable Objects (WebSocket hibernation), with D1 for accounts and Pages for the web console. The relay core is written against a small platform interface so the same code runs as a plain Node server for development, tests, self-hosting and as an exit route if the hosted service ever goes away.
  • The daemon API is the only API. Remote clients use the documented daemon HTTP API and event stream carried inside the encrypted channel. The local dashboard is reused unchanged through a transport adapter.

Contents: 1 Topology · 2 Identities and keys · 3 Pairing · 4 The secure channel · 5 What is tunnelled · 6 Push notifications · 7 The web console's trust problem · 8 What the relay sees · 9 Failure modes · 10 Build plan and tests · 11 Remote control with your nid.dev account · Appendix A: crypto options · Appendix B: wire formats · Adversarial review · Independent security review

Vocabulary#

TermMeaning
daemonThe nid daemon on your machine. Owns the teams, the local API, the secrets store and (new) a long-term remote key.
deviceA phone, tablet or browser profile that has been paired with a daemon. Holds its own long-term key.
relayThe service both sides connect to. Authenticates accounts, routes sealed frames between a daemon and its devices, sends push notifications. Never holds an end-to-end key.
accountA relay login (email or passkey). Decides who may reach a daemon through the relay. Does not decide who may read it.
connectorThe part of the daemon that keeps the outbound WebSocket to the relay open and terminates the end-to-end channels.
channelOne end-to-end encrypted session between one device and one daemon, multiplexed over the WebSockets.
consoleThe web dashboard served from a static origin (Pages for the hosted one) as a PWA. The same UI as the local dashboard, with a remote transport adapter.

Who is trusted with what:

PartyTrusted toNot trusted to
Your machine and daemonEverything (it runs the agents)—
A paired deviceAct within its role and scopeAnything outside them; it is revocable
The console originServe the client the devices run; see section 7Nothing less: a malicious client could use a device's key while it runs
The relay (and the account, D1, Workers, logs, network)Route frames, keep metadata, post ciphertextRead or steer anything; pair anything; forge anything

Two separate questions run through the whole design, and they have separate answers:

  • Who can reach the daemon through the relay? Account authentication (section 2.3).
  • Who can read what the daemon says and tell it what to do? Paired device keys (sections 2 and 3).

Having the password gives you the first. Only pairing, which needs physical access to the machine's terminal or an already-paired admin device plus a local confirmation, gives you the second.

1. Topology#

   your machine                      relay (Cloudflare or your own Node)          anywhere
 ┌──────────────────┐   outbound    ┌──────────────────────────────────┐  outbound  ┌───────────┐
 │ nid daemon  │ ─WebSocket──▶ │  account auth · routing · push   │ ◀─WebSocket─ │ phone PWA │
 │  loopback API    │               │  sees: sealed frames + metadata  │  ◀─────────  │ browser   │
 │  connector       │               │  never: keys, plaintext          │  ◀─────────  │ native app│
 └──────────────────┘               └──────────────────────────────────┘            └───────────┘
        ▲ unchanged: CLI, terminal app and local dashboard keep using 127.0.0.1
  • The daemon opens one outbound WebSocket (TLS) to the relay and keeps it open with reconnect and backoff. It never accepts inbound connections for remote access. The loopback listeners described in the API are unchanged and remain the only listeners.
  • Each device opens its own WebSocket to the relay. The relay pairs a device socket with the daemon socket of the daemon the device asks for, and from then on forwards frames in both directions. Several devices can be connected to one daemon at once; one device can be connected to several daemons (an account with a laptop and a desktop pairs the phone with each).
  • Everything that matters is inside the frames. Inside, the device and the daemon run an authenticated key exchange (section 4) and then an encrypted request/response and stream protocol that carries the daemon API (section 5).
  • The relay is a dumb pipe with three jobs: let only account members route to that account's daemons, forward frames with flow control, and post push notifications it cannot read (section 6).

Opt-in, step by step:

nid cloud login            # link THIS daemon to your account (device-code flow in a browser)
nid cloud pair             # show a QR code / short code; scan it with the console on your phone
nid cloud devices          # list paired devices, their role and last connection
nid cloud devices rm <id>  # revoke a device (takes effect immediately)
nid cloud status           # connected? which relay? how many devices online?
nid cloud off | on         # close / reopen the outbound connection without unpairing
nid cloud logout           # drop the account link and the connector token; keeps device keys
nid cloud relay <wss-url>  # use your own relay (self-hosted Node relay)

Without cloud login nothing connects anywhere, no key is generated and nid doctor reports "remote access: off". The local product is complete without it (open-core).

2. Identities and keys#

2.1 Daemon identity#

ItemWhereNotes
Daemon static key DX25519 key pair. The private key lives in the existing secrets store under the key name cloud-key (keychain on macOS, Secret Service on Linux, DPAPI file on Windows, 0600 file fallback).Generated at the first cloud login. Never leaves the machine. Its public half is what devices pin.
Daemon idRandom 128-bit id, base32, generated at cloud login and registered with the relay. Stored with the relay URL in $NID_HOME/cloud.json (not secret). The executable-configuration snapshots (section 5.2) are kept separately in $NID_HOME/config-hashes.json (round A change: they are recorded at every local start whether or not remote access is set up, so a later cloud login starts with a baseline).The relay routes by it. It is not a secret and not an identity proof: proof is the handshake with D.
Connector tokenOpaque bearer token issued by the relay at cloud login, stored in the secrets store as cloud-token.Lets the daemon connect to the relay under your account. Revocable from the console. Has nothing to do with encryption.
Device registryTable in the daemon's SQLite store: device id, device public key, label, role, created, last seen, key rotation history, push subscription (section 6).The daemon's list is authoritative. The relay keeps only routing data.

Fingerprint (for daemon and device keys alike): the first 80 bits of the SHA-256 of the public key, shown as four groups of four base32 characters (k7qm-3x9a-pz2h-4v8r) by nid cloud status, nid cloud devices and the console, so two humans can compare them out of band.

2.2 Device identity#

Each device generates an X25519 key pair V the first time it pairs and stores the private key in the most protected storage the platform offers:

  • Browser / PWA: a non-extractable WebCrypto X25519 CryptoKey in IndexedDB where the browser supports it (Chrome, Firefox and Safari ship it as of 2026); the handshake then performs the static-key Diffie-Hellman operations through deriveBits, so the raw private key never exists in JavaScript. Only where WebCrypto X25519 is missing does the console fall back to raw bytes in IndexedDB (extractable; the Devices page shows which storage a device uses). One key per browser profile per origin.
  • Native apps (later): Secure Enclave / Android Keystore wrapped storage.

A device also has a device id (random 128-bit) that the relay uses for routing and that the daemon records. Like the daemon id it is not secret and not a proof.

The Noise state machine therefore takes an asynchronous DH interface (dh(priv, pub): Promise<Uint8Array>) and the whole handshake is asynchronous; only the transport phase (AEAD with counters) is synchronous. Implementations must reject an all-zero shared secret on both paths (noble throws on low-order points; WebCrypto deriveBits for X25519 throws OperationError) and must never export a key to feed it to the pure-JS path. The construction is the same whichever path computes the DH.

2.3 Account authentication is not end-to-end authentication#

The relay authenticates accounts: email + passkey (WebAuthn) or a magic link, with an optional second factor. From an account session the relay derives:

  • which daemons (by daemon id) a device socket may be routed to,
  • which push subscriptions belong to which account (for abuse limits only),
  • metering and rate limits.

The relay cannot verify a device key, a daemon key or a handshake; it only forwards. A valid account session gets you to the daemon's doorstep. The daemon then decides, based on its own device registry and the handshake, whether to talk to you at all. Section 8.4 spells out what an attacker with the password gets: nothing readable, nothing steerable.

The daemon's connector token, the device's account session and the end-to-end keys are three independent credentials with three independent lifetimes. Revoking any one of them does not leak or change the others.

2.4 Roles#

A device is paired with one of three roles, chosen on the daemon side at pairing time (nid cloud pair --role view|operate|admin, default view). Locally a role can be raised or lowered and a scope widened or narrowed; from an admin device a role can only be lowered and a scope only narrowed (cloud.devices.role, cloud.devices.scope). Section 5.3 maps roles to methods.

RoleCan
viewWatch: everything read-only, including event streams and terminal output. The default.
operateWatch and steer: approve, answer, send, interrupt, stop, resume, change models, create and edit tasks and notes, review, land, restart a known team.
adminOperate, plus device management (list, rename, revoke other devices), raw terminal input, permission profile changes, group removal.

Be clear about what operate is: as far as the agents are concerned, an operate device is the human at the dashboard. It can tell an agent to run a command and then approve it, within the agent's permission profile. The allowlist keeps secrets, billing, configuration and machine paths off the channel; it cannot make a stolen operate phone harmless. That is why view is the default role, why every remote mutation is attributed (approved from "phone-7" in the event stream, the board and the local dashboard), why a device can be scoped to specific groups (section 5.4), and why revocation is one local command. Remote approvals also cannot widen an agent's permissions: remember ("allow for this session") is refused remotely, so a phone approves one request at a time.

No role can pair a new device, rotate the daemon key, change billing or API keys, read secrets, change webhook targets, run init, run the daemon's shutdown, or take over a session natively through the channel. Those stay on the machine (section 5.3).

The indirect path through agents. An operate device can tell an agent to run nid cloud pair or nid cloud login or nid auth set on the machine and approve it, and a yolo or prompt-injected agent needs no approval at all. The machine-side operations therefore defend themselves, not just the channel:

  • Platform user verification is the control. Pairing, login and re-link, relay change, rotate-key and raising a role or widening a scope complete only after the operating system has verified the person, through a prompt that software running as the user cannot answer: Windows Hello (UserConsentVerifier, falling back to the credential UI), macOS LocalAuthentication (Touch ID or the account password), Linux polkit with the account password. The local dashboard uses the same prompt: approving there calls the same daemon method, which shows the operating-system prompt on the machine (round A changed this, see the next item). The daemon records which path verified. Environment, parent-process, terminal and Origin checks are not controls: an approved shell command can shed the agent environment, allocate a pseudo-terminal and type y, read the bearer token in daemon.json, mint a dashboard session and post a confirmation with forged headers, or drive a browser or a plain OS dialog through automation. Only a prompt that needs the person's biometric or password stops that.
  • How the dashboard path is built (round A, changed at implementation; reason: the original design used a WebAuthn assertion in the local dashboard as the equivalent of the operating-system prompt, and the independent re-review showed it could not be one). The daemon runs every verification itself, inside the operation it guards: a "verified" flag sent by a CLI or a page would be forgeable by anyone holding the bearer token. The dashboard listener is reachable with the bearer token: dashboard.open mints a login link, and any program can follow it and send the requests a browser would, with a forged Origin and the browser's Sec-Fetch-* headers. So no credential that only the dashboard can hold (a passkey enrolled through it, a cookie) can stand for the person: an agent with daemon.json would enroll a software passkey once and then confirm its own pairings with it, as the re-review reproduced (C1). The WebAuthn substitute was therefore removed: approving a pairing, raising a role or widening a scope in the dashboard calls the same cloud.* method as the CLI, and the daemon shows the operating-system prompt on the machine every time, with the same text (fingerprints, role and scope first, the scanner-chosen label last). GET /login also requires a browser's top-level navigation markers (Sec-Fetch-Mode: navigate, Sec-Fetch-Dest: document); that is hygiene against accidental non-browser use, not a control, for the reason above. A browser-bound credential becomes useful only for a client the bearer token cannot impersonate (a native app with hardware-backed keys, round C).
  • Fallback when no verifier is available (headless Linux without polkit, a Windows account without Hello or a password, a remote shell): the daemon refuses to create a remote-capable pairing or re-link and says why; nid cloud pair --no-user- verification exists for such machines but caps every device paired that way at view and marks them so in the device list. The cost is that a locked-down server cannot hand out operate to a phone without a human present at a verifier; that is the intended trade. Independent-review B-M1 amendment: an unavailable probe is not permission for fallback. The human first runs nid cloud no-verifier --experimental in a plain interactive terminal and confirms a persistent local decision (no-verifier.json, timestamped). No bearer or dashboard RPC can create that declaration. The daemon also persists verifierObservedAt in cloud.json when it observes an available verifier; after that, fallback is refused even if the desktop locks or a helper later fails. Probe errors/timeouts fail closed. Same-user file writes can alter this local decision, as they can replace nid; this remains the local-write residual, not proof of a person. Coordinator re-review C4: a verifier probe has three outcomes, available, absent and unknown. A helper that fails, times out or prints anything unexpected is unknown, which neither runs a prompt nor permits the fallback; only an explicit, completed "no verifier here" answer is absent. The daemon probes once at start, without a prompt, and records an available verifier then; a fallback request waits for that probe, so a race at startup cannot slip through. On Linux only a missing /usr/bin/pkaction or /usr/bin/pkcheck (the absolute system path does not exist) counts as absent, which is what makes the "headless Linux without polkit" fallback reachable; a missing or broken nid polkit action, exit code 127 or any other failure stays unknown. A machine that has polkit but not the nid policy therefore has neither verification nor the fallback until an administrator installs the policy (appendix D). The daemon waits up to 35 seconds for a check, longer than the slowest helper's own 30-second limit, so a slow but working verifier is not mistaken for unknown (review of C4).
  • Best-effort refusal of the obvious command. The daemon refuses a remote approval of an agent request whose command matches nid (cloud|auth) or the terminid and pawmux equivalents, and tells the device "confirm this on the machine". This is pattern matching on an agent's own description of the command and is documented as best effort only; the user-verification prompt is what actually holds. Round A matches the program under any wrapper or path (npx nid, sh -c "nid ...", $(which nid), C:\...\nid.exe, the source checkout's cli.js or src/cli/main.ts), global flags with values before the subcommand (nid --home x cloud), and a direct call of the daemon API naming a cloud.* method (curl .../rpc -d '{"method":"cloud.pair.begin"}'). An agent that obfuscates the command (an alias, a script file, encoded text) is not caught; that is what "best effort" means here (src/remote/policy.ts isNidCloudCommand).
  • Every pairing, re-link, relay change or key rotation raises a desktop notification, a cloud board note and an announcement to every connected device, so a device paired behind your back is visible within seconds on every screen you have.

What this does not cover: any agent that can run commands as your user, whether unrestricted (yolo, a PTY agent with its own auto-approve) or approved by the very device that is attacking, is your user on that machine for everything except the user-verification prompt. It can read daemon.json and the file-fallback secrets store, open dashboard sessions, and drive the UI. That is the existing local security model (security): permission profiles and approvals are the boundary against agents, and remote access adds one more gate (the person's biometric or password) in front of exactly the operations that turn "can reach" into "can read".

3. Pairing#

Independent-review B-M2 amendment: a new local or device request never silently replaces a live/consumed pairing offer. Explicit cloud.pair.cancel (CLI nid cloud pair --cancel --experimental) is required first and raises a notice. Pending confirmations expose offerId; cloud.pair.confirm requires that expected ID. The CLI polls and confirms its own offer, checks role/scope against the displayed offer, and cancels only that offer during cleanup. This prevents a second local caller from swapping a view pairing for an admin pairing under the first prompt.

Independent-review B-L3 amendment: pairing consoles require HTTPS outside loopback; plaintext self-hosted development requires explicit --insecure-console. That flag is forbidden for nid.dev and every subdomain, all of which count as hosted for release-key verification and hostedConsoleMaxRole, including non-default ports.

Pairing is how a device learns the daemon's public key and how the daemon learns the device's public key, in a way the relay cannot interfere with. It needs the daemon's terminal (or an already-paired admin device, which still needs a local confirmation).

3.1 QR pairing (preferred)#

nid cloud pair first verifies the console origin the code will point to (console-check, section 7; it refuses on an unsigned or unknown release unless --insecure-console is given), then prints a QR code, the same content as a URL, and the console release hash it verified:

https://<console host>/pair#v=1&relay=<relay host>&d=<daemon id>&k=<daemon public key, base64url>&s=<one-time secret, base64url>&exp=<unix seconds>&role=view[&t=<one-time routing token, self-hosted relays only>]
  • Every field is in the URL fragment, which browsers never send to the server, so the secret cannot land in the console host's request logs. A phone camera opens the link in the browser, where the installed PWA (or the native app, which registers the same URL) takes over.
  • k is the daemon's X25519 public key: the device pins it. Nothing the relay sends can replace it.
  • s is a 32-byte one-time pairing secret. It is the Noise pre-shared key (section 4.2) of the pairing handshake: a device that did not scan the code cannot complete the handshake, and a relay that tries to put itself in the middle cannot either, because it has neither s nor the private half of k.
  • The code is valid for 120 seconds or one successful pairing, whichever comes first. Failed pairing handshakes are counted per source channel, and the daemon discards the code after 1,000 failures in total: the limit exists to stop online guessing of the secret, and the 120-second life is the real bound, so a few junk attempts from someone the relay routes cannot burn the code while the human is holding the phone up.
  • The device opens the link (the console immediately removes the fragment from the address bar and history with history.replaceState, so it is not synced or recorded), connects to the relay, asks to be routed to daemon d, and runs the pairing handshake. After message 3 the daemon always asks on the machine, whatever the pairing mode: Pair "<label>" (fingerprint k7qm-3x9a-pz2h-4v8r) as view?, answered through the user-verification prompt of section 2.4. The label is chosen by whoever scanned, so it proves nothing ("Cody's iPhone" is what an attacker would type); the phone shows its own fingerprint on screen next to a "confirm on your machine" message, and the human compares that with the fingerprint in the prompt before verifying. Anyone who saw the QR within its 120 seconds (a screen share, a recording, a shoulder) can be first to the daemon, but cannot be paired without that confirmation; a second scanner is told code already consumed by <fingerprint> and the CLI shows the competing fingerprint so the human declines the wrong one. On y the daemon writes the registry row and answers with its version, the granted role and the device's registry id. The CLI prints paired "<label>" as view (fingerprint …, console release …); the phone shows the same release hash so the human can compare it with the one printed next to the QR. Be clear about what this comparison is worth: the hash the phone shows and reports in payload1.console is self-reported by the console, so a deliberately malicious console can display the expected value. The comparison and the pre-pair console-check catch deployment and CDN mistakes and a careless attacker; against a targeted one the controls are the operator commitments and the transparency log (section 7). Round A development consoles are unsigned, so --insecure-console is accepted for self-hosted and loopback console origins only, behind --experimental; it is never accepted for the hosted console.

The QR content is sensitive for 120 seconds (it contains s); it is not logged, not written to the board and the CLI clears the screen region after use when it can.

3.2 Short-code pairing (no camera)#

For a browser on a laptop or when the camera is unusable, nid cloud pair --code prints a 16-character base32 code (80 bits, shown as four groups of four) instead (after the same console check):

  • The device, signed in to the account, picks the daemon from the account's list and enters the code. The relay delivers that daemon's public key to the device. This is where the relay could lie about k, so the code is also the pre-shared key of the handshake: a relay substituting its own key cannot produce a valid second handshake message without the code. A relay that does lie about k learns the device's public key, label and platform from message 1 and nothing else; the pairing fails.
  • The code can be attacked offline, so it must be long. In IKpsk2 the daemon's message 2 is derived from the code, and any initiator the relay routes (an account thief, or the relay itself) can send a message 1 and receive a message 2, then test candidate codes against it offline at AEAD speed. The attempt limit does not help against that; only the code's entropy and the 120-second life do. 80 bits make the offline search infeasible within any lifetime; a later version may replace the psk with a PAKE (CPace) so that a shorter, typable code is safe, and the handshake payloads already carry a ver to negotiate it.
  • Because the key did not come from the QR, both ends show the fingerprints and the daemon-side confirmation (always required, 3.1) reads Pair "<label>" (device fingerprint …, daemon fingerprint …) as view? [y/N]. The device shows the same two fingerprints and the comparison is mandatory for short codes: a MITM that somehow got the code would still produce different fingerprints on the two screens.

3.3 Pairing from an admin device#

An admin device can request cloud.pair.begin over its channel; the daemon prints the QR / code on the machine and requires the user-verification prompt of section 2.4 there. Pairing without someone at the machine (or at its local dashboard) is deliberately impossible through the channel: it is the one operation that converts "can reach" into "can read". The indirect path through an agent is covered in section 2.4.

3.4 Device list, revoke, lost phone#

  • nid cloud devices and the console's Devices page (admin role) show every paired device: label, role, fingerprint, created, last seen, online now, push subscribed.
  • Revoke: nid cloud devices rm <id> locally, or Devices → Revoke from an admin device (which may not revoke itself; it may revoke another admin; nid cloud devices rm --all-others exists only locally). The daemon deletes the registry row, closes that device's channels immediately and refuses its handshakes from then on. It also tells the relay to drop the device's routing entry and push subscription, and tells every remaining device and the local dashboard which device was revoked and by whom. Revocation does not depend on the relay cooperating: a malicious relay can keep routing the old device's frames, and the daemon will keep rejecting them.
  • What the device left behind. Every artifact a remote mutation creates carries the originating device id: tasks, notes, messages, memory entries, review verdicts, model changes, pr.watch schedules, goal edits. On revoke the daemon lists them (revoked "phone-7": 3 tasks, 1 note, 2 memory entries, 1 CI watch), stops the device's pr.watch, disables its memory entries pending local review (nid memory shows them as quarantined; nid memory rm deletes one, nid memory release keeps it), and flags its open tasks and notes on the board as from revoked device. Messages already delivered to agents cannot be unsent; the board note tells the lead which ones came from the revoked device. What cannot be undone is listed for review too: the approvals and denials it gave, questions it answered, review verdicts, model and permission changes, terminal takeovers, lands and settings changes, and every session it left in yolo is called out by name (round A, onDeviceRevoked in src/daemon/remote/attribution.ts).
  • Lost phone: revoke it from the machine or another admin device (one command). Until then the phone's device key still works, which is exactly the capability the phone had. The phone's screen lock and non-extractable key storage are the first line; the console's own biometric gate is JavaScript in the page and is a convenience, not a control. The console is also signed out of the account from the account's security page, which stops routing immediately even before the daemon-side revoke.
  • Lost machine (daemon key compromised): nid cloud logout on a new install generates a fresh key; devices must be re-paired. Devices show a clear "daemon key changed, re-pair" error instead of silently accepting the new key.

3.5 Key rotation#

  • Routine daemon key rotation (nid cloud rotate-key, local only): the daemon generates D', then over every currently connected channel sends a key_rotate message containing pub(D') under the current channel (the channel itself authenticates it, no extra signature scheme). Devices that receive it replace their pinned key. Devices that are offline at rotation time get the announcement the next time they connect, during a grace period of 30 days in which the daemon still answers handshakes to the old key only to deliver the rotation announcement (no API traffic). After the grace period they must re-pair. The old private key is destroyed when the grace period ends. Routine rotation limits the lifetime of a key; it does not protect against a key that has already leaked, because whoever holds the old key can complete a handshake during the grace period and announce their own key.
  • Compromised daemon key (nid cloud rotate-key --compromised): no grace period. The old key is destroyed at once, every registry row is marked re-pair, and every device sees "daemon key changed, re-pair" the next time it connects. Use this when the machine or its secrets store may have been read.
  • Rotation of the daemon key is never available from a device, admin or not.
  • Device key rotation: a device sends key_rotate with pub(V') over its channel; the daemon updates the registry row and records the previous fingerprint and the time. Native apps rotate yearly by default; the PWA rotates when the user asks (its key storage is the weaker one and rotation does not fix that).
  • Rotation never goes through the relay in cleartext and never through pairing codes.

4. The secure channel#

4.1 The pick#

Noise IK handshake, Noise_IK_25519_ChaChaPoly_SHA256, with the psk2 variant during pairing (Noise_IKpsk2_25519_ChaChaPoly_SHA256), over the noble primitives (@noble/curves for X25519, @noble/ciphers for ChaCha20-Poly1305, @noble/hashes for SHA-256/HMAC/HKDF), with the platform's WebCrypto used for X25519 key storage where it exists.

Why (details and the two alternatives in appendix A):

  • IK is the pattern made for "the initiator already knows the responder's static key": one round trip, mutual authentication and forward secrecy for the whole session once the second message is in. That is exactly our situation after pairing. (IK also encrypts the initiator's static key; that hides the key from the relay, not the device, which the relay already knows from routing.)
  • psk2 turns the one-time pairing secret into part of the key schedule, so pairing uses the same code path as every later connection, and a relay substituting keys fails cryptographically instead of "by policy".
  • The Noise specification has a formally analysed set of patterns, published test vectors, and several independent implementations to cross-check against. The state machine is small (a few hundred lines), has no WASM loading step, and runs in a browser, a service worker, Node and Bun; its DH is asynchronous so that a non-extractable WebCrypto key can do the static operations (section 2.2).
  • noble is audited (Cure53: secp256k1 2021, hashes and ed25519 2022, curves and ciphers 2024; Kudelski: curves 2023), has zero dependencies, is tree-shakable to about 30 KB for what we use, runs the same code in Node, Bun, browsers and Workers, and is what hpke-js, libp2p and several wallets already rely on in browsers. SHA-256 (rather than BLAKE2s) keeps the hash in the set WebCrypto accelerates everywhere.

4.2 Handshake#

Prologue (bound into the handshake hash, so any tampering fails the first decrypt):

"nid-remote/1" || 0x00 || daemonId || 0x00 || deviceId || 0x00 || mode   (mode = "pair" | "session")

The device is the Noise initiator; the daemon is the responder. The device's static key V is the Noise s of the initiator; the daemon's pinned key D is the responder's rs.

mode = session   (device already paired)
  -> e, es, s, ss      payload1: {ver:[1], deviceId, label?, console?, resumeHint?}
  <- e, ee, se         payload2: {ver:1, daemon:{version, fingerprint}, role, lastEventId, features}

mode = pair      (device not yet paired; psk = one-time pairing secret s)
  -> e, es, s, ss      payload1: {ver:[1], deviceId, label, platform, requestedRole}
  <- e, ee, se, psk    payload2: {ver:1, daemon:{version, fingerprint}, role, deviceRecordId}

Daemon-side acceptance rules:

  1. mode=session: after message 1 the daemon knows the initiator's static key. It must equal the key of the registry row named by the deviceId in open (never "any active row"); otherwise the daemon finishes nothing, sends a plaintext relay close(reason=unknown_device) and counts the attempt (section 8.2). Before any Diffie-Hellman the daemon also checks the deviceId and mode the relay's open carries: in mode=session an id that is not in the registry is refused outright (cheap rejection; the handshake, not the id, is what authenticates); in mode=pair an unknown id is accepted only while a pairing code is live, bounded by the unauthenticated channel slots (rule 6) and the per-code limit (3.1).
  2. mode=pair: the static key must not match an active row, a pairing code must be live, and the psk must be the code's secret. A wrong psk fails the message 2 decrypt on the device side and the daemon's own check (the daemon derives message 2 with the psk; it detects a bad guess only on the device's absence of further traffic, so it additionally requires message 3, an empty transport message from the device, before it writes the registry row; a missing or undecryptable message 3 counts as a failed attempt).
  3. Message 1 alone proves nothing fresh. Noise IK message 1 is replayable, so the daemon acts on nothing in payload1 (label, requested role, console release, resume hint, "last seen") until message 3 (pair) or the first authenticated transport message (session). In particular the Pair "<label>"? prompt of short-code pairing is shown only after message 3.
  4. Version: payload1.ver lists the device's supported protocol versions; the daemon picks the highest it supports and states it in payload2.ver. The prologue carries the version the two ends agreed in the plaintext open, which the relay could lower, so both ends check: the daemon aborts when the prologue version is lower than the highest version in payload1.ver that it supports (the check that matters, since only the daemon knows both sets), and the device aborts when payload2.ver differs from the prologue version. Version 1 ships with this rule.
  5. The daemon never sends anything but payload2 before it has authenticated the device, and payload2 contains nothing sensitive (version, role, event cursor).
  6. A handshake that has not completed within 5 seconds of open is closed. Channels that are not yet authenticated are counted separately from the 16 authenticated ones (section 4.5): at most 8 at a time. When the slots are full a new open evicts the oldest unauthenticated channel only if that channel is older than one second (a legitimate handshake completes in one round trip); otherwise the new open is refused. Unauthenticated attempts can therefore neither fill the channel table, nor make the daemon stop accepting, nor evict a handshake that is about to complete.

After message 2 (Split()), each side holds two CipherStates: one for each direction. Everything from then on is a transport message.

4.3 Framing#

Inside each WebSocket the two layers are:

relay envelope (plaintext, read by the relay):   type(1) | channel(4) | body
sealed body (Noise transport message):           ChaCha20-Poly1305(key_dir, nonce = counter_dir, ad = "", plaintext)
plaintext:                                       kind(1) | flags(1) | length(2) | payload
  • WebSocket messages are binary and message-oriented, so no extra length prefix is needed on the wire; one binary WebSocket message carries exactly one envelope. The only text messages are the keepalive ping/pong pair, which a hibernating Durable Object answers without waking and which the Node relay answers in its socket layer. Compression (permessage-deflate) is disabled: ciphertext does not compress and compression leaks plaintext length patterns.
  • Max sealed body: 65,535 bytes (the Noise message limit), so the plaintext payload is at most 65,515 bytes. Both relays reject envelopes above 65,560 bytes outright; the platform limits (32 MiB per Durable Object WebSocket message, 16 MiB default in Bun, maxPayload in ws) are never approached. Larger application messages are fragmented by the sender into consecutive frames with the MORE flag set on every fragment but the last; the receiver reassembles up to the application limit (16 MiB for an RPC body, the same as the daemon's own limit; 2 MiB for a single stream event) and closes the channel on overflow. Reassembly buffers are bounded: at most 32 MiB in flight per channel and 128 MiB per daemon, a 10-second timeout between fragments, and the channel closes when either is exceeded, so a device cannot park half-received requests in the daemon's memory. Small frames are padded to a multiple of 256 bytes before sealing so that control traffic (approve, answer, acks) has no distinctive size.
  • Direction and peer binding: Split() yields one key per direction, and the prologue binds daemonId and deviceId. A relay that reflects a device's frames back to it, or forwards device A's frame on device B's channel, produces frames under the wrong key and they fail authentication.
  • Ordering and replay: nonces are the Noise per-direction 64-bit counters, starting at 0 after Split(), incremented by exactly one per transport message. The receiver decrypts with its own expected counter and does not accept any other value. A dropped, duplicated, reordered or modified frame therefore fails authentication; the receiver then closes the channel (close(reason=crypto)), discards the keys and the client re-handshakes. There is no "skip ahead" window: the underlying transport (TCP + WebSocket + relay forwarding on a single socket pair) is ordered, and anything out of order is by definition tampering or a relay bug, both of which should end the channel rather than be tolerated. The relay cannot replay a frame into a later channel either: keys are per handshake.
  • Rekey: both sides call Noise Rekey() on a direction when that direction's counter reaches a multiple of 2^16; the schedule is fixed, so no signal is needed. The sender sets the REKEY flag on the first message under each new key purely as a consistency check: a decrypted frame whose flag disagrees with the receiver's schedule is close(reason=crypto). Independently of traffic, a channel is re-handshaked after 24 hours (the client closes and reopens; the daemon enforces it with a one-hour margin). Nonce exhaustion (2^64) is thus unreachable.
  • Frames carry no timestamps and the protocol has no clock-based security check (section 9.3).

Plaintext kind values: req, res, sub, ev, end, cancel, ctl (channel control: key_rotate, ping, pong, goodbye), pushsub (device → daemon push subscription management, section 6). The payload for every kind is JSON (UTF-8) except stream data for the raw terminal, which is binary with a small JSON header (appendix B).

4.4 Reconnect and resume#

Channels are not resumed cryptographically. A dropped WebSocket (either side) ends every channel on it; the device reconnects to the relay with exponential backoff (0.5 s → 15 s, with jitter) and runs a fresh IK handshake (one round trip, two X25519 operations per side: cheap enough to do on every reconnect, even on a phone).

Resumption happens one layer up, with the semantics the daemon API already has:

  • The device remembers lastEventId per daemon and re-subscribes with after; the daemon replays per the SSE rules (bounded replay, resync when behind, hello always triggers snapshot refetch).
  • Requests in flight when the channel dropped fail on the device with disconnected; the adapter never retries a mutating method on its own (a second send would be a second message). Read-only methods are retried once after reconnect.
  • The daemon keeps no per-device state across channels except the registry row and the push subscription.

resumeHint in payload1 is informational (last known daemon version and event id) and lets the daemon decide whether to send a resync hint immediately.

4.5 Multiple devices, multiple daemons#

  • One daemon WebSocket carries any number of channels, one per connected device (channel in the envelope). The relay allocates channel ids per daemon socket and tells the daemon open(channel, deviceId) before forwarding the first handshake frame. The daemon treats deviceId as a routing hint only; the handshake decides.
  • A device holds one channel per daemon it is connected to. Switching daemons in the console does not require re-pairing, only the pinned key of that daemon.
  • Devices never talk to each other. Their channels are independent and a compromised device learns nothing about another device's channel keys.
  • Limits (daemon side, configurable in ~/.nid/config.yaml under cloud:): 16 concurrent channels, 32 open subscriptions per channel, 4 outstanding requests per channel per method class (reads can pipeline, mutations serialize), 60 mutating requests per device per minute. Request timeouts on the device side are 60 seconds (the events RPC may long-poll for 30 seconds; the console uses the stream instead).

4.6 Backpressure and flow control#

The relay cannot see which frame belongs to which request, so flow control is per channel and plaintext (relay envelope type flow):

  • Every channel starts with a credit of 64 frames in each direction. Each forwarded frame costs one credit at the relay; the relay returns credits to the sender as the receiving socket drains (flow(channel, credits)), in batches of 16.
  • A sender with zero credits stops sending on that channel (the daemon pauses that channel's stream subscriptions; the API's own "slow client is disconnected after 2 MiB" rule applies inside the daemon at the stream level and surfaces to the device as end(reason=overflow), after which it re-subscribes).
  • A channel that stays at zero credits for 60 seconds is closed by the relay with close(reason=stalled). One slow phone therefore never blocks the daemon's socket for other devices.
  • The relay buffers at most 64 frames × 65,560 bytes ≈ 4 MiB per channel and at most 32 MiB per daemon socket; beyond that it closes channels, newest first. Buffered frames live in the relay's memory only: a hosted relay object that hibernates while a channel is under backpressure loses them and closes the channel (close(reason=relay_reset)), and the device re-handshakes. Frames are never reordered or partially delivered.

5. What is tunnelled#

5.1 The daemon API, unchanged#

The payloads inside the channel are the daemon API as documented, nothing else:

LocalRemote frameNotes
POST /rpc {method, params}req {id, method, params} → res {id, ok, result | error}Same envelope, same results and error strings. id is a per-channel counter chosen by the device.
GET /events?group=&session=&after=sub {id, kind:"events", group?, session?, after?} → ev {id, event:"hello"|"agent"|"change"|"resync", data, sseId?} … end {id, reason}sseId carries the SSE id: of agent frames. The daemon applies the same replay, filter and overflow rules as SSE.
GET /terminal?session=sub {id, kind:"terminal", session} → ev frames with snapshot/data/control/resize/closed; data is binaryViewer id arrives in snapshot and is used with session.terminal.* exactly as locally.
POST /logoutctl goodbyeCloses the channel; the account session is separate.

The device-side adapter implements the same interface as the dashboard's src/dashboard/ui/api.ts (rpc, openEvents, terminalStreamUrl, logout, onUnauthorized). openEvents maps EventSource reconnect semantics to channel re-handshake; terminalStreamUrl is replaced by an openTerminal(session) returning an EventSource-like object, the one place the UI needs a small seam (Terminal.tsx uses the URL today). The UI code, the store and the components are otherwise identical to the local dashboard: there is one dashboard, two transports.

The daemon-side connector calls the same in-process API the loopback listener calls, with a new remote principal {kind:"device", deviceId, role} instead of the dashboard-cookie principal. All permission decisions below live in that principal, inside the daemon, next to the existing "dashboard cookies cannot call shutdown" rules.

5.2 Principles for the allowlist#

  1. Read and steer, do not reconfigure. Anything the local dashboard shows can be shown remotely. Anything that answers an agent (approve, answer, send, interrupt, stop, resume), moves work along (tasks, notes, reviews, land, model) or starts a team the machine already knows is steering and allowed for operate.
  2. Nothing that reaches secrets, billing, pairing or the filesystem by path. API keys and billing modes, webhook URLs (they can contain credentials), the secrets store, init (writes files to a chosen directory), reading arbitrary config files by path, repository discovery on the filesystem, and anything that executes a command the remote caller chose: local only.
  3. Executable configuration needs a local decision. setup commands and custom agent definitions run on the machine. They can be used remotely (restarting a team whose nid.yaml the machine has already run) but not changed remotely, and not changed by an agent either: an agent can edit nid.yaml in its worktree, a phone can land it, and a restart would then run it. So each time a local command starts or resumes a group, the daemon records a hash of the group's whole configuration except goal and budgetUsd (every sessions: entry with its agent, permissions, billing, prompt and extra arguments; setup; agents; copy; link; mcp; every settings key including notify, permissions and fallback) together with the agents:, mcp: and notify: blocks of ~/.nid/config.yaml, plus the files the configuration points at: every repository script a setup command names, package.json and the scripts it defines whenever setup invokes a package manager, repository-local agent commands, the MCP server scripts of the project's MCP configuration, the files and directories named as copy and link targets, and shell-activated files such as .envrc, .nvmrc hooks and direnv configuration. Their contents are hashed at local start and compared on remote restart, so an agent cannot swap the body of a script the configuration already trusts. Independently, when land integrates a diff that touches any of those files or nid.yaml, the project MCP configuration or the agents' own config directories (.claude/, .codex/, …), the group is marked needs local start and the console says so; a remote restart is refused until a local nid up. The check lives in the daemon's driver start and restart path, keyed on the principal, not in a list of methods: any driver start that a remote principal triggers (team.start, resume, session.permissions, session.model on agents that switch by restarting, session.rotate in handoff mode, and anything added later) refuses with "nid.yaml changed since the last local start; run nid up on the machine" when the hash differs, because a restart re-resolves the custom agent definition from the current catalog. diff and group.summary flag changes to nid.yaml prominently so the human sees them before landing.

    Following references one level is not enough (independent review B-M5): a hashed script imports, sources or includes other files, and MCP servers and hooks do the same. So the snapshot also holds one hash over every executable-capable file of the tree (tracked files and untracked ones git does not ignore, by path and content; only prose, images, fonts, media and office documents are left out): always for the checkout a setup command runs in, and for a session worktree whenever its driver runs anything from it (any project MCP configuration, agent config directory or shell-activated file). A land that touches any executable-capable file marks the group "needs local start". The cost is deliberate: after code lands, or after an agent edits code in a worktree that has MCP servers or hooks, a phone can steer the running team but cannot restart it until a local nid up or nid resume. Most files git ignores (node_modules, build output) are not hashed as a whole, but the ignored files that are known to be executed are (coordinator re-review C2): every .env* and .envrc* file in the tree (dotenv and direnv load them, and a NODE_OPTIONS=--require line there runs code), the files any MCP server or hook command names (a path into node_modules or .venv included), and for MCP servers started by a package runner (npx, bunx, pnpx, uvx, pnpm dlx, yarn dlx, npm exec) the installed package's package.json, bin and main entries and its node_modules/.bin shim (--package= and -p included). What stays uncovered is code inside an ignored dependency tree that those entry files load in turn, python -m modules in a virtual environment, and packages a runner fetches from a registry; an agent that can write the dependency tree can already change what its own commands run. Files are hashed by content, streamed (large files included). A local start may reuse a hash cached by size, modification time, change time and inode, but a device-triggered check always reads every file again: on Windows a writer can restore both the change and the modification time (review #59). The daemon checks at most max(20, 2 × sessions) device-triggered starts per group per minute, so a device cannot keep the daemon busy hashing (re-review C3, C5).

    Round A implementation (src/daemon/remote/config-hash.ts): a snapshot is a set of labelled hashes, recorded by the local spawn, up, team.start and resume (never by a device, never by the daemon's own restarts). A device-triggered driver start compares the group's nid.yaml entry (and the file's other top-level keys), the agents, mcp, notify, permissions, billing and fallback blocks of ~/.nid/config.yaml (the last three added because they change how agents start), repository-local agent commands, and the session worktree's project MCP configs, the local scripts they start and its shell-activated files; a device-triggered setup run (a new worktree) also compares the setup scripts as found in the new worktree and the copy/link sources. Globs in copy/link are covered by the configuration text, not expanded. A group with nothing recorded refuses device starts with "this group has no recorded local start; run nid up on the machine". The daemon remembers which device started each running driver: when that agent process crashes, the automatic restart runs as the device, so it is checked too and refused (the session stops, the human is told) when the configuration changed. Residual: that memory lives in the running daemon only. After a daemon restart, sessions are resumed under the crash-resume setting (default stopped: nothing restarts without a human), as local work.

  4. Destructive is admin plus a daemon-side confirmation. group.remove, session.permissions {profile:"yolo"} and memory.clear are admin-only (clear: operate) and two-step: the first call returns a nonce, the second must echo it within 60 seconds. The console performs both behind its dialog. A client-side confirmation dialog is a fat-finger guard; the nonce is what the daemon relies on, and it means a buggy or hostile client still needs two sealed round trips with intent. On the wire the first call's result is {twoStep: true, confirm: <nonce>, expiresInSeconds: 60, message}; the second call repeats the same parameters plus confirm. A nonce is bound to the device, the method and the parameters, and works once.
  5. Parameters are filtered, not just methods. Every remote request, whatever the method, is rejected if it carries any of dir, file, repo, cwd, configFile, setup, copy, link or options.extraArgs: those are the only parameters in the API that name machine paths or run commands. Everything is addressed by group, session, task, approval, question or event id instead. group.update additionally carries settings that would violate rule 2 (billing, fallback, permissions, copy, link, mcp, notify, excludes, defaultExcludes, pr); the remote principal rejects the whole call when one is present, with a readable error. The allowed subset is listed below.
  6. No blanket actions from a phone. approvals.answer without id or session resolves every pending approval locally; remotely it requires one of them, and remember is refused (section 2.4).
  7. Records are redacted for the channel. Session records carry the custom agent definition (options.agentDef), whose env map and arguments may hold provider keys. The remote principal strips options.agentDef.env, options.agentDef.args, options.extraArgs and options.env from every session record for every role (command and mode stay for admin, so the Devices and Spawn views still make sense). Round A implements this as an allowlist of agentDef fields (name, source, description, plus command and mode for admin), so later fields such as modelArgs, mcp or the definition's file path are stripped too. The local dashboard should get the same redaction; until it does, the difference is documented here.

5.3 The remote allowlist#

view has READ. operate has READ + STEER. admin has all three remote tiers. LOCAL means the remote principal rejects the method with error: "<method> is not available remotely; run it on the machine".

TierMethods
READping, status, groups.list, group.summary, sessions.list, session.get (both redacted, principle 7), session.events, events, board, review.list, approvals.list, questions.list, inbox (it marks the inbox read, as locally), diff, models.list (without dir), compression.stats, compression.compare, pr.status, learn (read-only per the API), learn.archives, memory.list (by group, never repo), notify.recent, cloud.status; sub events; sub terminal (viewing)
STEERsend, interrupt, session.interrupt, stop, resume (subject to the configuration hash, principle 3), answer, approve/approvals.answer (with id or session, never remember), task.create, task.update, note, review.approve, review.requestChanges, session.model, group.model, session.rotate, session.limitClear, land, pr.checks (uses the machine's GitHub credentials and creates tasks), pr.watch; group.update restricted to goal, budgetUsd (may lower; raising above the current cap requires admin) and settings ⊂ {review, reviewers, stallMinutes, stallGraceMinutes, maxReassign, idleClaimMinutes, idleClaimNudges, limitReleaseMinutes, limitWarnPercent, models, budgetIncludesEstimates, compression}; team.start only as {group, goal} for a group that already exists (the daemon uses the group's stored repository; dir is rejected; refused when the configuration hash changed), which is how "start the team again with a new goal" works from a phone
ADMINgroup.remove (two-step nonce), session.permissions (yolo is two-step), session.terminal.control (taking control is two-step and attributed; taking and releasing the lease are tagged with the device in the event stream, and writes need the lease, so every write in between is attributed to it; round A does not log each write), session.terminal.write, session.terminal.resize, memory.add, memory.update, memory.remove, memory.clear (two-step; memory is prompt material for every future session, so it is admin-only remotely and quarantined on revoke, 3.4), agents.list (without dir), notify.config (masked URLs only, as locally), notify.test, cloud.devices.list, cloud.devices.rename, cloud.devices.revoke (one device, not self), cloud.devices.role (lower only), cloud.devices.scope (narrow only), cloud.pair.begin (still needs the local confirmation, 3.3); group.update raising budgetUsd
LOCALshutdown, dashboard.open, spawn, up, team.start with dir or for a new group, session.takeover, session.release, config.read, init.detect, init.write, repos.recent, repos.check, repos.suggest, memory.release (keeping a quarantined entry is the local review, 3.4), usage (account-level subscription usage: billing-adjacent, principle 2; added when the usage work merged), group.update with setup or any settings key not listed above, everything under nid auth, nid cloud login|logout|pair|relay|rotate-key, cloud devices rm --all-others, group notify.webhooks changes, agent RPC envelopes (session set)

Why some of these land where they do:

  • events/session.events/diff/terminal viewing expose code and output: that is the product, and it is what end-to-end encryption protects. view sees it all.
  • land pushes to the repository's remote with the machine's credentials. It is the primary "approve from the phone" action and is STEER; it is also fully visible in the event stream, and the group's pr settings decide draft/base.
  • team.start with a path, up and spawn load nid.yaml from a directory the caller names and run its setup and custom agent commands. Remotely a team can only be (re)started for a group this daemon already created from a local command, and no path crosses the channel. Spawning new sessions remotely is deliberately out of round A; a later group.restart-style method that reuses stored configuration can add it without ever taking a path.
  • Raw terminal input is a shell. ADMIN, off until the console confirms taking control.
  • agents.list reveals installed commands and custom agent definitions, repos.* reveal or scan the filesystem (machine metadata): ADMIN or LOCAL.
  • pr.checks looks read-only but fetches with the machine's GitHub credentials and creates failure tasks: STEER. learn without write is read-only: READ.
  • notify.test fires every configured webhook and bypasses the rate limits: ADMIN.
  • Budget: lowering is safe; raising spends money and is ADMIN.
  • What a paired device learns that the relay does not: session records carry cwd (worktree paths on the machine) and custom agents' commands; diff and events carry code. That is the product, sealed end to end, and view sees all of it.

The allowlist is data (src/remote/allowlist.ts), tested in both directions: every human method in api.md must appear in exactly one tier, and the test fails when a new method is added without a tier (the safe default for an unlisted method at runtime is LOCAL).

Rotation configuration (group.update.settings.rotate) is local-only: it can schedule detached handoff restarts, so allowing a device to change it would lose the initiating principal at the configuration-hash guard. Explicit session.rotate remains subject to that guard.

5.4 Per-device permissions beyond roles#

Roles are the coarse control. The registry row also has a scope: the groups (or repositories) a device may see and steer, set at pairing (--groups web,api) or later with nid cloud devices scope <id> ... and cloud.devices.scope (admin), enforced in the same principal for reads, streams, push and mutations. Scope ships in round A because it is the practical answer to a lost operate phone: a phone scoped to one side project cannot approve anything in the others. The console shows a device's role and scope on the Devices page. Not in round A: per-method grants and time-boxed elevation.

How scope applies to calls that do not name a group (round A, src/remote/allowlist.ts SCOPE_RULES): a scoped device's reads that list several groups (status, groups.list, sessions.list, approvals.list, questions.list, events, learn, notify.recent, and an events stream without a group) are answered with the result cut to its groups (status totals recomputed from the remaining groups); calls whose group comes from a record (approve by id, answer) are checked against that record's group; and calls that would otherwise act on every group or that have no per-group filter (inbox, which marks every group read without one, learn.archives, compression.stats) must name a group. A scoped admin can narrow another device's scope only to groups inside its own. A task's owner given as <group>/<name> names a group too, and task.update accepts only an owner that is a session of the task's group, for every caller, as task.create always did: a task cannot carry its title and nudges into another group.

6. Push notifications#

6.1 Flow#

daemon ── sealed push payload + endpoint ──▶ relay ── Web Push POST (VAPID) ──▶ push service ──▶ device
          (RFC 8291 aes128gcm, keys only on device)      (ciphertext passes through unchanged)
  1. The device subscribes with its browser's push manager using the relay's VAPID public key as applicationServerKey, and sends the resulting subscription {endpoint, keys:{p256dh, auth}} to the daemon over the channel (push subscribe). The relay never receives p256dh or auth. The browser generates p256dh/auth on behalf of the console's JavaScript, which can read them, so the trusted-console caveat of section 7 applies to push confidentiality exactly as it applies to the channel: a malicious console could hand the keys to the relay.
  2. The daemon validates the endpoint, then stores the subscription in the device's registry row. It checks again immediately before delivery, including subscriptions from older builds: HTTPS on port 443 only, no userinfo or fragment, no IP literals (including alternate numeric forms), no localhost, .localhost, .local, .internal, or single-label hosts. Allowed hosts are fcm.googleapis.com, updates.push.services.mozilla.com, *.push.apple.com, and *.notify.windows.com. Matching ignores case and a trailing dot; *. matches one or more subdomain labels, not the base host. Self-hosters can add exact DNS names or *. patterns in the local $NID_HOME/config.yaml setting cloud.push.endpointHosts: [push.example.com] (up to 64 entries, ASCII/punycode DNS names). This extends the list without relaxing the HTTPS, port, IP or private-name restrictions; changes apply to the next delivery. Prefer exact host names: a pattern such as *.co.uk would admit every host under a public suffix (re-review of #34). The hosted sender independently enforces the endpoint and network policy in appendix E.5.
  3. When a notifiable event occurs, the daemon builds the payload, encrypts it for each subscribed device per RFC 8291 (ECDH P-256 with the device's p256dh, HKDF with auth, AES-128-GCM, Content-Encoding: aes128gcm, record size 4096) using Node's WebCrypto, and sends the relay a plaintext envelope push {endpoint, body:<ciphertext>}. TTL (300 seconds) and urgency (high) are constants for every kind, and there is no topic: per-kind values or a deterministic topic would tell the relay which kind of event fired and link repeats of the same one. The device deduplicates by the key inside the payload; the daemon's own 10-minute dedupe keeps repeats rare enough that push-service collapsing is not needed.
  4. The relay signs a VAPID JWT for the endpoint's origin and POSTs the ciphertext with the standard headers. It sees the endpoint, the (constant) size and the push service's response code, which it reports back to the daemon (push_result). The daemon records deliveries like it does for webhooks today but treats the result as a hint, since the relay chooses it: a reported 404/410 marks the subscription suspect, the device is told "push delivery failing" over the channel, and the subscription is replaced when the device next sends it (devices re-send their subscription on every connect, so a healthy device recovers on its own). A subscription is never deleted on the relay's word alone.
  5. The service worker on the device decrypts (the browser does this before the push event fires), shows the notification, and on tap opens the console on the right group, session, approval or question.

Payloads are padded to 1,024 bytes before encryption (push services accept about 4,078 bytes of aes128gcm ciphertext, so plaintext stays under 3.9 KB), so the relay and the push service learn nothing from the size; a body that does not fit is truncated and the deep link kept.

Why the relay posts instead of the daemon: push services are public HTTPS endpoints and a daemon could POST to them directly. Routing through the relay keeps the VAPID identity per console origin (a subscription is bound to the VAPID key it was created with, so every console origin needs its own sender), lets the relay raise its own "daemon offline" alert, and keeps push-service hostnames off the daemon's outbound connections. A self-hosted setup may instead let the daemon send directly with its own VAPID key (cloud.push.direct: true); the payload format is identical.

6.2 What notifies#

The same notification kinds the desktop and webhooks use, with the same once-per-10-minutes-per-key and 6-per-minute rules, applied per device: approval, question, blocked, stall, limit, done, budget, review, pr. test is sent by notify.test and by the console's "Send a test notification" button.

Each device filters kinds and groups on the Devices page (stored on the daemon in the registry row, not on the relay). Default for a new device: approval, question, done, budget, blocked.

Payload (before padding): the generic-JSON webhook shape plus a reference the console deep-links to, without the CLI hint lines the desktop and webhook bodies carry.

{"v":1,"event":"approval","group":"web","session":"web/worker","title":"web/worker needs approval","body":"codex wants to run `npm install x`","at":"2026-10-12T10:00:00Z","key":"approval:42","ref":{"approval":42},"daemon":"<daemon id>"}

ref is one of {approval}, {question}, {task} or {group}. Notifications are not replayed: a device that was offline reconciles pending approvals and questions through the event stream and snapshots on reconnect, exactly as the local dashboard does.

No topic header is sent (6.1). What the relay and the push service can still learn is when pushes happen, which correlates with approvals and questions; that is listed as metadata in 8.1 and is inherent in push.

6.3 Trust boundaries#

  • The relay can delay, drop or duplicate pushes and can send junk to an endpoint it knows. Junk fails decryption in the browser and never reaches the service worker; a duplicate is deduplicated by the key and at fields inside the payload on the device.
  • The relay cannot forge a notification with content: it does not have auth/p256dh, and Web Push does not let a sender without them deliver anything but an empty push. That is how the relay raises its own alert: it sends an empty push, the service worker fetches the daemon status from the relay with its account session and shows a fixed text chosen by the client from a status enum (offline, online, suspended) and a timestamp: "relay reports: daemon offline since …". The relay supplies no free text that reaches the screen. The same mechanism lets the relay wake the service worker at will (a metadata and battery lever, not a content one); the worker rate-limits empty pushes.
  • The console never acts on push content. A tap opens the referenced approval or question, and the view refetches state over the channel before showing any button.
  • An attacker with the account password cannot subscribe a device: subscriptions are sent to the daemon over an authenticated channel, which needs a device key.
  • Push endpoints reveal the device's browser vendor to the relay (they are hosted by Google, Mozilla and Apple). This is unavoidable with Web Push and listed in section 8.1.

7. The web console's trust problem#

A browser runs whatever JavaScript the origin serves. The end-to-end channel keeps your code, prompts, output and decisions off our servers and makes a compromised relay (the Workers, Durable Objects, D1, logs, network and the Cloudflare account behind them) unable to read or steer anything. The console origin is a different party. The hosted console at app.nid.dev gets full operate and admin control, because that is what makes a phone useful; and whoever can change what that origin serves could, in principle, ship a client that reads and steers on behalf of the devices that load it. You trust it the way you trust your AI vendor's app: as an operator with controls, not as a cryptographic guarantee. This section says what those controls are, what the technical measures do and do not do, and how to opt out of trusting the hosted console altogether.

The promise, stated once. The relay cannot read or steer. The console operator could, if it chose to or were compromised, for the devices that use the hosted console and within their roles and scopes; everything below makes that hard, auditable and visible, and the self-hosted console and native apps make it unnecessary.

Operator controls (commitments for the hosted service; they map to SOC 2 change management, logical access and monitoring controls and are stated here as what we commit to operate, not as certified facts):

  • The console is deployed from infrastructure and credentials separate from the relay: a different Cloudflare account, different deploy tokens, different people on call. Compromising the relay does not give the console, and vice versa.
  • Deploys come only from the public, reproducible CI build of a tagged release in this repository, with two-person approval of the release and of the deploy; no one can push a hand-built bundle to the origin.
  • Every console deploy is written to an audit log (who, what release hash, when) that is retained and reviewed, and the deployed hash is published to the transparency log below at the same time.
  • The hosting setup has no per-user or targeted serving capability: no edge logic, A/B tooling or geo/user rules on the console origin, so that "a good build for the machine, a bad build for the phone" is not a configuration anyone can make without changing the deployment itself (which is logged and two-person).

Stronger options, for people who do not want to trust the hosted console:

  • Self-host the console. It is a static bundle; serve it from any origin you control (your own Pages project, an internal host, file:// in an installed PWA) and point it at the hosted relay or your own. Pairing records which console origin a device uses.
  • Cap hosted-console devices at view. A local daemon setting (cloud.hostedConsoleMaxRole: view in ~/.nid/config.yaml, changeable only on the machine) makes the daemon grant at most view to any device paired through the hosted console, whatever role was chosen at pairing. The daemon decides which console a device uses from the console host it put in the pairing URL itself (the QR, or the host chosen for --code), recorded in the registry row at pairing; nothing the client reports (payload1.console is informational only) can change it. The setting is local because the account service is part of the untrusted relay party: a cap that arrived from the account could be dropped by whoever controls the account. Devices on a self-hosted console or a native app keep their full role.
  • Native apps (round C): the same signed binary for everyone, distributed through the stores, with hardware-backed keys. No origin to trust at all.

What the technical measures do (supply-chain hygiene and detection, not protection against the operator):

  1. Open source, reproducible builds. The console is the dashboard in this repository plus the remote transport adapter, built by a public CI job that publishes the release's asset hashes alongside the signed release manifest (releasing). Anyone can rebuild and compare.
  2. Subresource Integrity and a strict CSP. index.html references every script and stylesheet with integrity= hashes; the CSP is default-src 'none'; script-src 'self' '<hashes>'; connect-src <relay>; img-src 'self' data:; style-src 'self'; worker-src 'self'; frame-ancestors 'none'. No third-party script, font, analytics or CDN, same as the local dashboard.
  3. Signed releases, verified assets, visible version. Every console release ships a manifest (asset hashes, version, build id) signed with the release key kept offline. The installed service worker caches a complete release and, before serving any asset, verifies it against the manifest and the manifest against the release public key compiled into the worker; a CDN, cache or storage substitution of an asset is refused. The console shows the running release hash in Daemon info, and the daemon logs the release hash each device reports at handshake (payload1.console) so nid cloud devices shows which release each phone runs. What the worker cannot do is veto its own replacement: a browser fetches the worker script from the origin on navigation and at least daily and installs any byte-different script it gets, so a malicious origin ships a worker without the checks. Pinning against the origin is therefore not something a web app can promise (see below); the controls are detection (item 4), transparency (item 1) and the release hash on both screens.
  4. nid cloud console-check. The CLI fetches the live console's index.html, service worker and manifest from the origin, verifies the signature and hashes against the release it was built with (or any published release), and prints a verdict. It runs automatically before nid cloud pair prints a code (section 3.1) and on a daily schedule while remote access is on; a mismatch is a cloud board note, a desktop notification and a line in nid cloud status. Run it by hand whenever you like. Its limit: an origin can serve a good build to the machine's address and a bad one to the phone, and the hash the phone reports is self-reported. So every release hash is also published to an append-only transparency log (a signed, public, hash-chained list; the log is open source and mirrorable), and both console-check and the console itself refuse a release that is not in the log. A targeted build must then appear in a public record that anyone, including the release team, can audit.
  5. Key storage. Device keys are non-extractable WebCrypto keys where the browser supports X25519 in WebCrypto (section 2.2), so even malicious JS in the origin cannot export them and a compromise ends when the bad client is replaced; it could still use them while running, which is why the operator controls and items 1–4 exist.

What we do not promise:

  • The origin is trusted on every launch, not just the first. A browser installs whatever service worker the origin serves; SRI and CSP are set by the page itself. Nothing a web page does can stop its own origin from replacing it. The only web mechanism that gives real pinning today is Chrome's Isolated Web Apps (signed web bundles that are not served by an origin); we may ship the console that way where it is available, and native apps are the general answer.
  • A targeted build does not need the signing key. An operator (or someone with the operator's deploy access) who serves a modified client to one device defeats console-check from the machine and the phone-side hash, both of which are self-reported by the client under test. The transparency log and the no-targeted- serving control make that a logged, deployment-level act rather than a quiet one; they do not make it impossible. A compromised release signing key additionally lets a build pass every check everywhere.
  • Browser extensions, a compromised OS or a compromised browser are out of scope, as they are for every web application.

Round A never exposes the hosted console in a weaker mode than this section describes: --insecure-console is accepted only for self-hosted or loopback console origins, and the whole cloud command group ships behind --experimental until round B (section 10).

8. What the relay sees, abuse and account takeover#

8.1 Metadata and retention#

The relay (hosted or self-hosted) necessarily sees:

DataWhyRetention (hosted)
Account identity (email, passkey ids), account sessions, IP addresses of loginsAccount securityAccount lifetime; login IPs 90 days
Daemon ids, device ids, hashed connector tokens, routing table (which device may reach which daemon)RoutingUntil unlinked or revoked
Labels of daemons and devicesShown in the account's device pageStrict mode: stored sealed (the daemon encrypts labels to each paired device; the relay holds ciphertext and shows "daemon 3" style placeholders). Account mode (section 11, appendix E): the accounts service stores the machine and device labels the person chose, in clear, because it lists machines and devices and sends new-device emails before any channel exists. Defaults name the platform ("Windows PC" for a machine, the browser's platform such as "iPhone" for a device), never a host or user name; nid rc --label picks another
Connection events: connect/disconnect time, IP, user agent, channel open/close, close reasonsAbuse handling, status page30 days
Per-channel frame counts and byte volumes, timingFlow control, meteringAggregated per day after 24 hours; raw counters 7 days
Activity patterns: a burst of frames when an agent produces output, a pause before an approval is answeredInherent in any relay; padding (4.3) hides which small action, not that there was oneNot stored beyond the counters above
Push endpoints (hostname identifies the browser vendor), push sizes (constant), push timing (correlates with approvals and questions), push-service response codesDeliveryUntil unsubscribed; delivery results 7 days

The relay never stores a frame body. Frames are forwarded or dropped; there is no queue that survives a disconnect, so an outage cannot leave plaintext-adjacent data anywhere (there is no plaintext to begin with). Logs never contain frame bodies or push bodies, only the fields above.

8.2 Abuse and rate limits#

Limits on the hosted relay are published here because clients and self-hosted relays should expect them; the exact numbers may be tuned.

LimitDefault
Daemons per account, devices per account10, 20
Concurrent device sockets per account32
Channel opens per device per minute30
Failed handshakes (daemon reports unknown_device / crypto) per account session and per source socket per hour before that session's routing is suspended for an hour (counted by who opened the socket, never by the device id the opener claims, so a spoofed id cannot get the real device suspended)10
Channel opens and failed handshakes per account per hour (on top of the per-session limits, so logging in again does not reset the budget)300, 30
Pairing attempts per daemon per hour (the daemon itself allows 1,000 per code, counted per source channel)200
Device-to-daemon frames per channel per second, aggregate bytes per daemon socket per minute (both directions)200, 64 MiB
Push sends per daemon per minute, per device per minute60, 20

The daemon enforces its own limits independently (section 4.5, pairing in 3.1) and never trusts the relay to have done so.

8.3 Denial of service#

  • The relay can refuse or drop any connection: that is the inherent capability of a relay and the reason for the self-host exit route. Nothing in the daemon depends on the relay being up (section 9.1).
  • A hostile client cannot make the daemon do expensive work before authentication, and cannot make it stop serving legitimate devices: an open whose device id is not in the registry is refused before any Diffie-Hellman; a known id costs two X25519 operations and one AEAD decrypt before rejection; unauthenticated channels are capped separately (8, newest evicts oldest, 5-second handshake timeout, section 4.2). The daemon never stops accepting new channels, because "stop accepting" would be the lockout lever an attacker wants. Failures are reported to the relay as close reasons, and the relay suspends the opener's account session, not the claimed device id.
  • Authenticated but abusive devices (a bug in a client, say) are bounded by the per-channel limits in 4.5 and 4.6 and by the daemon's existing API limits (16 MiB bodies, 64 streams, 2 MiB stream buffers).
  • The relay's own protection against volumetric attacks is the platform's (Cloudflare's edge for the hosted relay; whatever sits in front of a self-hosted one).

8.4 Account takeover#

Someone who has the account password, or who phishes the second factor or steals an account session cookie, can:

  • see the metadata in 8.1 for that account (which daemons exist, placeholder labels, when devices were online),
  • open device sockets and ask to be routed to the account's daemons, which produces unknown_device rejections at the daemon (visible to the owner as "unknown device tried to connect" in nid cloud status, the local dashboard and a cloud board note),
  • revoke the account's connector tokens and device routing entries (denial of service against the real owner, repaired by cloud login again); these account-page actions require a fresh second factor, and the daemon tells every connected device and the local dashboard when its routing is dropped,
  • delete the account (same confirmation, with an email notice and a grace period).

They cannot read any event, approval, output or diff; cannot send any command; cannot pair a device (that needs the machine's terminal); cannot subscribe to push; cannot see labels; cannot make the daemon accept a new key. Taking over the account is therefore equivalent to being the relay operator, and the relay operator was designed out of the trust boundary from the start.

The owner recovers by changing the account credentials and signing out all sessions on the account's security page; device keys and the daemon key do not need to change. An attacker who changes the account email first does not block this: email changes notify the old address with a 7-day undo link and require a fresh second factor, and because routing and keys are independent the machine can always re-link to a new account with nid cloud logout then cloud login without re-pairing any device (devices sign in to the new account and keep their keys).

8.5 Compromised relay operator#

A fully compromised relay (Workers, Durable Objects, D1, logs, the push sender, the network, the Cloudflare account) adds, over 8.4, the ability to tamper with frames and pushes, every form of which fails authentication and ends the channel (section 4.3, 6.3). It cannot complete a pairing (3.1, 3.2), cannot forge a key rotation (3.5; a leaked old key during routine rotation is the one exception, handled by --compromised), and cannot turn a device's push off by lying about delivery (6.1).

A compromised console deployment is a different party with a different answer (section 7): whoever can change what the console origin serves can ship a client that reads and steers on behalf of the devices that load it, within their roles and scopes, until it is detected. That is why the console is deployed under separate controls from the relay, why the account can cap hosted-console devices at view, and why self-hosting the console and native apps exist. The guarantee "cannot read or steer" is a guarantee about the relay path, not about the client you choose to run.

9. Failure modes#

9.1 Relay down#

The daemon's connector retries with exponential backoff and jitter (1 s → 60 s) and nid cloud status / doctor show "relay unreachable since <time>". Nothing local changes: teams, approvals, the local dashboard and the CLI keep working; notifications still go to the desktop and webhooks. Devices show "relay unreachable" with the time of the last successful connection. Pushes raised during the outage are dropped (they are time-sensitive by nature); the next connection delivers the current pending approvals and questions through the event stream instead.

Exit route: run the relay core yourself (nid cloud relay wss://relay.example.com on the daemon, and the console's "Relay" setting on each device, which re-pairs nothing: device keys and daemon keys stay valid; only routing moves).

A self-hosted Node relay has no accounts, so it gates routing with a relay secret set in its configuration: the daemon presents it at nid cloud login against that relay (stored as the connector token). Devices are bootstrapped through pairing: when nid cloud pair prints a code, the daemon asks the relay over its connector socket for a one-time routing token bound to the code's lifetime and puts it in the QR fragment (t=); the device uses it for the pairing connection only, and receives its long-lived per-device relay token from the daemon inside the sealed payload2. Revocation reaches the relay through the daemon's revoke message (3.4), which deletes that device's token and routing entry; token rotation by itself revokes nothing. Without a valid token the relay accepts nothing, so strangers who find the URL cannot register daemon ids, open channels or read the metadata in 8.1. The daemon requires wss:// except for loopback and verifies the relay's TLS certificate normally; an attacker on that hop learns only the connector token, which grants routing, not reading. (The hosted relay uses the account session for the same two steps, so t= is absent from its QR codes.)

9.2 Daemon offline#

The relay tells devices daemon offline since <time> when the daemon socket is gone. The console shows the last snapshot it had, greyed, and keeps the lastEventId for resume. A device cannot wake a daemon: there is no store-and-forward of commands, by design (a command queued at the relay for a daemon that comes back hours later would be acted on without the human watching).

9.3 Clock skew#

The protocol has no time-based security check between device and daemon: nonces are counters, replay is caught by the AEAD, and keys are per handshake. Clocks matter in three bounded places:

  • Pairing code expiry is measured by the daemon's own clock from when it printed the code, so skew does not matter.
  • Account sessions and VAPID JWTs are relay-side and carry the usual tolerance (the relay signs VAPID with its own clock; push services allow several minutes of skew).
  • The 24-hour re-handshake uses each side's monotonic uptime clock, not wall time.

Event timestamps displayed in the console are the daemon's, as they are locally.

9.4 Partial, oversized and malformed frames#

  • A WebSocket message that is not a complete envelope (shorter than 5 bytes, unknown type, unknown channel) is dropped by the relay and counted against the sender.
  • A sealed body that fails to decrypt, a fragment sequence that exceeds the reassembly limit, a MORE chain cut off by a channel close, or a JSON payload that fails to parse: the receiver closes the channel with a reason code, discards the keys and the device re-handshakes. Nothing is retried blindly and nothing is partially applied: a request is executed only once its last fragment has been authenticated.
  • A response larger than 16 MiB (the daemon's own body limit) is answered with an error, as locally.

9.5 Version mismatch#

The daemon and the console are released together but updated separately. payload2.ver and features tell the console what the daemon supports; the console hides actions the daemon does not have, as the local dashboard already does after a reconnect to a newer daemon ("a changed build after reconnect prompts Reload").

10. Build plan and test strategy#

Round A: relay core, connector, pairing CLI#

  1. Protocol package (packages/nid/src/remote/): Noise IK/IKpsk2 state machine over noble, framing and fragmentation, the channel API (req/res/sub/ ev/ctl), the remote principal and allowlist. Pure TypeScript, no I/O, runs in Node, Bun and browsers.
  2. Connector in the daemon: outbound WebSocket, channel multiplexing, flow control, device registry with roles and scopes, pairing codes, the executable-configuration hash, two-step nonces for destructive methods, record redaction, remote-mutation attribution, cloud.* RPCs, doctor and status lines, unknown-device notices.
  3. CLI: nid cloud login|logout|pair|devices|status|on|off|relay|rotate-key|console-check, with the platform user-verification prompts of section 2.4 (Windows Hello, macOS LocalAuthentication, Linux polkit, WebAuthn in the local dashboard; the WebAuthn path was removed during round A and the dashboard uses the platform prompt, see 2.4). console-check ships in round A with the release public key compiled in; a self-hosted console's key is explicit local configuration (cloud.consoleKeys in ~/.nid/config.yaml), never fetched from the origin it checks. In round A the whole cloud command group is behind --experimental and --insecure-console is accepted only for self-hosted or loopback console origins; the hosted console is never offered to users in a weaker mode than section 7 describes.
  4. Relay core (platform-agnostic routing and flow control behind a small socket interface: send(bytes) → accepted, onMessage, onClose, close(code), plus store routing and post push) with adapters: Durable Objects with hibernation for the hosted service (one object per daemon id; no timers, so the object can hibernate between frames), plain Node (ws, compression off, maxPayload 1 MiB, proxy support for the daemon connector) for development, tests and self-hosting, and Bun. The core, the Node and Bun adapters and the protocol are open source; the hosted adapter, accounts and metering live in the private repository.
  5. Device reference client in TypeScript (the transport adapter plus a headless harness) used by tests and by the console in round B.

Exit criterion: a phone browser pointed at a development console can pair with a daemon through the Node relay on another machine and approve a request; every test below is green in CI with no network access.

Round B: PWA console and push#

Pages deployment of the dashboard with the remote adapter, the service worker with signed release manifests and asset verification, the transparency log, console-check, push subscription and the daemon's RFC 8291 encryption, the relay's VAPID sender, the Devices page, notification filters.

Round R: /rc, nid.dev accounts, hosted relay and console#

Human decision 2026-10-15 (section 11): merges round B with the hosted service and makes account-trusted remote control the default. Public repository: appendix E (contract first), account mode in the daemon (signed device lists, shares, notices), nid rc and /rc, the console app (dashboard over the remote adapter, PWA, push), and a dev-accounts implementation of appendix E in the Node relay so everything is tested locally. Private repository: the Durable Objects relay adapter, the nid.dev accounts service on D1 (passkeys, device-code login, device lists, signing key), hosting app.nid.dev and relay.nid.dev, the VAPID push sender, metering. Exit criterion: on the deployed service, a phone on mobile data signs in at app.nid.dev and approves a request from a daemon that ran nid rc, with no LAN, tunnel or per-device prompt.

Round C: native apps#

iOS and Android apps with hardware-backed device keys, store-signed clients and native push (APNs / FCM carrying the same sealed payloads). Same protocol, same allowlist.

Test strategy#

  • Vectors: the Noise handshake and transport are checked against the published Noise_IK_25519_ChaChaPoly_SHA256 and psk2 test vectors and cross-checked against an independent implementation in CI.
  • Local end-to-end (test/remote-*.test.ts, temp NID_HOME): mock-driver daemon + Node relay + headless device, all in one process group and torn down by the existing process supervisor. Covers pairing (QR and short code, wrong code, expired code, attempt limits, local confirmation), roles and the allowlist (every method in every tier, parameter filtering), event replay and resync after reconnect, terminal viewing and control, multiple devices and multiple daemons, revoke while connected, daemon and device key rotation including the offline-device grace path.
  • Adversarial relay: a test relay that tampers, replays, reorders, drops, duplicates, truncates and oversizes frames, swaps keys during pairing, lowers the version in open, spoofs device ids in open, floods unauthenticated opens, injects frames into other channels, starves credits, lies in push_result and hibernates mid-backpressure; every case must end in a closed channel, a rejected pairing or an ignored hint, never in a decrypted or executed frame, a lost legitimate channel slot or a deleted push subscription.
  • Allowlist and policy: every method in every tier for every role; parameter filtering; remember refused; the configuration-hash refusal after an agent edits nid.yaml; the two-step nonce (missing, expired, reused); record redaction of agentDef.env; scope enforcement on reads, streams, push and mutations; revoke while connected; --compromised rotation forcing re-pair.
  • Fake push service: a local HTTP server that accepts Web Push POSTs, checks the VAPID headers and the aes128gcm framing, decrypts with the test device's keys and asserts the payload, padding and topic; also returns 410 to test subscription cleanup.
  • Browser tests (Playwright, optional like the existing dashboard tests): the console against the Node relay, service worker asset verification (good release, unsigned release, hash mismatch, release missing from a fake transparency log), push subscription through a fake push manager.
  • check-public keeps hosted and business code out of this repository; the relay core's Node adapter lives under a name the check permits.

Round A demonstration#

The exit criterion is demonstrated by packages/nid/test/browser/remote-pair.smoke.ts (pnpm --filter nid test:browser): headless Chromium with a Pixel 7 phone profile opens the pairing URL the daemon produced for the development console (packages/nid/src/devconsole/), the page strips the fragment, generates a non-extractable WebCrypto X25519 key and pairs through the Node relay; the machine side confirms through cloud.pair.confirm (the RPC nid cloud pair uses; the run injects a fake user verifier, so no operating-system prompt appears) with the fingerprint the phone shows; the phone then approves a mock agent's request, and the test checks the daemon resolved it and attributed it to the device. The daemon is a real daemon process on a temporary NID_HOME. Screenshots of one run: confirm on the machine, paired, approval waiting, approved.

The automated run uses loopback. NID_DEMO_LAN=1 serves the relay and the console on the machine's LAN address instead. A device only talks to a wss:// relay, or a plaintext ws:// one on loopback (independent review A-M1: routing tokens must not cross a network in cleartext), so a plaintext LAN relay needs an explicit development opt-in on both ends: cloud.insecureDeviceRelay: true on the daemon and --insecure-lan-relay on the development console. Use it only on a network you trust; the better setup for a real phone is a TLS-terminating reverse proxy in front of the relay (appendix C) and a wss:// device relay. With a real phone on the same network and the plaintext opt-in:

NID_HOME=$(mktemp -d) nid daemon start
NID_RELAY_SECRET=<16+ characters> nid relay-serve --experimental --host 0.0.0.0 --port 8787
node --import tsx packages/nid/src/devconsole/serve.ts --host <lan-ip> --port 8080 --insecure-lan-relay
# config.yaml of that NID_HOME:  cloud: { deviceRelay: ws://<lan-ip>:8787, insecureDeviceRelay: true }
NID_RELAY_SECRET=<same> nid cloud login --experimental --relay ws://127.0.0.1:8787   # 9.1: the relay secret is the connector token
nid cloud pair --experimental --role operate --insecure-console --console http://<lan-ip>:8080

Scan the QR with the phone, compare the fingerprint the phone shows with the one in the prompt, pass the Windows Hello / Touch ID / polkit prompt, then approve a request from the phone. That run is also the one manual check of a real user-verification prompt; the automated suites never trigger one.

cloud.deviceRelay (added in round A) is the relay address put in pairing URLs when devices reach the relay differently from the daemon (here: the daemon over loopback, the phone over the LAN). The device pins the daemon key from the QR whatever relay carries the frames, but routing tokens travel to that address, so it must be wss:// or loopback unless cloud.insecureDeviceRelay is set.

11. Remote control with your nid.dev account (/rc)#

Human decision, 2026-10-15. The default way to reach a daemon from another device is the one people know from Claude Code's remote control: run /rc in the terminal app (or nid rc), sign in to nid.dev, and every device signed in to the same nid.dev account can watch and steer, with no QR code, fingerprint comparison or prompt on the machine per device. Per-device pairing (section 3) stays as an opt-in strict mode (remote.strictPairing: true) for teams and cautious users. This section is normative for round R (section 10) and overrides sections 2.3, 2.4, 3 and 8.4 where they disagree, in account mode only.

11.1 What changes in the trust model#

In strict mode the daemon trusts a device because a person at the machine compared fingerprints and passed the operating-system prompt. In account mode the daemon trusts a device because the nid.dev accounts service vouches that the device key belongs to the account the daemon is linked to. Consequences, stated plainly in the docs and in nid rc's first-run text:

  • The nid.dev account is the lock. Whoever signs in to it (or steals a signed-in device) can do what an operate device can (section 2.4), which includes telling agents to run commands. Same trade as Claude Code's remote control.
  • The relay still cannot read or forge traffic. Frames stay end-to-end encrypted (Noise, section 4) between the device key and the daemon key; a relay operator with only relay access sees what section 8.1 says and nothing more.
  • The accounts service could add a device. Because it vouches for device keys, a compromised accounts service (or its signing key) could enroll a device of its own. This is the new residual risk of account mode; strict mode removes it. The service also tells a device the daemon's key (appendix E.6), so it could equally point a device at a key of its own; consoles pin daemon keys on first use and warn when only the service reports a change.
  • What stays on the machine is unchanged (section 2.4: no remote pairing, key rotation, billing, secrets, init, shutdown).

11.2 Linking the machine (/rc, nid rc)#

  • nid rc / /rc: if the daemon is not linked, run the device-code login against nid.dev (open the browser, the person signs in with a passkey and approves the code shown in the terminal). Linking the daemon to an account, or changing which account it is linked to, needs the operating-system user verification once (section 2.4): without it a prompt-injected agent could link your machine to an attacker's account. After that, adding phones and browsers needs nothing on the machine.
  • Sharing scope: /rc in the terminal app shares the current team (group); nid rc --all shares every group on the daemon; nid rc <group> shares one. nid rc off [<group>|--all] stops sharing (connector stays linked); nid rc --for 8h expires automatically; nid rc status lists what is shared, the account, and connected devices. Shares survive a daemon restart.
  • Role: account devices get operate by default; nid rc --view shares read-only; remote.accountMaxRole: view|operate caps it locally. admin is never granted by the account; it needs strict-mode pairing.
  • The terminal app shows a status-line badge (rc: 2 devices) while anything is shared.
  • nid rc inside an agent session is refused like nid cloud (best effort; the OS prompt at link is what holds).
  • Round R decision: nid rc is the common path and needs no --experimental; nid cloud login|pair|… stays behind --experimental for strict mode and self-hosted relays in secret mode. Defaults are https://accounts.nid.dev and wss://relay.nid.dev, overridable with cloud.accounts / NID_ACCOUNTS_URL and cloud.relay / NID_RELAY_URL (or --accounts/--relay).

11.3 Devices on the account#

  • A browser or phone signs in at app.nid.dev with a passkey (magic link only as account recovery). On first sign-in it generates its device key (section 2.2, non-extractable WebCrypto where available) and registers the public key with the accounts service.
  • The accounts service keeps, per account, a device list: device id, public key, label, created, revoked flag, and a monotonically increasing version. It signs the list with the service's Ed25519 account-device signing key. The daemon pins that signing key's public half at link time (compiled in for nid.dev; cloud.accountKeys for a self-hosted service, never fetched from the server it verifies). The relay delivers the newest signed list to the daemon on connect and on every change; the daemon refuses an older version than one it has seen (rollback) and a list for another account id.
  • A device whose key is in the current list, not revoked, may complete the Noise IK handshake (section 4) for a shared group; others are refused exactly as unknown devices are today.
  • New-device notice: the first connection of a device the daemon has not seen raises the existing unknown-device machinery: a desktop notification, a board note, a status-line badge and an announcement to every connected device, each with "revoke" (nid rc devices rm <id> locally, or remove it on nid.dev). Revoking on nid.dev publishes a new list; revoking locally blocks that key on this daemon whatever the list says.
  • Mixed mode: strict-paired devices (section 3) keep working in account mode; with remote.strictPairing: true the daemon ignores the account device list entirely.

11.4 The console behind the login#

The hosted console at app.nid.dev is the local dashboard UI over the remote transport adapter (section 5.1), behind the nid.dev login: after sign-in it lists the account's linked machines and their shared groups, connects to one through the relay and shows the usual groups, sessions, approvals and questions. It is installable (PWA). Push notifications (section 6) for approvals and questions are part of round R. Section 7's console trust model applies unchanged; account mode adds no new console powers.

11.5 Interfaces between this repository and the hosted service#

Defined in appendix E (written in round R before the hosted side is built): the accounts HTTP API used by nid rc (device-code login, link, unlink), the signed device-list format and its verification, how the relay delivers lists to the daemon, the console's sign-in and device-registration calls, and a development implementation of all of it in the Node relay's dev-accounts mode so the whole flow runs locally in tests with no network.

Appendix A: crypto options considered#

Noise IK/IKpsk2 over noble (chosen)HPKE (RFC 9180, hpke-js)libsodium (crypto_kx + secretstream)
ConstructionStandard handshake pattern with formal analysis and test vectors; mutual auth, forward secrecy, identity hiding, PSK bindingStandard one-way sealed messaging; auth and PSK modes existMature primitives; secretstream gives ordered AEAD with rekey; kx is an unauthenticated DH
Fit for a two-way sessionDirect: Split() yields both directionsNeeds a composition of two one-way contexts or an exporter-derived session; no standard way to do thatNeeds an authenticated handshake designed around kx (no standard pattern)
Pairing with a one-time secretpsk2, built inmode_auth_psk, built in, but one-wayHand-built
Runs in browser, Node, BunYes, ~30 KB, audited pure JS, no load step; the handshake DH is async so a non-extractable WebCrypto X25519 key can hold the device static key (2.2)Yes, via WebCrypto with noble fallback for X25519WASM, async init (awkward in a service worker), ~200 KB+, needs wasm-unsafe-eval in the CSP
Audit statusnoble: independent audits 2021–2024; the Noise state machine is ours (small, vector-tested)Library well maintained, RFC test vectors; CVE-2025-64767 (nonce reuse under concurrent seal, fixed) is a reminder that nonce handling is where channel bugs live, which Noise's counter discipline makes mechanicalLibrary audited; the composition would be ours
VerdictBest fit; the only custom code is the pattern state machine, which is what Noise was designed to make small and checkableRight tool for sealed one-shot messages (we use the same idea in Web Push), wrong shape for a long-lived channelStrong primitives, worse ergonomics in browsers, and still a custom handshake

Rejected outright: TLS client certificates through the relay (the relay would terminate TLS, so not end-to-end); JWE/JWS with RSA (no forward secrecy, large, slow on phones); Signal's Double Ratchet (asynchronous messaging ratchet; our sessions are online and short-lived, so per-handshake keys already give the forward secrecy we want with far less state).

Appendix B: wire formats#

Relay envelope (binary WebSocket message):

Byte(s)Field
0type: 0x01 open, 0x02 close, 0x03 data, 0x04 flow, 0x05 push, 0x06 push_result, 0x07 ping, 0x08 pong, 0x09 status, 0x0a relay_ctl (appendix C)
1–4channel id (big-endian uint32; 0 for socket-level messages)
5…body: data = sealed Noise message; open = {deviceId, daemonId, ver, mode} JSON (ver is the protocol version the prologue uses, mode is pair or session; both are re-checked inside the handshake, section 4.2); close = {reason}; flow = credits (uint16); push = {endpoint, ttl, urgency, topic} JSON + 0x00 + ciphertext; status = {daemon:"online"|"offline", since}

Plaintext frame (inside the sealed body):

Byte(s)Field
0kind: 0x01 req, 0x02 res, 0x03 sub, 0x04 ev, 0x05 end, 0x06 cancel, 0x07 ctl, 0x08 pushsub
1flags: bit 0 MORE (fragment continues), bit 1 REKEY, bit 2 BINARY payload
2–3payload length (uint16)
4…payload: JSON unless BINARY; a BINARY terminal ev is {id, event:"data"} JSON, 0x00, raw bytes

Pairing URL fields, handshake payloads and the cloud.* RPC parameters are JSON as shown in sections 3 and 4; they are versioned by v/ver and new fields are additive.

Details fixed during the round A implementation (packages/nid/src/remote/); each closes a point the sections above left open, and the reason is given:

  • Key confirmation in both modes. The device's first transport message after Split() is always an empty sealed message (16 bytes on the wire, counter 0). In pair mode it is the "message 3" of section 4.2 rule 2; in session mode it is the "first authenticated transport message" of rule 3. The daemon treats a channel as unauthenticated (it counts against the 8 unauthenticated slots) until it has decrypted it, and a first transport message that is not empty closes the channel with crypto. Reason: one rule for both modes, and the daemon never acts on a replayed message 1.
  • Prologue version. The prologue's first field is nid-remote/<ver> with the ver from open, so version 1 is the literal nid-remote/1 of section 4.2.
  • Text encodings. Ids, fingerprints and short codes use lowercase Crockford base32 (0-9a-z without i l o u; the section 2.1 example k7qm-3x9a-pz2h-4v8r is in this alphabet, RFC 4648 base32 has no 8/9); input is accepted in any case with dashes or spaces and with i/l read as 1 and o as 0. Keys and secrets in the pairing URL are base64url without padding. Ids are 128 random bits (26 characters).
  • Short-code psk. Noise needs a 32-byte psk; a short code's psk is SHA-256("nid-remote/1 short-code" || 0x00 || normalised code). The code's 80 bits are still what bounds an offline search (3.2); the hash only sizes and domain-separates it.
  • Rekey schedule. Before sealing or opening the transport message whose counter n is a non-zero multiple of 2^16, that direction calls Rekey(); n is not reset (Noise section 11.3). The REKEY flag is set on exactly those frames.
  • Padding. A frame is padded with zeros to the next multiple of 256 bytes, capped at 65,519 bytes (the sealed-body limit minus the tag); non-zero padding, unknown flags or kinds and a length beyond the body close the channel with protocol.
  • Fragments are consecutive. A sender emits all fragments of one message back to back; a frame of another kind (or BINARY setting) while a MORE chain is open closes the channel with protocol.
  • Relay control envelope. Type 0x0a relay_ctl carries socket-level JSON between a daemon and its relay on channel 0 (routing tokens and revocation for self-hosted relays, section 9.1); appendix C defines its operations. Reason: 9.1 needs a daemon-to-relay request path and none of the existing types fit.

Appendix C: self-hosted relay HTTP and WebSocket contract#

Added in Round A to make the self-hosted wire contract concrete. The routing core, Node and Bun adapters are public; the hosted accounts service, metering and Durable Objects adapter are implemented separately. This appendix does not change the Noise protocol or the daemon's authority over paired devices.

Run nid relay-serve --experimental --port 8787: without --secret (or NID_RELAY_SECRET) it generates a 256-bit secret and prints it once for nid cloud login. A secret you supply must look random: at least 32 characters, 12 distinct, about 128 bits by character classes and no repeated words; weaker ones are refused (prefer NID_RELAY_SECRET to --secret to keep it out of the process list). After ten failed WebSocket authentications from one address within a minute, further failures from that address get HTTP 429 for five minutes; valid credentials always pass, because behind the reverse proxy every client shares the proxy's address and a scanner must not lock out every daemon and device (the ≥128-bit secrets make guessing moot). The default bind is 127.0.0.1; --host chooses another bind address. Put a TLS-terminating reverse proxy in front of a non-loopback deployment. Clients require wss: except on loopback and validate certificates normally. The Node connector helper supports HTTP/HTTPS CONNECT through HTTPS_PROXY or https_proxy (and honours NO_PROXY/no_proxy); proxy credentials are sent only on CONNECT, independently of the relay bearer token. A proxy is used only for wss: relays: a plaintext ws: relay would hand the bearer token to the proxy in clear, so that combination is refused.

Self-host deployment note (review R31-3): the unauthenticated per-address cap uses the socket peer address. Behind a reverse proxy, clients sharing its address therefore share the eight-socket cap. Forwarded-address headers are not trusted. Enforce per-client connection limits at the proxy and account for this shared backend cap; there is no trusted-proxy address configuration in Round A.

The supplied self-hosted store keeps routing metadata and token hashes in memory. Restart clears device routing tokens and development account sessions; repeat local login/pairing to obtain routing credentials. End-to-end keys are unaffected. A durable host supplies the same RoutingStore interface; no sealed frame belongs in that store. authorizeOpen only checks an open and must consume nothing; the core calls commitOpen once the open has passed every limit and the daemon is online, and only then is a single-use pairing token spent (an open refused because the daemon was offline leaves the code usable). In secret mode every party belongs to one "self-host" account, so the per-account budgets of section 8.2 (sockets, opens, failed handshakes) are charged per device token, and per daemon for its pairing tokens, instead of relay-wide; the 20-device limit counts per daemon. At most ten self-hosted daemon ids are registered at a time; an id that is offline and holds no tokens is released to make room.

EndpointContract
WebSocket /v1/daemon?daemonId=<id>Authorization: Bearer <connector token>. In secret mode this is the relay secret. A token is bound to its daemon; a second connection replaces the old socket and ends all its channels.
WebSocket /v1/deviceAuthorization: Bearer <account-session or device-token> where the client can set headers. Browsers instead send a routing token in their first binary open (below); tokens never go in query strings. An unauthenticated socket cannot inspect routing metadata and is closed after five seconds without an authenticated open.
POST /v1/login/startDevelopment accounts only. JSON {kind:"daemon"|"device"} returns {deviceCode,userCode,verificationUri,expiresIn:300,interval:2}. At most ten starts per minute (429 otherwise, and nothing is printed for a refused start); at most 64 pending logins, the oldest dropped to make room.
GET /v1/login/verify?code=<userCode>Local development approval page; GET never approves.
POST /v1/login/approveSame-origin browser form, fields code and approval. approval is a code the relay prints only to its own terminal ([relay] daemon login ABCD2345 waiting: approval code …) when the login starts; the page never shows it. Cross-origin/missing-Origin requests and wrong approval codes are refused; five wrong codes discard the login.
POST /v1/login/pollJSON {deviceCode} returns {pending:true} until approval, then a single-use result {token,daemonId} for a daemon or {token} for an account device session. Expired/consumed codes fail.

--dev-accounts explicitly enables the single development account and requires a loopback bind. It is a local integration harness, not a hosted authentication service. The server returns no CORS permission and checks Host and Origin on login routes. Those browser checks do not stop a local program, which can send any Origin; the approval code from the relay's terminal is what proves a person at the relay approved (the page also says whether the request links a daemon or signs in a device).

A device sends open on its chosen nonzero channel ID, with {daemonId,deviceId,ver,mode,token?}. The optional token is a single-use pairing token (mode:"pair", expires within 120 seconds) or a device token bound to both daemon and device (mode:"session"). A header-authenticated account session may route only to its account's daemons. The relay strips token, allocates a new ID on the daemon socket and sends open there before any data. Each device has at most one channel per daemon. Device-supplied IDs are routing hints, never authentication or abuse keys. An offline daemon gets no queued commands; the device receives status {daemon:"offline",since:<epoch-ms>}. Disconnect sends status and closes the old channels; reconnect requires new opens and handshakes.

Only an authenticated daemon may send relay_ctl on channel zero:

Request bodySuccessful reply body
{id,op:"pair_token",expiresInMs?:120000}{id,ok:true,token,expiresAt:<epoch-ms>}; lifetime must be positive and at most 120000 ms, token consumed by the one open that is actually routed to the daemon.
{id,op:"device_token",deviceId}{id,ok:true,token}; replaces that device's routing token. The daemon delivers it inside sealed handshake payload2.
{id,op:"revoke",deviceId}{id,ok:true}; drops the token and immediately closes that device's live channels.

Failures reply {id,ok:false,error}. IDs are strings or numbers, at most 64 characters when rendered. deviceId and daemonId use 1–64 ASCII letters, digits, _ or -. Control calls are limited to 120 per daemon per minute. Frames, channel opens and handshake failures have the limits in section 8.2; failed-handshake accounting is triggered only by a daemon's close with crypto or unknown_device and is charged to the opening socket/session/account, never the device ID it claimed.

The socket adapter implements send(bytes) -> accepted, onMessage, onClose and close(code). False means no bytes were accepted, so retry is safe; Bun's -1 return means accepted/backpressured and must not be retried. The adapter calls core.drain(socket) only after accepted writes drain. Credits start at 64 and the core returns them to the sender in batches of 16; peers cannot manufacture credits by sending flow. Queues preserve order, cap at 64 envelopes per channel (about 4 MiB) and 32 MiB per daemon, and close newest channels first on aggregate overflow. Control envelopes (open, close, status, flow, relay_ctl, push_result) to a socket that is momentarily backed up wait in a per-socket queue that goes ahead of its data (an open always precedes the channel's first data); only overflowing that queue (256 envelopes or 512 KiB) disconnects the socket, never a single refused write. Data is also budgeted per minute: 16 MiB of device-to-daemon traffic per channel, and 64 MiB per daemon socket across both directions, above which the heaviest channel of that daemon is closed rather than whichever sends next. The 200 frames/second per-channel abuse limit also applies only device-to-daemon. Daemon-to-device bursts use credits, queue bounds and the aggregate budget instead: applying ingress caps to responses would reject legitimate 1000-event replays and maximum-size fragmented RPC results (review R31-1/R31-2). Response bytes do not consume the device's ingress allowance. tick() is called by the host's scheduler/alarm for the 60-second stall deadline; the core itself owns no timer. Before hibernating with pending writes, call resetBuffered() to close those channels with relay_reset; ciphertext is never persisted or resumed. A host that hibernates keeps its routes with core.snapshot() and RelayCore.restore(store, now, snapshot, socketOf, push) (round R, hosted review F3/F4): attach returns a peer id the host stores with each socket (for example in a WebSocket attachment); the snapshot is JSON with the peers (principal, daemon id, routing hint), channel-id pairs, credits, per-channel budgets, the core's open/failure budgets and suspensions, and recently ended channel ids. It never holds a frame: snapshot() first ends channels that hold buffered data with relay_reset and disconnects sockets with queued control envelopes. On wake the host passes socketOf(id) for each socket that survived; a missing one is a disconnect (its channels end with relay_reset, a daemon goes offline). Hooks do not fire again; the host sets its account hooks on the restored core. A snapshot is not a credential: restore authorizes every restored channel again (by default store.authorizeOpen on the open with the channel's principal; a host may pass its own reauthorize), so a token, session or device revoked while the host slept ends its channel with revoked; a pairing channel is exempt, its single-use token being already spent (review A6). Tested with the Node adapter mid-session (remote-relay-snapshot.test.ts). The core's budgets are per core; a host that runs one core per daemon (one object per hinted daemon id, E.5) must key the 8.2 limits that span daemons (sockets, opens and failed handshakes per account, per session and per address) in shared storage, never per object, or a device could spread its budget over the account's daemons. A PushPoster host interface is reserved for Round B; no default network push sender is installed. Any future sender must supply endpoint validation and an SSRF policy before making outbound requests.

Both adapters disable compression and set a 1 MiB platform payload cap, while the core enforces the smaller 65,560-byte envelope and 65,535-byte sealed-body limits. Only literal text ping and pong are accepted outside binary envelopes; at most four pings per ten seconds are answered (more closes the socket), and no pong is queued behind a backed-up socket. Unknown types/channels, invalid bodies, oversized envelopes and close reasons outside the set of section 4 (unknown_device, crypto, stalled, relay_reset, timeout, overflow, revoked, goodbye, evicted, refused, version, rehandshake, protocol, repair, offline; an unknown reason ends the channel as protocol) are dropped and counted; ten malformed messages within a minute close the source socket. Frames and closes for a channel that ended in the last minute (the ordinary close/overflow race) are dropped silently and not counted. The core caps authenticated sockets at 256 and, separately, sockets still waiting for their first authenticated open at 64 in total and 8 per remote address, evicting the oldest waiting socket, so unauthenticated sockets can never take a daemon's or device's slot; the 32 device sockets per account (or per budget key, above) apply as well.

Adversarial review#

Reviewed 2026-10-12 by a second session that did not write the spec, from two positions: R = an attacker who fully controls the relay (Cloudflare account, Workers, Durable Objects, D1, Pages, the push sender: can serve any console JS and tamper with every frame, pairing exchange, timestamp and push), and T = a thief who has the account password and can pass account login, but has no paired device. Each finding names the position(s), a severity, the scenario and a proposed resolution. Status is tracked per finding; the spec body was amended by the resolution pass on the same day, and each status names the sections that changed.

Severity scale: HIGH = breaks a promise this document makes; MEDIUM = weakens a promise or gives an attacker a lever the document does not mention; LOW = bounded nuisance or an inconsistency with a safe fix; INFO = wording or classification.

Findings#

  1. R1 (R) HIGH: a service worker cannot pin itself against its own origin. Section 7 makes the installed service worker "the pinning agent" that refuses an unsigned release. Browsers do not work that way: the browser refetches the service worker script on navigation and at least every 24 hours, and installs any byte-different script the origin serves; the running worker has no veto and cannot observe its own replacement. A malicious origin therefore ships a new worker that skips every check, waits for the next launch, and runs with the device's (non-extractable but usable) key. Resolution: rewrite item 3 of section 7 so it promises only what holds: the worker verifies assets against the signed manifest (defeats CDN or storage substitution and unsigned builds), the console shows the running release hash, and console-check from a trusted machine is the detection control; the origin itself stays trusted on every launch. Add the honest sentence to "what we do not promise". Note Chrome's Isolated Web Apps (signed web bundles, no origin serving) as the only web mechanism that gives real pinning, and keep native apps as the answer. Status: resolved (section 7, items 3–4 and "what we do not promise"): the worker verifies assets, not itself; the origin is trusted on every launch; detection is console-check (automatic before pairing and daily) plus the release hash shown on the phone and recorded by the daemon; Isolated Web Apps and native apps named as the real pinning.

  2. R2 (R, T) MEDIUM: land + team.start {group, goal} runs executable configuration an agent wrote. Principle 3 says setup and custom agent commands can be used remotely but not changed remotely. Chain: an agent edits nid.yaml in its worktree (setup: curl … | sh, or an agents: entry with any command), the task is integrated, an operate device calls land (merges the integration branch into the checkout), then team.start {group, goal} or resume of a custom-agent session re-reads the checkout's nid.yaml and runs it. No path crossed the channel, yet a remote caller chose what runs. Locally the same chain needs a human to run nid up after seeing the diff. Resolution: the daemon records the hash of the executable parts of the config (setup, agents, copy, link, mcp) each time a local command starts the group; remote team.start/resume refuse with "nid.yaml changed since the last local start" when the hash differs. Also show changes to nid.yaml and ~/.nid/config.yaml prominently in diff/group.summary. Status: resolved (section 5.2 principle 3, 5.3, 2.1): the daemon hashes the executable configuration at every local start; remote team.start/resume refuse when it changed; diff/group.summary flag nid.yaml changes.

  3. R3 (T, lost phone) MEDIUM: "nothing that executes a command the remote caller chose" overstates what the allowlist buys. An operate device can send "run curl … | sh" to an agent and then approve it: steering agents is remote code execution within the agent's permission profile. The allowlist blocks direct access to secrets, billing, paths and configuration; it does not and cannot make a stolen operate phone harmless. Resolution: say so in sections 2.4, 5.2 and 8.4 ("an operate device is the human at the dashboard, as far as the agents are concerned"), make view the default pairing role (operate is one --role away), tag every remote mutation in the event log and board with the device label (approved from phone-7), and offer per-device scope (5.4) in round A rather than later. Consider a daemon-side rule that remote approvals cannot grant "broad permission" requests (the ones remember already refuses). Status: resolved (sections 2.4, 5.2 principle 6, 5.4): view is the default role; the operate-is-the-human statement is in 2.4; remote mutations are attributed by device label; remember is refused remotely; per-device group scope ships in round A.

  4. R4 (T, lost phone) MEDIUM: session records leak custom-agent env values to any paired device. sessions.list/session.get return options.agentDef, which is the full custom agent definition including its env map; users put provider keys there (env: {MYAGENT_API_KEY: …}). The local dashboard shows this too, but principle 2 says nothing that reaches secrets crosses the channel, and a view device would see it. Resolution: the remote principal strips options.agentDef.env, options.agentDef.args that look like secrets, and options.extraArgs from session records (keep command and mode for admin), and the local dashboard should get the same redaction. Status: resolved (section 5.2 principle 7, 5.3 READ row): agentDef.env, agentDef.args, extraArgs and options.env are stripped from session records for every role; local-dashboard redaction noted as a follow-up.

  5. R5 (R, T) MEDIUM: unauthenticated channel attempts can lock legitimate devices out. Three levers: (a) 8.3 says that after a per-daemon threshold the daemon "stops accepting new channels for 60 seconds"; a thief, who is routed to the daemon, repeats unknown-key handshakes and keeps the owner out indefinitely. (b) 4.5 caps channels at 16 per daemon with no handshake timeout, so 16 opens with no message 1 (or 16 slow handshakes) fill the table. (c) 8.2 counts failed handshakes "per device" using the device id the opener claims in the plaintext open; spoofing the owner's device id burns its budget and gets the real device's routing suspended. Resolution: never stop accepting; make the unauthenticated path cheap (reject opens whose deviceId is not in the registry before any DH), give handshakes a 5-second timeout, count unauthenticated channels separately with a small cap and newest-evicts-oldest, and have the relay count failures per account session/socket, not per claimed device id. Status: resolved (sections 4.2 rules 1 and 6, 8.2, 8.3): the daemon never stops accepting; unknown ids are refused before DH; 5-second handshake timeout; unauthenticated channels capped separately (8, newest evicts oldest); the relay counts failures per account session and socket, never per claimed device id.

  6. R6 (R) MEDIUM: the key-rotation grace period is exactly what a leaked old key exploits. 3.5 lets the daemon answer handshakes with the old key for 30 days to deliver key_rotate. If the key was rotated because it leaked, whoever holds the old key and can reach the device (the relay, or anyone the relay routes) completes a handshake as the daemon and announces their key; the device replaces its pin. The only sign is a fingerprint the user is not asked to check. Resolution: split the command: rotate-key (routine; grace allowed, announcement only to devices that were online and authenticated during the rotation window) and rotate-key --compromised (no grace, old key destroyed at once, every device must re-pair, devices see "daemon key changed, re-pair"). Document that routine rotation does not protect against a key already compromised. Status: resolved (section 3.5): routine rotate-key keeps the grace path and says it does not help against a leaked key; rotate-key --compromised destroys the old key at once and forces re-pairing; rotation is local only.

  7. R7 (self-host) MEDIUM: the Node relay has no stated authentication. The hosted relay gates routing by account; the self-hosted exit route (nid cloud relay <wss-url>) says nothing about who may connect as a daemon or a device. Without it, anyone who finds the URL can register daemon ids, open channels against your daemon (R5 levers) and read all metadata in 8.1. End-to-end content stays safe; availability and metadata do not. Resolution: specify the Node relay's minimal auth (a relay secret in its config; daemons present it at cloud login against that relay, devices receive a per-device relay token at pairing inside the sealed payload2), require wss:// except for loopback, and state that the daemon verifies the relay's TLS certificate normally (a MITM on that hop learns only the connector token, which grants routing, not reading). Status: resolved (section 9.1): the Node relay gates routing with a relay secret (daemon) and per-device relay tokens delivered in the sealed payload2; wss:// required except loopback; TLS verified normally.

  8. R8 (R) MEDIUM: pairing is the moment a malicious console wins, and the spec checks the console at other times. Section 7 accepts that a first visit trusts the origin; 3.1 has the console read the QR, so a malicious console at pairing time learns s, generates its own extractable key and is paired as "the phone" with the role the human grants; nothing later detects it (the fingerprint shown is simply the attacker's key). Resolution: nid cloud pair runs the equivalent of console-check against the console origin the QR points to before printing the code, refuses on an unsigned or unknown release (--insecure-console to override), and prints the release hash next to the QR so the phone's console can show the same hash for the human to compare. Status: resolved (sections 3.1, 3.2, 7 item 4, 8.5): nid cloud pair runs console-check against the console origin before printing a code, refuses unsigned or unknown releases unless --insecure-console, and prints the release hash next to the QR; the phone shows the same hash; the daemon records each device's reported release.

  9. R9 (R) MEDIUM: protocol version can be downgraded through the plaintext open. The prologue carries the major version, but nothing says how the two ends agree on it before the handshake; the natural place is the relay-visible open, which the relay can rewrite to the lowest version both still accept. payload1.ver is encrypted and bound to the transcript, which is enough to detect this if the rule is stated. Resolution: the daemon aborts when the prologue version is lower than the highest version in payload1.ver that it supports; the device aborts when payload2.ver is lower than that same maximum. Version 1 ships with this rule so it is not retrofitted. Status: resolved (section 4.2 rule 4): both ends abort when the prologue or payload2 version is lower than the highest version in payload1.ver the daemon supports; part of version 1.

  10. R10 (R) MEDIUM: push_result is relay-controlled and drives subscription deletion. 6.1 step 4 drops a subscription on 404/410 reported by the relay. A relay reporting 410 for every push silently turns a device's notifications off; the user sees nothing until they miss an approval. Resolution: treat push_result as a hint only: mark the subscription suspect, ask the device to re-send its subscription on the next channel open (devices re-send it on every connect anyway, which makes the fix cheap), and tell the device "push delivery failing" through the channel. Status: resolved (section 6.1 step 4, 8.5): push_result is a hint; a reported 404/410 marks the subscription suspect and tells the device; devices re-send their subscription on every connect; nothing is deleted on the relay's word.

  11. R11 (R) LOW: the REKEY flag is inside the ciphertext. 4.3 says the receiver rekeys "on seeing the flag", but the flag can only be read after a successful decrypt with the key the flag announces. Rekey is already deterministic (after 2^16 messages per direction). Resolution: both sides rekey at the fixed counter; the flag becomes a consistency check (mismatch = close(reason=crypto)), or is dropped. Status: resolved (section 4.3): rekey happens at fixed counter multiples on both sides; the REKEY flag is a consistency check whose mismatch closes the channel.

  12. R12 (R, T) LOW: pairing codes can be burned by anyone the relay routes. Message 1 of IK can be produced by anyone who has the daemon's public key (it is es/ss with the attacker's own static), so a thief sends five garbage pairings and the code is discarded (3.1) while the human is holding the phone up. Pure nuisance, but repeatable. Resolution: the attempt limit exists to stop online guessing of a 60-bit code, which five vs. a thousand attempts does not change; raise it (1,000 per code), count failed attempts per source socket, and keep the 120-second life as the real bound. Status: resolved (section 3.1): failures counted per source channel, 1,000 per code in total; the 120-second life is the bound.

  13. R13 (R) LOW: pairing message 1 is replayable, so nothing may depend on it alone. Noise IK message 1 authenticates the initiator but is replayable (payload property "authentication 1"); the spec already defers the registry write to message 3, which is correct. Make it explicit that label, requestedRole, resumeHint and "last seen" are acted on only after message 3 (pair) or the first transport message (session), and that the daemon never shows the Pair "<label>"? prompt before message 3. Status: resolved (section 4.2 rule 3): nothing in payload1 is acted on before message 3 (pair) or the first authenticated transport message (session); the pairing prompt appears only after message 3.

  14. R14 (T) LOW: an attacker who changes the account email first blocks the recovery path in 8.4. "Change credentials and sign out all sessions" assumes the owner still owns the account. Resolution: email changes notify the old address with a 7-day undo link and require a fresh second factor; and state that the machine can always re-link to a new account with cloud logout + cloud login without re-pairing devices, because keys and routing are independent. Status: resolved (section 8.4): email changes notify the old address with a 7-day undo and need a fresh second factor; the machine can re-link to a new account with cloud logout + cloud login without re-pairing.

  15. R15 (R) LOW: the relay cannot send an "unencrypted daemon offline notification". Web Push payloads must be encrypted to the subscription keys the relay does not have; a sender without them can only send an empty push. Resolution: the relay sends an empty push; the service worker then asks the relay (account-authenticated fetch) for the daemon status and shows "relay says: daemon offline since …" labelled as relay-origin. Note that this also lets the relay wake the service worker at will (metadata and battery, not content). Status: resolved (section 6.3): the relay sends an empty push; the service worker fetches the status with its account session and labels the alert as relay-origin; the wake-at-will lever is stated and rate-limited.

  16. R16 (T, lost phone) LOW: destructive methods rely on a confirmation the daemon cannot see. Principle 4 and the ADMIN row cite "typed confirmation in the console" for group.remove, memory.clear and yolo. A console the attacker controls, or any client bug, skips it; it is a fat-finger guard, not a security control. Resolution: say so, and for group.remove and session.permissions {yolo} make the daemon issue a nonce in a first call that must be echoed within 60 seconds in the second (two sealed round trips the UI performs behind its dialog). Status: resolved (section 5.2 principle 4, 5.3): group.remove, session.permissions {yolo} and memory.clear are two-step with a daemon-issued nonce valid 60 seconds; client dialogs are a fat-finger guard only.

  17. R17 (admin phone) LOW: cloud.rotate.announce and device-management from a stolen admin phone. A stolen admin device can trigger a daemon key rotation (forcing every offline device into R6's grace path), revoke every other device and change roles. It cannot pair. Resolution: make daemon key rotation LOCAL in round A; keep revoke, but notify every remaining device and the local dashboard, and make "revoke all others" a local command only. Status: resolved (sections 2.4, 3.4, 3.5, 5.3): daemon key rotation is local only (cloud.rotate.announce removed); an admin device revokes one device at a time, every remaining device and the local dashboard are told; devices rm --all-others is local only.

  18. R18 (R, T) LOW: reassembly buffers are an authenticated memory DoS. 16 channels × 4 outstanding requests × 16 MiB reassembly = 1 GiB of half-received requests a misbehaving device can park in the daemon. Resolution: cap in-flight reassembly to 32 MiB per channel and 128 MiB per daemon, close the channel on overflow, and give fragments a 10-second inter-fragment timeout. Status: resolved (section 4.3): 32 MiB in-flight reassembly per channel, 128 MiB per daemon, 10-second inter-fragment timeout, channel closed on overflow.

  19. R19 (R) LOW: the pairing URL scheme and where the secret lands. nid://pair?… needs a native handler; a phone camera opens an https link in the default browser, not the installed PWA, and if the console is at https://console/pair?s=… the secret is sent to the relay's static host and ends up in its request logs. Resolution: QR content is https://<console>/pair#v=1&… with every field in the fragment (never sent to the server); the native app registers the same URL. Status: resolved (section 3.1): the QR is https://<console>/pair#… with every field in the fragment; the native app registers the same URL.

  20. R20 (R) INFO: pr.checks is not read-only. The READ tier lists pr.checks, but the API says it fetches CI status with the machine's GitHub credentials and creates failure tasks. Move it to STEER next to pr.watch. Conversely learn is read-only per the API and could be READ. Status: resolved (section 5.3): pr.checks moved to STEER, learn moved to READ, with the reasons listed under the table.

  21. R21 (R) INFO: identity hiding is moot and should not be claimed. 4.1 credits IK with hiding the initiator from a passive relay, but the relay learns the device id from open and from account routing. Keep the property out of the "why" list or qualify it (it hides the key, not the device). Status: resolved (section 4.1): the identity-hiding claim is qualified (hides the key, not the device).

  22. R22 INFO: small wire and wording nits. topic must be at most 32 characters of base64url; an HMAC-SHA-256 is 43, so truncate to 32 (192 bits) and say so. Relay envelope type 0x05 push and plaintext kind 0x08 push share a name for different things (rename the inner one pushsub). Durable Object frame buffers (4.6) live in memory and are lost on hibernation; say that a hibernation during backpressure ends the channel (re-handshake) rather than reordering. The diagram's "local API :7420" named the dashboard port, not the bearer API; fixed inline to "loopback API". Status: resolved (sections 6.2, 4.3, 4.6, appendix B, diagram): topic truncated to 32 base64url characters; inner kind renamed pushsub; hibernation under backpressure ends the channel with relay_reset; diagram fix kept.

What held up#

The reviewer tried and could not find a path for either attacker to read a frame, forge a frame, complete a pairing, forge a rotation (outside R6), subscribe to push, or make the daemon accept a new key without a local action. Specifically checked: key substitution in QR and short-code pairing (the PSK blocks the relay even when it supplies the key, and the only leak is the device's public key and payload1 to a relay that lies about k); replay, reorder, truncation and cross-channel injection (per-direction keys, strict counters, prologue binding); reflection; relay-controlled flow, status and close (denial and delay only); account takeover (metadata and denial only); the parameter filter (no path or command parameter survives it); and the absence of store-and-forward.

Re-review#

Re-reviewed 2026-10-12 after the resolution pass, reading the sections each status names. R1, R3, R4, R6, R9, R10, R11, R13, R14, R15, R16, R17, R18, R19, R20 and R21 are closed as described: the text now promises only what the mechanisms deliver. Residuals, each with a proposed fix:

  • R2-b (MEDIUM): the configuration hash gates team.start and resume but not the other remote methods that restart a driver. A driver (re)start resolves the custom agent definition from the current catalog first (catalog.agents.get(session.agent) ?? session.options.agentDef), and session.permissions (restart to apply the profile), session.rotate in handoff mode (fresh vendor conversation) and session.model on agents that switch by restart all restart drivers. Fix: put the check in the daemon's driver start/restart path keyed on the principal, so any remote-triggered start refuses when the hash changed, instead of listing methods. Status: resolved (5.2 principle 3): the check lives in the driver start/restart path keyed on the principal; the restarting methods are named as examples, not as the list.
  • R2-c (MEDIUM): the hash covers too few keys. It lists setup, agents, copy, link, mcp, but an agent can also add or change entries under sessions: (a new session with yolo, permissions: yolo, billing: api, extraArgs, a prompt) and group settings (notify.webhooks = an exfiltration channel for agent text, permissions, fallback), all of which team.start {group, goal} applies. Fix: hash the whole group configuration minus goal and budgetUsd (and the whole agents:, mcp and notify blocks of ~/.nid/config.yaml), and say so in 5.2 principle 3. Status: resolved (5.2 principle 3): the hash covers the whole group configuration minus goal and budgetUsd plus the agents:, mcp: and notify: blocks of ~/.nid/config.yaml.
  • R5-b (LOW): the unknown-id precheck conflicts with pairing, and open lacks the fields the rules read. 4.2 rule 1 refuses an open whose deviceId is not in the registry before any DH, but a pairing device is unknown by definition. Fix: the precheck applies to mode=session only; in mode=pair unknown ids are accepted while a code is live (bounded by the 8 unauthenticated slots and the per-code limit). Appendix B must then define open as {deviceId, daemonId, ver, mode}, since rules 1 and 4 and the prologue read mode and ver from it. Status: resolved (4.2 rule 1, appendix B): the precheck applies to mode=session; pair mode accepts unknown ids while a code is live; open is {deviceId, daemonId, ver, mode}.
  • R5-c (LOW): newest-evicts-oldest is itself a small lockout lever. A thief who opens more than 8 channels per round trip keeps evicting a legitimate handshake before its message 2 arrives. The relay's per-account-session suspension (8.2) stops it only until the thief logs in again. Fix: count failed handshakes and channel opens per account per hour as well as per session, and have the daemon evict only unauthenticated channels older than one second (a legitimate handshake completes in one round trip). Status: resolved (4.2 rule 6, 8.2): eviction only of unauthenticated channels older than one second, otherwise the new open is refused; per-account hourly limits on opens and failed handshakes.
  • R7-b (MEDIUM): the self-hosted relay has no pairing bootstrap. 9.1 hands a device its relay token inside payload2, but the device must already be routed through the relay to send message 1, so the first connection has no token. The hosted relay uses the account session for that; the Node relay has nothing. Fix: when nid cloud pair prints a code, the daemon asks the relay (over its connector socket) for a one-time routing token bound to the pairing code's lifetime and puts it in the QR fragment (t=); the device uses it for the pairing connection only. Also state that revocation reaches the Node relay through the daemon's revoke message (3.4), not through token rotation ("a fresh one at every handshake" does not revoke anything by itself). Status: resolved (9.1, 3.1): one-time routing token t= in the QR fragment for self-hosted relays; long-lived device relay token in payload2; revocation via the daemon's revoke message; the token-rotation claim removed.
  • R8-b (MEDIUM): the release hash the phone shows and reports is self-reported. A malicious console displays the expected hash and sends it in payload1.console, so the "compare the two screens" step and the daemon's device list catch accidents, not attackers. console-check from the machine can also be defeated by an origin that serves a good build to the machine's address and a bad one to the phone. Fix: say both limits plainly in section 7 and 3.1 (keep the mechanisms; they still catch CDN and deployment mistakes), and add the one web-side control that is not self-reported: publish release hashes to an append-only transparency log that console-check and the console both consult, so a targeted build must also appear in the public log. Round A dev consoles are unsigned, so document --insecure-console as the expected round-A setting. Status: resolved (3.1, 7 item 4, section 10): the self-reported nature of the phone-side hash and the targeted-serving limit of console-check are stated; an append-only transparency log that both the console and console-check consult is added; --insecure-console documented as the round-A setting.
  • R12-b / R22-b (INFO): stale numbers and leftover wording. 8.2 still says "the daemon itself allows 5 per code" (now 1,000 per code, per source channel). Round B and the browser-test bullet in section 10 still say "service worker with release pinning" and "service worker pinning"; after R1 they should say asset verification. 4.2 rule 4's device-side check can only be payload2.ver == prologue version (the device does not know the daemon's supported set); the daemon-side check is the one that matters. Status: resolved (8.2, section 10, 4.2 rule 4): 1,000 per code; "asset verification" wording; the device-side check is payload2.ver == prologue version.

Everything else re-checked holds. pnpm check:public is green.

The residuals above were resolved by the lead on the same day; each status names the sections that changed.

Independent security review (third pass)#

Reviewed 2026-10-12 at commit 941b898 by an independent reviewer through the coordinator, after the two in-group passes above. Verdict on the channel: sound (IK and IKpsk2 over noble, action only after key confirmation, per-direction keys, strict counters, prologue binding, the downgrade rule, rekey; account takeover is metadata and denial of service only). The problems were in the trust boundaries. Each item below records the finding, the decision or resolution, and the sections that changed.

C1: the console origin is a trusted party (human decision)#

The earlier text claimed the operator "could ship you a bad client" while also claiming the operator "cannot read or steer", and called a targeted build a two-compromise event. Neither held: a targeted build needs deploy access, not the signing key, and a client with operate control can read and steer for the devices that run it.

Decision (human, final): the hosted console at app.nid.dev gets full operate/admin control; native apps come later. Users trust the operator the way they trust their AI vendor, and operational controls are part of what the paid tier sells. Resolved by rewriting the headline promise (introduction, vocabulary trust table, section 7, section 8.5): end-to-end encryption keeps content off our servers and makes a compromised relay, Cloudflare account, D1, logs or network unable to read or steer; the console origin is a trusted party; the signing, SRI, CSP, asset-verification and transparency-log measures are described as supply-chain hygiene and detection, not protection against the operator. Added the operator controls the design relies on (console deployed from infrastructure and credentials separate from the relay; deploys only from the public reproducible CI build with two-person approval; an audit log of every console deploy; no targeted-serving capability in the hosting setup; named as commitments that map to SOC 2 controls, not as certified facts). Added the stronger options: self-hosted console on any origin the user controls, the account setting hostedConsoleMaxRole: view enforced by the daemon, and native apps later. Round A accepts --insecure-console only for self-hosted or loopback consoles, behind --experimental (sections 3.1, 7, 10).

Findings#

  1. H1 (HIGH): LOCAL-tier bypass through agents. An operate device could send an agent "run nid cloud pair --role admin and post the URL" and approve it, or have it run cloud logout + cloud login into the attacker's account, nid auth, or read the 0600 secrets fallback; a yolo or prompt-injected agent needs no approval. Resolved (sections 2.4, 3.1, 3.3, 10), amended after the re-review below: pairing, login and re-link, relay change, rotation and role raising complete only after platform user verification (Windows Hello, macOS LocalAuthentication, Linux polkit, or a WebAuthn assertion with user verification bound to the fingerprint and role), with a defined fallback that caps devices at view; the daemon refuses remote approval of nid (cloud|auth) commands as a best-effort extra; every pairing, re-link, relay change or rotation raises a desktop notification, a board note and an announcement to all devices. The "no role can pair" and "remote-only pairing is impossible" claims are corrected to say "through the channel", and the residual is stated for approved agents as well as unrestricted ones.

  2. H2 (HIGH): QR pairing had no daemon-side confirmation. Anyone who saw the QR within 120 seconds (screen share, recording, history sync of the fragment) paired first; the human just saw "code used". Resolved (section 3.1): the daemon always asks Pair "<label>" <fingerprint> as <role>? locally after message 3, whatever the pairing mode; a second scanner is told code already consumed by <fingerprint> and the CLI shows the competing fingerprint; the console strips the fragment with history.replaceState on load.

  3. M1 (MEDIUM): short codes could be guessed offline. In IKpsk2 any routed initiator receives a message 2 derived from the code and can test the whole 60-bit space offline; the attempt limit was irrelevant. Resolved (section 3.2): the text now says so; short codes are 80 bits (16 base32 characters), the 120-second life is the other bound, fingerprint comparison is mandatory for short codes, and a PAKE (CPace) is named as the later way to make shorter codes safe, negotiable through ver.

  4. M2 (MEDIUM): a revoked device's influence persisted. Tasks, notes, memory entries and pr.watch schedules created from a stolen phone kept steering after revoke. Resolved (sections 3.4, 5.3): every remote artifact is tagged with the originating device; on revoke the daemon lists them, stops the device's pr.watch, quarantines its memory entries pending local review and flags its tasks and notes on the board; memory.* moved from STEER to ADMIN with the two-step nonce.

  5. M3 (MEDIUM): the configuration hash missed referenced executables. Setup scripts, repository-local agent commands, MCP server scripts and package.json scripts could be changed by an agent, landed remotely, and run by a remote team.start. Resolved (section 5.2 principle 3): the contents of the files the configuration points at are hashed at local start and compared on remote restart, and a land that touches any of them (or nid.yaml, the project MCP configuration, the agents' config directories) marks the group "needs local start" until a local nid up.

  6. M4 (MEDIUM): key storage would likely be implemented wrong. noble cannot use a non-extractable WebCrypto key, and the spec said the state machine was synchronous, so implementers would keep raw bytes. Resolved (sections 2.2, 4.1, appendix A): the Noise state machine takes an asynchronous DH interface and the handshake is asynchronous; non-extractable WebCrypto X25519 keys are required where supported and the static DH goes through deriveBits; all-zero shared secrets are rejected on both paths; keys are never exported to feed the pure-JS path.

  7. LOW items. All resolved:

    • Push TTL and urgency are constants and there is no topic (a per-kind value or a deterministic topic would tell the relay the event kind and link repeats); the undefined "per-device secret agreed at subscribe time" is gone; push timing is listed as relay metadata (sections 6.1, 6.2, 8.1).
    • The empty-push alert text is fixed client text chosen from a status enum; the relay supplies no free text (section 6.3).
    • Roles and scopes: from a device a role can only be lowered and a scope only narrowed (cloud.devices.role, cloud.devices.scope); raising and widening are local (sections 2.4, 5.3).
    • The session-mode lookup uses the registry row named by the open device id, never any active row (section 4.2 rule 1).
    • The round-A console-check has the release public key compiled in; self-hosted console keys are explicit local configuration, never fetched from the origin under test (section 10).
    • Lost phone: the console's biometric gate is JavaScript in the page and is stated as a convenience, not a control (section 3.4).
    • session.terminal.control is two-step and attributed, and every terminal write is tagged with the device in the event stream (section 5.3).

Residual risks stated after this pass#

  • The console origin can act for the devices that use it. Mitigated by operator controls, detection, the view cap and self-hosting; removed only by native apps.
  • An agent that can run commands as your user, unrestricted or approved by a hostile device, can do what you can on that machine except pass the user-verification prompt. Mitigated by that prompt in front of pairing, re-link, relay change, rotation and role raising, plus loud announcements; a machine without a verifier can only hand out view.
  • Push and frame timing reveal activity to the relay. Inherent; padding hides sizes, not events.

Re-review of 07f3714#

The independent reviewer re-checked the third pass and found C1, H2, M1, M2, M3, M4 and the LOW items fixed, with these amendments, all applied:

  1. H1 was partial. "A path agents cannot drive" was still drivable: one approved command can shed the agent environment, allocate a pseudo-terminal and type y; an approved shell can read the bearer token, mint a dashboard session and post the confirmation with forged headers; UI automation can click a plain OS dialog. And the H1 attacker is the remote operate device, so "needs an approval" is no barrier. Resolved (section 2.4): the confirmations now require platform user verification (Windows Hello UserConsentVerifier or credential UI, macOS LocalAuthentication, Linux polkit with password, or WebAuthn with userVerification: required in the local dashboard, challenge bound to fingerprint and role); the fallback without a verifier refuses remote-capable pairing or caps it at view; the daemon refuses remote approval of nid (cloud|auth) commands as documented best effort; the "cannot drive" claims are removed and the residual covers approved agents.
  2. New HIGH: the hosted-console view cap was unenforceable. The device's console origin came from the client, so a malicious console could claim to be self-hosted, and the cap arrived as an account statement from the untrusted account party. Resolved (section 7): the cap is a local daemon setting (cloud.hostedConsoleMaxRole), and a device's console is the host the daemon itself put in the pairing URL, recorded at pairing; payload1.console is informational.
  3. Push confidentiality shares the console caveat. The console's JavaScript can read the p256dh/auth keys the browser generates. Resolved (section 6.1); the stale "deduplicated by topic" line in 6.3 is fixed.
  4. Nits. In QR mode the phone shows its own fingerprint next to the "confirm on your machine" message, because the label is attacker-chosen (section 3.1). The hashed executable set now spells out package.json and its scripts, copy/link targets and shell-activated files such as .envrc (section 5.2).

Appendix D: round-A console verification and platform setup#

src/remote/console-check.ts implements the CLI checker. The release operator has not supplied a hosted-console signing key for round A. Its immutable compiled-in RELEASE_CONSOLE_KEYS slot is therefore empty: hosted checks fail closed, including when local keys or --insecure-console are supplied. Before round B, the release operator must provision and review the Ed25519 public key in that slot and retain the private signing key outside the repository. This is an explicit round-A limitation, not a generated bootstrap identity. Self-hosted deployments configure lowercase hex Ed25519 public keys locally through cloud.consoleKeys; a key fetched from the console itself is never trusted. Unsigned round-A LAN consoles require explicit --insecure-console; nid.dev and its subdomains never accept that bypass.

The console serves /release-manifest.json and /transparency-log.json. Both use {"signed":"<exact JSON text>","signature":"<128 lowercase hex characters>"}. The Ed25519 signature covers UTF-8 nid-console-manifest-v1\n or nid-console-log-v1\n, respectively, followed by the exact signed text. The manifest payload is {"version":1,"assets":{"/index.html":"<sha256 hex>", "/sw.js":"<sha256 hex>","/other-asset.js":"<sha256 hex>"}}. Include every release asset. The release hash is SHA-256 of the manifest's exact signed UTF-8 text. The log payload is {"version":1,"entries":[{"previous":"<chain hash>", "releaseHash":"<release hash>"}]}. The first previous hash is 64 zeroes; subsequent previous hashes are SHA-256 of UTF-8 previous + "\n" + releaseHash of the preceding entry. The checker verifies the signed chain and release membership, then every asset hash, with bounded downloads and no redirects. Public mirrors and independent log-head monitoring are needed to detect signed forks or rollback: one fetched log snapshot cannot by itself prove global append-only history. The checker does not claim to detect an origin serving different bytes to the phone.

src/cli/user-verify.ts supplies the daemon's injected UserVerifier. Windows uses WinRT desktop UserConsentVerifier interop with a parent HWND, with secure-desktop credential authentication as fallback; credentials must log on as the daemon's own SID and buffers are wiped. macOS uses LocalAuthentication device-owner authentication with zero reuse duration through /usr/bin/swift (a missing Swift runtime fails closed). Helper processes receive only a fixed system PATH, startup-captured temporary directory, C locale, and Windows SystemRoot where needed. Loader/profiler/module variables (LD_*, DYLD_*, COR_*, PSModulePath, NODE_OPTIONS, etc.) are not inherited. Startup SystemRoot and temporary-directory selection remain part of the same-user daemon-launch trust residual; this does not defend a replaced nid executable.

Linux requires polkit and a manually administrator-installed policy at /usr/share/polkit-1/actions/org.nid.remote.policy. The template is shipped at packages/nid/assets/org.nid.remote.policy.in and exported as POLKIT_POLICY. It uses auth_self, never auth_self_keep, refuses an already authorized preflight, and identifies the daemon by PID, start time and UID. Missing platform support or denied authentication fails closed; callers apply only the spec's explicitly requested view-only fallback. No environment variable bypasses UV.

Independent-review B-L5 amendment: the policy must also annotate org.freedesktop.policykit.owner with unix-user:<daemon UID>. Without caller authorization, polkit refuses nonempty details, including the fingerprint-bearing polkit.message. See the authority API and owner annotation. From a Linux checkout, as the account that will run the daemon, the administrator can review and install the policy explicitly (nid never runs these commands itself):

daemon_uid="$(id -u)"  # the non-root daemon account, not root
policy_file="$(mktemp)"
sed "s/@NID_UID@/${daemon_uid}/g" packages/nid/assets/org.nid.remote.policy.in > "$policy_file"
sudo install -o root -g root -m 0644 "$policy_file" /usr/share/polkit-1/actions/org.nid.remote.policy
pkaction --action-id org.nid.remote.verify --verbose

The adapter requires that numeric owner entry and the strict defaults, then probes without user interaction with the same details permission before reporting availability. This probe cannot prove that a graphical authentication agent exists; verification still refuses if the active local session lacks one. A headless/SSH session is not claimed to support Linux user verification. Real Linux polkit, distribution policy overrides, the password prompt and freshness across consecutive prompts have not been exercised here. Tests cover injected policy/probe responses; manual administrator installation and the following platform validation are required.

Automated tests inject process runners and use a loopback fake console; they never invoke OS prompts. Manual platform validation remains necessary: in an isolated temp NID_HOME/TERMINID_HOME/PAWMUX_HOME, invoke a pairing confirmation, verify that the OS prompt shows the operation, fingerprint and role, test success and cancel, and on Windows test the credential fallback with both the current and a different account. On Linux verify that two consecutive operations each require fresh authentication. These manual prompts were not run on the operator's machine during round A.

Appendix E: accounts, device lists and the hosted contract (round R)#

Written in round R before the hosted service is built (section 11.5). It is the whole contract between this repository (daemon, CLI, terminal app, console, Node/Bun relay) and the nid.dev service (accounts, hosted relay, push sender) that the private repository builds. The development implementation is nid relay-serve --dev-accounts (E.10). Appendices B and C still hold; this appendix adds to them and changes nothing in the Noise protocol.

Conventions for every section below:

  • Two origins. The accounts origin A (default https://accounts.nid.dev, its own origin so that nothing else served on nid.dev runs script same-origin with sign-in; the WebAuthn RP ID stays nid.dev) serves the HTTP API under /api/v1/; the relay origin R (default wss://relay.nid.dev, HTTP on the same host for E.8) serves appendix C's WebSockets. They may be the same origin (the development relay serves both). The daemon reads cloud.accounts / NID_ACCOUNTS_URL and cloud.relay / NID_RELAY_URL; both must be https:/wss: except on loopback (checkRelayUrl).
  • JSON. Requests and responses are application/json; charset=utf-8, at most 64 KiB. Unknown response fields are ignored by clients (additive changes); unknown request fields are ignored by the service. Times are integer epoch milliseconds unless a field says otherwise.
  • Ids (account, daemon, device): 1–64 characters of a-z0-9_- (appendix C). Generated ids are 128 random bits in lowercase base32 (26 characters). An id is a name, never a proof.
  • Keys. X25519 keys (daemon and device static keys, section 2) and Ed25519 keys (the account-device signing key, E.4) are 32 raw bytes in base64url without padding. Fingerprints are section 2.1's.
  • Errors. Any non-2xx response carries {"error":{"code":"<code>","message":"<text>"}}; clients branch on code only and show message as untrusted text (control characters stripped, at most 200 characters). Codes are listed in E.9.
  • Tokens are opaque bearer strings of at most 1024 characters, sent only in Authorization: Bearer headers or in the request body fields named below, never in URLs. The service stores only their hashes.

E.1 Who holds what#

CredentialHeld byProvesLifetime
Connector tokenDaemon (secrets store, cloud-token)"this socket is daemon d of account a" to the relay (appendix C /v1/daemon)until unlink
Account sessionConsole (IndexedDB)"this browser signed in to account a", optionally bound to device v (E.3)30 days, refreshable
Account-device signing keyThe accounts service (private half); daemons pin the public half (E.4)"account a vouches for these device keys"rotated by shipping new builds
Device static key VDevice (section 2.2)the Noise handshake, end to enduntil the device re-registers
Daemon static key DDaemon (section 2.1)the Noise handshake, end to enduntil rotation (3.5)

The relay and the accounts service see no plaintext frame. What changes from strict mode is only who tells the daemon which device keys to accept (section 11.1).

E.2 Linking a daemon (nid rc): device-code login#

The daemon runs the whole exchange itself (the CLI and the terminal app only show what the daemon returns, E.7), so the connector token never passes through a process an agent could read, and the OS prompt (section 2.4) runs in the daemon before anything is committed.

  1. POST A/api/v1/device-code {"client":"nid-daemon","label":"<machine label>","daemonId":"<current id or null>","publicKey":"<D, base64url>"} → 200 {"deviceCode","userCode","verificationUri","verificationUriComplete","expiresIn":600,"interval":5}. userCode is 8 characters from BCDFGHJKLMNPQRSTVWXZ shown as XXXX-XXXX; verificationUriComplete is A/link?code=<userCode>. label is visible to the service (8.1); the daemon's default names the platform, never the host name. The service keeps the label, daemon id and public key with the pending code. At most 10 starts per minute per address, 600 s life.
  2. The daemon returns the user code and URL; nid rc opens the browser (--no-browser prints the URL only). On A/link the person signs in with a passkey (the hosted service's WebAuthn; E.10 stubs it), sees the user code, the machine label and the daemon fingerprint, and approves or denies. Approval requires a passkey assertion with user verification made within the last 5 minutes; a session that signed in through recovery (magic link) cannot approve a link.
  3. The daemon polls POST A/api/v1/device-code/token {"deviceCode"} no faster than interval:
    • 400 authorization_pending until the person acts; 400 slow_down (add 5 s to the interval); 400 expired_token; 400 access_denied when denied;
    • 200 {"grant":"<single-use, 10 minutes>","account":{"id","email","name"}} once approved.
  4. The daemon now runs the OS user verification (VerifyAction link, section 2.4) with a prompt that names the account email and id, the accounts origin and the relay. Refused → the grant is dropped (it expires unused) and nothing changes. This is the one prompt of account mode: linking, or changing the linked account, accounts origin or pinned keys.
  5. POST A/api/v1/daemons/link {"grant","daemonId":"<current id or null>","publicKey":"<D>","label"} → 200 {"account":{"id","email","name"},"daemonId","connectorToken","relay":"<R>"}. The service keeps the daemon id the daemon proposes when it is free or already this account's, otherwise assigns a new one; a daemon re-linking to the same account keeps its id and key (finding R14). publicKey must equal the one in step 1 (409 conflict otherwise). The response's relay is informational: the daemon connects to its configured relay.
  6. The daemon stores the account id, the connector token (secrets store), the pinned key set (E.4) and the link time in cloud.json, then connects to the relay with the connector token as appendix C's /v1/daemon bearer.

Linking to a different account than the current one is an unlink followed by a link (both in one nid rc run, one OS prompt). The daemon forgets the old account's device list and admits nobody from it; registry rows of the old account's devices stay as history and are marked revoked with revokedBy: "account-change".

Other daemon calls, authenticated with Authorization: Bearer <connector token>:

CallBody → resultUse
POST A/api/v1/daemons/unlink{} → 200 {}nid rc unlink / account change. Revokes the connector token, closes the daemon socket, drops the machine from the account. Idempotent; a 401 is treated as already unlinked.
POST A/api/v1/daemons/key{"publicKey":"<new D>"} → 200 {}After nid cloud rotate-key (3.5), so consoles that look the machine up get the new key. Connected devices learn it authenticated through key_rotate (3.5); E.6 says how consoles treat a key that changed only at the service.
POST A/api/v1/daemons/label{"label"} → 200 {}Rename the machine.
GET A/api/v1/daemons/self→ 200 {"account":{"id","email","name"},"daemonId","label","linkedAt"}nid rc status, and the daemon's check after reconnect that it is still linked.

A 401 unauthorized from any of these (or a 4401 WebSocket close from the relay) means the connector token was revoked, usually by "remove machine" on nid.dev: the daemon shows "unlinked by your nid.dev account" in nid rc status, the terminal app and the board, stops admitting account devices, and keeps strict-paired devices (which need a relay route, so they work again after a new nid rc or nid cloud login).

E.3 Console sign-in and device registration#

The console (app.nid.dev, section 11.4) is a static app in this repository (packages/nid/src/webconsole/, built by scripts/build-console.ts). Sign-in uses OAuth 2.0 authorization code with PKCE (RFC 7636, S256) so that the passkey ceremony lives entirely on the accounts origin and the console never handles WebAuthn:

  1. The console navigates to A/signin?client_id=console&redirect_uri=<console origin>/&state=<random>&code_challenge=<S256>&code_challenge_method=S256. redirect_uri must be exactly a registered console origin with path / (hosted: https://app.nid.dev/; the development relay registers its own loopback console).
  2. The person signs in with a passkey (recovery: magic link, see below) and is redirected to <redirect_uri>?code=<code>&state=<state>. The console checks state, removes the query with history.replaceState and exchanges the code:
  3. POST A/api/v1/session/token {"code","codeVerifier","redirectUri"} → 200 {"session":"<token>","expiresAt","account":{"id","email","name"},"freshUntil":<ms>,"recovery":false}. The code is single use and lives 60 seconds. CORS on A/api/v1/session/*, A/api/v1/devices*, A/api/v1/machines* allows exactly the registered console origins, with Authorization and Content-Type headers, no cookies (credentials: "omit"). freshUntil is when the passkey assertion behind this session stops counting as fresh (5 minutes after it). recovery: true marks a magic-link session.

Session calls, all with Authorization: Bearer <session>:

CallBody → resultNotes
POST A/api/v1/session/refresh{} → 200 {"session","expiresAt","freshUntil"}Rotates the token; the old one stops working. A refreshed session is not fresh.
POST A/api/v1/session/revoke{} → 200 {}Sign out.
POST A/api/v1/devices{"deviceId","publicKey":"<V>","label","platform"} → 200 {"device":<entry E.4>,"listVersion"}Register the device key. Needs a fresh, non-recovery session (403 fresh_auth_required otherwise: the console sends the person back through step 1). Binds the session to deviceId (a session binds to at most one device; registering again with another id is 409 conflict). deviceId must be unused on the account (409 conflict); publicKey must be a valid X25519 key unused by any non-revoked device of the account. At most 20 non-revoked devices per account (403 device_limit). The service emails the account "new device: <label>, fingerprint <fp>" and publishes a new list (E.4, E.5).
GET A/api/v1/devices→ 200 {"devices":[<entry>…],"listVersion"}For the console's Devices page.
DELETE A/api/v1/devices/<deviceId>→ 200 {"listVersion"}Revoke on the account. Needs a fresh session unless the device revokes itself ("sign out and forget this device"). Publishes a new list; the relay closes that device's channels (E.5).
GET A/api/v1/machines→ 200 {"machines":[{"daemonId","label","publicKey","fingerprint","linkedAt","online","since"}]}The account's linked daemons; online/since from the relay's status. Group names never reach the service: the console asks each daemon over the channel (E.6).
DELETE A/api/v1/machines/<daemonId>→ 200 {}"Remove machine": revokes its connector token (fresh session required).

Hosted pages. The sign-in and link pages check Origin on their own form POSTs, so they must not send Referrer-Policy: no-referrer: under it Chrome sends Origin: null on a same-origin form POST (found with the development pages, #16; they use same-origin). The console host sends its CSP, including frame-ancestors 'none', as HTTP headers; the static build's meta tag cannot set frame-ancestors.

Recovery sessions (magic link) can sign in, list machines and devices, revoke devices and remove machines (recovery exists to lock a thief out), but cannot register a device or approve a link until the person has added a passkey and signed in with it. A recovery session is fresh for 5 minutes after the magic link is used (freshUntil, recovery: true), and that freshness counts only for revoking devices and removing machines; registering a device and approving a link always need a passkey-backed fresh session. A refreshed recovery session is not fresh and stays a recovery session.

Binding. Only a session bound to a registered, non-revoked device can be used to route to a daemon (E.5). Revoking the device ends the binding; the console then signs in again and registers a new key under a new device id.

E.4 The signed device list#

The service keeps, per account, the list of device keys it vouches for and signs it with the account-device signing key (Ed25519, RFC 8032, pure, not prehashed).

Payload (version 1): a JSON object with exactly these members, in this order:

{"v":1,"type":"nid-device-list","account":"<account id>","version":42,"issuedAt":1792060800000,"expiresAt":1792320000000,"devices":[{"id":"<device id>","publicKey":"<V, base64url>","label":"Pixel 7","platform":"android","created":1792000000000,"revoked":null}]}
MemberRule
v1. Any other value is refused (a daemon that does not know the version fails closed and says "update nid").
type"nid-device-list" (domain separation from anything else the key might sign).
accountThe account id.
versionInteger, 1 ≤ version ≤ 2^53 − 1, strictly increasing per account for every list the service ever signs (a re-sign with no change also gets a new version).
issuedAt, expiresAtIntegers (epoch ms); issuedAt < expiresAt ≤ issuedAt + 72 hours (a longer list is refused as malformed, so the E.11 bound on withholding holds).
devicesAt most 200 entries, sorted by id (ascending by UTF-16 code unit, i.e. JavaScript <), ids unique.
entry idDevice id (E conventions).
entry publicKeyX25519 public key, 32 bytes, canonical (section 2.2, A-L3: no non-canonical encodings, not all-zero or low-order); unique among entries whose revoked is null.
entry label0–64 characters, no C0/C1 control characters; display only (the daemon sanitises again, B-L2).
entry platform0–32 characters of a-z0-9._-; display only.
entry createdInteger epoch ms.
entry revokednull, or the integer epoch ms of revocation.

Canonical encoding. The payload bytes are the UTF-8 encoding of the object serialised with ECMAScript JSON.stringify (no whitespace, members in the order above, strings escaped as JSON.stringify escapes them, integers in plain decimal). There is exactly one valid encoding of a given list: a verifier parses the bytes, re-serialises them canonically and refuses the list if the result is not byte-identical, so duplicate members, other member orders, whitespace, 1.0, escapes JSON.stringify would not produce and unknown members are all refused.

Signature. sig = Ed25519.sign(signingKey, utf8("nid-device-list/1") || 0x00 || payloadBytes).

Envelope (what the relay and GET calls carry):

{"payload":"<base64url of payloadBytes>","sig":"<base64url, 64 bytes>","keyId":"<base64url of the first 8 bytes of SHA-256(public key)>"}

The envelope as JSON is at most 64 KiB. keyId only selects which pinned key to try first; a verifier may try every pinned key and must not trust keyId for anything else.

Verification rules (the daemon, src/remote/device-list.ts), all of which must pass, in this order, before anything in the list is used:

  1. Size and shape: the envelope parses, payload decodes to at most 48 KiB, sig is 64 bytes.
  2. Signature over "nid-device-list/1" || 0x00 || payloadBytes with one of the pinned keys: for the nid.dev accounts origin, the keys compiled into this build (NID_ACCOUNT_KEYS; empty in this repository, so account mode against nid.dev fails closed until the private team ships the real key); for any other accounts origin, cloud.accountKeys as it was when the daemon was linked (copied into cloud.json at link; editing the config later has no effect until a re-link, which needs the OS prompt). Keys are never fetched from the service they verify. Ed25519 verification rejects non-canonical S and small-order public keys (noble's default zip215: false behaviour).
  3. Canonical: the payload re-serialises to the same bytes; every member satisfies the table above.
  4. Account: account equals the daemon's linked account id. Otherwise refused (wrong_account).
  5. Rollback: version is greater than the highest version this daemon has accepted for this account (persisted in cloud.json, so it survives restarts and is reset only by a re-link to another account). An equal version with byte-identical payload is a no-op; an equal version with a different payload is refused and raised as equivocation (board note, nid rc status): the service signed two different lists with one version.
  6. Expiry: expiresAt is in the future, with 10 minutes of tolerance for clock skew (9.3).

A refused list leaves the previously accepted list in force, increments a counter shown in nid rc status and nid doctor, and writes at most one board note per hour per reason (forged, wrong_account, rollback, equivocation, expired, malformed, version). When the accepted list itself passes its expiresAt, the daemon treats it as empty: live account channels are closed with revoked and new ones are refused until a fresh list arrives. The service therefore signs a new list (new version) at least every 24 hours with expiresAt = issuedAt + 72 hours; that also bounds how long a relay that withholds a revocation can keep a revoked device admitted (E.5).

Test vector (test/remote-device-list.test.ts). Ed25519 secret key: 32 bytes of 0x2a (public GX9rI-FshTLGq8g4-s1ep4m-DHaykgM0A5v6iz02jWE, keyId tgAwbPp2cj8). Payload:

{"v":1,"type":"nid-device-list","account":"acct1","version":5,"issuedAt":1792060800000,"expiresAt":1792320000000,"devices":[{"id":"laptop","publicKey":"zo060cy2M-x7cMF4FKXHbs0CloUFDTRHRboFhw5YfVk","label":"Work laptop","platform":"macos","created":1792060798000,"revoked":1792060799500},{"id":"phone","publicKey":"pOCSkrZRwni5dyxWn1-puxPZBrRqtoyd-dwrRAn4ogk","label":"Pixel 7","platform":"android","created":1792060799000,"revoked":null}]}

sig = MFYhuxUL4xcWGDneX9BBl4_ka5-OaKQrdUPgVlfHZZsmsDIkcPSztz32fQiGypWI7H4_fLwVSlg6dagaSUJaBg.

Signing key rotation. Builds carry every currently valid key in NID_ACCOUNT_KEYS. To rotate, the service first ships builds that contain the new key next to the old one, then switches signing, then drops the old key from later builds. A daemon on an old build keeps working until its pinned keys no longer sign; it then refuses lists (forged) and nid rc status says to update. Self-hosted services rotate by the operator editing cloud.accountKeys and re-linking.

E.5 Relay: delivering lists and routing account devices#

Delivery. On a daemon socket authenticated with a connector token of account a, the relay sends on channel 0, as an unsolicited relay_ctl (appendix B, no id):

{"op":"device_list","list":<envelope E.4>}
  • immediately after the socket is authenticated (before any open is routed to it), and
  • whenever the account's list changes (register, revoke, re-sign), within 5 seconds.

The daemon may also ask: {"id","op":"device_list"} → {"id","ok":true,"list":<envelope>} (at most once a minute; the connector asks once after connecting and again when an account device's open arrives for a device id that is not in the current list and the list is older than one minute, so a phone that registered a second ago is not refused for long). The relay is not trusted with the content: it can only withhold or delay, which E.4's expiresAt bounds. The daemon never takes a list from anywhere else (not from devices, not from GET A/api/v1/devices).

Routing hint. A device may open R/v1/device?daemonId=<id> (the reference client and the console always do). The hint lets a host that keeps one object per daemon (the hosted Durable Objects relay) hand the socket to that daemon's object before any frame arrives. It proves nothing: the relay authenticates every open on that socket exactly as without the hint (the rules below and appendix C), refuses (close {reason:"refused"}, counted as malformed) any open that names another daemon, and refuses at the upgrade a hint that is not a valid id. Without a hint, appendix C applies unchanged.

Routing an account device. A console opens R/v1/device and sends its first open (appendix C) with {"daemonId","deviceId","ver","mode":"session","token":"<account session>"}. The relay routes it only if all hold:

  • the session is valid and bound to deviceId (E.3), and that device is not revoked;
  • daemon daemonId is linked to the session's account and online (otherwise status {daemon:"offline"} as in appendix C);
  • appendix C's limits (32 device sockets per account, one channel per device per daemon, 8.2 budgets charged to the session's account).

The relay strips token before forwarding, exactly as in appendix C. Strict-paired devices keep using their per-device routing tokens (appendix C device_token); those are independent of the account and keep working while the daemon stays linked.

Revocation. When a device is revoked on the account, the relay (a) publishes the new list to every online daemon of the account, (b) ends the device's bindings, and (c) closes its live channels with revoked. (b) and (c) are availability measures; (a) is what the daemon acts on (it closes any live channel of a device that is no longer admitted, whatever the relay does).

Status for the console (section 6.3's empty-push alert and the machine list): GET R/v1/status?daemonId=<id> with Authorization: Bearer <session> → 200 {"daemon":"online"|"offline"|"suspended","since":<ms>} for a daemon of the session's account (404 not_found otherwise). The console shows only fixed text chosen from this enum. The service worker, which gets an empty push without a daemon id, asks this for every machine the console has pinned and shows the fixed text for the first one that is not online. R's HTTP base is the relay URL with wss: → https: (ws: → http: on loopback). CORS on R/v1/status and R/v1/push/key allows exactly the registered console origins with the Authorization header and no credentials.

Push (section 6). GET R/v1/push/key → 200 {"publicKey":"<uncompressed P-256, base64url>"} is the relay's VAPID public key for the console origin, used as applicationServerKey. The console sends its subscription to the daemon over the channel, never to the service, as a pushsub frame: {"endpoint":"https://…","keys":{"p256dh","auth"},"events"?:[kind…],"groups"?:[name…]|null}; {"endpoint":null} unsubscribes. The daemon answers with announce events push-subscription (stored), push-subscription-error (invalid) and push-delivery-failing (a 404/410 hint, R10). The daemon sends the relay appendix B's push envelope; the hosted sender POSTs it with VAPID (RFC 8292) and returns the status in push_result. Pushes go only for groups the device may see (shares, E.7).

Push endpoint policy (both sides, review M1). An endpoint is chosen by a device, so it is untrusted input that makes the daemon and the relay send requests.

  • The daemon accepts a subscription, and sends a push for a stored one, only for https: on port 443 with no user info, a host name (never an IP literal in any notation, never localhost, *.localhost, *.local, *.internal or a single-label name) that matches the push-service allowlist: fcm.googleapis.com, updates.push.services.mozilla.com, *.push.apple.com, *.notify.windows.com (case-insensitive, trailing dot removed, *. matches on a label boundary). A self-hosted setup adds hosts with cloud.push.endpointHosts in config.yaml (same syntax and the same https, port and IP-literal rules); nothing else widens it.
  • The hosted sender MUST apply the same allowlist itself (it never trusts the daemon's check), MUST refuse a host whose resolved address is loopback, private, link-local, unique-local, multicast or otherwise non-public, MUST NOT follow redirects, and MUST bound each POST (body at most 4,096 bytes, a timeout of at most 10 seconds). A refused endpoint is reported as push_result {status: 400}, which the daemon treats as a hint like any other.
  • The public relay core applies the daemon-side rules itself before any PushPoster sees an endpoint (a self-hosted daemon may be hostile), and reports every refusal, sender exception or out-of-range status as the same 400, so push_result cannot be used to probe networks (PushPoster.endpointHosts carries a self-hosted sender's extra hosts; review NEW-1).

E.6 How the console connects#

  1. GET A/api/v1/machines gives each machine's daemon id, label and daemon public key. The console pins that key per daemon id on first use (IndexedDB). If the service later reports a different key for the same daemon id and the console has not learned it through an authenticated key_rotate (3.5), the console shows "this machine's key changed at nid.dev; if you did not rotate it, remove the machine" and asks before connecting. Reason: in account mode the device takes the daemon key from the service, so a compromised service could point a device at a key of its own (E.11).
  2. It opens a session-mode Noise IK handshake (section 4.2) with its registered key V; payload1 is unchanged (section 4.2), with console set to the console origin.
  3. payload2.features includes "account" when the daemon admitted the device through the account list; the role is the device's effective role (E.7).
  4. The console calls groups.list over the channel; the daemon returns only the groups shared with account devices (and, for a strict-paired device, its scope), so the machine list can show shared groups without telling the service their names.

E.7 What the daemon does with a verified list#

  • Admission. A session open from device id v is admitted through the account when: account mode is on (linked to an account, remote.strictPairing not true); the current list is verified and not expired; it has an entry with id v and revoked: null; the Noise static key rs equals that entry's publicKey; no local registry row with that key or that device id is locally revoked; at least one share is active (E.8). Otherwise the open is refused as an unknown device (unknown_device, counted per 8.4), exactly as today. A strict-paired row (section 3) whose device id or key matches is handled as today and wins over the list: the list neither widens nor narrows a strict-paired device.
  • Registry. The first admission creates a registry row with kind: "account", the list's label and platform, verifiedBy: "account", consoleHost = the configured console's host (app.nid.dev for nid.dev) and raises the new-device notice (section 11.3) through the unknown-device machinery: a desktop notification, a cloud board note, the status-line badge and an announce to every connected device, each naming nid rc devices rm <id>.
  • Role. For each shared group: the share's role (operate, or view for --view), capped by remote.accountMaxRole (default operate) and cloud.hostedConsoleMaxRole for a hosted console host; never admin (admin needs strict pairing). Methods bound to a group use that group's role; methods bound to no group use the lowest role among the device's shared groups.
  • Scope. Exactly the shared groups (* = all groups); unshared groups are invisible (reads, streams, push, mutations), as section 5.4 scope.
  • Live changes. A new list that drops or revokes the device, a local revoke, rc off, a share that expires, an unlink or an expired list close the device's live channels with revoked (or narrow its scope when other shares remain, with the subscriptions on the removed groups ended). Expiry is checked on admission and by the connector's timer.
  • Local revoke (nid rc devices rm <id>) marks the row revoked with the key; that device id and that key are refused on this daemon whatever any later list says. Undoing it means registering a new device on the account.
  • A known device keeps its key (review A1). A device id this daemon has already seen keeps its key: a list that gives a known id another publicKey is refused for that device (no admission, the stored key is never overwritten), with one cloud board note per id. Re-registration uses a new id (E.3), which raises the new-device notice.
  • Strict-mode controls do not apply (review A3). cloud.devices.role and cloud.devices.scope refuse account rows, whose access comes from the shares; cloud.devices.list shows an account row's effective role and scope (view and [] when it is not admitted now).
  • Service text is cleaned (review A4). Account email, name and labels from the service are NFKC-normalised and stripped of Cc, Cf, Zl and Zp characters (bidi controls, isolates, zero-width and line separators) before they reach the OS prompt, a notice or RcStatus; the prompt names the accounts host before any service text.
  • No prompt for any of this: the OS prompt ran once at link (E.2).

E.8 Daemon-local RPCs for nid rc and /rc#

These are LOCAL-tier methods (section 5.3: never reachable from a device), on the daemon's local API like cloud.*. Every result that reports state returns the RcStatus below.

MethodParams → result
cloud.rc.status{ack?: true} → RcStatus. Cheap: no verifier probe, no network (the terminal app polls it about every 1.5 s). newDevices counts account devices first seen within the last 24 hours or after the last ack; ack cannot clear a device first seen within the last 24 hours, because any holder of the local bearer token can send it (review A5). nid rc status sends it, the terminal app does not.
cloud.rc.link.begin{accounts?, relay?, label?} → {loginId, userCode, verificationUri, verificationUriComplete, expiresAt, firstRun}. Starts E.2 steps 1–2. accounts/relay default to config/env/the nid.dev defaults; a non-default accounts origin needs cloud.accountKeys. firstRun is true when the daemon was never linked (the CLI then prints section 11.1's text). Refused (agent_session) when called from an agent session, like cloud.*. Refused with link_pending while another sign-in is pending: only cloud.rc.link.cancel with its loginId, or its expiry, ends it; at most 5 begins per 10 minutes (rate_limited; refused begins do not count). The result adds daemonFingerprint, and the OS prompt reads "Link this nid daemon (fingerprint <fp>, code <userCode>) at <accounts host>, relay <relay host>, to account <id> (<email>)?" ("Change the account of this nid daemon …" for a change), so the person matches it with the terminal (review A2).
cloud.rc.link.wait{loginId} → {state:"pending"} | {state:"linked", status:RcStatus} | {state:"refused", reason}. Long-polls up to 25 s. On approval the daemon runs the OS prompt (E.2 step 4) before linking.
cloud.rc.link.cancel{loginId} → {}
cloud.rc.unlink{} → RcStatus. No prompt (it removes access).
cloud.rc.share{group?: string, all?: true, role?: "view"|"operate", forMs?: number} → RcStatus. Exactly one of group/all. Replaces an existing share of the same group. forMs between 1 minute and 30 days. No prompt: sharing grants the linked account's own devices access, and linking was the verified step (section 11.2).
cloud.rc.unshare{group?: string, all?: true} → RcStatus. all removes every share. nid rc off without a group means --all (fail safe); /rc off in the terminal app means the current team.
cloud.rc.devices{ack?: true} → {devices: RcDevice[]} (ack as in cloud.rc.status)
cloud.rc.devices.rm{id} (record id or device id) → RcStatus. Local revoke (E.7).
interface RcStatus {
  linked: boolean;
  account: { id: string; email: string | null; name: string | null } | null;
  accounts: string;            // accounts origin
  relay: string | null;
  connector: "online" | "connecting" | "offline" | "unlinked-by-account" | "off";
  strictPairing: boolean;      // remote.strictPairing: the list is ignored
  accountMaxRole: "view" | "operate";
  shares: { group: string /* name or "*" */; role: "view" | "operate"; createdAt: number; expiresAt: number | null }[];
  list: { state: "none" | "ok" | "expired"; version: number | null; issuedAt: number | null; expiresAt: number | null; devices: number;
          refused: { reason: string; at: number; count: number } | null };
  devices: RcDevice[];
  badge: { devices: number };  // connected devices on shared groups (account and strict), for "rc: N devices"
  newDevices: number;          // account devices first seen and not yet acknowledged (the status-line notice)
}
interface RcDevice {
  recordId: string; deviceId: string; kind: "account" | "paired"; label: string; platform: string;
  fingerprint: string; role: "view" | "operate" | "admin"; groups: string[] | "*";
  online: boolean; firstSeenAt: number | null; lastSeenAt: number | null;
  status: "active" | "revoked" | "listed";  // listed: on the account list, never connected here
}

Updates: in round R the terminal app polls cloud.rc.status (cheap, about every 1.5 s) for the badge and the new-device notice (newDevices, devices[].firstSeenAt). Local event-stream events ({type:"rc"}, {type:"rc.new_device"}) are a later addition (E.12).

Shares live in $NID_HOME/cloud.json (rc.shares) and survive restarts; an expired share is removed at the next check and the change is a cloud board note.

E.9 Error codes#

CodeHTTPMeaning / client behaviour
invalid_request400Malformed body or field. Bug; show message.
authorization_pending400E.2 poll: keep polling.
slow_down400E.2 poll: add 5 s to the interval.
expired_token400Device code, grant or authorization code expired: start again.
access_denied400The person denied the link.
invalid_grant400Grant or code unknown, used or for another client.
unauthorized401Token missing, expired or revoked. Daemon: unlinked (E.2). Console: sign in again.
fresh_auth_required403Needs a passkey assertion within 5 minutes: re-run sign-in.
recovery_session403A recovery session cannot do this (E.3).
device_limit40320 non-revoked devices on the account.
daemon_limit40310 linked daemons on the account.
forbidden403Origin not allowed, or not the account's resource.
not_found404No such device, machine or code (for this account).
conflict409Id or key in use, session already bound, key mismatch on link.
payload_too_large413Over 64 KiB.
rate_limited429Retry-After seconds.
unsupported_version426The client's API version is no longer served: update nid.
server_error500/503Retry with backoff (max 60 s).

Daemon-local refusals (E.8, in the RPC error string): not_linked, agent_session, link_pending, rate_limited, verification_refused, verifier_unavailable, no_account_keys (non-default accounts origin without cloud.accountKeys, or nid.dev with an empty NID_ACCOUNT_KEYS), unknown_group, strict_mode (sharing works but the list is ignored; returned as a warning in RcStatus, not as an error).

List refusal reasons (E.4): malformed, version, forged, wrong_account, rollback, equivocation, expired.

E.10 The development implementation (nid relay-serve --dev-accounts)#

The Node/Bun relay's dev-accounts mode implements E.2–E.5 in memory on one loopback origin (it is both A and R), so nid rc, the console and the daemon run the whole flow locally with no network:

  • one in-memory store of accounts, daemons, devices, sessions and lists; nothing persists;
  • an Ed25519 signing key generated at start; its public half is printed ([relay] account signing key: <base64url>) and is what the test daemon pins through cloud.accountKeys with cloud.accounts pointing at the relay;
  • passkeys are stubbed: A/link and A/signin show the request and approve only with the approval code printed in the relay's terminal (appendix C's rule against local programs approving themselves); there is no WebAuthn, email or recovery;
  • a scriptable person for tests (test/remote-accounts-harness.ts, in-process only, never an HTTP endpoint): approve or deny a device code, sign in a console (returns a session), make a session fresh, register and revoke devices, remove a machine, and publish arbitrary envelopes to a daemon (forged, rolled back, other account, expired, equivocating) for the adversarial tests;
  • a development push sender for the fake push service of the tests (section 6, loopback endpoints only), with a test VAPID key served at GET R/v1/push/key.

The dev mode refuses to bind anything but loopback and is not a hosted authentication service.

E.11 What account mode adds to the threat model#

Stated in section 11.1 and expanded here for the reviewers:

  • The accounts service can enroll a device (11.1). It can also tell a console a daemon key of its own (E.6) and so impersonate a machine to a device that has never connected; consoles pin daemon keys on first use and warn on a change the service alone reports. A compromised service plus relay can therefore read and steer within operate on shared groups. Strict mode (remote.strictPairing: true) removes this.
  • The relay can withhold lists: a revocation can be delayed until the accepted list expires (at most 72 hours plus 10 minutes after the last list it let through). A local revoke is immediate.
  • Rollback and equivocation are refused by the daemon (E.4 rules 5); equivocation is evidence of a compromised signer and is shown to the user.
  • A prompt-injected agent cannot link the machine to another account without the OS prompt (E.2 step 4), which names the account. It can share more groups with the user's own account (no prompt by design, section 11.2) and unshare or revoke (availability only).
  • Stolen signed-in phone or browser: it is a device on the account until revoked on nid.dev or locally; that is "the account is the lock" (11.1). Registering a new device needs a fresh passkey assertion, so a stolen session token alone cannot add a key.

E.12 Changes to this appendix#

Appendix E is stable from the board note announcing it. Every later change is listed here with the date and the reason, and announced on the board.

  • 2026-10-15: first version.
  • 2026-10-15: the console lives in src/webconsole/ (check-public refuses console/ directories, which it reserves for hosted code); R/v1/status is also what the service worker uses for empty pushes, and CORS on it and R/v1/push/key is stated; cloud.rc.status/cloud.rc.devices take ack and cloud.rc.status must stay cheap (the terminal app polls it); nid rc off alone means --all. Requested by the console and CLI authors.
  • 2026-10-15, from the daemon implementation (#5), all in the daemon only:
    • E.8 events are replaced by polling cloud.rc.status in round R (the daemon's internal events are not yet forwarded to the local event stream; nothing in the hosted contract depends on them).
    • "Unlinked by your account" (E.2) is detected by GET A/api/v1/daemons/self answering 401 after the relay connection drops, at most once a minute, since a relay may drop the socket without a 4401 close.
    • An invalid remote: block in config.yaml fails closed: strictPairing: true and accountMaxRole: view until it is fixed.
    • Changing the linked account also removes every share: they were granted to the old account.
    • List refusals (E.4): equivocation raises the full notice (desktop notification, board note, announcement to devices); the other reasons write at most one board note per hour per reason.
    • Per-group roles (E.7): methods bound to several groups use the lowest of their roles.
  • 2026-10-15, from the reviews of round R:
    • agent_session (E.8) is refused by nid rc in the CLI and the terminal app, as nid cloud is; the daemon does not try to detect agent callers. Best effort, as in section 11.2: the OS prompt at link is the control.
    • E.3 states two hosting requirements for the hosted pages (referrer policy with Origin checks; CSP as HTTP headers).
    • The console's sign-out removes this browser's device from the account (the E.3 self-revoke, no fresh session needed) and unsubscribes push; a new sign-in registers a new key (#30).
    • 2026-10-15, from the hosted team's review (security-review-rc.md, F2-F6), after DONE:
      • F3/F4: the relay core can snapshot and restore its routing state (appendix C), so a hibernating host keeps live channels, budgets and suspensions instead of ending them with relay_reset. Additive: attach now returns a peer id.
      • F4: R/v1/device?daemonId= routing hint (E.5), untrusted; what the relay still checks is stated there. Additive: clients without it are routed as before. The reference client and the console send it.
      • F2: section 8.1 said labels are sealed; in account mode the accounts service sees them (it lists machines and devices and emails new-device notices before any channel exists). 8.1 now says so, and the daemon's default label is the platform, not the host name (nid rc --label). Sealing labels to each device key was considered and not done: the service needs a readable name for the account page and email, the only places a person decides to revoke.
      • F6: recovery-session freshness for revoke and remove-machine (E.3), confirming the hosted team's reading.
    • 2026-10-15, from the independent review of 650c5e0: the push endpoint policy (E.5) is new (M1: the daemon accepted any https: endpoint, including link-local, loopback and private addresses; the hosted sender's policy was a sentence, now MUSTs). cloud.push.endpointHosts is the self-hosting opt-out. The console's static build ships a _headers file with the CSP (including frame-ancestors 'none'), X-Frame-Options: DENY, nosniff and a referrer policy for every path including sw.js (L2, L3), which the host must serve as response headers (E.3). The console drops push payloads whose at is more than 15 minutes old or more than 2 minutes ahead (a relay replaying old ciphertext), and keeps its dedupe and empty-push limit in IndexedDB so a restarted service worker does not forget them (L1).
    • 2026-10-15, from the second independent review of 650c5e0 (account mode): restore re-authorizes restored channels and hosted rate limits are keyed globally (appendix C, A6); the daemon refuses lists that live longer than 72 hours, matching E.11 (E.4, A7: it accepted 7 days).
    • 2026-10-15, A1-A5 of the same review: a known device id keeps its key (E.7); cloud.rc.link.begin refuses while another sign-in is pending, is rate-limited and the OS prompt carries the code and the daemon fingerprint (E.8, new code link_pending); strict-mode role and scope controls refuse account rows (E.7); service text is normalised and stripped of format characters (E.7); ack cannot clear devices first seen in the last 24 hours (E.8).
    • 2026-10-15, re-review of b91d64d: the relay core enforces the push endpoint policy and one generic refusal status (E.5, NEW-1); restore re-authorizes channels of every peer (NEW-3a); device labels use the same character cleaning as account text, and a stored account email is cleaned again when read (E.7, NEW-3b/c); scoped admin devices do not see account devices outside their scope (NEW-2).
    • 2026-10-15, hosted review M4: the default accounts origin is https://accounts.nid.dev (was https://nid.dev), so the marketing site on nid.dev can never run script same-origin with sign-in, the link page or the session API. The WebAuthn RP ID stays nid.dev, so passkeys keep working from accounts.nid.dev; the relay stays relay.nid.dev and the console app.nid.dev. Pinned keys are unchanged (any *.nid.dev accounts origin uses the compiled NID_ACCOUNT_KEYS). Daemons linked before this change keep the origin stored at link.
    • Per-group roles reach the device (additive, #26): payload2.groupRoles ({<group>|"*": role}, account devices only; role stays the highest) and a channel ctl message {type:"access", role, groupRoles?} when shares or caps change on a live channel. Display only: the daemon decides every request (E.7). Older clients ignore both.

Round A implementation and self-review#

Written by the round A lead on 2026-10-14 after the build. Every crypto, pairing and allowlist piece was reviewed by someone who did not write it: the Noise state machine, framing and channel by a second agent (findings R15-1..9, all fixed and approved); the pairing lifecycle, connector, platform verifiers and console-check by a third (F1-F7, L1-L4, D1, V1-V2, CC1-CC3, fixed or documented in appendix D); the allowlist, policy and relay core by the lead (policy HIGH-1..3, MED-4..8 and LOWs fixed and re-verified by probing authorize()/shapeResult(); relay-core findings in the residuals below). The local-dashboard WebAuthn path was reviewed by an agent that did not write it, then removed after the coordinator's re-review (C1, 2.4). Paths are under packages/nid/.

Where round A lives#

PieceCodeTests
Noise IK / IKpsk2, async DH, WebCrypto keyssrc/remote/noise.ts, dh.tstest/remote-noise.test.ts (published cacophony and snow vectors, both roles), remote-noise-crosscheck.test.ts (an independent node:crypto implementation, both roles, across a rekey), remote-channel.test.ts, remote-review-2.test.ts
Framing, fragmentation, channel APIsrc/remote/frame.ts, channel.ts, envelope.ts, flow.ts, encoding.ts, pairing-code.ts, types.tsremote-channel.test.ts, remote-review-2.test.ts
Remote principal, allowlist, policysrc/remote/allowlist.ts, policy.tsremote-allowlist.test.ts, remote-policy-e2e.test.ts
Connector, dispatch, lifecyclesrc/daemon/remote/connector.ts, dispatch.ts, access.ts, src/daemon/main.tsremote-connector.test.ts, remote-dispatch.test.ts, remote-e2e.test.ts, remote-adversarial.test.ts
Registry, keys, pairing, rotation, cloud.* RPCs, doctor and status linessrc/daemon/remote/registry.ts, keys.ts, pairing.ts, state.ts, rpc.ts, src/cli/doctor.tsremote-registry.test.ts, remote-review-cw.test.ts
Configuration hash, nonces, attribution, quarantinesrc/daemon/remote/config-hash.ts, nonces.ts, attribution.ts, origin.ts, orchestrator call sitesremote-policy.test.ts, remote-policy-e2e.test.ts
User verificationsrc/cli/user-verify.ts (platform), run by src/daemon/remote/state.ts for the CLI and the local dashboard alikeremote-verifier.test.ts, remote-registry.test.ts, remote-review-c1.test.ts
CLIsrc/cli/cloud.ts (nid cloud … behind --experimental), nid relay-serveremote-cli.test.ts
Console checksrc/remote/console-check.ts (appendix D)remote-console-check.test.ts
Relay core and adapterssrc/relay-core/core.ts, auth.ts, node.ts, bun.ts, proxy.ts, dev-accounts.ts (appendix C)remote-relay.test.ts, remote-relay-bun.test.ts, remote-adversarial.test.ts
Device reference client, harness, development consolesrc/remote/device.ts, test/remote-harness.ts, test-only test/remote-daemon-entry.ts, src/devconsole/remote-connector.test.ts, remote-e2e.test.ts, test/browser/remote-pair.smoke.ts (exit criterion)

Every finding of the review passes#

FindingImplemented inTested in
R1 a service worker cannot pin itselfRound B has the service worker. Detection ships now: console-check runs before every pairing (cli/cloud.ts) and daily while remote access is on (daemon/remote/access.ts)remote-console-check.test.ts; remote-cli.test.ts "pair checks console before producing a secret"
R2, R2-b, R2-c, M3 executable configurationconfig-hash.ts (whole group configuration minus goal and budget; global agents, mcp and notify; referenced scripts, package-manager files and lockfiles, agent config directories, shell files), checked in the orchestrator's driver start and restart path keyed on origin.ts; land marks needs-local-start; rotate is not remotely settableremote-policy.test.ts; remote-policy-e2e.test.ts "a device-triggered restart is refused…"
R3 operate is the human; attribution; view default; scopeattribution.ts (from "label" in events, board and artifacts), view default in state.ts, scope in policy.tsremote-policy.test.ts; remote-policy-e2e.test.ts (attributed approvals, scope)
R4, principle 7 redactionpolicy.ts redactSessionRecord and shapeResult (group records too: webhook URLs masked, setup dropped)remote-allowlist.test.ts; remote-policy-e2e.test.ts "session records are redacted…"
R5, R5-b, R5-c lockout leversconnector.ts onOpen: unknown session ids refused before any DH, 8 unauthenticated slots, eviction only of slots older than 1 s, a pairing awaiting the human is not evictable, 5 s timeout, never stops accepting; the relay counts failures per opener (relay-core/core.ts)remote-connector.test.ts (cap, timeout); remote-relay.test.ts "spoofed device IDs burn attacker's session budget"; remote-adversarial.test.ts (200 opens keep the authenticated slot)
R6 rotation grace and --compromisedstate.ts rotateKey; connector.ts grace path sends only key_rotate, and only to a device whose active row has the same keyremote-registry.test.ts; remote-connector.test.ts; remote-e2e.test.ts (offline grace, compromised re-pair)
R7, R7-b self-hosted relay auth and bootstrapRelay secret, one-time pairing token t=, per-device token in the sealed paired message, revoke through relay_ctl (auth.ts, core.ts, connector.ts); NID_RELAY_SECRET login (cli/cloud.ts); wss:// except loopback (connector.ts, state.ts, proxy.ts)remote-relay.test.ts; remote-cli.test.ts; remote-connector.test.ts
R8, R8-b pairing is when a bad console winscli/cloud.ts runs console-check before showing a code; --insecure-console only for loopback and self-hosted origins; the transparency log is checked in console-check.tsremote-cli.test.ts; remote-console-check.test.ts
R9 downgrade through openchannel.ts daemonHandshake (the prologue version must equal the best common version) and deviceHandshake (payload2.ver must equal the prologue's)remote-channel.test.ts "version downgrade…"; remote-review-2.test.ts R15-8; remote-adversarial.test.ts
R10 push_result is a hintRound B has the sender; the connector ignores push_result and the relay installs no default senderremote-adversarial.test.ts (forged 404/410 leave the stored subscription)
R11 REKEY flagchannel.ts: fixed schedule, the flag is a consistency checkremote-channel.test.ts "fixed rekey schedule…"; the crosscheck across 2^16
R12 burning a codepairing.ts: 1,000 failures per code, counted per source channel; 120 s liferemote-registry.test.ts "pairing manager: 120 s life…"
R13, rule 3 nothing acted on from message 1connector.ts: payload1 is used only after key confirmation; the prompt only after message 3remote-connector.test.ts; remote-e2e.test.ts (a wrong code reaches no prompt)
R14 email changes on the accountHosted accounts service (private repository, later); re-link keeps keys and id (state.ts login)remote-registry.test.ts "re-link keeps the daemon key and id"
R15 relay's empty-push alertRound B—
R16 two-step destructive methodsnonces.ts (60 s, single use, bound to device, method and params, per-device cap), dispatch.tsremote-policy.test.ts; remote-policy-e2e.test.ts "two-step nonce…"
R17 rotation and revoke-all from an admin phoneallowlist.ts (rotation and removeAllOthers LOCAL), rpc.ts (no self-revoke, notices)remote-allowlist.test.ts; remote-registry.test.ts
R18 reassembly memoryframe.ts: 32 MiB per channel, 128 MiB shared, 10 s between fragments, non-final fragments full-sizedremote-channel.test.ts; remote-review-2.test.ts R15-1
R19 the secret only in the fragmentpairing-code.ts; the console strips it with history.replaceStateremote-channel.test.ts "pairing URL keeps every field in the fragment"; remote-pair.smoke.ts
R20 pr.checks STEER, learn READallowlist.tsremote-allowlist.test.ts "tiers match docs/remote.md 5.3"
R21 identity hiding wordingSpec text—
R22, R22-b wire nitstypes.ts (pushsub); the relay refuses a push with a topic (core.ts); relay_reset on hibernation (core.ts resetBuffered)remote-relay.test.ts "hibernation loses buffered data…"
R12-b numbers and wordingSpec text—
C1 the console origin is trustedSpec text; cloud.hostedConsoleMaxRole enforced from the console host the daemon put in the URL (state.ts cappedRole, policy.ts effectiveRole)remote-allowlist.test.ts; remote-registry.test.ts (hosted cap)
H1 LOCAL-tier bypass through agentsUser verification runs inside the daemon for pairing, login and re-link, relay change, rotation, role raise and scope widening (state.ts); platform verifiers by absolute system path (user-verify.ts); the local dashboard calls the same methods, so the same OS prompt (2.4; the WebAuthn substitute was removed, C1); --no-user-verification only where no verifier exists, capped at view; refusal of remote approvals of nid/terminid/pawmux cloud/auth (attribution.ts); refusal inside agent sessions (cloud.ts); notices on every pairing, re-link, relay change and rotationremote-registry.test.ts; remote-verifier.test.ts; remote-review-c1.test.ts; remote-cli.test.ts; remote-policy-e2e.test.ts
H2 QR pairing needs a confirmationconnector.ts and state.ts confirmPairing: always asked after message 3; the prompt leads with fingerprint, role and scope; a second scanner gets "code already consumed by"; labels sanitisedremote-registry.test.ts; remote-review-cw.test.ts F1, F2; remote-e2e.test.ts
M1 short codespairing-code.ts: 80-bit codes, canonical parsing, domain-separated psk; both fingerprints in the promptremote-channel.test.ts; remote-registry.test.ts; remote-e2e.test.ts
M2 what a revoked device left behindattribution.ts onDeviceRevoked (flags tasks, notes and edits, quarantines memory by the stored repository, stops pr.watch); nid memory releaseremote-policy.test.ts "revoke: lists what the device left behind…"
M4 key storagedh.ts: asynchronous DH, non-extractable WebCrypto keys, all-zero rejection on both paths; the console keeps the CryptoKey in IndexedDBremote-channel.test.ts (non-extractable, low-order); remote-pair.smoke.ts ("non-extractable WebCrypto key")
LOW push TTL, urgency, topicThe relay refuses a push with a topic or another TTL (core.ts); the sender is round Bremote-relay.test.ts
LOW fixed text for the empty-push alertRound B—
LOW lower and narrow only from devicespolicy.ts, rpc.ts (also capping cloud.pair.begin; a scoped admin reaches only devices inside its scope)remote-allowlist.test.ts; remote-registry.test.ts MED-8, LOW-12
LOW the row named by the open's device idconnector.ts handshakeremote-connector.test.ts "a known id with the wrong key is unknown_device"
LOW compiled-in release keyconsole-check.ts RELEASE_CONSOLE_KEYS (empty: hosted fails closed, see residuals); cloud.consoleKeys is local configurationremote-console-check.test.ts
LOW the console's biometric gateSpec text—
LOW terminal control two-step and attributedallowlist.ts TWO_STEP; dispatch.ts binds terminal views to the device that opened themremote-e2e.test.ts terminal; remote-dispatch.test.ts
Re-review 1 (H1 partial)As H1As H1
Re-review 2 (hosted cap unenforceable)As C1As C1
Re-review 3 (push keys and the console)Round B—
Re-review 4 (own fingerprint, hashed file set)The console shows its own fingerprint; config-hash.ts file setremote-pair.smoke.ts; remote-policy.test.ts

Independent security review of 9147b56#

Two reviewers outside the round A group reviewed the integrated branch: one for crypto, channel and relay, one for the daemon side. They found no HIGH. Every MEDIUM was fixed after a regression test reproduced the attack, and every LOW was fixed. Each fix was reviewed by an agent that did not write it. Test files are under packages/nid/test/.

FindingFixed inReproducer / regression testReviewed by
A-M1 device accepted a plaintext ws:// relay on any hostsrc/remote/pairing-code.ts checkDeviceRelay, src/remote/device.ts, daemon/remote/state.ts (cloud.deviceRelay must be wss:// or loopback unless cloud.insecureDeviceRelay), dev console --insecure-lan-relay, "Round A demonstration"remote-review-ir.test.ts "A-M1: …" (2)codex
A-L1 weak relay secret, no throttle on failed upgradesrelay-core/auth.ts (strength check, generated 256-bit secret in relay-serve), core.ts AuthThrottle, Node and Bun upgrade paths. The review found the first throttle let a scanner behind the shared TLS proxy lock everyone out (MEDIUM); now valid credentials always pass and only failures are throttledremote-relay.test.ts "review A-L1: …", "review A-L1 + #47: …"; remote-review-relay.test.ts "#47 A-L1"cw
A-L2 pair rule 2 checked only the row the open namesdaemon/remote/connector.ts (any active row with the key), access.tsremote-connector.test.ts "A-L2: …"codex
A-L3 non-canonical X25519 encodings aliased keyssrc/remote/dh.ts assertPublicremote-review-ir.test.ts "A-L3: …"codex
A-L4 device swapped keys before the daemon applied the rotationsrc/remote/device.ts: switch only after device_key_rotated. The review found the converse (MEDIUM R46-1: the daemon stored the key, the ack was lost, the device stayed on the old key): the device keeps the unconfirmed key and tries it when the old one is refused, forgets it when the old one still works, and refuses concurrent rotations, claiming the guard before any await. A second review round (R52-1 LOW, R52-2 MEDIUM) found a same-turn race and that one forged refusal discarded the new key: an unconfirmed key is now kept until one key authenticates, and the client alternates between the tworemote-connector.test.ts "A-L4: …", "A-L4 follow-up …", "R46-1: …", "R52-1: …" (2), "R52-2: …"codex
A-L5 crosscheck never drove SecureChannel; a missing module skippedremote-noise-crosscheck.test.ts section (c) and remote-noise-reference.ts reference frame codec; missing product = failure"(c) product SecureChannel as device/daemon vs reference across the 2^16 Rekey…"cw
A-L6 ephemeral key, hash and HKDF intermediates not wipedsrc/remote/noise.ts (wipe on split and failure; HKDF temp, DH outputs, replaced keys zeroed)remote-review-ir.test.ts "A-L6: …"codex
A-L7 unauthenticated login starts filled the pending tablerelay-core/dev-accounts.ts (10/min, oldest evicted, no stderr)remote-relay.test.ts "review A-L7: …"cw
B-M1 view-only fallback decided by a live, fail-open checkdaemon/remote/state.ts (verifier observation persisted; fallback only after a local plain-terminal declaration, nid cloud no-verifier; probe errors and timeouts fail closed), cli/cloud.tsremote-ir-pairing.test.ts "B-M1: …" (6)cw
B-M2 a second local pair.begin replaced the user's offerstate.ts (no replacement of a live or consumed offer without explicit cancel, notice), rpc.ts (offerId required on confirm), cli/cloud.ts (pins offer, role and scope)remote-ir-pairing.test.ts "B-M2: …" (4)cw
B-M3 one WebAuthn enrollment became promptless authority (C1 in the re-review: the bearer token reaches the dashboard through dashboard.open → login link → cookie, so even a dashboard-only, capped passkey let an agent confirm its own operate pairings)The WebAuthn substitute is removed (webauthn.ts, its RPCs, table and UI are gone); the dashboard approves through the same cloud.* methods, so every pairing, role raise and scope widening shows the OS prompt; GET /login requires browser navigation markers (hygiene only); 2.4 says the dashboard listener is reachable with the bearer tokenremote-review-c1.test.ts "C1: …" (reproduces the chain: no passkey path, one OS prompt per pairing)codex
B-M4 task.update owner reached another groupdaemon/server.ts (owner must be a session of the task's group), remote/allowlist.ts + policy.ts (owner is a scope source)remote-policy.test.ts "IR B-M4: …", remote-allowlist.test.ts "IR B-M4: …"lead
B-M5 configuration hash followed references one level (re-review C2, C3, C5: ignored env files and MCP entry points, a stat-keyed cache plus a per-group rate limit, big files hashed by content)daemon/remote/config-hash.ts treeHash (every executable-capable file of the tree; .txt counts, since requirement files and CMakeLists.txt are executable input), land-sensitivity of every executable-capable path; 5.2 principle 3remote-config-tree.test.ts "B-M5: …" (3), "C2: …" (2), "C3: …", "C5: …", "#59 LOW: …"; remote-review-cw.test.ts "#47 B-M5", "#59 C3" (Windows)cw
B-L1 revoke summary missed several kinds of actiondaemon/remote/attribution.ts (approvals, answers, verdicts, model and permission changes with sessions left in yolo, terminal, land, settings)remote-policy.test.ts "IR B-L1: …"lead
B-L2 label filter only caught some separatorsdaemon/remote/registry.ts cleanLabelremote-ir-pairing.test.ts "B-L2: …"cw
B-L3 plaintext consoles on any host; only app.nid.dev treated as hostedstate.ts checkConsoleUrl, policy.ts hosted hosts (every *.nid.dev), console-check.tsremote-ir-pairing.test.ts "B-L3: …"cw
B-L4 verifier helpers inherited the daemon's environmentcli/user-verify.ts (fixed allowlisted environment)remote-verifier.test.ts "B-L4: …" (2)cw2
B-L5 Linux polkit message details refused for non-root callerscli/user-verify.ts (owner-annotated policy checked before any prompt), shipped assets/org.nid.remote.policy.in, appendix D (administrator install; not exercised on a real Linux desktop)remote-verifier.test.ts "B-L5: …" (3)cw2
B-L6 nid cloud|auth refusal missed wrappers and direct RPCremote/policy.ts isNidCloudCommand (still best effort, 2.4)remote-allowlist.test.ts "IR B-L6: …"lead
B-L7 crash auto-restart skipped the configuration checkdaemon/orchestrator.ts (a driver a device started restarts as that device); residual in 5.2 principle 3remote-policy.test.ts "IR B-L7: …"lead

Re-review of 72ef3ff#

The coordinator's re-review confirmed every A-* fix, plus B-M2 and B-M4. It found B-M1 fixed for agents that hold only the bearer token; agents that write files are the stated same-user residual. Its remaining items were fixed the same way as before: reproducer first, non-author review.

ItemFixed inReproducer / regression testReviewed by
C1 (B-M3 partial) the bearer token reaches the dashboard, so a passkey could be enrolled without a browser and approve new pairingsThe WebAuthn substitute is removed. Every pairing, role raise and scope widening runs the OS verifier, and the dashboard's buttons call the same RPCs (daemon/remote/state.ts, rpc.ts, main.ts, dashboard panel). /login requires Sec-Fetch-Mode: navigate and Sec-Fetch-Dest: document as hygiene, not as a control. Section 2.4 states that the dashboard listener is reachable with the bearer token, and explains the removalremote-review-c1.test.ts "C1: bearer token -> dashboard cookie gets no passkey path; every pairing it approves shows the OS prompt"codex
C2 (B-M5 partial) executed files that git ignoresconfig-hash.ts: .env*/.envrc* anywhere in the tree; the files every MCP config and hook command names; and the installed entries and .bin shims of runner packagesremote-config-tree.test.ts "C2: …" (2), "#59 LOW: …"cw
C3 the tree hash ran without bound on the event loopconfig-hash.ts: a per-group limit of max(20, 2 × sessions) device checks a minute. Device checks read every file, because the stat cache is forgeable on Windows (#59), so the cache serves only local recordsremote-config-tree.test.ts "C3: …"; remote-review-cw.test.ts "#59 C3"cw
C4 helper failures counted as "no verifier"cli/user-verify.ts, state.ts: the check has three outcomes (available, absent, unknown); unknown never prompts and never permits the fallback; the daemon probes at startup without blocking. Follow-ups: on Linux, only a missing system pkaction/pkcheck counts as absent; the check deadline (35 s) now covers the slowest helper (30 s)remote-verifier.test.ts "C4: …", remote-ir-pairing.test.ts "C4: …"cw2
C5 big files recorded by size and mtimeconfig-hash.ts: streamed content hash with no size limitremote-config-tree.test.ts "C5: …"cw
C6 a device's pending rotated key lives only in memoryRound B item (see the list below): a console that offers rotation must persist it——

Not delivered from the round A goal, with the reason: "WebAuthn userVerification: required in the local dashboard" as a way to verify the person. The re-review showed that anything with the bearer token reaches the dashboard listener, so a passkey held by the dashboard cannot stand in for the person. Every narrower grant still widened past the human's last OS-verified decision (2.4). The dashboard stays a place to start these actions; the OS prompt on the machine is what approves them.

Decisions on the reviewers' remaining LOW and INFO notes:

  • The tree hash runs on the daemon's event loop. Re-review C3 showed a device could make it run repeatedly. Device-triggered checks read every file each time (the stat cache is forgeable on Windows and is used only for local starts) and are capped at max(20, 2 × sessions) per group per minute; local records reuse unchanged files. The work is still synchronous: about 150 ms for 700 files, seconds on a very large monorepo, at most that many times a minute per group. Moving the check to a worker is a round B item.
  • Label filtering has false positives. cleanLabel removes anything shaped like a fingerprint (four groups of four letters or digits, any separator), so a label such as "Cody home iPad mini" becomes "…". The filter is kept as it is: a label is chosen by whoever scanned and is cosmetic, the prompt leads with the real fingerprint, and a narrower pattern would let a forged fingerprint through.
  • Approve only the prompts you started (B-M2 residual). A process with the bearer token can still begin and cancel its own offers. The CLI pins its own offer, role and scope and aborts on any change. The OS prompt states device, role and scope, and that prompt is the control.
  • Real verifier helpers are not exercised on this machine. The Windows Hello and CredUI helper, the macOS helper and polkit now run with a minimal allowlisted environment (B-L4). Their real prompts are part of the manual check (appendix D), as on Linux.

Changes to this spec made during round A, with reasons#

  • Appendix B gained the implementation details (key confirmation in both modes, Crockford base32, the short-code psk, the rekey schedule, padding, consecutive fragments, relay_ctl), each with its reason. Appendix C is the self-hosted relay contract, appendix D the console verification format and platform verifier setup.

  • Message 2 goes out before the human decides, so the granted role, the record id and the device's relay token travel in a sealed paired channel message after the confirmation (pending, paired, pair_refused in types.ts), not in payload2.

  • Revocation keeps the registry row as revoked instead of deleting it (3.4 said "deletes"): handshakes are refused the same way, and the artifacts a revoked device left keep naming it.

  • cloud.deviceRelay (see "Round A demonstration"): the relay address advertised in pairing URLs. Routing tokens travel to it, so it must be wss:// or loopback unless cloud.insecureDeviceRelay is set (independent review A-M1).

  • rotate left the remotely settable group.update settings (5.3): it makes the daemon restart drivers outside the device's context, which would skip the configuration check.

  • Device-side key rotation is held in memory (re-review C6). The reference client keeps an unconfirmed rotated key in memory (src/remote/device.ts). A console that offers key rotation must persist the pending key (round B: add a persistence hook before any console uses rotation), or a reload between the rotation and its confirmation strands the device. Optional round B nicety: the dashboard's pending list marks offers the user's own CLI did not start.

Residual risks and open items after round A#

  • Same-user writes (raised as a design HIGH in review). User verification guards the RPC paths. A process running as the user can still write nid.db (insert a remote_devices row), cloud.json, the 0600 secrets fallback or the nid binary. That is the residual already stated in section 2.4: such an agent is the user on that machine and does not need the prompt, because it edits the state the prompt protects. A narrowing step (an HMAC over registry rows with a key the OS keychain releases only after user presence) is a round B candidate; it cannot help on the Windows DPAPI or plain-file fallback, where the key is readable by the user.
  • Hosted console release key not provisioned. RELEASE_CONSOLE_KEYS is empty, so console-check fails closed for the hosted console; the operator must provision the offline release key before round B. Self-hosted consoles use cloud.consoleKeys.
  • Platform prompts not exercised on a real machine. The automated suites inject the verifier. The manual check in "Round A demonstration" and appendix D is for the human.
  • Relay core review. The lead's review of the relay core (control messages under backpressure, a malformed counter that counts ordinary races, the unauthenticated share of the socket cap, pong buffering, dev-account approval, self-host budgets per token, a per-channel byte budget, and LOWs) was fixed by a non-author and re-reviewed and approved by another; so were the follow-ups R31-1/R31-2 (the per-channel frame and byte caps now apply to the device-to-daemon direction only, so an event replay or a 16 MiB result is not cut off). Remaining LOWs: a single maximum-size (16 MiB) request from a device meets the 16 MiB/min ingress cap, so devices should keep requests well below it (no round A method sends one); behind a reverse proxy the per-address cap for unauthenticated sockets becomes one shared cap of eight (appendix C), since forwarded headers are not trusted.
  • WebAuthn review. R30-1 (the short-code confirmation in the dashboard now shows and binds both fingerprints) and R30-2 (strict CBOR and attestation parsing) were fixed and approved by the reviewer; the whole WebAuthn path was later removed (C1, 2.4), and the dashboard shows the same prompt text and both fingerprints before its OS prompt.
  • Not covered end to end, by design: PTY terminal control (the test daemon runs under Node; PTY needs Bun or the release binary), the 120 s natural expiry, the 1,000-attempt limit and nonce expiry (unit-tested with an injected clock), and push (round B).

Round R implementation and self-review#

Written by the round R lead on 2026-10-15. Round R's public half (section 10): appendix E, the development accounts service in the Node and Bun relay, account mode in the daemon, nid rc and /rc, the console app and Web Push. The hosted half (Durable Objects relay, D1 accounts with passkeys, the VAPID sender, hosting app.nid.dev and relay.nid.dev) is built later in the private repository from appendix E alone. Paths are under packages/nid/.

Where round R lives#

PieceCodeTests
Contractappendix E (E.12 records every later change)—
Signed device listssrc/remote/device-list.ts (NID_ACCOUNT_KEYS placeholder, empty)remote-device-list.test.ts (fixed vector of E.4), remote-device-list-review.test.ts (reviewer's independent suite: OpenSSL cross-check, malleability, JSON ambiguity, key aliases)
Development accounts (E.10)src/relay-core/accounts-dev.ts; hooks in core.ts (AccountHooks, sendToDaemon, endChannels, dropDevices, dropDaemon) and auth.ts (account sessions bound to a device id); node.ts, bun.ts; nid relay-serve --dev-accounts prints the signing keyremote-relay-accounts.test.ts (Node and Bun), remote-relay-accounts-bun.test.ts, helper remote-accounts-harness.ts
Daemon account modesrc/daemon/remote/account.ts; hooks in state.ts, connector.ts, access.ts, registry.ts (kind, first_seen_at), store.ts, rpc.ts (cloud.rc.*, LOCAL tier), policy.ts (groupRoles, effectivePrincipal)remote-account.test.ts, remote-account-access.test.ts, remote-review-9.test.ts (reviewer's reproducers), remote-rc-e2e.test.ts (real daemon process, written against E.7/E.8 by a non-author)
CLI and terminal appsrc/cli/rc.ts, src/remote/rc.ts (types, first-run text, badge), src/frontdoor/commands.ts (/rc), status lineremote-rc-cli.test.ts
Web Pushsrc/remote/webpush.ts (RFC 8291, WebCrypto only), src/daemon/remote/push.ts, src/relay-core/dev-push.ts (loopback-only development sender)remote-push.test.ts (RFC 8291 vector, fake push service with VAPID check, scope, R10)
Console appsrc/webconsole/ (check-public reserves console/ for hosted code), scripts/build-console.ts (pnpm build:console); dashboard transport seam in src/dashboard/ui/api.tswebconsole.test.ts, remote-console-review.test.ts (the bundled service worker), test/browser/webconsole.smoke.ts, rc-console.smoke.ts, rc-console-fixes.smoke.ts, remote-console-review.smoke.ts, remote-rc.smoke.ts (exit demonstration); scripts/rc-binary-smoke.ts (the compiled binary)

Round R demonstration#

test/browser/remote-rc.smoke.ts runs the whole flow with no network: a real daemon process on a temporary NID_HOME with a mock agent and an injected verifier, the Node relay in dev-accounts mode, the built console in headless Chromium with a phone profile. The real nid rc demo command links the machine (one verifier prompt, naming the account); the phone signs in through the relay's sign-in page, registers its key and opens the machine; the daemon raises the new-device notice (board note, cloud.rc.status badge and newDevices, an announcement to the other connected device); a mock agent's approval is pushed to a fake push service and decrypted; the phone allows it and the event is attributed to it; nid rc devices rm and nid rc off end it. Screenshots: signed in, connected, approval waiting, approved.

Decisions#

  • nid rc is the common path; nid cloud is strict mode. nid rc links through the accounts service and ships without --experimental (reviewed in #9). nid cloud login|pair|... stays behind --experimental for strict mode and self-hosted relays in secret mode: its pairing flow is unchanged from round A, and the hosted console's release key is not provisioned yet (round A residual), so the hosted strict path still fails closed.
  • The daemon runs the device-code exchange (E.2), so the connector token never passes through the CLI process, and the OS prompt comes after the person approved in the browser, naming the account.
  • Lists expire (72 hours, re-signed daily): the only bound on a relay that withholds a revocation (E.4, E.11).
  • Consoles pin daemon keys on first use (E.6): in account mode the device learns the daemon key from the service, a power strict mode never gave it.

Reviews#

Every security-relevant piece was reviewed by an agent that did not write it; every finding was reproduced first, fixed by the author and re-reviewed.

PieceAuthorReviewerFindingsOutcome
Device lists (device-list.ts) and the development signerleadcodex (#13, #18, #20)R13-1 LOW envelope size counted three strings, not the whole UTF-8 JSON; R13-2 MEDIUM a continuously connected daemon was never sent a re-signed list, so admission stopped after 72 h; R18-1 MEDIUM a re-sign triggered by one daemon's request did not reach the othersAll fixed (#17, #19), approved
Web Push (webpush.ts, push.ts, dev-push.ts)codexlead (#10, #14)R10-1 MEDIUM a device could store an endpoint whose normalised form exceeded the relay's limit, and every push then counted toward disconnecting the daemon; R10-2 LOW extractable ephemeral keyFixed (#14), approved
Account admission (account.ts and hooks)cw2cw (#9)R9-1 MEDIUM hostedConsoleMaxRole (C1) did not apply to account devices when cloud.console named a self-hosted console; R9-2 MEDIUM edits to remote.strictPairing, accountMaxRole, hostedConsoleMaxRole did not reach live account channels; R9-3 MEDIUM a re-link to another accounts origin with the same account id kept the old list, rollback watermark and shares; R9-5 MEDIUM a re-link with changed pinned keys kept the list the removed key signed and its watermark (a forged maximum version would brick account mode); R9-4 LOW a failure after the link call left partial stateAll fixed (#25): account devices are always capped by hostedConsoleMaxRole; configuration changes are re-checked every 5 s and narrow or close live channels; a list is valid only for the (accounts origin, account id) and pinned keys it was verified under; the link commits atomically. Re-reviewed and approved (#28)
Console app (src/webconsole/)cw, cw2codex (#27)R27-1 MEDIUM a returning sign-in kept the old registration while the new session was unbound, so opens were refused; R27-2 MEDIUM accepting a changed daemon key pinned whatever key the service returned at that moment, not the one shown; R27-3 MEDIUM forget/sign-out kept the push subscription and the worker still showed private notifications; R27-4 LOW the key-changed warning showed a fingerprint the service suppliedAll fixed (#30): registration bound to its session, the exact accepted key is pinned, sign-out unsubscribes and the worker drops payloads without an identity, fingerprints derived from keys. Re-reviewed and approved (#31)
Console against the relaycw(contract check #16)The development sign-in and link pages refused real browsers (referrer-policy: no-referrer makes Chrome send Origin: null on the page's own POST); an invalid CSP sourceFixed; E.3 note for the hosted pages

Lead self-review against section 11.1 and the earlier findings this round touches#

Against the risks section 11.1 states:

  • "The nid.dev account is the lock." Said in nid rc's first-run text (src/remote/rc.ts FIRST_RUN_TEXT), in getting started and in security.md. What narrows it: shares per group with expiry (--for), --view, remote.accountMaxRole, never admin; a new device raises the notice on its first connection (desktop, board, badge, announcement) with the revoke command; a local revoke wins over every later list (tested with a list that still names the device and with a device that never connected). Registering a new device key needs a fresh passkey assertion on the hosted service (E.3; the development service enforces the freshness window).
  • "The relay still cannot read or forge traffic." Unchanged: account devices complete the same Noise IK handshake; the daemon checks that the handshake's static key equals the list entry at the open and again at key confirmation; the relay strips the session token from open, and an account session routes only for the device id it is bound to. A relay can withhold lists; the 72-hour expiry bounds that and is the reason lists expire at all (E.4). Rollback and equivocation are refused and the watermark survives restarts.
  • "The accounts service could add a device." True and stated. New in round R, and not spelled out in 11.1 before: the service also tells a console the daemon's key (E.6), so it could impersonate a machine to a device that never connected; consoles pin daemon keys and warn on a change only the service reports. Strict mode removes both.
  • "What stays on the machine is unchanged." cloud.rc.* is LOCAL tier (never reachable from a device), and linking or changing the account runs the OS prompt in the daemon (one prompt, before the link call, nothing committed on refusal, the prompt names the account). Sharing more groups needs no prompt by design: it grants only the account the person already verified.

Earlier findings this round touches:

  • H1 (LOCAL tier through agents). Link and account change are verified in the daemon; the CLI refuses agent sessions (best effort, E.12). An agent that can call cloud.rc.share can share more groups with the user's own account, as stated.
  • C1 (the hosted console cap). Broken by the first version of account admission (R9-1) and fixed in #25: the cap applies to account devices whatever cloud.console names.
  • B-M1 / C4 (verifier fallback). Account linking has no no-verifier path; only a verified prompt links (confirmed by the #9 reviewer).
  • A-L2 (the row an open names). Strict rows are looked up first and win; an account row is never created for a key a strict row holds; the key is checked against the list entry, not only the device id.
  • R5 (lockout). Account opens use the same unauthenticated-slot rules; a list request for an unknown device id is limited to one a minute on the daemon and three a minute at the relay.
  • R10, R22 (push hints and metadata). Unchanged rules, now implemented: push_result only marks a subscription suspect; TTL, urgency and the absence of a topic are constants; pushes go only to groups the device is admitted to at the moment of sending, re-checked after encryption. R10-1 (an endpoint that grew past the relay's limit after normalisation) was a new lever of the same family and is fixed.
  • A-M1 (plaintext relays). Accounts origins must be https: except on loopback; relays as in round A.
  • R14 (re-link keeps keys and id). Kept: a daemon re-linking to the same account keeps its id and key; the development service keeps a free id and never hands over another account's.
  • M2, B-L1 (what a revoked device left). nid rc devices rm goes through the same revoke path, so attribution and the revoke summary are the same as for strict devices.
  • B-L2 (labels). List labels are cleaned by the service and again by the daemon before any notice.

R9-2 and R9-3/R9-5 were real gaps in that the daemon did not treat a configuration or origin change as an account change; the rule now is that the accepted list is valid only for the account id, accounts origin and pinned keys it was verified under.

Changes to the spec made during round R, with reasons#

  • Appendix E is new; its own change log (E.12) lists every amendment after it was announced.
  • Section 11.2: nid rc replaces nid cloud login for the common path (Decisions above).
  • Section 11.1 gained the daemon-key point: in account mode a device learns the daemon's key from the accounts service (E.6), so a compromised service could impersonate a machine; consoles pin daemon keys. Reason: found while writing E.6; 11.1 listed only the device side.
  • Section 11.3: lists carry an expiry (E.4) so a relay cannot withhold a revocation forever, and a list is valid only under the account id, accounts origin and pinned keys it was verified with (review #9).
  • Section 8.4 (account takeover) still describes strict mode; in account mode section 11.1 replaces it, as section 11 says.

Residual risks and open items after round R#

  • The accounts service is trusted (11.1, E.11): it can enroll a device and point a console at a daemon key of its own. Strict mode removes this; consoles pin daemon keys.
  • Placeholders the hosted team replaces: NID_ACCOUNT_KEYS (empty: account mode against nid.dev fails closed) and RELEASE_CONSOLE_KEYS (round A). The development VAPID key in dev-push.ts is the public RFC 8291 example key and never leaves loopback.
  • Not exercised offline: real WebAuthn (hosted), a real browser PushManager subscription (needs a real push service; the browser test proves push with a headless account device), the hosted VAPID sender and its push-service host allowlist.
  • Hosting requirements for the console: send the CSP (including frame-ancestors) as HTTP headers, since a meta tag cannot set frame-ancestors; sign-in and link pages that check Origin on POST must not use referrer-policy: no-referrer.
  • Local events: the terminal app polls cloud.rc.status (E.12); forwarding the daemon's rc events to the local event stream is a later improvement.
  • Same-user writes (round A residual) now include cloud.json's rc block (shares, accepted list, pinned keys): a process running as the user can widen shares without a prompt, as it could already edit the registry.