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/;
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, thennid cloud pairper device) and off by default. - Hosted on Cloudflare, portable by construction. The hosted relay (
relay.nid.dev, next to the console atapp.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#
| Term | Meaning |
|---|---|
| daemon | The nid daemon on your machine. Owns the teams, the local API, the secrets store and (new) a long-term remote key. |
| device | A phone, tablet or browser profile that has been paired with a daemon. Holds its own long-term key. |
| relay | The 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. |
| account | A relay login (email or passkey). Decides who may reach a daemon through the relay. Does not decide who may read it. |
| connector | The part of the daemon that keeps the outbound WebSocket to the relay open and terminates the end-to-end channels. |
| channel | One end-to-end encrypted session between one device and one daemon, multiplexed over the WebSockets. |
| console | The 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:
| Party | Trusted to | Not trusted to |
|---|---|---|
| Your machine and daemon | Everything (it runs the agents) | — |
| A paired device | Act within its role and scope | Anything outside them; it is revocable |
| The console origin | Serve the client the devices run; see section 7 | Nothing 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 ciphertext | Read 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#
| Item | Where | Notes |
|---|---|---|
Daemon static key D | X25519 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 id | Random 128-bit id, base32, generated at cloud login and registered with the relay. Stored with the relay URL in $NID (not secret). The executable-configuration snapshots (section 5.2) are kept separately in $NID (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 token | Opaque 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 registry | Table 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
CryptoKeyin 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 throughderiveBits, 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, cloud).
Section 5.3 maps roles to methods.
| Role | Can |
|---|---|
view | Watch: everything read-only, including event streams and terminal output. The default. |
operate | Watch and steer: approve, answer, send, interrupt, stop, resume, change models, create and edit tasks and notes, review, land, restart a known team. |
admin | Operate, 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,
loginand re-link,relaychange,rotate-keyand 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), macOSLocalAuthentication(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 andOriginchecks are not controls: an approved shell command can shed the agent environment, allocate a pseudo-terminal and typey, read the bearer token indaemon.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.openmints a login link, and any program can follow it and send the requests a browser would, with a forgedOriginand the browser'sSec-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 withdaemon.jsonwould 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 samecloud.*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 /loginalso 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- verificationexists for such machines but caps every device paired that way atviewand marks them so in the device list. The cost is that a locked-down server cannot hand outoperateto 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 runsnid cloud no-verifier --experimentalin a plain interactive terminal and confirms a persistent local decision (no-verifier, timestamped). No bearer or dashboard RPC can create that declaration. The daemon also persists.json verifierObservedAtincloud.jsonwhen 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/orusr/ bin/ pkaction /(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).usr/ bin/ pkcheck - 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'scli.jsorsrc/), global flags with values before the subcommand (cli/ main .ts nid --home x cloud), and a direct call of the daemon API naming acloud.*method (curl .../). An agent that obfuscates the command (an alias, a script file, encoded text) is not caught; that is what "best effort" means here (rpc -d '{"method":"cloud .pair .begin"}' src/remote/ policy .ts isNidCloudCommand). - Every pairing, re-link, relay change or key rotation raises a desktop notification, a
cloudboard 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 (CLI
nid cloud pair --cancel --experimental) is required first and raises a notice.
Pending confirmations expose offerId; cloud 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.
kis the daemon's X25519 public key: the device pins it. Nothing the relay sends can replace it.sis 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 neithersnor the private half ofk.- 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, so it is not synced or recorded), connects to the relay, asks to be routed to daemon.replaceState 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 toldcode already consumed by <fingerprint>and the CLI shows the competing fingerprint so the human declines the wrong one. Onythe daemon writes the registry row and answers with its version, the granted role and the device's registry id. The CLI printspaired "<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 inpayload1is self-reported by the console, so a deliberately malicious console can display the expected value. The comparison and the pre-pair.console console-checkcatch 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-consoleis 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 aboutklearns 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
IKpsk2the 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 averto 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/. 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.N]
3.3 Pairing from an admin device#
An admin device can request cloud 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 devicesand 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-othersexists 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.watchschedules, goal edits. On revoke the daemon lists them (revoked "phone-7": 3 tasks, 1 note, 2 memory entries, 1 CI watch), stops the device'spr.watch, disables its memory entries pending local review (nid memoryshows them as quarantined;nid memory rmdeletes one,nid memory releasekeeps it), and flags its open tasks and notes on the board asfrom 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 inyolois called out by name (round A,onDeviceRevokedinsrc/).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 logouton 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 generatesD', then over every currently connected channel sends akey_rotatemessage containingpub(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 markedre-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_rotatewithpub(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, with the psk2 variant
during pairing (Noise), 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:
mode=session: after message 1 the daemon knows the initiator's static key. It must equal the key of the registry row named by thedeviceIdinopen(never "any active row"); otherwise the daemon finishes nothing, sends a plaintext relayclose(reason=unknownand counts the attempt (section 8.2). Before any Diffie-Hellman the daemon also checks the_device) deviceIdandmodethe relay'sopencarries: inmode=sessionan id that is not in the registry is refused outright (cheap rejection; the handshake, not the id, is what authenticates); inmode=pairan 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).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).- Message 1 alone proves nothing fresh. Noise
IKmessage 1 is replayable, so the daemon acts on nothing inpayload1(label, requested role, console release, resume hint, "last seen") until message 3 (pair) or the first authenticated transport message (session). In particular thePair "<label>"?prompt of short-code pairing is shown only after message 3. - Version:
payload1.verlists the device's supported protocol versions; the daemon picks the highest it supports and states it inpayload2.ver. The prologue carries the version the two ends agreed in the plaintextopen, which the relay could lower, so both ends check: the daemon aborts when the prologue version is lower than the highest version inpayload1.verthat it supports (the check that matters, since only the daemon knows both sets), and the device aborts whenpayload2.verdiffers from the prologue version. Version 1 ships with this rule. - The daemon never sends anything but
payload2before it has authenticated the device, andpayload2contains nothing sensitive (version, role, event cursor). - A handshake that has not completed within 5 seconds of
openis 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 newopenevicts the oldest unauthenticated channel only if that channel is older than one second (a legitimate handshake completes in one round trip); otherwise the newopenis 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/pongpair, 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,
maxPayloadinws) are never approached. Larger application messages are fragmented by the sender into consecutive frames with theMOREflag 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 bindsdaemonIdanddeviceId. 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 theREKEYflag on the first message under each new key purely as a consistency check: a decrypted frame whose flag disagrees with the receiver's schedule isclose(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
lastEventIdper daemon and re-subscribes withafter; the daemon replays per the SSE rules (bounded replay,resyncwhen behind,helloalways 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 secondsendwould 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
(
channelin the envelope). The relay allocates channel ids per daemon socket and tells the daemonopen(channel, deviceId)before forwarding the first handshake frame. The daemon treatsdeviceIdas 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
~/under.nid/ config .yaml 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 (theeventsRPC 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), and the device re-handshakes. Frames are never reordered or partially delivered._reset)
5. What is tunnelled#
5.1 The daemon API, unchanged#
The payloads inside the channel are the daemon API as documented, nothing else:
| Local | Remote frame | Notes |
|---|---|---|
POST / | 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 / | 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 / | sub {id, kind:"terminal", session} → ev frames with snapshot/data/control/resize/closed; data is binary | Viewer id arrives in snapshot and is used with session exactly as locally. |
POST /logout | ctl goodbye | Closes the channel; the account session is separate. |
The device-side adapter implements the same interface as the dashboard's
src/ (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#
- 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. - 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. Executable configuration needs a local decision.
setupcommands and custom agent definitions run on the machine. They can be used remotely (restarting a team whosenid.yamlthe machine has already run) but not changed remotely, and not changed by an agent either: an agent can editnid.yamlin its worktree, a phone canlandit, 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 exceptgoalandbudgetUsd(everysessions:entry with its agent, permissions, billing, prompt and extra arguments;setup;agents;copy;link;mcp; everysettingskey includingnotify,permissionsandfallback) together with theagents:,mcp:andnotify:blocks of~/, plus the files the configuration points at: every repository script a.nid/ config .yaml setupcommand names,package.jsonand thescriptsit defines wheneversetupinvokes a package manager, repository-local agent commands, the MCP server scripts of the project's MCP configuration, the files and directories named ascopyandlinktargets, and shell-activated files such as.envrc,.nvmrchooks anddirenvconfiguration. 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, whenlandintegrates a diff that touches any of those files ornid.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 localnid 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.modelon agents that switch by restarting,session.rotatein handoff mode, and anything added later) refuses with "nid.yaml changed since the last local start; runnid upon the machine" when the hash differs, because a restart re-resolves the custom agent definition from the current catalog.diffandgroup.summaryflag changes tonid.yamlprominently 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
landthat 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 localnid upornid 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 aNODEline there runs code), the files any MCP server or hook command names (a path into_OPTIONS=--require node_modulesor.venvincluded), and for MCP servers started by a package runner (npx,bunx,pnpx,uvx,pnpm dlx,yarn dlx,npm exec) the installed package'spackage.json,binandmainentries and itsnodeshim (_modules/ .bin --package=and-pincluded). What stays uncovered is code inside an ignored dependency tree that those entry files load in turn,python -mmodules 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/): a snapshot is a set of labelled hashes, recorded by the localdaemon/ remote/ config-hash .ts spawn,up,team.startandresume(never by a device, never by the daemon's own restarts). A device-triggered driver start compares the group'snid.yamlentry (and the file's other top-level keys), theagents,mcp,notify,permissions,billingandfallbackblocks of~/(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.nid/ config .yaml copy/linksources. Globs incopy/linkare covered by the configuration text, not expanded. A group with nothing recorded refuses device starts with "this group has no recorded local start; runnid upon 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 (defaultstopped: nothing restarts without a human), as local work.- Destructive is admin plus a daemon-side confirmation.
group.remove,sessionand.permissions {profile:"yolo"} memory.clearare 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 plusconfirm. A nonce is bound to the device, the method and the parameters, and works once. - 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,linkoroptions: 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..extraArgs group.updateadditionally 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. - No blanket actions from a phone.
approvalswithout.answer idorsessionresolves every pending approval locally; remotely it requires one of them, andrememberis refused (section 2.4). - Records are redacted for the channel. Session records carry the custom agent
definition (
options), whose.agentDef envmap and arguments may hold provider keys. The remote principal stripsoptions,.agentDef .env options,.agentDef .args optionsand.extraArgs options.envfrom every session record for every role (commandandmodestay for admin, so the Devices and Spawn views still make sense). Round A implements this as an allowlist ofagentDeffields (name,source,description, pluscommandandmodefor admin), so later fields such asmodelArgs,mcpor 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".
| Tier | Methods |
|---|---|
| READ | ping, 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, compression, 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) |
| STEER | send, interrupt, session, stop, resume (subject to the configuration hash, principle 3), answer, approve/approvals (with id or session, never remember), task.create, task.update, note, review.approve, review, session.model, group.model, session.rotate, session, 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 |
| ADMIN | group.remove (two-step nonce), session (yolo is two-step), session (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, session, 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, cloud, cloud (one device, not self), cloud (lower only), cloud (narrow only), cloud (still needs the local confirmation, 3.3); group.update raising budgetUsd |
| LOCAL | shutdown, dashboard.open, spawn, up, team.start with dir or for a new group, session, session, 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 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.viewsees it all.landpushes 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'sprsettings decide draft/base.team.startwith a path,upandspawnloadnid.yamlfrom a directory the caller names and run itssetupand 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 latergroup.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.listreveals installed commands and custom agent definitions,repos.*reveal or scan the filesystem (machine metadata): ADMIN or LOCAL.pr.checkslooks read-only but fetches with the machine's GitHub credentials and creates failure tasks: STEER.learnwithoutwriteis read-only: READ.notify.testfires 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;diffand events carry code. That is the product, sealed end to end, andviewsees all of it.
The allowlist is data (src/), 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) 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 (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/
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) 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>/ 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)
- 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 receivesp256dhorauth. The browser generatesp256dh/authon 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. - 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 arefcm,.googleapis .com updates,.push .services .mozilla .com *.push, and.apple .com *.notify. Matching ignores case and a trailing dot;.windows .com *.matches one or more subdomain labels, not the base host. Self-hosters can add exact DNS names or*.patterns in the local$NIDsetting_HOME/ config .yaml cloud(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.push .endpointHosts: [push .example .com] *.co.ukwould 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. - 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 withauth, AES-128-GCM,Content-Encoding: aes128gcm, record size 4096) using Node's WebCrypto, and sends the relay a plaintext envelopepush {endpoint, body:<ciphertext>}. TTL (300 seconds) and urgency (high) are constants for every kind, and there is notopic: 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 thekeyinside the payload; the daemon's own 10-minute dedupe keeps repeats rare enough that push-service collapsing is not needed. - 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. - The service worker on the device decrypts (the browser does this before the
pushevent 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); 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
keyandatfields 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 (cloudin.hostedConsoleMaxRole: view ~/, changeable only on the machine) makes the daemon grant at most.nid/ config .yaml viewto 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 (payload1is 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..console - 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):
- 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.
- Subresource Integrity and a strict CSP.
index.htmlreferences every script and stylesheet withintegrity=hashes; the CSP isdefault-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. - 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) so.console nid cloud devicesshows 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. nid cloud console-check. The CLI fetches the live console'sindex.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 beforenid cloud pairprints a code (section 3.1) and on a daily schedule while remote access is on; a mismatch is acloudboard note, a desktop notification and a line innid 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 bothconsole-checkand 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.- 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-checkfrom 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:
| Data | Why | Retention (hosted) |
|---|---|---|
| Account identity (email, passkey ids), account sessions, IP addresses of logins | Account security | Account lifetime; login IPs 90 days |
| Daemon ids, device ids, hashed connector tokens, routing table (which device may reach which daemon) | Routing | Until unlinked or revoked |
| Labels of daemons and devices | Shown in the account's device page | Strict 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 reasons | Abuse handling, status page | 30 days |
| Per-channel frame counts and byte volumes, timing | Flow control, metering | Aggregated 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 answered | Inherent in any relay; padding (4.3) hides which small action, not that there was one | Not 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 codes | Delivery | Until 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.
| Limit | Default |
|---|---|
| Daemons per account, devices per account | 10, 20 |
| Concurrent device sockets per account | 32 |
| Channel opens per device per minute | 30 |
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 minute | 60, 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
openwhose 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_devicerejections at the daemon (visible to the owner as "unknown device tried to connect" innid cloud status, the local dashboard and acloudboard note), - revoke the account's connector tokens and device routing entries (denial of service
against the real owner, repaired by
cloud loginagain); 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:/
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
MOREchain 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#
- Protocol package (
packages/): Noisenid/ src/ remote/ IK/IKpsk2state 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. - 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,doctorandstatuslines, unknown-device notices. - 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-checkships in round A with the release public key compiled in; a self-hosted console's key is explicit local configuration (cloudin.consoleKeys ~/), never fetched from the origin it checks. In round A the whole.nid/ config .yaml cloudcommand group is behind--experimentaland--insecure-consoleis 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. - Relay core (platform-agnostic routing and flow control behind a small socket
interface:
send(bytes) → accepted,onMessage,onClose,close(code), plusstore routingandpost 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,maxPayload1 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. - 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
Noiseand_IK _25519 _ChaChaPoly _SHA256 psk2test vectors and cross-checked against an independent implementation in CI. - Local end-to-end (
test/, tempremote-*.test .ts 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 inopen, floods unauthenticated opens, injects frames into other channels, starves credits, lies inpush_resultand 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;
rememberrefused; the configuration-hash refusal after an agent editsnid.yaml; the two-step nonce (missing, expired, reused); record redaction ofagentDef.env; scope enforcement on reads, streams, push and mutations; revoke while connected;--compromisedrotation forcing re-pair. - Fake push service: a local HTTP server that accepts Web Push POSTs, checks the
VAPID headers and the
aes128gcmframing, 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/
(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/), the page strips the fragment, generates a
non-extractable WebCrypto X25519 key and pairs through the Node relay; the machine side
confirms through cloud (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 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 (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 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) 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
operatedevice 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:
/rcin the terminal app shares the current team (group);nid rc --allshares every group on the daemon;nid rc <group>shares one.nid rc off[<group>|--all] stops sharing (connector stays linked);nid rc --for 8hexpires automatically;nid rc statuslists what is shared, the account, and connected devices. Shares survive a daemon restart. - Role: account devices get
operateby default;nid rc --viewshares read-only;remotecaps it locally..accountMaxRole: view|operate adminis 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 rcinside an agent session is refused likenid cloud(best effort; the OS prompt at link is what holds).- Round R decision:
nid rcis the common path and needs no--experimental;nid cloud login|pair|…stays behind--experimentalfor strict mode and self-hosted relays in secret mode. Defaults arehttps:/and/ accounts .nid .dev wss:/, overridable with/ relay .nid .dev cloud.accounts/NIDand_ACCOUNTS _URL cloud.relay/NID_RELAY_URL(or--accounts/--relay).
11.3 Devices on the account#
- A browser or phone signs in at
app.nid.devwith 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;
cloudfor 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..accountKeys - 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
remotethe daemon ignores the account device list entirely..strictPairing: true
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) | |
|---|---|---|---|
| Construction | Standard handshake pattern with formal analysis and test vectors; mutual auth, forward secrecy, identity hiding, PSK binding | Standard one-way sealed messaging; auth and PSK modes exist | Mature primitives; secretstream gives ordered AEAD with rekey; kx is an unauthenticated DH |
| Fit for a two-way session | Direct: Split() yields both directions | Needs a composition of two one-way contexts or an exporter-derived session; no standard way to do that | Needs an authenticated handshake designed around kx (no standard pattern) |
| Pairing with a one-time secret | psk2, built in | mode_auth_psk, built in, but one-way | Hand-built |
| Runs in browser, Node, Bun | Yes, ~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 X25519 | WASM, async init (awkward in a service worker), ~200 KB+, needs wasm-unsafe-eval in the CSP |
| Audit status | noble: 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 mechanical | Library audited; the composition would be ours |
| Verdict | Best fit; the only custom code is the pattern state machine, which is what Noise was designed to make small and checkable | Right tool for sealed one-shot messages (we use the same idea in Web Push), wrong shape for a long-lived channel | Strong 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 |
|---|---|
| 0 | type: 0x01 open, 0x02 close, 0x03 data, 0x04 flow, 0x05 push, 0x06 push, 0x07 ping, 0x08 pong, 0x09 status, 0x0a relay_ctl (appendix C) |
| 1–4 | channel 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 |
|---|---|
| 0 | kind: 0x01 req, 0x02 res, 0x03 sub, 0x04 ev, 0x05 end, 0x06 cancel, 0x07 ctl, 0x08 pushsub |
| 1 | flags: bit 0 MORE (fragment continues), bit 1 REKEY, bit 2 BINARY payload |
| 2–3 | payload 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/); 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). Inpairmode it is the "message 3" of section 4.2 rule 2; insessionmode 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 withcrypto. 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/with the<ver> verfromopen, so version 1 is the literalnid-remote/1of section 4.2. - Text encodings. Ids, fingerprints and short codes use lowercase Crockford base32
(
0-9a-zwithouti l o u; the section 2.1 examplek7qm-3x9a-pz2h-4v8ris in this alphabet, RFC 4648 base32 has no8/9); input is accepted in any case with dashes or spaces and withi/lread as1andoas0. 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/. The code's 80 bits are still what bounds an offline search (3.2); the hash only sizes and domain-separates it.1 short-code" || 0x00 || normalised code) - Rekey schedule. Before sealing or opening the transport message whose counter
nis a non-zero multiple of 2^16, that direction callsRekey();nis not reset (Noise section 11.3). TheREKEYflag 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
BINARYsetting) while aMOREchain is open closes the channel withprotocol. - Relay control envelope. Type
0x0a relay_ctlcarries 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)
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 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.
| Endpoint | Contract |
|---|---|
WebSocket / | 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/device | Authorization: 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 / | Development 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 / | Local development approval page; GET never approves. |
POST / | Same-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/poll | JSON {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 body | Successful reply body |
|---|---|
{id,op:"pair | {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 | {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 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 and
RelayCore (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 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). 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#
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-checkfrom 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 isconsole-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.R2 (R, T) MEDIUM:
land+teamruns executable configuration an agent wrote. Principle 3 says.start {group, goal} setupand custom agent commands can be used remotely but not changed remotely. Chain: an agent editsnid.yamlin its worktree (setup: curl … | sh, or anagents:entry with anycommand), the task is integrated, anoperatedevice callsland(merges the integration branch into the checkout), thenteamor.start {group, goal} resumeof a custom-agent session re-reads the checkout'snid.yamland runs it. No path crossed the channel, yet a remote caller chose what runs. Locally the same chain needs a human to runnid upafter 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; remoteteam.start/resumerefuse with "nid.yaml changed since the last local start" when the hash differs. Also show changes tonid.yamland~/prominently in.nid/ config .yaml diff/group.summary. Status: resolved (section 5.2 principle 3, 5.3, 2.1): the daemon hashes the executable configuration at every local start; remoteteam.start/resumerefuse when it changed;diff/group.summaryflagnid.yamlchanges.R3 (T, lost phone) MEDIUM: "nothing that executes a command the remote caller chose" overstates what the allowlist buys. An
operatedevice cansend"runcurl … | sh" to an agent and thenapproveit: 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 stolenoperatephone 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"), makeviewthe default pairing role (operate is one--roleaway), 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 onesrememberalready refuses). Status: resolved (sections 2.4, 5.2 principle 6, 5.4):viewis the default role; the operate-is-the-human statement is in 2.4; remote mutations are attributed by device label;rememberis refused remotely; per-device group scope ships in round A.R4 (T, lost phone) MEDIUM: session records leak custom-agent
envvalues to any paired device.sessions.list/session.getreturnoptions, which is the full custom agent definition including its.agentDef envmap; users put provider keys there (env: {MYAGENT). The local dashboard shows this too, but principle 2 says nothing that reaches secrets crosses the channel, and a_API _KEY: …} viewdevice would see it. Resolution: the remote principal stripsoptions,.agentDef .env optionsthat look like secrets, and.agentDef .args optionsfrom session records (keep.extraArgs commandandmodefor 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,extraArgsandoptions.envare stripped from session records for every role; local-dashboard redaction noted as a follow-up.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 plaintextopen; 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 (rejectopens whosedeviceIdis 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.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) androtate-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): routinerotate-keykeeps the grace path and says it does not help against a leaked key;rotate-key --compromiseddestroys the old key at once and forces re-pairing; rotation is local only.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 atcloud loginagainst that relay, devices receive a per-device relay token at pairing inside the sealed payload2), requirewss://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 sealedpayload2;wss://required except loopback; TLS verified normally.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 pairruns the equivalent ofconsole-checkagainst the console origin the QR points to before printing the code, refuses on an unsigned or unknown release (--insecure-consoleto 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 pairrunsconsole-checkagainst 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.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-visibleopen, which the relay can rewrite to the lowest version both still accept.payload1.veris 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 inpayload1.verthat it supports; the device aborts whenpayload2.veris 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 orpayload2version is lower than the highest version inpayload1.verthe daemon supports; part of version 1.R10 (R) MEDIUM:
push_resultis 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: treatpush_resultas 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_resultis 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.R11 (R) LOW: the
REKEYflag 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; theREKEYflag is a consistency check whose mismatch closes the channel.R12 (R, T) LOW: pairing codes can be burned by anyone the relay routes. Message 1 of
IKcan be produced by anyone who has the daemon's public key (it ises/sswith 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.R13 (R) LOW: pairing message 1 is replayable, so nothing may depend on it alone. Noise
IKmessage 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 thatlabel,requestedRole,resumeHintand "last seen" are acted on only after message 3 (pair) or the first transport message (session), and that the daemon never shows thePair "<label>"?prompt before message 3. Status: resolved (section 4.2 rule 3): nothing inpayload1is acted on before message 3 (pair) or the first authenticated transport message (session); the pairing prompt appears only after message 3.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 loginwithout 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 withcloud logout+cloud loginwithout re-pairing.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.
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.clearandyolo. 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 forgroup.removeandsessionmake 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):.permissions {yolo} group.remove,sessionand.permissions {yolo} memory.clearare two-step with a daemon-issued nonce valid 60 seconds; client dialogs are a fat-finger guard only.R17 (admin phone) LOW:
cloudand 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 (.rotate .announce cloudremoved); an admin device revokes one device at a time, every remaining device and the local dashboard are told;.rotate .announce devices rm --all-othersis local only.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.
R19 (R) LOW: the pairing URL scheme and where the secret lands.
nid://pair?…needs a native handler; a phone camera opens anhttpslink in the default browser, not the installed PWA, and if the console is athttps:/the secret is sent to the relay's static host and ends up in its request logs. Resolution: QR content is/ console/ pair?s=… https:/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/ <console>/ pair#v=1&… https:/with every field in the fragment; the native app registers the same URL./ <console>/ pair#… R20 (R) INFO:
pr.checksis not read-only. The READ tier listspr.checks, but the API says it fetches CI status with the machine's GitHub credentials and creates failure tasks. Move it to STEER next topr.watch. Converselylearnis read-only per the API and could be READ. Status: resolved (section 5.3):pr.checksmoved to STEER,learnmoved to READ, with the reasons listed under the table.R21 (R) INFO: identity hiding is moot and should not be claimed. 4.1 credits
IKwith hiding the initiator from a passive relay, but the relay learns the device id fromopenand 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).R22 INFO: small wire and wording nits.
topicmust be at most 32 characters of base64url; an HMAC-SHA-256 is 43, so truncate to 32 (192 bits) and say so. Relay envelope type0x05 pushand plaintext kind0x08 pushshare a name for different things (rename the inner onepushsub). 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 renamedpushsub; hibernation under backpressure ends the channel withrelay_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.startandresumebut not the other remote methods that restart a driver. A driver (re)start resolves the custom agent definition from the current catalog first (catalog), and.agents .get(session .agent) ?? session .options .agentDef session(restart to apply the profile),.permissions session.rotatein handoff mode (fresh vendor conversation) andsession.modelon 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 undersessions:(a new session withyolo,permissions: yolo,billing: api,extraArgs, aprompt) and groupsettings(notify= an exfiltration channel for agent text,.webhooks permissions,fallback), all of whichteamapplies. Fix: hash the whole group configuration minus.start {group, goal} goalandbudgetUsd(and the wholeagents:,mcpandnotifyblocks of~/), and say so in 5.2 principle 3. Status: resolved (5.2 principle 3): the hash covers the whole group configuration minus.nid/ config .yaml goalandbudgetUsdplus theagents:,mcp:andnotify:blocks of~/..nid/ config .yaml - R5-b (LOW): the unknown-id precheck conflicts with pairing, and
openlacks the fields the rules read. 4.2 rule 1 refuses anopenwhosedeviceIdis not in the registry before any DH, but a pairing device is unknown by definition. Fix: the precheck applies tomode=sessiononly; inmode=pairunknown ids are accepted while a code is live (bounded by the 8 unauthenticated slots and the per-code limit). Appendix B must then defineopenas{deviceId, daemonId, ver, mode}, since rules 1 and 4 and the prologue readmodeandverfrom it. Status: resolved (4.2 rule 1, appendix B): the precheck applies tomode=session; pair mode accepts unknown ids while a code is live;openis{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
openis 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: whennid cloud pairprints 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'srevokemessage (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 tokent=in the QR fragment for self-hosted relays; long-lived device relay token inpayload2; revocation via the daemon'srevokemessage; 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, so the "compare the two screens" step and the daemon's device list catch accidents, not attackers..console console-checkfrom 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 thatconsole-checkand the console both consult, so a targeted build must also appear in the public log. Round A dev consoles are unsigned, so document--insecure-consoleas 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 ofconsole-checkare stated; an append-only transparency log that both the console andconsole-checkconsult is added;--insecure-consoledocumented 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(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.ver == prologue version 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#
H1 (HIGH): LOCAL-tier bypass through agents. An
operatedevice couldsendan agent "runnid cloud pair --role adminand post the URL" and approve it, or have it runcloud logout+cloud logininto the attacker's account,nid auth, or read the 0600 secrets fallback; ayoloor 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 atview; the daemon refuses remote approval ofnid (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.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 toldcode already consumed by <fingerprint>and the CLI shows the competing fingerprint; the console strips the fragment withhistoryon load..replaceState M1 (MEDIUM): short codes could be guessed offline. In
IKpsk2any 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 throughver.M2 (MEDIUM): a revoked device's influence persisted. Tasks, notes, memory entries and
pr.watchschedules 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'spr.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.M3 (MEDIUM): the configuration hash missed referenced executables. Setup scripts, repository-local agent commands, MCP server scripts and
package.jsonscripts could be changed by an agent, landed remotely, and run by a remoteteam.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 alandthat touches any of them (ornid.yaml, the project MCP configuration, the agents' config directories) marks the group "needs local start" until a localnid up.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.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); raising and widening are local (sections 2.4, 5.3)..devices .scope - The session-mode lookup uses the registry row named by the
opendevice id, never any active row (section 4.2 rule 1). - The round-A
console-checkhas 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).
sessionis two-step and attributed, and every terminal write is tagged with the device in the event stream (section 5.3)..terminal .control
- Push TTL and urgency are constants and there is no
Residual risks stated after this pass#
- The console origin can act for the devices that use it. Mitigated by operator
controls, detection, the
viewcap 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:
- 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 remoteoperatedevice, so "needs an approval" is no barrier. Resolved (section 2.4): the confirmations now require platform user verification (Windows HelloUserConsentVerifieror credential UI, macOSLocalAuthentication, Linux polkit with password, or WebAuthn withuserVerification: requiredin the local dashboard, challenge bound to fingerprint and role); the fallback without a verifier refuses remote-capable pairing or caps it atview; the daemon refuses remote approval ofnid (cloud|auth)commands as documented best effort; the "cannot drive" claims are removed and the residual covers approved agents. - New HIGH: the hosted-console
viewcap 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), and a device's console is the host the daemon itself put in the pairing URL, recorded at pairing;.hostedConsoleMaxRole payload1is informational..console - Push confidentiality shares the console caveat. The console's JavaScript can
read the
p256dh/authkeys the browser generates. Resolved (section 6.1); the stale "deduplicated by topic" line in 6.3 is fixed. - 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.jsonand its scripts,copy/linktargets and shell-activated files such as.envrc(section 5.2).
Appendix D: round-A console verification and platform setup#
src/ implements the CLI checker. The release operator has
not supplied a hosted-console signing key for round A. Its immutable compiled-in
RELEASE 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; 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 / and /. Both use
{"signed":"<exact JSON text>","signature":"<128 lowercase hex characters>"}.
The Ed25519 signature covers UTF-8 nid-console-manifest-v1\ or
nid-console-log-v1\, respectively, followed by the exact signed text.
The manifest payload is {"version":1,"assets":{"/. 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 + "\ 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/ 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
/. The template is shipped at
packages/ 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 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(defaulthttps:/, its own origin so that nothing else served on/ accounts .nid .dev nid.devruns script same-origin with sign-in; the WebAuthn RP ID staysnid.dev) serves the HTTP API under/api/v1/; the relay originR(defaultwss:/, 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/ relay .nid .dev cloud.accounts/NIDand_ACCOUNTS _URL cloud.relay/NID_RELAY_URL; both must behttps:/wss:except on loopback (checkRelayUrl). - JSON. Requests and responses are
application/, 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.json; charset=utf-8 - 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 oncodeonly and showmessageas 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: Bearerheaders or in the request body fields named below, never in URLs. The service stores only their hashes.
E.1 Who holds what#
| Credential | Held by | Proves | Lifetime |
|---|---|---|---|
| Connector token | Daemon (secrets store, cloud-token) | "this socket is daemon d of account a" to the relay (appendix C /v1/daemon) | until unlink |
| Account session | Console (IndexedDB) | "this browser signed in to account a", optionally bound to device v (E.3) | 30 days, refreshable |
| Account-device signing key | The 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 V | Device (section 2.2) | the Noise handshake, end to end | until the device re-registers |
Daemon static key D | Daemon (section 2.1) | the Noise handshake, end to end | until 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.
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}.userCodeis 8 characters fromBCDFGHJKLMNPQRSTVWXZshown asXXXX-XXXX;verificationUriCompleteisA/.link?code=<userCode> labelis 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.- The daemon returns the user code and URL;
nid rcopens the browser (--no-browserprints the URL only). OnA/linkthe 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. - The daemon polls
POST A/api/ v1/ device-code/ token {"deviceCode"}no faster thaninterval:400 authorizationuntil the person acts;_pending 400 slow_down(add 5 s to the interval);400 expired;_token 400 accesswhen denied;_denied 200 {"grant":"<single-use, 10 minutes>","account":{"id","email","name"}}once approved.
- 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. 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).publicKeymust equal the one in step 1 (409 conflictotherwise). The response'srelayis informational: the daemon connects to its configured relay.- 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/daemonbearer.
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>:
| Call | Body → result | Use |
|---|---|---|
POST A/ | {} → 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/ | {"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/ | {"label"} → 200 {} | Rename the machine. |
GET A/ | → 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/, built by scripts/).
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:
- The console navigates to
A/.signin?client _id=console&redirect _uri=<console origin>/ &state=<random>&code _challenge=<S256>&code _challenge _method=S256 redirect_urimust be exactly a registered console origin with path/(hosted:https:/; the development relay registers its own loopback console)./ app .nid .dev/ - The person signs in with a passkey (recovery: magic link, see below) and is redirected to
<redirect. The console checks_uri>?code=<code>&state=<state> state, removes the query withhistoryand exchanges the code:.replaceState 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 onA/,api/ v1/ session/ * A/,api/ v1/ devices* A/allows exactly the registered console origins, withapi/ v1/ machines* AuthorizationandContent-Typeheaders, no cookies (credentials: "omit").freshUntilis when the passkey assertion behind this session stops counting as fresh (5 minutes after it).recovery: truemarks a magic-link session.
Session calls, all with Authorization: Bearer <session>:
| Call | Body → result | Notes |
|---|---|---|
POST A/ | {} → 200 {"session","expiresAt","freshUntil"} | Rotates the token; the old one stops working. A refreshed session is not fresh. |
POST A/ | {} → 200 {} | Sign out. |
POST A/ | {"deviceId","publicKey":"<V>","label","platform"} → 200 {"device":<entry E | Register the device key. Needs a fresh, non-recovery session (403 fresh 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). The service emails the account "new device: <label>, fingerprint <fp>" and publishes a new list (E.4, E.5). |
GET A/ | → 200 {"devices":[<entry>…],"listVersion"} | For the console's Devices page. |
DELETE A/ | → 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/ | → 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/ | → 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}]}
| Member | Rule |
|---|---|
v | 1. 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). |
account | The account id. |
version | Integer, 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, expiresAt | Integers (epoch ms); issuedAt < expiresAt ≤ issuedAt + 72 hours (a longer list is refused as malformed, so the E.11 bound on withholding holds). |
devices | At most 200 entries, sorted by id (ascending by UTF-16 code unit, i.e. JavaScript <), ids unique. |
entry id | Device id (E conventions). |
entry publicKey | X25519 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 label | 0–64 characters, no C0/C1 control characters; display only (the daemon sanitises again, B-L2). |
entry platform | 0–32 characters of a-z0-9._-; display only. |
entry created | Integer epoch ms. |
entry revoked | null, 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.
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/), all of which must pass, in
this order, before anything in the list is used:
- Size and shape: the envelope parses,
payloaddecodes to at most 48 KiB,sigis 64 bytes. - Signature over
"nid-device-list/with one of the pinned keys: for the nid.dev accounts origin, the keys compiled into this build (1" || 0x00 || payloadBytes NID; 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,_ACCOUNT _KEYS cloudas it was when the daemon was linked (copied into.accountKeys cloud.jsonat 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-canonicalSand small-order public keys (noble's defaultzip215: falsebehaviour). - Canonical: the payload re-serialises to the same bytes; every member satisfies the table above.
- Account:
accountequals the daemon's linked account id. Otherwise refused (wrong_account). - Rollback:
versionis greater than the highest version this daemon has accepted for this account (persisted incloud.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. - Expiry:
expiresAtis 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/). 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.
Signing key rotation. Builds carry every currently valid key in NID. 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 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
openis 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 → {"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/).
Routing hint. A device may open R/ (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
daemonIdis linked to the session's account and online (otherwisestatus {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/ 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/ → 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:/;
{"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, neverlocalhost,*.localhost,*.local,*.internalor a single-label name) that matches the push-service allowlist:fcm,.googleapis .com updates,.push .services .mozilla .com *.push,.apple .com *.notify(case-insensitive, trailing dot removed,.windows .com *.matches on a label boundary). A self-hosted setup adds hosts withcloudin.push .endpointHosts 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, which the daemon treats as a hint like any other._result {status: 400} - The public relay core applies the daemon-side rules itself before any
PushPostersees an endpoint (a self-hosted daemon may be hostile), and reports every refusal, sender exception or out-of-range status as the same400, sopush_resultcannot be used to probe networks (PushPostercarries a self-hosted sender's extra hosts; review NEW-1)..endpointHosts
E.6 How the console connects#
GET A/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 authenticatedapi/ v1/ machines 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).- It opens a
session-mode Noise IK handshake (section 4.2) with its registered keyV;payload1is unchanged (section 4.2), withconsoleset to the console origin. payload2includes.features "account"when the daemon admitted the device through the account list; the role is the device's effective role (E.7).- The console calls
groups.listover 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
sessionopen from device idvis admitted through the account when: account mode is on (linked to an account,remotenot.strictPairing true); the current list is verified and not expired; it has an entry with idvandrevoked: null; the Noise static keyrsequals that entry'spublicKey; 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.devfor nid.dev) and raises the new-device notice (section 11.3) through the unknown-device machinery: a desktop notification, acloudboard note, the status-line badge and anannounceto every connected device, each namingnid rc devices rm <id>. - Role. For each shared group: the share's role (
operate, orviewfor--view), capped byremote(default.accountMaxRole operate) andcloudfor a hosted console host; never.hostedConsoleMaxRole 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 withrevoked(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 rowrevokedwith 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
publicKeyis refused for that device (no admission, the stored key is never overwritten), with onecloudboard 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).
cloudand.devices .role cloudrefuse account rows, whose access comes from the shares;.devices .scope cloudshows an account row's effective role and scope (.devices .list viewand[]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,ZlandZpcharacters (bidi controls, isolates, zero-width and line separators) before they reach the OS prompt, a notice orRcStatus; 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.
| Method | Params → result |
|---|---|
cloud | {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 | {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. 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 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 | {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 | {loginId} → {} |
cloud | {} → 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 | {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 | {ack?: true} → {devices: RcDevice[]} (ack as in cloud) |
cloud | {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 (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) are a later addition (E.12).
Shares live in $NID (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#
| Code | HTTP | Meaning / client behaviour |
|---|---|---|
invalid | 400 | Malformed body or field. Bug; show message. |
authorization | 400 | E.2 poll: keep polling. |
slow_down | 400 | E.2 poll: add 5 s to the interval. |
expired_token | 400 | Device code, grant or authorization code expired: start again. |
access_denied | 400 | The person denied the link. |
invalid_grant | 400 | Grant or code unknown, used or for another client. |
unauthorized | 401 | Token missing, expired or revoked. Daemon: unlinked (E.2). Console: sign in again. |
fresh | 403 | Needs a passkey assertion within 5 minutes: re-run sign-in. |
recovery | 403 | A recovery session cannot do this (E.3). |
device_limit | 403 | 20 non-revoked devices on the account. |
daemon_limit | 403 | 10 linked daemons on the account. |
forbidden | 403 | Origin not allowed, or not the account's resource. |
not_found | 404 | No such device, machine or code (for this account). |
conflict | 409 | Id or key in use, session already bound, key mismatch on link. |
payload | 413 | Over 64 KiB. |
rate_limited | 429 | Retry-After seconds. |
unsupported | 426 | The client's API version is no longer served: update nid. |
server_error | 500/503 | Retry with backoff (max 60 s). |
Daemon-local refusals (E.8, in the RPC error string): not_linked, agent_session, link_pending, rate_limited,
verification, verifier, no (non-default accounts
origin without cloud, or nid.dev with an empty NID),
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 throughcloudwith.accountKeys cloud.accountspointing at the relay; - passkeys are stubbed:
A/linkandA/signinshow 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/, 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;remote-accounts-harness .ts - 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
operateon shared groups. Strict mode (remote) removes this..strictPairing: true - 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/(check-public refuseswebconsole/ console/directories, which it reserves for hosted code);R/v1/statusis also what the service worker uses for empty pushes, and CORS on it andR/v1/push/keyis stated;cloud/.rc .status cloudtake.rc .devices ackandcloudmust stay cheap (the terminal app polls it);.rc .status nid rc offalone 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
cloudin round R (the daemon's internal events are not yet forwarded to the local event stream; nothing in the hosted contract depends on them)..rc .status - "Unlinked by your account" (E.2) is detected by
GET A/answeringapi/ v1/ daemons/ self 401after the relay connection drops, at most once a minute, since a relay may drop the socket without a4401close. - An invalid
remote:block inconfig.yamlfails closed:strictPairing: trueandaccountMaxRole: viewuntil it is fixed. - Changing the linked account also removes every share: they were granted to the old account.
- List refusals (E.4):
equivocationraises 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.
- E.8 events are replaced by polling
- 2026-10-15, from the reviews of round R:
agent_session(E.8) is refused bynid rcin the CLI and the terminal app, asnid cloudis; 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
Originchecks; 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, F2-F6), after DONE:.md - 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:attachnow returns a peer id. - F4:
R/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.v1/ device?daemonId= - 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.
- 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
- 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).cloudis the self-hosting opt-out. The console's static build ships a.push .endpointHosts _headersfile with the CSP (includingframe-ancestors 'none'),X-Frame-Options: DENY,nosniffand a referrer policy for every path includingsw.js(L2, L3), which the host must serve as response headers (E.3). The console drops push payloads whoseatis 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):
restorere-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);
cloudrefuses while another sign-in is pending, is rate-limited and the OS prompt carries the code and the daemon fingerprint (E.8, new code.rc .link .begin link_pending); strict-mode role and scope controls refuse account rows (E.7); service text is normalised and stripped of format characters (E.7);ackcannot 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);
restorere-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:/(was/ accounts .nid .dev https:/), so the marketing site on/ nid .dev nid.devcan never run script same-origin with sign-in, the link page or the session API. The WebAuthn RP ID staysnid.dev, so passkeys keep working fromaccounts; the relay stays.nid .dev relay.nid.devand the consoleapp.nid.dev. Pinned keys are unchanged (any*.nid.devaccounts origin uses the compiledNID). Daemons linked before this change keep the origin stored at link._ACCOUNT _KEYS - Per-group roles reach the device (additive, #26):
payload2(.groupRoles {<group>|"*": role}, account devices only;rolestays the highest) and a channelctlmessage{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#
| Piece | Code | Tests |
|---|---|---|
| Noise IK / IKpsk2, async DH, WebCrypto keys | src/, dh.ts | test/ (published cacophony and snow vectors, both roles), remote-noise-crosscheck (an independent node:crypto implementation, both roles, across a rekey), remote-channel, remote-review-2 |
| Framing, fragmentation, channel API | src/, channel.ts, envelope.ts, flow.ts, encoding.ts, pairing-code, types.ts | remote-channel, remote-review-2 |
| Remote principal, allowlist, policy | src/, policy.ts | remote-allowlist, remote-policy-e2e |
| Connector, dispatch, lifecycle | src/, dispatch.ts, access.ts, src/ | remote-connector, remote-dispatch, remote-e2e, remote-adversarial |
Registry, keys, pairing, rotation, cloud.* RPCs, doctor and status lines | src/, keys.ts, pairing.ts, state.ts, rpc.ts, src/ | remote-registry, remote-review-cw |
| Configuration hash, nonces, attribution, quarantine | src/, nonces.ts, attribution.ts, origin.ts, orchestrator call sites | remote-policy, remote-policy-e2e |
| User verification | src/ (platform), run by src/ for the CLI and the local dashboard alike | remote-verifier, remote-registry, remote-review-c1 |
| CLI | src/ (nid cloud … behind --experimental), nid relay-serve | remote-cli |
| Console check | src/ (appendix D) | remote-console-check |
| Relay core and adapters | src/, auth.ts, node.ts, bun.ts, proxy.ts, dev-accounts (appendix C) | remote-relay, remote-relay-bun, remote-adversarial |
| Device reference client, harness, development console | src/, test/, test-only test/, src/ | remote-connector, remote-e2e, test/ (exit criterion) |
Every finding of the review passes#
| Finding | Implemented in | Tested in |
|---|---|---|
| R1 a service worker cannot pin itself | Round 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-console-check; remote-cli "pair checks console before producing a secret" |
| R2, R2-b, R2-c, M3 executable configuration | config-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 settable | remote-policy; remote-policy-e2e "a device-triggered restart is refused…" |
| R3 operate is the human; attribution; view default; scope | attribution.ts (from "label" in events, board and artifacts), view default in state.ts, scope in policy.ts | remote-policy; remote-policy-e2e (attributed approvals, scope) |
| R4, principle 7 redaction | policy.ts redactSessionRecord and shapeResult (group records too: webhook URLs masked, setup dropped) | remote-allowlist; remote-policy-e2e "session records are redacted…" |
| R5, R5-b, R5-c lockout levers | connector.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/) | remote-connector (cap, timeout); remote-relay "spoofed device IDs burn attacker's session budget"; remote-adversarial (200 opens keep the authenticated slot) |
R6 rotation grace and --compromised | state.ts rotateKey; connector.ts grace path sends only key_rotate, and only to a device whose active row has the same key | remote-registry; remote-connector; remote-e2e (offline grace, compromised re-pair) |
| R7, R7-b self-hosted relay auth and bootstrap | Relay 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 login (cli/cloud.ts); wss:// except loopback (connector.ts, state.ts, proxy.ts) | remote-relay; remote-cli; remote-connector |
| R8, R8-b pairing is when a bad console wins | cli/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 | remote-cli; remote-console-check |
R9 downgrade through open | channel.ts daemonHandshake (the prologue version must equal the best common version) and deviceHandshake (payload2.ver must equal the prologue's) | remote-channel "version downgrade…"; remote-review-2 R15-8; remote-adversarial |
R10 push_result is a hint | Round B has the sender; the connector ignores push_result and the relay installs no default sender | remote-adversarial (forged 404/410 leave the stored subscription) |
| R11 REKEY flag | channel.ts: fixed schedule, the flag is a consistency check | remote-channel "fixed rekey schedule…"; the crosscheck across 2^16 |
| R12 burning a code | pairing.ts: 1,000 failures per code, counted per source channel; 120 s life | remote-registry "pairing manager: 120 s life…" |
| R13, rule 3 nothing acted on from message 1 | connector.ts: payload1 is used only after key confirmation; the prompt only after message 3 | remote-connector; remote-e2e (a wrong code reaches no prompt) |
| R14 email changes on the account | Hosted accounts service (private repository, later); re-link keeps keys and id (state.ts login) | remote-registry "re-link keeps the daemon key and id" |
| R15 relay's empty-push alert | Round B | — |
| R16 two-step destructive methods | nonces.ts (60 s, single use, bound to device, method and params, per-device cap), dispatch.ts | remote-policy; remote-policy-e2e "two-step nonce…" |
| R17 rotation and revoke-all from an admin phone | allowlist.ts (rotation and removeAllOthers LOCAL), rpc.ts (no self-revoke, notices) | remote-allowlist; remote-registry |
| R18 reassembly memory | frame.ts: 32 MiB per channel, 128 MiB shared, 10 s between fragments, non-final fragments full-sized | remote-channel; remote-review-2 R15-1 |
| R19 the secret only in the fragment | pairing-code; the console strips it with history | remote-channel "pairing URL keeps every field in the fragment"; remote-pair |
R20 pr.checks STEER, learn READ | allowlist.ts | remote-allowlist "tiers match docs/remote.md 5.3" |
| R21 identity hiding wording | Spec text | — |
| R22, R22-b wire nits | types.ts (pushsub); the relay refuses a push with a topic (core.ts); relay_reset on hibernation (core.ts resetBuffered) | remote-relay "hibernation loses buffered data…" |
| R12-b numbers and wording | Spec text | — |
| C1 the console origin is trusted | Spec text; cloud enforced from the console host the daemon put in the URL (state.ts cappedRole, policy.ts effectiveRole) | remote-allowlist; remote-registry (hosted cap) |
| H1 LOCAL-tier bypass through agents | User 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 rotation | remote-registry; remote-verifier; remote-review-c1; remote-cli; remote-policy-e2e |
| H2 QR pairing needs a confirmation | connector.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 sanitised | remote-registry; remote-review-cw F1, F2; remote-e2e |
| M1 short codes | pairing-code: 80-bit codes, canonical parsing, domain-separated psk; both fingerprints in the prompt | remote-channel; remote-registry; remote-e2e |
| M2 what a revoked device left behind | attribution.ts onDeviceRevoked (flags tasks, notes and edits, quarantines memory by the stored repository, stops pr.watch); nid memory release | remote-policy "revoke: lists what the device left behind…" |
| M4 key storage | dh.ts: asynchronous DH, non-extractable WebCrypto keys, all-zero rejection on both paths; the console keeps the CryptoKey in IndexedDB | remote-channel (non-extractable, low-order); remote-pair ("non-extractable WebCrypto key") |
| LOW push TTL, urgency, topic | The relay refuses a push with a topic or another TTL (core.ts); the sender is round B | remote-relay |
| LOW fixed text for the empty-push alert | Round B | — |
| LOW lower and narrow only from devices | policy.ts, rpc.ts (also capping cloud; a scoped admin reaches only devices inside its scope) | remote-allowlist; remote-registry MED-8, LOW-12 |
| LOW the row named by the open's device id | connector.ts handshake | remote-connector "a known id with the wrong key is unknown_device" |
| LOW compiled-in release key | console-check RELEASE (empty: hosted fails closed, see residuals); cloud is local configuration | remote-console-check |
| LOW the console's biometric gate | Spec text | — |
| LOW terminal control two-step and attributed | allowlist.ts TWO_STEP; dispatch.ts binds terminal views to the device that opened them | remote-e2e terminal; remote-dispatch |
| Re-review 1 (H1 partial) | As H1 | As H1 |
| Re-review 2 (hosted cap unenforceable) | As C1 | As 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 set | remote-pair; remote-policy |
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/.
| Finding | Fixed in | Reproducer / regression test | Reviewed by |
|---|---|---|---|
A-M1 device accepted a plaintext ws:// relay on any host | src/ checkDeviceRelay, src/, daemon/ (cloud must be wss:// or loopback unless cloud), dev console --insecure-lan-relay, "Round A demonstration" | remote-review-ir "A-M1: …" (2) | codex |
| A-L1 weak relay secret, no throttle on failed upgrades | relay-core/ (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 throttled | remote-relay "review A-L1: …", "review A-L1 + #47: …"; remote-review-relay "#47 A-L1" | cw |
| A-L2 pair rule 2 checked only the row the open names | daemon/ (any active row with the key), access.ts | remote-connector "A-L2: …" | codex |
| A-L3 non-canonical X25519 encodings aliased keys | src/ assertPublic | remote-review-ir "A-L3: …" | codex |
| A-L4 device swapped keys before the daemon applied the rotation | src/: switch only after device. 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 two | remote-connector "A-L4: …", "A-L4 follow-up …", "R46-1: …", "R52-1: …" (2), "R52-2: …" | codex |
A-L5 crosscheck never drove SecureChannel; a missing module skipped | remote-noise-crosscheck section (c) and remote-noise-reference 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 wiped | src/ (wipe on split and failure; HKDF temp, DH outputs, replaced keys zeroed) | remote-review-ir "A-L6: …" | codex |
| A-L7 unauthenticated login starts filled the pending table | relay-core/ (10/min, oldest evicted, no stderr) | remote-relay "review A-L7: …" | cw |
| B-M1 view-only fallback decided by a live, fail-open check | daemon/ (verifier observation persisted; fallback only after a local plain-terminal declaration, nid cloud no-verifier; probe errors and timeouts fail closed), cli/cloud.ts | remote-ir-pairing "B-M1: …" (6) | cw |
B-M2 a second local pair.begin replaced the user's offer | state.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 "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 token | remote-review-c1 "C1: …" (reproduces the chain: no passkey path, one OS prompt per pairing) | codex |
B-M4 task.update owner reached another group | daemon/ (owner must be a session of the task's group), remote/ + policy.ts (owner is a scope source) | remote-policy "IR B-M4: …", remote-allowlist "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/ 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 3 | remote-config-tree "B-M5: …" (3), "C2: …" (2), "C3: …", "C5: …", "#59 LOW: …"; remote-review-cw "#47 B-M5", "#59 C3" (Windows) | cw |
| B-L1 revoke summary missed several kinds of action | daemon/ (approvals, answers, verdicts, model and permission changes with sessions left in yolo, terminal, land, settings) | remote-policy "IR B-L1: …" | lead |
| B-L2 label filter only caught some separators | daemon/ cleanLabel | remote-ir-pairing "B-L2: …" | cw |
B-L3 plaintext consoles on any host; only app.nid.dev treated as hosted | state.ts checkConsoleUrl, policy.ts hosted hosts (every *.nid.dev), console-check | remote-ir-pairing "B-L3: …" | cw |
| B-L4 verifier helpers inherited the daemon's environment | cli/ (fixed allowlisted environment) | remote-verifier "B-L4: …" (2) | cw2 |
| B-L5 Linux polkit message details refused for non-root callers | cli/ (owner-annotated policy checked before any prompt), shipped assets/, appendix D (administrator install; not exercised on a real Linux desktop) | remote-verifier "B-L5: …" (3) | cw2 |
B-L6 nid cloud|auth refusal missed wrappers and direct RPC | remote/ isNidCloudCommand (still best effort, 2.4) | remote-allowlist "IR B-L6: …" | lead |
| B-L7 crash auto-restart skipped the configuration check | daemon/ (a driver a device started restarts as that device); residual in 5.2 principle 3 | remote-policy "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.
| Item | Fixed in | Reproducer / regression test | Reviewed by |
|---|---|---|---|
| C1 (B-M3 partial) the bearer token reaches the dashboard, so a passkey could be enrolled without a browser and approve new pairings | The 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/, 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 removal | remote-review-c1 "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 ignores | config-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 packages | remote-config-tree "C2: …" (2), "#59 LOW: …" | cw |
| C3 the tree hash ran without bound on the event loop | config-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 records | remote-config-tree "C3: …"; remote-review-cw "#59 C3" | cw |
| C4 helper failures counted as "no verifier" | cli/, 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 "C4: …", remote-ir-pairing "C4: …" | cw2 |
| C5 big files recorded by size and mtime | config-hash.ts: streamed content hash with no size limit | remote-config-tree "C5: …" | cw |
| C6 a device's pending rotated key lives only in memory | Round 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.
cleanLabelremoves 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
pairedchannel message after the confirmation (pending,paired,pair_refusedintypes.ts), not inpayload2.Revocation keeps the registry row as
revokedinstead of deleting it (3.4 said "deletes"): handshakes are refused the same way, and the artifacts a revoked device left keep naming it.cloud(see "Round A demonstration"): the relay address advertised in pairing URLs. Routing tokens travel to it, so it must be.deviceRelay wss://or loopback unlesscloudis set (independent review A-M1)..insecureDeviceRelay rotateleft the remotely settablegroup.updatesettings (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/). 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.remote/ device .ts
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 aremote_devicesrow),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.
RELEASEis empty, so_CONSOLE _KEYS console-checkfails closed for the hosted console; the operator must provision the offline release key before round B. Self-hosted consoles usecloud..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#
| Piece | Code | Tests |
|---|---|---|
| Contract | appendix E (E.12 records every later change) | — |
| Signed device lists | src/ (NID placeholder, empty) | remote-device-list (fixed vector of E.4), remote-device-list-review (reviewer's independent suite: OpenSSL cross-check, malleability, JSON ambiguity, key aliases) |
| Development accounts (E.10) | src/; 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 key | remote-relay-accounts (Node and Bun), remote-relay-accounts-bun, helper remote-accounts-harness |
| Daemon account mode | src/; 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, remote-account-access, remote-review-9 (reviewer's reproducers), remote-rc-e2e (real daemon process, written against E.7/E.8 by a non-author) |
| CLI and terminal app | src/cli/rc.ts, src/ (types, first-run text, badge), src/ (/rc), status line | remote-rc-cli |
| Web Push | src/ (RFC 8291, WebCrypto only), src/, src/ (loopback-only development sender) | remote-push (RFC 8291 vector, fake push service with VAPID check, scope, R10) |
| Console app | src/ (check-public reserves console/ for hosted code), scripts/ (pnpm build:console); dashboard transport seam in src/ | webconsole, remote-console-review (the bundled service worker), test/, rc-console, rc-console-fixes, remote-console-review, remote-rc (exit demonstration); scripts/ (the compiled binary) |
Round R demonstration#
test/ 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 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 rcis the common path;nid cloudis strict mode.nid rclinks through the accounts service and ships without--experimental(reviewed in #9).nid cloud login|pair|...stays behind--experimentalfor 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.
| Piece | Author | Reviewer | Findings | Outcome |
|---|---|---|---|---|
Device lists (device-list.ts) and the development signer | lead | codex (#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 others | All fixed (#17, #19), approved |
Web Push (webpush.ts, push.ts, dev-push.ts) | codex | lead (#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 key | Fixed (#14), approved |
Account admission (account.ts and hooks) | cw2 | cw (#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, 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 state | All 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/) | cw, cw2 | codex (#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 supplied | All 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 relay | cw | (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 source | Fixed; 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, never.accountMaxRole 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.sharecan 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.consolenames. - 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_resultonly 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 rmgoes 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 rcreplacesnid cloud loginfor 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(empty: account mode against nid.dev fails closed) and_ACCOUNT _KEYS RELEASE(round A). The development VAPID key in_CONSOLE _KEYS dev-push.tsis the public RFC 8291 example key and never leaves loopback. - Not exercised offline: real WebAuthn (hosted), a real browser
PushManagersubscription (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 setframe-ancestors; sign-in and link pages that checkOriginon POST must not usereferrer-policy: no-referrer. - Local events: the terminal app polls
cloud(E.12); forwarding the daemon's.rc .status rcevents to the local event stream is a later improvement. - Same-user writes (round A residual) now include
cloud.json'srcblock (shares, accepted list, pinned keys): a process running as the user can widen shares without a prompt, as it could already edit the registry.