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, Hosted's account routing and one-time rendezvous todocs/specs/security-hosted.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.
A Burrow that never enrolls with a Relay has no relay, pairing, or push; what
still applies to it is One-time connection, the direct path
it runs on, and the service→webview checks.
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.
The setup-password and burrowToken rows describe the self-host Relay's passkey
account; Hosted login is docs/specs/security-hosted.md -> "Account boundary".
| 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). On the direct path it keeps only the lifecycle levers — it can end the 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 only a Burrow-minted setup token — 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 but checked by possession over HTTPS, so a leaked token redeems remotely — single-use, 24-hour expiry, dead after 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 |
Must mint an ACL record only after the Burrow accepts one local confirmation of the phone's two digits. The webview relays the immutable ceremony id and typed digits; it cannot read the expected code, choose the record, or fabricate a pending request, so a compromised webview gets one 1/100 guess per ceremony (rationale). Removal is Revocation and the audit trail.
- FAIL IF the Burrow stops being the final authority: before any session is established,
BurrowRuntimeinlib/src/remote/burrow/burrow-runtime.tsmust consume its own challenge, verify the presence proof withverifyPresenceProofagainst a binding built from its 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 — with no Relay-supplied claim standing 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 displayed request's immutablepairingId, 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 any rule in
docs/specs/remote-security-model.md-> "Burrow bounds" stops being enforced by the Burrow itself, on its own clock, with no help from the relay: noclient-goneit must receive, no Relay gate, and no deadline that waits for a frame. The pending-pairing cap binds bothBurrowRuntime's client map inlib/src/remote/burrow/burrow-runtime.tsand the service's mirrored queue, oldest evicted first, and outstanding invitations are capped atMAX_TOKENS_PER_BURROW. A handshake that fails to decrypt, or aninitthe bucket refuses, allocates no entry, and a frame the Burrow refuses performs no operation and allocates nothing. 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 guard inrelay/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 every service→webview shape inlib/src/host/remote/service-protocol.ts,OneTimeStateincluded; the test is whether the webview calls anything with the value, not whether exposing it is currently exploitable. The only credentials that cross outbound are the Relay's setup token and the invitation's public half, 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,EnrollParamscarries the setup password by design (rationale). - FAIL IF a private key agreement ever leaves WebCrypto. X25519 stays WebCrypto-only and never a JavaScript curve (
@noble/curves,tweetnacl,libsodium, or any other). The one bundled primitive is ChaCha20-Poly1305 from an exactly-pinned@noble/ciphers, whose import sites and audit deltaremote-lib-common/src/security/noise.ts's header records, rewritten by any version bump in the same commit (rationale). - FAIL IF the Burrow's Noise static is ever sent to the Relay, persisted anywhere
burrowTokenis not, or used with halves that do not correspond (docs/specs/remote-security-model.md-> "Burrow identity");BurrowServicechecks the halves before starting, and 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 self-host Relay (
relay/) begins admitting anaccountIdother thanSELFHOST_ACCOUNT_ID(remote-lib-common/src/remote/wire.ts), or gains a self-serve signup path. The Hosted Relay's accounts aredocs/specs/security-hosted.md-> "Relay boundary"; Reserved: paid activation remains subject to## Future-> Cloud-hosted mode.
docs/specs/relay.md -> "Relay origin" owns the rule these checks audit.
- FAIL IF
DEFAULT_RELAY_ORIGINis not exactlyhttps://relay.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
HOSTED_VOICE_ORIGINinlib/src/host/relay-origin.tsis not exactlyhttps://voice.dormouse.shor is read from anything a build or user sets, orcreateManagedVoiceHostinlib/src/host/managed-voice-host.tssends the voice token anywhere buthostedVoiceOrigin's answer. - FAIL IF a Hosted build's enrollment opens an account page other than the one
enrollVerificationUrlinlib/src/host/remote/service.tscomposes (a dev Hosted build's exception:docs/specs/relay.md-> "Burrow side"),HOSTED_ACCOUNT_ORIGINinlib/src/host/relay-origin.tsis not exactlyhttps://hosted.dormouse.sh, is read from anything a build or user sets, or is requested by the desktop, or the enrollment's device code reaches a webview (HostedEnrollmentState). Pinned bylib/src/host/remote/service.test.ts. - 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 or enrolls with a Relay at any origin but its baked one — taking one from a command, the offer file, or a stored enrollment — or connects on, or saves, an enrollment naming another (
docs/specs/relay.md-> "Relay origin"; readBurrowServiceinlib/src/host/remote/service.ts), orPOST /api/burrow/enrollinrelay/src/app.tsreads the credential or touchesburrows.jsonfor a request naming another origin. - FAIL IF a self-host build can reach
dormouse.shor any host under it unless the user clicks a link to it (docs/specs/relay.md-> "Relay origin").hostedOriginandhostedVoiceOrigininlib/src/host/relay-origin.tsmust answernullthere and every caller do nothing onnull;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 adormouse.shhost. - FAIL IF an enrollment exchange in
lib/src/remote/burrow/enrollment.tsorburrowFetchinlib/src/remote/burrow/burrow-fetch.tsdropsredirect: '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. File-backed credentials use mode 0700/0600 on Unix and
owner-only DACLs in the installed Windows Relay and standalone Burrow, since Node
modes do not protect Windows files; VS Code uses its own storage, per row.
| 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); on the Burrow both in the enrollment record (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, under VS Code's storage permissions rather than a Dormouse-applied DACL. Mostly public keys, except each record's deliveryId, a bearer capability for that Client's push rows: a reader could delete or hijack a subscription, not reach a terminal. Neither store defends integrity against a same-user process; standalone's private storage stops another local account adding a record (rationale). Never on the Relay |
Must apply explicit private permissions to file-backed credentials rather
than rely on the ambient umask. 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.tsand the Web Push senderremote-lib-common/src/remote/web-push.ts, whoseaes128gcmrecord RFC 8291 fixes.scripts/e2e-lint.mjspins these exceptions. - FAIL IF
relay/src/state.tsstops creating$DORMOUSE_STATE_DIRmode0o700or writing every file throughwriteAtomicat mode0o600— 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 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, orVsCodeBurrowStateStorestops keeping the enrollment, which carriesburrowToken, inSecretStorage. The ACL's home inglobalStateis not a finding. - FAIL IF the Relay stops deleting
state/hosts.jsonunread at boot (forgetRetiredStateinrelay/src/state.ts, called fromrelay/src/start.ts): the v1.0–v1.1server/Relay kept a livehostTokenper row there, under a name the Host→Burrow rename retired, and nothing else reads or removes it. Pinned byrelay/test/state-records.test.mjs. - FAIL IF
burrow_state_dirinstandalone/src-tauri/src/lib.rspasses the sidecar a state directoryrestrict_to_ownerdid not lock — on Windows Node modes are no-ops, so this holds the guarantee, and a refusal keeps the Burrow in memory. The lock must reach both a newly written enrollment file (by inheritance) and one a prior version left under the%LOCALAPPDATA%ACL with a liveburrowTokenin it (by propagation). - 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 other principal in the effective permissions. macOS and Linux use0700/0600underumask 077; Windows a single owner-only ACE, carried directly or inherited 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 on WindowsTest-OwnerOnlystops checking the owner SID alongside the DACL or accepts an empty access-rule set, or verify stops walking the files insidestate/(whererelay/src/state.ts's0o600is a no-op) or passes an enumeration that failed.scripts/installer-verify-test.mjsexercises the unix checks;scripts/deploy-lint.mjspins all three platforms (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, drops its length guard — 64 hex characters, not 32, since the offer redeems for a Burrow enrollment — prints it, or writes it anywhere but its publication file in
<install root>/run/, published by atomically renaming a complete owner-only temporary file there. There is nomanage show-passwordcounterpart: the reader is a Burrow process (rationale). - FAIL IF the offer's publication file, or
run/itself, is reachable by any principal other than the installing user, or is locked only after the token is written.run/is0700(a single-ACE DACL on Windows), andmanage verifyasserts it (rationale). - FAIL IF any installer stops re-minting the offer on runs before the first Burrow enrollment, mints one once
state/burrows.jsonexists — the durable "bootstrap completed" marker even after every row is removed — or mints it before the switched release, HTTPS Serve mapping, and pruning have succeeded (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.mjspins 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. The Relay generates it, never the operator (docs/specs/relay.md ->
"Configuration"). Online guessing is bounded without trusting network identity
(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 delay, 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, stops answering an empty bucket 429 withRetry-After, or allocates state per caller. Every POST counts; OPTIONS does not. Pinned byrelay/test/token-bucket.test.mjs.
Never grant cross-origin browser reads or authenticate from a cookie.
Pocket uses relative API URLs at the configured origin; Burrow HTTP runs in
Node. The Relay grants no preflight or CORS response; it does not reject every
request carrying a foreign Origin (rationale).
- FAIL IF the Relay installs CORS middleware, emits
Access-Control-Allow-Origin, or accepts authentication from a cookie (rationale). Pinned byrelay/test/cors.test.mjs.
scripts/deploy-lint.mjs checks that every installer still holds the controls this
section and "Credentials at rest" name (AGENTS.md lint table); whether each is
correct is this audit's (rationale).
The Relay always speaks plain HTTP, so the listen interface is a security boundary
when the TLS proxy is local. The shipped self-host Relay is a per-login user service
behind tailscale serve on the node's MagicDNS name (SELF_HOST.md); an unbound socket
would publish its plaintext port to the LAN and the tailnet.
May publish the HTTPS origin publicly. Tailnet-only Serve is the installer default and defense in depth, never an authentication premise: under Funnel, public admission is The setup password, and a Client still reaches no Burrow without the Burrow-local authorization above. Must not make Funnel state an install or health verdict (rationale).
A direct path opens the one listener no loopback rule covers
(docs/specs/remote-security-model.md -> "Direct path"): neither
docs/specs/security-local.md -> "Loopback Listeners" nor scripts/loopback-lint.mjs
reaches a UDP socket the browser or the addon binds.
- 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: it is durable WebAuthn identity, and rewriting it invalidates the registered passkey and every enrolled Burrow. - 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. Held byscripts/deploy-lint.mjs(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. Once a Burrow has decrypted them, both
directions carry untrusted bytes — inbound, terminal.write is keystrokes into a real
shell and the ACL is the entire gate; outbound, notification text is Pane-derived, so it
is bounded on the Burrow before sealing and re-bounded at the render sink (rationale).
Web Push is the one path where the Relay makes an outbound request to an address a
Client supplied, a live SSRF concern on a Relay inside a tailnet, where 100.64/10
is exactly the range a push endpoint must not reach (egress rules: docs/specs/relay.md
-> "Web Push"). The blocked ranges are loopback, private, CGNAT, link-local,
documentation, benchmark, multicast, reserved, IPv4-mapped, unique-local, and site-local.
The Hosted Relay, which cannot pin a resolution, admits only known push services' hosts
(docs/specs/security-hosted.md -> "Relay boundary").
- 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, forwards anything but the sealed envelope plus the token's own
burrowId, or spreads the envelope rather than copying its fields, which would let a Burrow override thatburrowId. The Relay holds no key for it, so a route that could read a payload is one that was handed plaintext. - FAIL IF a push stops being sealed per recipient as
docs/specs/remote-security-model.md-> "Push sealing" constructs it (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.lib/src/remote/burrow/push-delivery.tsholds a seal capability, 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, counters, and bounds.
docs/specs/remote-api.md -> "Direct path" owns the design and
docs/specs/remote-security-model.md -> "Direct path" why it adds no trust layer.
- 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(true once per session) before their firstawait, and aDirectEndpointis built only for a promoted session, byEstablishedE2eSessionon the Burrow andClientSessionCore.establishon the Client — 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) 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 shipped source names an ICE server but Cloudflare's STUN, or hands it to a Burrow's peer at any level but
anywhereor to a page a self-host Relay serves; a STUN server learns the address of each end that asks it. Underremote-lib-common/src/,lib/src/,relay/src/, andhosted/server/, the onlystun:,stuns:,turn:, orturns:URL is exactlystun:stun.cloudflare.com:3478, spelled only inlib/src/remote/direct/ice-servers.tsand listed only bystunServersthere, which only the two peer factories call, the native one never with a literaltrue;iceServersappears only in the two peer factories,lib/src/host/remote/native-direct-peer.tsandlib/src/remote/client/browser-direct-peer.ts. Who gathers through it isdocs/specs/remote-network.md-> "Anywhere"; readdirectPeeringForinlib/src/host/remote/direct-peering.tsanddeploymentDirectPeerinlib/src/remote/pocket-app/deployment.ts.scripts/e2e-lint.mjsholds the spelling textually. - FAIL IF a peer connection can outlive its session by more than the goodbye's flush: every path that ends a session, at either end, paired or one-time, must reach
DirectEndpoint.dispose— or, once the goodbye is on a switched channel,DirectEndpoint.disposeAfterFlush, which sends and delivers nothing more and closes bySESSION_END_FLUSH_MS; a Burrow'sstop()leaves its goodbyes unflushed so no timer survives it. The endings run throughEstablishedE2eSessionon the Burrow anddisposeSessioninlib/src/remote/client/session-core.tson the Client. - FAIL IF the direct path stops bounding what it holds, or stops disposing on a violation. Held frames and a sender's queue are each capped by
MAX_DIRECT_PENDING_FRAMESandMAX_DIRECT_PENDING_BYTES— neither direction may hand the implementation unbounded data — and overflow disposes the session rather than dropping a frame. So do a relaytransportframe after inbound has switched (before any decrypt), the channel closing or erroring after either direction switched (at both ends), a switch onto a channel this end abandoned, and an undecodable relayct.DirectCutoverinremote-lib-common/src/security/direct-path.tsdecides each but the last;DirectEndpoint, both ends' only entry for a relay frame, decodes thectand acts on every outcome. Pinned byremote-lib-common/test/direct-path.test.mjsandlib/src/remote/direct/direct-endpoint.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 barerequireinside an authorized session's first offer, and a load failure answersdirect-declineand leaves that session relayed rather than failing 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. Before reporting the open,
DirectPeerinlib/src/remote/direct/direct-peer.tsmust refuse a channel not labelledDIRECT_CHANNEL_LABEL, one reported unordered or partially reliable, and one whose association's per-message limit is belowNOISE_MAX_MESSAGE_LENGTH, 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: neither Burrow's implementation reports those flags, 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. - FAIL IF under Local networks a paired phone's application message is read off the Relay, or its session outlives a given-up attempt or a missed
DIRECT_ONLY_DEADLINE_MS(docs/specs/remote-network.md-> "Local networks").BurrowRuntime.#promoteConnectioninlib/src/remote/burrow/burrow-runtime.tsmust derivedirectOnlyfrom the path policy alone, say so inConnectionOutcomeV1.directOnly, and hand it toEstablishedE2eSession, andBurrowServiceinlib/src/host/remote/service.tsmust start alocalBurrow onlocalNetworksPathover the policy'sallowedand restart it on any changesamePathssees.scripts/e2e-lint.mjsholds the derivation and its hand-off textually; pinned bylib/src/remote/burrow/burrow-direct-only.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), and every Burrow bound holds whichever path carried a frame.
docs/specs/one-time.md owns the link and the rendezvous wire ("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, an outcome stops being one padded control message, or the approval modal or any
OneTimeStatecan carry a label the phone chose, which could tell the person which digits to type (knownOneTimeDeviceLabel;docs/specs/remote-security-model.md-> "One-time connection",docs/specs/one-time.md-> "Burrow runtime"; readOneTimeRuntime). 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:
OneTimeRuntimemust make its one sessiondirectOnlyonEstablishedE2eSession, so an application message decrypted off the rendezvous ends the session unread and a decline, an abandoned attempt, or no switch byDIRECT_ONLY_DEADLINE_MSends it — no relayed fallback. 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 (docs/specs/remote-security-model.md-> "One-time connection").scripts/e2e-lint.mjsholds the flag textually. - FAIL IF the phone opens the room's socket outside
connectOnce, sends protocol-v1 before both directions are direct, keeps the rendezvous open past the switch, parses a frame beforeparseOneTimeFrameinlib/src/remote/one-time-rendezvous.tshas measured it againstMAX_ONE_TIME_FRAME_LENGTH, reads a frameisOneTimeBurrowFramerefuses, or outlivesDIRECT_ONLY_DEADLINE_MSwithout both directions direct; a decline or an abandoned attempt fails the attempt too (docs/specs/one-time.md-> "Phone client"; readOneTimeClientinlib/src/remote/client/one-time-client.ts). 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; orBurrowServiceinlib/src/host/remote/service.tsholds more than one runtime, stops ending the one a new link replaces, or opens a new link while a phone is connecting or connected; or the socket carries anOriginheader. 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 (docs/specs/remote-network.md-> "Local networks"). ReadlocalNetworksPathinlib/src/host/remote/local-networks.tsand its consumers,DirectPeerinlib/src/remote/direct/direct-peer.tsandcreateNativeDirectPeerFactoryinlib/src/host/remote/native-direct-peer.ts. Pinned bylib/src/host/remote/local-networks.test.tsandlib/src/remote/direct/direct-peer.test.ts. - FAIL IF the one-time runtime relies on the rendezvous for any bound or deadline (
docs/specs/one-time.md-> "Burrow runtime"). It must read every message throughparseOneTimeFrame, 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 byDIRECT_ONLY_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 have no
production callers, no relay frame carries a revocation, and there is no management UI.
Revoking a lost phone means hand-editing JSON on the Burrow and restarting it: a
running BurrowRuntime holds the ACL snapshot it started with, and the restart both
reloads it 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 structured audit trail covering connects, attaches, denials, or
writes. The ACL records approvedAt / approvedBy; owner-local logs report some
rejections. A self-hoster cannot answer "did anyone connect to my laptop last night".
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.
Hosted's admin-entitled routing is implemented (docs/specs/security-hosted.md
-> "Relay boundary"). Broad paid activation remains staged; its review must
cover these operator responsibilities:
- Must review Hosted operator handling of residual metadata before paid activation. The visible metadata is
docs/specs/remote-security-model.md-> "Residual metadata"; the trust boundary above still excludes plaintext and new Burrow authorization. - An independent cryptographic review is a precondition of claiming this model for a paid service (
docs/specs/remote-security-model.md-> "Security Guarantees"). - Never rely on tailnet reachability for paid Hosted admission. Review public admission and the multi-tenant account boundary before activation (
docs/specs/hosted.md-> "Burrow enrollment"); the self-host setup password supplies no Hosted identity.