See
docs/specs/glossary.mdfor Pane; this spec uses it bare. Owns the boundary the product presents to the network: remote control. Defers the trust model todocs/specs/remote-security-model.md, the Relay runtime todocs/specs/relay.md, the self-host deployment toSELF_HOST.md, and the boundaries a local user has todocs/specs/security-local.md. Readdocs/specs/security.mdfirst;docs/specs/security-audit.mdsays how theFAIL IFlines here are run.
Pocket lets a phone attach to a terminal on the user's laptop, so the pairing stack is
the one part of the product that takes input from the network. An authorized Client
is equivalent to a person at that laptop's keyboard — terminal.write is raw
keystroke injection into a live PTY and protocol-v1 has no restricted session — so the
model exists to make authorized hard to reach and impossible to reach by accident.
A Burrow that never enrolls with a Relay has no relay, pairing, or push; what
still applies to it is One-time connection — the one way in
without them, one session confirmed at the Burrow and writing nothing — with the
direct path it runs on and the service→webview checks. Two deployment modes are
defined (docs/specs/remote-api.md -> "Transport"); all of the below but the
one-time connection, which needs no Relay, is self-hosted, the only one that
ships. Cloud-hosted is staged
(Cloud-hosted mode).
Five layers, none sufficient alone (docs/specs/remote-security-model.md ->
"Trust Model"). A deployment may raise the presence layer to user verification with
DORMOUSE_REQUIRE_USER_VERIFICATION=true.
There is exactly one channel and no other path. One suite
(Noise_IK_25519_ChaChaPoly_SHA256) carries both ceremonies, protocol-v1, and the
terminal stream; there is no negotiation, no cipher or pattern selector,
no plaintext relay route, and no reader for any of the pre-cutover frames.
| Compromise | Buys | What still stands |
|---|---|---|
| Relay | account state, routing metadata | no new authorization and no plaintext. On an established session, availability only — drop, delay, reorder, or refuse, never read and never inject — and the first invalid ciphertext destroys the session. Web Push holds confidentiality, not freshness: a kept envelope re-delivers as current, accepted residual (rationale). A session switched to the direct path leaves it the lifecycle levers alone — it can still end that session by dropping a socket, but sees, delays, and reorders none of its traffic |
| Setup password | one endpoint, /api/burrow/enroll, and thence a burrowToken |
it registers no passkey — /api/setup/* takes a Burrow-minted setup token and nothing else — so it reaches an owner passkey only via the next row. /api/burrow/enroll accepts one other credential, the installer's enrollment offer: owner-only at rest, the whole of what the file mode protects, checked by possession over HTTPS rather than local identity, so a leaked token redeems remotely — bounded single-use, 24-hour expiry, permanently disabled by the first Burrow enrollment. Still no Burrow access |
burrowToken |
the Burrow's own relay traffic and, transitively, account takeover: it mints setup tokens at /api/burrow/setup-token, the only thing that registers an owner passkey |
bounded three ways — single-use and dead 5 minutes after minting; revoking the Burrow (deleting its row from burrows.json) stops minting immediately and kills already-minted tokens, re-checked at both setup gates; a signed-in phone retires an unused token at /api/setup/retire. Still no Burrow access (rationale) |
| Synced or stolen passkey | sign-in, and the ability to ask | the paired Client static is missing, so BurrowAcl answers client-not-paired |
| Client static | use in place; encrypted fallback also permits private-byte extraction by compromised same-origin code | connecting still needs the paired passkey's fresh assertion, and it authorizes exactly one Burrow |
The only path into a Burrow's ACL is a human typing, on that Burrow, two digits displayed on the phone that is asking, and the Burrow gets the comparison exactly once. The webview is inside the trust boundary for relaying a confirmation and for nothing else — it cannot choose what is authorized, satisfy the confirmation without the phone, or fabricate a request (rationale). The only path back out is Revocation and the audit trail.
- FAIL IF the Burrow stops being the final authority:
BurrowRuntime.#onConnectionTransportinlib/src/remote/burrow/burrow-runtime.tsmust consume its own challenge, verify the presence proof withverifyPresenceProofagainst a binding built from the Burrow's ownburrowId, connection id, challenge and handshake hash, and require one activeBurrowAclRecordholding the account, the passkey credential, that key's hash, and the IK-authenticated Client static — before any session is established, and with no code path letting a Relay-supplied claim stand in for any of them. - FAIL IF local confirmation stops being the only thing that mints an ACL record:
BurrowAcl.approvemust have no caller butBurrowRuntime.#approvePairing, the comparison must be constant-time and happen exactly once per ceremony, and it must match the immutablepairingIdof the request that was displayed, never a mutableclientIdalone. - FAIL IF the expected two-digit code, or an invitation's private key, ever leaves the Burrow process:
PairingQueueIteminlib/src/host/remote/service-protocol.tscarries{ kind, clientId, pairingId, label, requestedAt }and nothing else (rationale). An answer is routed by itskind— a one-time request's only to the runtime that asked, by the random ticket its modal displayed — and a missingkindis a pairing (approvalKind), so an answer that names none can never reach a one-time request. - FAIL IF the pending-ceremony maps are unbounded, in both
BurrowRuntime's client map and the service's mirrored queue: pairings capped atMAX_PENDING_PAIRINGSon both sides, oldest evicted first; connection handshakes atMAX_PENDING_CONNECTION_HANDSHAKES; outstanding invitations atMAX_TOKENS_PER_BURROW.MAX_CLIENT_ID_LENGTHboundsclientIdat the frame boundary, before any map is touched, and a handshake that fails to decrypt allocates no entry at all (rationale). - FAIL IF any Burrow bound stops being enforced by the Burrow itself, on its own clock, with no help from the relay. The relay-frame FIFO must enforce its count and cumulative received-string limits before enqueueing, including
client-gone; overflow synchronously tears down the socket and transient state, and reconnects retain at most one in-flight operation.MAX_ESTABLISHED_E2E_SESSIONSis checked at promotion only — after the presence proof and the ACL conjunction — and a Client static replaces its own session while any other identity at the cap getsburrow-busyand evicts no other entry. A Burrow-global token bucket (E2E_INIT_BURSTdecaying at one perE2E_INIT_REFILL_INTERVAL_MS) gates the WebCrypto an acceptedinitbuys, and a frame it refuses performs no operation and allocates nothing. One reaper over absolute timestamps — invitation expiry, pairing TTL, challenge TTL,ESTABLISHED_E2E_IDLE_TIMEOUT_MS, the last refreshed only by a successfully decrypted Client→Burrow transport message — runs on every init, every local decision, every relay lifecycle event, and a next-expiry timer cleared onstop(). Values, and which file declares each:docs/specs/remote-security-model.md-> "Burrow bounds". Pinned bylib/src/remote/burrow/burrow-bounds.test.tsandrelay/test/malicious-relay.test.mjs(rationale). - FAIL IF
requireUserVerificationis reachable on one side without being mirrored to the other: the Relay readsDORMOUSE_REQUIRE_USER_VERIFICATION, andBurrowEnrollResponsemust carry it into the Burrow'sConnectionPolicy(rationale). - FAIL IF the Burrow accepts an
e2eframe it has not shape-validated itself withisE2eRelayToBurrowFrame— relying instead on the relay's own overlapping guard (isE2eClientFrame/isE2eBurrowFrameinrelay/src/relay.ts) — or lets the Client's device label reach any consumer un-reduced byboundedPairingLabel(rationale). - FAIL IF a ceremony outcome stops being a fixed-size padded control message, or begins carrying which ACL half failed: success and every denial encrypt to the same length, every ACL miss answers
pairing-required, and the specific miss is logged owner-locally only. - FAIL IF any service→webview message can carry
burrowToken, or any other bearer credential the receiving realm has no route that takes —deliveryIdmost of all, which is whyPushDevicesResultis labels only. Check the direction, not just the identifier:BurrowResult,BurrowStatusEvent,PairingQueueEvent,InvitationEvent,OneTimeEvent(itsOneTimeState, fromlib/src/remote/burrow/one-time-runtime.ts),SetupQrResult,BurrowConsoleStatus, andPushDevicesResultinlib/src/host/remote/service-protocol.tsare the outbound shapes and none may expose one; the test is whether the webview calls anything with the value, not whether exposing it is currently exploitable. The credentials that do cross outbound are the Relay's setup token and the invitation's public half, both insideSetupQrResult.url, and a one-time link's room id and one-use public key, insideOneTimeState'swaiting.url— each minted only on request, single-use, and short-lived. Inbound differs —EnrollParamscarries the setup password by design (rationale). - FAIL IF a private key agreement ever leaves WebCrypto. X25519 stays WebCrypto-only (
generateKey/deriveBits/importKey) and never a JavaScript curve (@noble/curves,tweetnacl,libsodium, or any other). The one bundled primitive is ChaCha20-Poly1305, from an exactly-pinned@noble/ciphersrelease; its two import sites and the pin's audit delta are recorded inremote-lib-common/src/security/noise.ts's header, rewritten by any version bump in the same commit (rationale). - FAIL IF the Burrow's Noise static is ever sent to the Relay, or a Burrow runs with halves that do not correspond: it is minted locally before the enrollment request and never sent in it, persisted only where
burrowTokenis, andBurrowServicederives the public point from the private half and compares before starting — a mismatch keeps the Burrow down (rationale). - FAIL IF
remote-lib-common/src/security/stops being the shared implementation: the Relay, the Burrow, and the Pocket client must verify assertions, presence challenges, handshakes, and transport framing with the same modules. Conformance is proven against an independent implementation's published vector (remote-lib-common/test/noise.test.mjs), never against a value the production state machine computed, and this section's properties are driven end to end byremote-lib-common/test/security-guarantees.test.mjs. - FAIL IF
scripts/e2e-lint.mjsandscripts/e2e-lint-selftest.mjsstop running in the rootpnpm test, or a rule is added to the lint without the self-test proving it load-bearing. Each rule inRULESnames the line above that it enforces, or one indocs/specs/security-hosted.md-> "Rendezvous boundary" (rationale). - FAIL IF the Relay begins admitting an
accountIdother thanSELFHOST_ACCOUNT_ID(remote-lib-common/src/remote/wire.ts), or gains a self-serve signup path, while cloud-hosted mode is staged. Reserved: the cloud boundary is analyzed in## Future-> Cloud-hosted mode before the code that needs it ships.
docs/specs/relay.md -> "Relay origin" owns the rule these checks audit.
- FAIL IF
DEFAULT_RELAY_ORIGINis not exactlyhttps://hosted.dormouse.shin bothscripts/relay-origin.mjsandlib/src/host/relay-origin.ts(lib/src/host/relay-origin.test.tspins both), or.github/workflows/release.ymlsetsDORMOUSE_RELAY_ORIGIN— either changes what every shipped binary talks to. - FAIL IF
assertRelayOriginBakedis no longer called on the built bundle by bothstandalone/scripts/build-sidecar-proxy.mjsandvscode-ext/scripts/esbuild.mjs— including the watch branch of the VS Code script — orresolveRelayOriginstops failing the build on any casedocs/specs/relay.md-> "Relay origin" lists (rationale). - FAIL IF a Burrow reaches a Relay at any origin but its
relayoption —bakedRelay(), passed bylib/src/host/remote/sidecar-entry.tsandvscode-ext/src/burrow.ts— taking one from a command, the offer file, or a stored enrollment, connects on an enrollment whose Relay URL ororiginnames another rather than reading it as none (loadEnrollmentFor), or saves one whose Relay reports anotherorigin(BurrowServiceinlib/src/host/remote/service.ts); or ifPOST /api/burrow/enrollinrelay/src/app.tsreads the credential or touchesburrows.jsonfor a request naming another origin. - FAIL IF a self-host build can reach
hosted.dormouse.shordormouse.shunless the user clicks a link to it.hostedOrigininlib/src/host/relay-origin.tsmust answernullthere, and every Hosted-reaching host feature must take that nullable origin and do nothing onnull:BurrowServicebuilds noOneTimeRuntime, andcreateManagedVoiceHostinlib/src/host/managed-voice-host.tsreads no token and sends no request. In standalone,standalone/vite.config.tsmust bake the webview throughresolveRelayOrigin;startUpdateCheckinstandalone/src/updater.tsmust return beforecheck()unlessbakedRelayMode()is'hosted';managedVoicePortForBuildinstandalone/src/managed-voice-port.tsmust give a self-host webview no port; andstandalone/scripts/tauri.mjsmust overlay a self-hosttauri buildwith no updater endpoint. Search the rest oflib/src/host/,standalone/, andvscode-ext/src/for any other request to either host. - FAIL IF the enrollment exchange in
lib/src/remote/burrow/enrollment.tsor the sharedburrowFetchinlib/src/remote/burrow/burrow-fetch.ts— the transport behind both push delivery and the setup-token mint — dropsredirect: 'error'. Every new Burrow→Relay call goes throughburrowFetch(rationale).
Persistent credentials are a full bypass of some layer if they leak to another
local account. Protection states the property — reachable only by
the owning user account — and every row reaches it the same way, the caption of the
column: mode 0700/0600 on unix, a one-ACE DACL on Windows, where Node's file modes
are a silent no-op. Rows carry only what is additional.
| Credential | Where it lives | Protection |
|---|---|---|
| Setup password | setup-password.json in the Relay state dir |
generated by the Relay on first boot; never accepted from configuration or printed by a routine install |
| Enrollment offer | run/enroll-offer.json in the install root, under an owner-only run/ |
mode and DACL both applied before the token is written; one-time (docs/specs/relay.md -> "Configuration"); never printed, the service definition and wrapper carrying only its path |
burrowToken (the /ws/burrow bearer) and the Burrow's Noise static private key |
Relay burrows.json (the token only); Burrow side both in the enrollment record, the Noise static minted locally and never sent to the Relay (docs/specs/remote-security-model.md -> "Burrow identity") |
the Relay state dir and every file in it; on Windows the files inherit the installer's DACL on state, so manage verify checks them individually. Burrow side a 0600 file in standalone (on Windows the app-data-dir DACL the Rust side applies), SecretStorage (the OS keychain) in VS Code — never a webview realm |
| VAPID private key | Relay vapid.json |
nothing additional |
| Burrow ACL | BurrowStateStore, keyed per burrowId |
a 0600 file in standalone; VS Code globalState. Mostly public keys, with one exception: each record's deliveryId is a bearer capability for that Client's push rows, so a reader could delete or hijack a subscription — not reach a terminal. Neither store provides integrity against a same-user process and nothing here claims otherwise; the mode only stops another local account adding a record (rationale). Deliberately never on the Relay |
Without explicit modes these files inherit the umask and end up world-readable,
handing live burrow tokens to any other local account on a shared machine. The Client's
per-Burrow browser storage follows docs/specs/remote-security-model.md ->
"Client statics".
-
FAIL IF Pocket persists plaintext Client private bytes, uses an extractable AES wrapping key, selects encrypted storage without a failed native probe and a passing encrypted reopen/use probe, or treats a corrupt encrypted record as permission to generate a replacement identity. Read
lib/src/remote/client/pocket-private-key.tsandlib/src/remote/client/pocket-db.ts; pinned bylib/src/remote/client/pocket-encrypted-storage.test.ts. -
FAIL IF AES-GCM appears in production source under
remote-lib-common/src/,lib/src/, orrelay/src/outside the local at-rest wrapperlib/src/remote/client/pocket-private-key.ts. The wire cipher is unchanged;scripts/e2e-lint.mjsandscripts/e2e-lint-selftest.mjspin this exception. -
FAIL IF
relay/src/state.tsstops creating$DORMOUSE_STATE_DIRmode0o700, or stops writing every file throughwriteAtomicat mode0o600. The "every file" clause is a negative search overrelay/src/: nowriteFile,appendFile, orcreateWriteStreammay target the state directory outsidewriteAtomic. A cheap default, not a cross-platform guarantee; the installer's directory permissions below protect the installed Relay's state (rationale). -
FAIL IF
FileBurrowStateStore(lib/src/host/remote/burrow-state-store.ts) stops creating its directory0o700and writing0o600on non-Windows platforms, or ifVsCodeBurrowStateStorestops keeping the enrollment inSecretStorage. The ACL's home inglobalStateis deliberate and is not a finding; the enrollment's is what carriesburrowToken. -
FAIL IF a credential the Host→Burrow rename retired stops being deleted unread at boot:
state/hosts.jsonon the Relay (forgetRetiredStateinrelay/src/state.ts, called fromrelay/src/start.ts),remote-host.jsonon a Node-resident Burrow (forgetRetiredStateinlib/src/host/remote/burrow-state-store.ts, called fromsidecar-entry.ts), anddormouse.remote-host.enrollment,dormouse.remote-host.acl.*,remote-host.peer-tokenin VS Code (vscode-ext/src/retired-state.ts, called fromactivate()). Each held a liveburrowTokenor peer secret, andSecretStoragecannot be enumerated — a key nothing removes by name outlives every build that knew it. Pinned byrelay/test/state-records.test.mjs,lib/src/host/remote/burrow-state-store.test.tsandvscode-ext/test/retired-state.test.ts. -
FAIL IF
burrow_state_dirinstandalone/src-tauri/src/lib.rsstops callingrestrict_to_owneron the state directory before spawning the sidecar — on Windows those Node modes are no-ops and Node cannot set an ACL, so the guarantee is held one layer down. That call carries both legs: a newly written enrollment file inherits the owner-only entry, and one a prior version already left under the%LOCALAPPDATA%ACL — with a liveburrowTokenin it — has that entry propagated onto it, the halfrestrict_to_owner_leaves_one_owner_only_acecovers with its pre-existingbefore.json. -
FAIL IF
relay/src/start.tsstops obtaining the setup password fromSetupPasswordStore.loadOrCreate(generateSetupPassword),generateSetupPasswordstops usingcrypto.randomBytes(32),readConfigreadsDORMOUSE_SETUP_PASSWORDor any other setup-password input, orSetupPasswordStorestops refusing a persisted or generated value outside 64 lowercase hexadecimal characters. Pinned byrelay/test/config.test.mjsandrelay/test/setup-password-store.test.mjs. -
FAIL IF
createAppaccepts anything but 64 lowercase hexadecimal characters as the setup password injected by the entrypoint; pinned byrelay/test/app.test.mjs. -
FAIL IF any installer stops making
config/,state/, andconfig/relay.envreachable only by the installing user — the effective propertymanage verifytests: no principal other than that user may appear in the effective permissions. macOS and Linux achieve it with0700/0600underumask 077; Windows with a single owner-only ACE, whether the path carries it directly or inherits it from an already-locked parent. The Windows and Linux installers createrelay.envand lock it before writing its contents (rationale). -
FAIL IF
manage verifystops checking mode and owner onconfig/,state/,run/,config/relay.env, and an unspent enrollment offer on macOS or Linux, or WindowsTest-OwnerOnlystops checking the owner SID alongside the DACL, or accepts an empty access-rule set. A NULL DACL grants everyone access.scripts/installer-verify-test.mjsexercises the unix checks;scripts/deploy-lint.mjsand its self-test pin all three platforms (rationale). -
FAIL IF
manage verifystops walking the files insidestate/on Windows, whererelay/src/state.ts's0o600is a no-op and they are covered by what they inherit from the directory. An enumeration that fails fails verify, because that walk is the only thing holding the property there (rationale). -
FAIL IF any installer stops preserving an existing
config/relay.envbyte-for-byte across an update. Each installer names the installer-owned keys a preserved file lacks and stops; nothing is rewritten or regenerated over it (rationale). -
FAIL IF any installer mints the enrollment offer's token from anything but its named CSPRNG or drops its length guard — 64 hex characters, not 32. The offer redeems for a Burrow enrollment, so its entropy is the password's.
-
FAIL IF the offer's publication file, or
run/itself, is reachable by any principal other than the installing user, or becomes so only after the token is written. Each installer creates an owner-only temporary file insiderun/, writes the complete offer, then atomically renames it over the well-known path: redemption sees one complete generation or the other, never a truncate/chmod/write window.run/is0700(a single-ACE DACL on Windows) alongsideconfig/andstate/, andmanage verifyasserts it (rationale). -
FAIL IF any installer prints the offer's token, or writes it anywhere but that owner-only same-directory publication file. There is no
manage show-passwordcounterpart: the reader is a Burrow process, not a human. -
FAIL IF any installer writes the offer anywhere but
<install root>/run/, stops re-minting it on runs before the first Burrow enrollment, mints one afterstate/burrows.jsonexists, or mints it before the switched release, HTTPS Serve mapping, and pruning have succeeded.burrows.jsonis the durable "bootstrap completed" marker even when every row is later removed; the Relay serializes that decision with the Burrow-store write and consumes the offer when either credential path wins (rationale). -
FAIL IF an installer accepts or supplies the setup password as configuration, prints it during routine installation, or
manage show-passwordreads anywhere but the Relay'sstate/setup-password.json.scripts/deploy-lint.mjsand its self-test pin all three installers.
One password bootstraps everything the Relay can grant. Enrolling Burrows is its only endpoint, but an enrolled Burrow mints setup tokens and a setup token registers an owner passkey, so the account is one step behind it rather than beside it.
The Relay generates it, never the operator (docs/specs/relay.md ->
"Configuration").
Online guessing is bounded without trusting network identity. Every
Burrow-enrollment POST spends from one process-global bucket before its body is read,
answering 429 with Retry-After when empty (relay.md holds
the burst and refill). The comparison is constant-time and a rejected credential pays
a fixed delay (rationale).
- FAIL IF the setup password comparison stops being constant-time, its rate-limited rejection loses the fixed delay, or a random setup/Burrow bearer rejection gains that delay and lets public traffic retain requests.
secretEqualsinrelay/src/secrets.tscompares SHA-256 digests withtimingSafeEqual;CREDENTIAL_FAILURE_DELAY_MSinrelay/src/app.tsis the 250 ms, andrelay/test/burrows.test.mjspins which rejections pay it. - FAIL IF
POST /api/burrow/enrollstops spending from one process-globalTokenBucketbefore body parsing, admits more thanBURROW_ENROLL_ATTEMPT_BURSTat once, refills faster than one perBURROW_ENROLL_ATTEMPT_REFILL_MS, or allocates state per caller. Every POST counts; OPTIONS does not.relay/test/token-bucket.test.mjspins ordering, concurrency and 429Retry-After;remote-lib-common/test/token-bucket.test.mjspins the refill arithmetic the Burrow's crypto budget shares.
No browser origin but the configured one may drive the API. Pocket is served with the API at that origin and calls it with relative URLs; a Burrow's HTTP client runs in its Node service, not a webview. No supported caller is a cross-origin browser, so a grant would widen the guessing surface and buy no compatibility (rationale).
- FAIL IF the Relay installs CORS middleware, emits
Access-Control-Allow-Origin, or accepts authentication from a cookie — the two clauses hold each other up (rationale). Pinned byrelay/test/cors.test.mjs.
scripts/deploy-lint.mjs (pnpm test) makes the cheap half of this section and of
"Credentials at rest" deterministic: every installer must still contain the control
each FAIL IF names, so a control deleted from one of the three fails a build. It is
textual and cannot tell whether a control is correct — the audit owns that — and on
Windows, which nothing in CI can execute, it is the only automated signal about those
controls; scripts/ps1-cmdlet-lint.mjs reads the same file for cmdlet syntax alone.
scripts/deploy-lint-selftest.mjs deletes each matched control in turn and requires
the lint to fail (rationale).
The shipped self-host deployment is a per-login user agent bound to loopback — a
macOS LaunchAgent, a Windows Scheduled Task, or a Linux systemd user service — with
tailscale serve terminating HTTPS on the node's own MagicDNS name. Two invariants
follow, the same on all three:
- The Relay always speaks plain HTTP, so the listen interface is a security boundary when the TLS proxy is local. An unbound socket publishes the plaintext port to the LAN and to the tailnet itself, so the install pins
DORMOUSE_BIND_HOST=127.0.0.1and refuses to proceed without it. DORMOUSE_ORIGINis durable WebAuthn identity. Rewriting it silently invalidates the registered passkey and every enrolled Burrow, so the installer stops rather than rewriting a mismatch.
May publish the HTTPS origin publicly. Tailnet-only Serve is the installer default and network-layer defense-in-depth, never an authentication premise. Enabling Tailscale Funnel publishes the same TLS origin and stays inside this analysis: public admission is owned by The setup password, and a Client still reaches no Burrow without the Burrow-local authorization above.
A direct path opens the one listener no loopback rule covers. ICE gathering
binds a UDP socket per local address, so while an attempt is live the machine
answers UDP from anyone routing to it on any of those networks.
docs/specs/security-local.md -> "Loopback Listeners" is about loopback TCP and
scripts/loopback-lint.mjs reads bind spellings in our own source, so neither
reaches a socket the browser or the addon binds. It exists only between an
offer and that session's disposal, and what answers on it is
docs/specs/remote-security-model.md -> "Direct path"; Local networks may
narrow it (docs/specs/remote-network.md -> "Local networks").
Must not make Funnel state an install or health verdict. The installers configure
Serve but neither inspect, warn about, enable, nor disable Funnel; manage verify
checks the local TLS-to-loopback path, while CI and this audit check application
controls.
- FAIL IF
deploy/local/install-macos.sh,deploy/local/install-windows.ps1, ordeploy/local/install-linux.shstops requiring the effectiveDORMOUSE_BIND_HOSTinconfig/relay.envto be127.0.0.1, or if anymanage verifystops asserting that the plaintext port is unreachable on the node's Tailscale IP. - FAIL IF the unset default of
DORMOUSE_BIND_HOSTinrelay/src/config.tsstops beingundefined— listen on every interface, what a container wants, where the namespace is the boundary — or ifrelay/test/bind-host.test.mjsstops spawning the real entrypoint to prove the plaintext port is unreachable off-loopback when it is set. - FAIL IF any installer stops refusing to rewrite a
DORMOUSE_ORIGINthat no longer matches the node's DNS name. - FAIL IF any installer stops refusing to run with elevated privileges —
id -uon macOS and Linux, theAdministratorrole check on Windows (rationale). - FAIL IF an installer or
managenamestailscale funnelorAllowFunnelat all — invoking it, judging its state, or changing it all begin there, and public reachability must exercise the application controls rather than become a forbidden deployment state. Held byscripts/deploy-lint.mjsas its oneforbiddenrule (rationale). - FAIL IF any decision taken on Tailscale CLI or listener output is reached by piping that output into
grep -q, or into ahead -1that exits first; every such search is over text captured first, in a helper as much as inline (rationale). - FAIL IF any decision about whether Serve maps
/to us — the install-time conflict gate,manage verify, and the uninstall that turns Serve off — is not additionally scoped to the root line with the port right-bounded. The post-mutationSERVE_AFTERassertion is the one deliberate exception (rationale). - FAIL IF
scripts/installer-verify-test.mjsstops drivinghas_off_loopbackandserve_stateover inputs larger than the pipe buffer, or stops pinningserve_proxies_root's root scoping and port bound.scripts/deploy-lint.mjsholds that helper's<<<pattern and counts its consumers;serve_root_targetis held by neither on purpose (rationale).
The relay is a dumb ciphertext pipe: it routes e2e envelopes within one
Client↔Burrow binding and decodes nothing. Both directions carry untrusted bytes once a
Burrow has decrypted them — inbound, terminal.write is keystrokes into a real shell and
the ACL is the entire gate; outbound, terminal bytes reach a phone and notification text
originates in a renderer and is Pane-derived, so it is bounded on the Burrow before
sealing and re-bounded at the render sink (below; rationale).
Web Push is the one path where the Relay makes an outbound request to an address a
Client supplied, which on a Relay inside a tailnet is a live SSRF concern:
100.64/10 is exactly the range a push endpoint must not be allowed to reach.
Registration rejects credentials, localhost, and non-public IP literals; delivery goes
through a dedicated agent whose connection-time DNS lookup rejects loopback, private,
CGNAT, link-local, documentation, benchmark, multicast, reserved, IPv4-mapped,
unique-local, and site-local ranges — rejecting a hostname wholesale if any answer is
blocked, and handing the socket the exact address it checked so rebinding cannot create
a second unchecked resolution.
- FAIL IF
relay/src/push-endpoint.tsstops rejecting non-public push endpoints at registration, stops applyingcreatePublicLookup/createPublicPushAgentto delivery, or stops rejecting a hostname whose DNS answers are mixed public and blocked. - FAIL IF
/api/push/sendstops taking theburrowIdfrom the Burrow's own token, begins selecting recipients whenrecipientsis absent or empty, stops clamping them atMAX_PUSH_QUERY_DELIVERY_IDS, or if any read endpoint begins reporting on a delivery id the caller did not present. Possession of the 256-bitdeliveryIdis the whole authorization for the Client-facing push routes, so the Relay must never list one to a session. - FAIL IF the send route reads, rewrites, or logs notification text, or forwards anything but the sealed envelope plus the token's own
burrowId. The Relay holds no key for it (docs/specs/remote-security-model.md-> "Push sealing"), so a route that could read a payload is one that was handed plaintext. The envelope's three fields must be copied individually rather than spread, since a spread would let a sending Burrow override its own token'sburrowId. - FAIL IF a push stops being sealed per recipient, to that ACL record's own Client static, under a fresh salt — the construction is
docs/specs/remote-security-model.md-> "Push sealing",sealPush/openPushinremote-lib-common/src/security/push-seal.ts, proven byremote-lib-common/test/push-seal.test.mjs. A NoiseCipherState, a shared group key, or a reused salt each break it.BurrowRuntime.sealPushForClienthandslib/src/remote/burrow/push-delivery.tsa seal capability and never the Burrow's private key, and the worker inlib/src/remote/pocket-app/sw.tsis the only thing that opens one. - FAIL IF push text stops being bounded with the shared
boundedPushTexton the Burrow before sealing, or re-bounded with it inlib/src/remote/pocket-app/sw.tsbeforeshowNotification. The worker is the sanitization sink (rationale). - FAIL IF the relay routes a Burrow-originated frame from a socket that is not the Client's current Burrow binding, or begins decoding, remembering, or acting on an
e2eciphertext.relay/src/relay.tsmust route thee2eenvelope and nothing else: it holds no gate, no challenge memory, and no notion of an authorized session (rationale). A Relay-side type import from the protocol-v1 half ofremote-lib-common/src/remote/wire.tsis the leading indicator and fails the same way, as does one underhosted/server/.
An authorized session may leave the Relay for a WebRTC data channel, carrying
what it already carried: the same Noise session, the same counters, the same
bounds. docs/specs/remote-api.md -> "Direct path" owns the design and
docs/specs/remote-security-model.md -> "Direct path" owns why it adds no trust
layer; neither is restated below.
- FAIL IF a
direct-offeris accepted or sent before promotion, or a session runs a second attempt. Both halves ofDirectEndpointinlib/src/remote/direct/direct-endpoint.tsmust passDirectCutover.begin, which answerstrueonce per session, before their firstawait; and the endpoint holding it must be built only at promotion —EstablishedE2eSessioninlib/src/remote/burrow/established-session.ts, built byBurrowRuntime.#promoteConnectioninlib/src/remote/burrow/burrow-runtime.tsand byOneTimeRuntime.#promoteinlib/src/remote/burrow/one-time-runtime.ts, reached only from a matching confirmation;ClientSessionCore.establishinlib/src/remote/client/session-core.ts, called only from theok: truebranches ofPocketClient.connectinlib/src/remote/client/pocket-client.tsandOneTimeClient.connectOnceinlib/src/remote/client/one-time-client.ts— since a peer connection built earlier is one an unauthorized party steered. - FAIL IF a byte crosses the channel that is not a Noise transport message of the promoted session: one message per frame, raw bytes, no second handshake, no plaintext, and no framing of ours beside it. Every inbound frame is bounded at
NOISE_MAX_MESSAGE_LENGTHbefore it reaches a cipher —DirectPeerinlib/src/remote/direct/direct-peer.tsmust refuse an over-cap frame and a non-binary message as violations rather than parse either. - FAIL IF any signaling leaves the ciphertext. The four signals and the Burrow's goodbye (
SessionEndV1, exact keys, no payload) arecontrolmessages on the established session, so no relay route, frame type, or Relay-side guard may carry, name, or validate an SDP, a candidate, or the goodbye: a negative search overrelay/src/andhosted/server/forsdp, the four signal names,session-end, andRTCPeerConnectionmust find nothing.scripts/e2e-lint.mjsholds it textually. - FAIL IF any ICE server reaches shipped source — a
stun:,stuns:,turn:, orturns:URL, or a non-emptyiceServersarray, anywhere underremote-lib-common/src/,lib/src/,relay/src/, orhosted/server/. Both factories passiceServers: []; a public default hands a third party the user's address.scripts/e2e-lint.mjsholds it textually. - FAIL IF a peer connection can outlive its session by more than the goodbye's flush.
DirectEndpoint.disposecloses it and must run on every path that ends one — or, onceEstablishedE2eSession.endhas put the goodbye on a switched channel,DirectEndpoint.disposeAfterFlush, which sends and delivers nothing more and closes it once the goodbye has left or afterSESSION_END_FLUSH_MS: inlib/src/remote/burrow/burrow-runtime.ts#disposeEstablished— which#disposeClientreaches fromclient-gone, socket loss andstop()— and the session#promoteConnectionreplaces, each throughEstablishedE2eSession.dispose; inlib/src/remote/burrow/one-time-runtime.ts#end, which every ending of a one-time connection runs through; inlib/src/remote/client/session-core.tsdisposeSession, on every ending —establishreplacing a session,PocketClient's intentionalclose()and dropped relay socket, and every ending of aOneTimeClientincluded. - FAIL IF the direct path stops bounding what it holds, or stops disposing on a violation. Held frames are capped by
MAX_DIRECT_PENDING_FRAMESandMAX_DIRECT_PENDING_BYTES, and a sender's queue by that same pair — neither direction may hand the implementation unbounded data instead, and overflow disposes the session rather than dropping a frame; a relaytransportframe arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends.DirectCutoverinremote-lib-common/src/security/direct-path.tsdecides all three, and throughonSwitchDecryptedthat a switch onto a channel this end abandoned ends the session;DirectEndpoint, which both ends run, must act on every outcome it returns, and must be both ends' only entry for a relay frame:onRelayFramedecodes thectthere, so an undecodable one ends the session rather than escaping a socket handler. Pinned byremote-lib-common/test/direct-path.test.mjs,lib/src/remote/direct/direct-endpoint.test.ts, and the direct cases inlib/src/remote/burrow/burrow-bounds.test.tsandlib/src/remote/client/pocket-client.test.ts. - FAIL IF either Burrow's native peer addon is loaded at host startup rather than at the first offer, or its absence changes anything but a decline.
createNativeDirectPeerFactoryinlib/src/host/remote/must reachnode-datachannel— declared instandalone/sidecar/package.jsonandvscode-ext/package.json— only through a barerequireperformed inside an authorized session's first offer, and a load failure must answerdirect-declineand leave that session relayed rather than fail the Burrow's start. - FAIL IF a switched end waits on its peer without a deadline, or a channel this protocol did not ask for is adopted.
DirectPeerinlib/src/remote/direct/direct-peer.tsmust refuse a channel that is notDIRECT_CHANNEL_LABEL, one reported unordered or partially reliable, and one whose association reports a per-message limit belowNOISE_MAX_MESSAGE_LENGTH— all before it reports the open, so each abandons the attempt while the relay still carries the session. The reliability half is defence in depth against a paired Client, not a boundary control, and reaches only as far as the implementation reports those flags: on either Burrow it does not, whichlib/src/host/remote/native-direct-peer.test.tspins so a version that changes it is noticed (docs/specs/remote-api.md-> Transport -> "Direct path").DirectEndpointmust armDIRECT_HANDOFF_TIMEOUT_MSon its own switch, since from there it sends only on the channel. Pinned bylib/src/remote/direct/direct-peer.test.tsandlib/src/remote/direct/direct-endpoint.test.ts. - FAIL IF a direct path survives
client-gone,burrow-gone, or a lost relay socket: the Relay stays the lifecycle authority on both paths of any session it carries (One-time connection is the one carve-out). Every Burrow bound is path-agnostic, and the idle deadline still moves only on a decrypted Client→Burrow transport message, whichever path carried it (docs/specs/remote-security-model.md-> "Burrow bounds").
docs/specs/one-time.md owns the link and the rendezvous wire, its own frame
family ("Wire contract"); docs/specs/remote-security-model.md -> "One-time
connection" owns the ceremony.
- FAIL IF the Relay or
BurrowRuntimecan accept a one-time frame:E2eKindandisE2eKindinremote-lib-common/src/remote/wire.tsmust admit exactlypairingandconnection, and no one-time name may appear underrelay/src/, inremote-lib-common/src/remote/wire.ts, or inlib/src/remote/burrow/burrow-runtime.ts.scripts/e2e-lint.mjsholds both textually. - FAIL IF the one-time prologue stops binding every link field under its own kind:
oneTimeLinkPrologueinremote-lib-common/src/security/one-time-link.tsmust hash, throughe2eOneTimePrologueinremote-lib-common/src/security/noise-transport.ts, the E2E domain,one-time, the room id, then the link's version, expiry, and one-use key in link order. Pinned byremote-lib-common/test/one-time-link.test.mjs. - FAIL IF a one-time connection grants or writes anything that outlives it.
OneTimeRuntimeinlib/src/remote/burrow/one-time-runtime.tsmust name no ACL, ACL store, delivery id, or presence verifier, persist nothing, and send a success outcome carrying the Burrow label alone.scripts/e2e-lint.mjsholds the naming textually. - FAIL IF a link can admit a second phone or a second guess. The first message 1 that completes IK against the link's key must reserve the link and erase that key; every later
initis dropped before any WebCrypto, and one that fails leaves the link open.OneTimeRuntime.#approvemust setattemptedbefore its expiry check andconstantTimeEqual, and every outcome — success and each denial — is one padded control message sealed withsealControlinlib/src/remote/burrow/established-session.ts. - FAIL IF the one-time approval modal can show text the phone chose, which could tell the person which digits to type.
OneTimeRuntimeinlib/src/remote/burrow/one-time-runtime.tsmust pass the request's label throughknownOneTimeDeviceLabelinremote-lib-common/src/security/e2e-ceremony.tsbeforerequestApprovalor anyOneTimeStatecarries it, so a label that is not exactly a member ofONE_TIME_DEVICE_LABELSreaches the modal, the panel, and the Baseboard asPhone browser; the page'soneTimeDeviceLabelinlib/src/remote/one-time-app/OneTimeApp.tsxreturns only members. Pinned bylib/src/remote/burrow/one-time-runtime.test.tsandremote-lib-common/test/e2e-ceremony.test.mjs. - FAIL IF an application message crosses the rendezvous, or a one-time session outlives a missed direct deadline. After the outcome an application message decrypted off the rendezvous must end the session unread (
onRelayedAppinlib/src/remote/burrow/established-session.ts), and a decline, an abandoned attempt, or no switch byONE_TIME_DIRECT_DEADLINE_MSmust end it — there is no relayed fallback, and the direct path is stilliceServers: []. After the switch the direct channel is the lifecycle authority: the runtime closes the rendezvous, and channel loss orESTABLISHED_E2E_IDLE_TIMEOUT_MSidle ends the session. - FAIL IF the phone can reach the room unasked or put protocol-v1 on it.
OneTimeClientinlib/src/remote/client/one-time-client.tsmust open its socket only insideconnectOnce, refuse every protocol-v1 method until both directions are direct, and close the rendezvous normally at the switch; a decline, an abandoned attempt, or no switch byONE_TIME_DIRECT_DEADLINE_MSfails the attempt. It reads every frame throughparseOneTimeFrameinlib/src/remote/one-time-rendezvous.ts, which measures it againstMAX_ONE_TIME_FRAME_LENGTHbeforeJSON.parse, and runsisOneTimeBurrowFrameon it. Pinned bylib/src/remote/client/one-time-client.test.tsandlib/src/remote/client/one-time-e2e.test.ts. - FAIL IF a one-time phone keeps anything past its session.
OneTimeClientmust mint its static withgenerateNoiseKeyPair, nonextractable, for the one handshake, and it,ClientSessionCoreinlib/src/remote/client/session-core.ts, and every module of the page inlib/src/remote/one-time-app/may name no browser store or service worker, nor import Pocket's records, key wrapping, passkeys, push, orPocketClient; the page's theme goes throughapplyPocketTheme, which writes nothing.scripts/e2e-lint.mjsholds the naming textually. - FAIL IF the rendezvous origin is anything but the Burrow's
hostedOrigin(Relay origin, above), comes from a command (oneTimeOpentakes no parameters), or is used by a socket opened before it is checked.BurrowServiceinlib/src/host/remote/service.tsholds at most one runtime, ending the one a new link replaces, and refuses a new link while a phone is connecting or connected; the socket carries noOriginheader. Pinned bylib/src/host/remote/service.test.ts. - FAIL IF under Local networks a one-time session's channel can report open, or stay open past a state change or
DIRECT_PATH_RECHECK_MS, on a selected pair whose two ends are not both IP literals inside the allowed networks; the Burrow's answer carries, or the offer it applies keeps, a candidate outside them; or the attempt's socket is not bound to the one allowed address where exactly one is present.BurrowServiceinlib/src/host/remote/service.tsmust hand alocalruntimelocalNetworksPathover the policy'sallowed, whichDirectEndpointinlib/src/remote/direct/direct-endpoint.tshands to the peer factory, andcreateNativeDirectPeerFactoryinlib/src/host/remote/native-direct-peer.tsmust bind the address itsbindAddressnames;DirectPeerinlib/src/remote/direct/direct-peer.tsmust apply only the offer the policy accepts, and consult the policy beforeonOpen, on each state change and everyDIRECT_PATH_RECHECK_MSwhile open,connected, and reporting a pair, and before an unopened channel's frame, ending the session on refusal;localNetworksPathinlib/src/host/remote/local-networks.tsreads only the Burrow's own selected pair, refusing a name or no pair. Pinned bylib/src/host/remote/local-networks.test.ts,lib/src/remote/direct/direct-peer.test.ts,lib/src/remote/burrow/one-time-runtime.test.ts, andlib/src/host/remote/native-direct-peer.test.ts. - FAIL IF the one-time runtime relies on the rendezvous for any bound. It must read every message through
parseOneTimeFrame, which measures it againstMAX_ONE_TIME_FRAME_LENGTHbeforeJSON.parse, shape-guard every frame, stop reading a room pastMAX_ONE_TIME_FORWARDEDmessages, gate eachinit's WebCrypto on its ownTokenBucketofE2E_INIT_BURST, and end on its own clock — a link not yet promoted at its expiry, claimed or not, and a promoted one byONE_TIME_DIRECT_DEADLINE_MS— never after the room would. Pinned bylib/src/remote/burrow/one-time-runtime.test.ts.
These are the two real gaps in the shipped model, and they are gaps rather than accepted risks — we intend to close them (rationale).
Revocation has no mechanism. BurrowAcl.revokeClient / revokePasskey exist and
have no production callers — only remote-lib-common/test/acl.test.mjs and
security-guarantees.test.mjs reach them; no relay frame carries a revocation; there is
no management UI.
Revoking a lost phone means hand-editing JSON on the Burrow and restarting it:
BurrowService.#startBurrow reads the store once and hands the BurrowRuntime a
snapshot for its whole lifetime, so an edit alone changes nothing that is running. The
restart is the whole lever — it reloads the ACL and, by dropping the relay socket, ends
every established session. Relay-pushed propagation is staged in
docs/specs/remote-security-model.md -> "Future" (Revocation propagation).
There is no audit trail. The ACL records approvedAt / approvedBy for a pairing,
and nothing records connects, attaches, denials, or writes. A self-hoster cannot answer
"did anyone connect to my laptop last night", which also means an ACL entry added by
any of the paths above would be invisible after the fact.
Must exclude unpromoted helpers from both remote directory discovery and direct attachment/resize resolution. Promotion enables ordinary terminal access; hidden helper output and input are unavailable before that ownership change.
Source of truth: collectDirectorySnapshot in lib/src/remote/burrow/directory-collect.ts; driveOwnSurface in lib/src/remote/burrow/peer-surfaces.ts.
Nothing here is implemented; it exists so the boundary is stated before the code arrives. When Dormouse operates the coordinating Relay, "Relay compromise buys no Burrow access" is unchanged, but two things change character and must be re-analyzed here rather than inherited:
- We become the operator of the relay. The end-to-end protocol keeps ceremony, terminal, remote-api, and notification content out of that operator's reach; what stays visible is exactly the metadata in
docs/specs/remote-security-model.md-> "Residual metadata". - An independent cryptographic review is a precondition of claiming this model for a paid service (
docs/specs/remote-security-model.md-> "Security Guarantees"). - The tailnet stops carrying load. Every argument above that leans on "the origin is reachable only from the user's tailnet" has no cloud equivalent, and the multi-tenant account model replaces the single-owner setup password entirely (
docs/specs/relay.md-> "Future", the saas-multitenant scope).