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.
Nothing here applies to a Burrow that never enrolls with a Relay: enrollment is
where the relay, the phone, and push begin, and a Burrow that never enrolls has none
of them. Two deployment modes are defined (docs/specs/remote-api.md -> "Transport"); all of
the below 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: pairing runs Noise IK against an invitation keypair the Burrow never sent anywhere (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{ clientId, pairingId, label, requestedAt }and nothing else (rationale). - 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,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, 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 (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.
The baked relay-origin allowlist is what stops an install from enrolling against, or
connecting to, a relay the build was never pointed at. DORMOUSE_REMOTE_CONNECT_SRC
is a build-time constant compiled into the Node bundle that holds the socket — the
Tauri sidecar and the VS Code extension host — enforced at two points: enroll,
before the setup password leaves the machine, and Burrow start-up from a persisted
enrollment naming an outside origin. Full semantics: docs/specs/relay.md -> "Where a
Burrow may reach a Relay (self-host builds)".
- FAIL IF
DEFAULT_REMOTE_CONNECT_SRCis not exactlyhttps://*.dormouse.sh wss://*.dormouse.shin bothscripts/csp-defaults.mjsandlib/src/host/remote/connect-src.ts, or ifCONNECT_SRC_SOURCE_PATTERNdiffers between them. The shipped default admits the SaaS origin only, so widening it — a localhost entry, anhttp/wsscheme, a bare*, or the apexdormouse.sh— is a per-build opt-in that changes what every shipped binary will talk to. - FAIL IF
assertConnectSrcBakedis 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 — or ifresolveRemoteConnectSrcstops rejecting an override the runtime matcher cannot parse. The value is duplicated (a.mjsbuild script cannot import TypeScript) andresolveRemoteConnectSrcvalidates with the build script's copy of the grammar, so this bullet is only as strong as the previous one's identical-copies requirement;lib/src/host/remote/connect-src.test.tspins them (rationale). - FAIL IF
originAllowedByConnectSrcstops gating bothenrolland Burrow start-up inlib/src/host/remote/service.ts, or fails open on an unparseable origin or source. - 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 DACL protected from inheritance carrying exactly one ACE, which is howProtect-Pathdoes it today but is not itself the invariant — a path that inherits that single ACE from an already-locked parent satisfies the property, andTest-OwnerOnlydeliberately accepts it. 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 at all.
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".
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. Thehead -1half binds every site whose 141 can still reach anifor an assignment — an inline substitution always, and a helper the moment the failing assignment is its last command or a caller invokes it outside$( )(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:/apion this port is not/on it, and127.0.0.1:31000contains127.0.0.1:3100. The post-mutationSERVE_AFTERassertion is the one deliberate exception, since it asserts our ownserve --bglanded rather than auditing a foreign config (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: a worker that renders what it decrypted without re-bounding it leaves the property with one enforcer instead of two (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.
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 —BurrowRuntime.#promoteConnectioninlib/src/remote/burrow/burrow-runtime.ts, theok: truebranch alone inlib/src/remote/client/pocket-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 are
controlmessages on the established session, so no relay route, frame type, or Relay-side guard may carry, name, or validate an SDP or a candidate: a negative search overrelay/src/forsdp, the four signal names, 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/, orrelay/src/. 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.
DirectEndpoint.disposecloses it and must run on every path that ends one: inlib/src/remote/burrow/burrow-runtime.ts#disposeEstablished— which#disposeClientreaches fromclient-gone, socket loss andstop()— and the session#promoteConnectionreplaces; inlib/src/remote/client/pocket-client.ts#disposeCeremony, on every teardown, an intentionalclose()and a dropped relay socket included. - 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 byMAX_DIRECT_OUTBOUND_FRAMESandMAX_DIRECT_OUTBOUND_BYTES— 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 the standalone Burrow's native peer addon is loaded at sidecar boot 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.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 the standalone 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. 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").
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 callers; 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).