See
docs/specs/glossary.mdfor Client, Burrow, Relay, and Session vocabulary.
The trust model for remote control: three primitives between the Client (Dormouse Pocket), Burrow (Dormouse Terminal), and coordinating Relay.
- One end-to-end channel per ceremony — pairing and connection each run a
Noise IK handshake whose two
CipherStates carry everything after it, and the Relay routes that ciphertext without reading it. - Passkeys prove fresh user presence inside that channel, over a challenge derived from the handshake itself. A passkey authenticates the user; it grants access to no Burrow.
- Each Client pairs explicitly, one-to-one, with each Burrow — the Burrow keeps its own local ACL of approved Clients, each identified by a per-Burrow X25519 static generated in the browser; storage follows Client statics below.
Account compromise is therefore insufficient for burrow access
(Security Guarantees). docs/specs/security-remote.md -> "Remote Control"
is this model's audited face — the properties checked nightly and the gaps left
open (revocation, the audit trail).
Every primitive here lives in remote-lib-common/src/security/, shared
verbatim by Relay, Burrow, and Pocket so the three cannot disagree on what a
valid credential is. Message sequences are relay.md (Relay);
this spec defines what they must establish. Evidence:
remote-security-model.rationale.md.
Primary: no native mobile application; strong protection against account compromise, newly-added credentials, and Relay compromise, including confidentiality of everything the two endpoints say to each other; explicit burrow-controlled authorization; long-lived trusted client devices; passkeys.
Non-goals: a compromised browser runtime or operating system; a user intentionally clearing browser data; permanent device identity across browser resets; availability — the relay is down whenever the machine is (a per-login user agent), and the Relay is a hard online dependency for every new session (relay.md); traffic analysis (Residual metadata).
| Layer | Responsibility |
|---|---|
| Noise channel | Confidentiality and peer authenticity |
| Passkey | Fresh user presence |
| Client static | Long-lived client identity |
| Burrow ACL | Authorization |
| Burrow | Final access decision |
No single layer is sufficient — a connection requires all of them to agree.
Exactly two endpoints are trusted: the distributed Burrow binaries and the exact served Pocket artifact — an operator serving modified Pocket code is outside the model, as is XSS in the Pocket origin. The Relay is trusted with nothing: it may drop, delay, reorder, or refuse traffic, and must gain no plaintext and no authorization by doing so.
Every pairing and every connection carries one WebAuthn assertion, verified by the Burrow inside the encrypted channel.
Passkeys are user credentials, not device identities — synchronization puts one passkey on many physical devices and changes nothing here (rationale).
Presence, or verification. The default demand is the authenticator's
user-presence flag; DORMOUSE_REQUIRE_USER_VERIFICATION=true raises it to user
verification, mirrored by the Relay into every Burrow's enrollment response and
copied by the Burrow into its policy. Both verifiers must demand the same thing
(rationale). Pocket asks userVerification: 'preferred' either way, so platform
authenticators prompt for biometrics in practice — convention, not a guarantee.
The Burrow stores only a hash of each paired passkey's public key, checked against the full key presented inside the channel, so a compromised Relay cannot substitute a passkey. The Relay likewise verifies against its own stored key, never one a request carries. Only ES256 (ECDSA P-256 / SHA-256) is accepted, the mandatory-to-implement WebAuthn algorithm.
Source of truth: verifyPasskeyAssertion / hashPasskeyPublicKey in
remote-lib-common/src/security/passkey.ts;
BurrowEnrollment.requireUserVerification in lib/src/remote/burrow/enrollment.ts.
A Client static is long-lived Client identity — the capability the Burrow actually authorizes.
Must generate one X25519 keypair per Burrow at scan time and persist it only after approval, never shared between Burrows. (rationale) The raw 32-byte public half, base64url, is the Client identifier on the ACL; Noise IK proves possession of the private half (rationale).
Must prefer a directly persisted nonextractable private key. Only a failed native storage probe may select AES-256-GCM-encrypted PKCS#8 with a per-key nonextractable AES key, after that format passes reopen and key agreement. Must import recovered X25519 keys nonextractably, never persist plaintext private bytes, and leave existing native records unchanged. (rationale)
Active XSS can use either format and can extract the X25519 private bytes in the encrypted format. Nonextractability is therefore not a universal at-rest guarantee; browser/OS compromise defeats both formats. A stolen static still requires its paired passkey's fresh presence proof. Clearing browser data destroys either format (Client static loss).
Source of truth: generatePocketKeyPair in
lib/src/remote/client/pocket-private-key.ts; what Pocket stores is
pocket-app.md.
The ACL is authoritative; the Relay cannot unilaterally grant access.
BurrowAclRecord binds the Burrow and account to one passkey credential and public
key hash, one Client static public key, a fresh 256-bit deliveryId, approval
metadata, and a nullable revocation time. Persisted through BurrowStateStore — a
0600 file in standalone, globalState in VS Code — never on the Relay
(relay.md).
Authorization is the conjunction, on one record. BurrowAcl.authorize
reports a miss (passkey-not-paired, client-not-paired, pairing-mismatch)
unless the passkey credential and the Client static match the same active
record. Halves on different records are not authorization, and a passkey added
to the account after pairing grants nothing until a new local approval.
The two E2E fields are checked for exact length on read, and that is the whole
of the Burrow-ACL version: isBurrowAclRecord + filterAclRecords drop anything
malformed, pre-cutover, or belonging to another burrowId before it reaches the
conjunction, and there is no migration reader — such a Burrow reads an empty
ACL and every phone pairs again. Hygiene, not authorization (rationale).
Delivery IDs are opaque bearer capabilities, minted by the Burrow at approval and held only by the record and the Client's own pinned copy. Possession is the whole authorization for registering, querying, and deleting a push subscription, so the Relay never lists one to a session (relay.md -> Web Push). They are not an anonymity mechanism (rationale).
Source of truth: remote-lib-common/src/security/acl.ts (the schema and
BurrowAcl.authorize), lib/src/remote/burrow/acl.ts (the read filter).
Use one verifier for pairing and connection.
- The WebAuthn challenge is derived, not random.
presenceChallenge(binding, relayNonce)is base64urlSHA-256(lengthPrefixedConcat(domain, kind, binding fields in declared order, relayNonce))underdormouse/presence/v1. One encoding rule: a base64url field is hashed as the bytes it encodes and everything else as UTF-8 — decoded areconnectionId,burrowChallenge,handshakeHash, and the nonce; text are the domain, the kind,burrowId, andpasskeyCredentialId. Relay mints, Burrow recomputes, one builder.isPresenceBindingtakes exactly one kind's fields, each bounded — anything the challenge does not cover must not reach the Burrow inside a verified binding. POST /api/reauth/begintakes a required, kind-tagged binding and answers the derived challenge over a one-use Relay nonce (relay.md owns both routes). No binding, or no nonce tofinish, is a 400 (rationale).finishconsumes the nonce, recomputes the challenge, verifies the assertion against the stored key for that exact credential, and extends nothing — not the session's life, not the relay socket.PresenceProofV1travels only inside the first Client→Burrow transport payload, carrying the binding, the Relay nonce,accountId, the passkey credential id, its canonical SPKI public key, and the assertion. The Burrow recomputes the challenge with the same builder, requires every binding field to equal what it built from its own state, verifies RP ID, origin, presence/verification policy, and signature against the presented key, and hashes that key for the ACL. A Relay success flag is never evidence, and the verifier never throws, including missing WebCrypto or rejected digest operations — a rejection is an ordinary denial (rationale).- Every proof is fresh and single-use: any restart — dropped transport, consumed challenge, failed handshake, later attempt — needs a new handshake, Burrow challenge, Relay nonce, and authenticator operation; one prompt per pairing or connection, three on a self-hosted first run (rationale).
- The Relay session is authentication-plane only. Its bearer token (relay.md) is never reusable proof of presence for a Burrow, and has no app-session signing key beside it (rationale).
Source of truth: utf8Encode in remote-lib-common/src/security/bytes.ts
(remote-lib-common/test/bytes.test.mjs); presenceChallenge / isPresenceBinding in
remote-lib-common/src/security/presence.ts, verifyPresenceProof in
remote-lib-common/src/security/e2e-ceremony.ts, relay/src/app.ts.
Local confirmation on the Burrow is the only path that mints an ACL record. A newly-added passkey is not automatically trusted; its Client must still pair.
- The invitation is Burrow memory.
setupQrmints the Relay's setup token and, locally, a 16-byte invitation id plus a one-use X25519 invitation keypair, bounded by the eight-invitation cap and expiring on the pairing TTL. The QR carriesburrowId, invitation id, expiry, setup token, and invitation public key (relay.md owns the grammar); it carries no Burrow static, no label, and no signature (rationale). - Invitation lifecycle, Burrow-owned, and the QR panel renders it:
liveuntil a valid Noise message 1 decrypts against it (reserved), which then always endsconsumed; an un-scanned one endsexpiredby TTL ordroppedwhen the Burrow discards it — lost relay socket, or evicted at the cap. A mint whose keygen straddles a teardown is refused rather than inserted (rationale). Each invitation accepts one request; a failed decrypt leaves it live and redemption at the Relay flips nothing, and neither may read as a scan. - IK against the invitation key: Client initiator, fresh per-Burrow static as
s, invitation public key asrs. Both handshake payloads are empty;Splityields the pairing channel, and no ACL, delivery ID, or resumable state exists yet. - Reverse two-digit confirmation. Pocket samples a uniform code
00–99(rejection sampling) and sends it with itsPresenceProofV1and sanitized device label in the first transport payload. After the proof verifies, the Burrow opens a modal with the label, an empty two-digit input, and the copy: Only authorize if your phone is showing a two-digit code. If it shows an error or no code, cancel this request. The Burrow holds the expected code and never displays, mirrors, or retransmits it, and compares the typed digits without early exit (relay.md owns the webview echo). Exactly one attempt (rationale). - Every terminal outcome consumes the invitation, erases handshake material, and is reported at both ends in fixed local copy, never text off the wire (relay.md -> "Remote control, in the Settings dialog"): mismatch, denial, pairing-TTL timeout, replacement by a newer pairing from the same Client, malformed input, or a failed proof.
- Confirmation writes one record, then answers. On a match the Burrow durably
writes one active
BurrowAclRecordbindingburrowId,accountId,passkeyCredentialId,passkeyPublicKeyHash, the Client static IK authenticated — never one the payload merely claimed — and a freshdeliveryId, then sendsPairingOutcomeV1: success carries the Burrow static public key, the local label, the paired passkey identifiers and hash, and thedeliveryId; denial carries onlyuser-denied,confirmation-mismatch,presence-rejected,invitation-expired,superseded, orburrow-error. Both use the same fixed padded control message, so approval and denial are one size on the wire (relay.md -> E2E framing). - Must serialize approval snapshots and publish the ACL only after saving.
Service lifecycle changes await approval writes. A failed save denies
burrow-errorwithout changing the live ACL. Once the write starts, local consent is final: teardown can discard its transport but cannot cancel the write or announce denial; completion never revives a retired ceremony. - An unparseable first control is terminal, not a retry: it spends the code (rationale).
- A resumed handshake re-checks that its invitation is still the live one (rationale).
Before storing the record, Pocket verifies the passkey fields match its ceremony
and compares the Burrow static to any existing pin for that burrowId: a
mismatch is a terminal security error that keeps the old pin.
Source of truth: BurrowRuntime.mintInvitation / #onPairingInit /
#onPairingTransport / #approvePairing in
lib/src/remote/burrow/burrow-runtime.ts, PairingRequestV1 / PairingOutcomeV1 /
samplePairingCode in remote-lib-common/src/security/e2e-ceremony.ts,
#setupQr in lib/src/host/remote/service.ts,
lib/src/remote/burrow/RemotePairingModal.tsx. Pinned by
lib/src/remote/burrow/burrow-runtime.test.ts.
- IK against the pinned Burrow static. Fresh 16-byte connection ID; Client
initiator with its paired per-Burrow static,
rsthe pin; message 2's payload is a fresh 32-byte Burrow challenge (ChallengeIssuer, 2-minute TTL). Completing Noise proves both statics and authorizes nothing. - Authorization = proof ∧ conjunction. The Burrow consumes the challenge
before verifying presence, verifies
PresenceProofV1against the binding it built from its ownburrowId, connection ID, challenge and handshake hash, then requires one activeBurrowAclRecordholding all four ofaccountId,passkeyCredentialId,passkeyPublicKeyHash, and the IK-authenticated Client static. - Then
ConnectionOutcomeV1: success carries the Burrow label; denial carries onlypairing-required,presence-rejected,protocol-rejected,burrow-busy, orburrow-error. Every ACL miss ispairing-required— individual ACL and presence failures are logged owner-locally and never returned. Success promotes the twoCipherStates into the established session; every terminal decision sends exactly one outcome and clears pending state; failures beforeSplityield only a generic outer error (rationale). - Protocol-v1 rides inside, as application messages on the session's byte stream (remote-api.md -> Transport).
Pocket accepts an outcome only after decrypting it on the cipher state for the
expected handshake hash; a timer expiring without one reports unavailable, not
denial. The proof asserts with the record's own paired credential — the sole
allowCredentials entry for that Burrow, never whichever passkey signed this
session in (pocket-app.md owns what the outcome then does to
the record).
Source of truth: BurrowRuntime.#onConnectionInit / #onConnectionTransport /
#promoteConnection in lib/src/remote/burrow/burrow-runtime.ts,
ChallengeIssuer in remote-lib-common/src/security/challenge.ts.
A push gets its own construction — no live session exists between the two endpoints when one is sent (rationale).
- Push is opt-in. A Burrow that never enrolls to a Relay sends none, and none of the push limitations apply; an enrolled Burrow pushes only to a phone that turned push on (pocket-app.md -> Installable web app owns the card).
- A fresh key per message, from the two pinned statics.
ss = X25519(burrowStatic, clientStatic), a random 32-byte salt,key = HKDF-SHA-256(ikm = ss, salt, info = "dormouse/push/v1", 32), and ChaCha20-Poly1305 under the all-zero 96-bit nonce. That nonce is spent exactly once per key, by construction: the key exists only for its own salt and no counter advances. - Never a Noise
CipherState, and never Noise's HKDF (rationale). The ChaChaPoly binding is the pinned@noble/ciphersthe suite already uses (Noise suite). - Confidentiality, not freshness: nothing binds a push to a moment and the sink keeps no replay memory, an accepted residual (Residual metadata).
- The Burrow seals once per recipient, to that ACL record's own Client static,
handing the delivery path a seal capability over its nonextractable
CryptoKeyrather than the key. There is no group key. - The Relay forwards exactly
{ burrowId, v, salt, ct },burrowIdtaken from the sending Burrow's token, validating only shape and bounds — the ciphertext bound is what keeps the envelope inside Web Push's ~4 KB ceiling (relay.md -> Web Push). Copied field by field, never spread, so no Burrow can override the token'sburrowId. - The worker decrypts at the sink, against the pinned record for that
burrowId, and re-bounds what it recovers. Any failure, including missing WebCrypto, shows the generic content-free notification, becauseuserVisibleOnlymakes showing nothing a browser-substituted notice (pocket-app.md -> Installable web app owns the branch list).
Source of truth: sealPush / openPush / isSealedPushV1 in
remote-lib-common/src/security/push-seal.ts, pinned by
remote-lib-common/test/push-seal.test.mjs; BurrowRuntime.sealPushForClient in
lib/src/remote/burrow/burrow-runtime.ts, sendPush in
lib/src/remote/burrow/push-delivery.ts;
lib/src/remote/pocket-app/sw.ts.
Every bound is Burrow-enforced and independent of the relay — Relay-side
gates are defense in depth only, and Burrow correctness must survive a relay that
omits client-gone, invents client IDs, or reorders frames.
| Bound | Value | Declared in |
|---|---|---|
MAX_PENDING_PAIRINGS |
8 | remote-lib-common/src/security/pairing.ts |
MAX_TOKENS_PER_BURROW |
8 | remote-lib-common/src/remote/wire.ts, shared with the Relay's setup-token cap (rationale) |
MAX_CLIENT_ID_LENGTH |
256 | remote-lib-common/src/remote/wire.ts |
MAX_RELAY_TO_BURROW_FRAME_LENGTH |
one maximal ct + MAX_CLIENT_ID_LENGTH + 512 |
same |
MAX_PENDING_CONNECTION_HANDSHAKES |
8 | lib/src/remote/burrow/burrow-runtime.ts |
MAX_QUEUED_RELAY_FRAMES / MAX_QUEUED_RELAY_FRAME_CHARS |
128 frames / 4,194,304 UTF-16 code units | same |
MAX_ESTABLISHED_E2E_SESSIONS |
16 | remote-lib-common/src/security/e2e-bounds.ts |
ESTABLISHED_E2E_IDLE_TIMEOUT_MS |
120 000 | same |
E2E_INIT_BURST / E2E_INIT_REFILL_INTERVAL_MS |
8 / 1 000 | same |
DIRECT_SETUP_TIMEOUT_MS / DIRECT_ANSWER_TIMEOUT_MS / DIRECT_GATHER_TIMEOUT_MS |
15 000 / 10 000 / 3 000 | remote-lib-common/src/security/direct-path.ts |
DIRECT_HANDOFF_TIMEOUT_MS / DIRECT_DISCONNECTED_GRACE_MS |
5 000 / 5 000 | same |
MAX_DIRECT_SDP_LENGTH |
2 000 characters | same |
MAX_DIRECT_PENDING_FRAMES / MAX_DIRECT_PENDING_BYTES |
8 192 frames / 4 MiB, bytes binding first (rationale) | same |
MAX_DIRECT_OUTBOUND_FRAMES / MAX_DIRECT_OUTBOUND_BYTES |
the same pair, for what a sender holds | same |
DIRECT_BUFFER_HIGH / DIRECT_BUFFER_LOW |
256 KiB / 64 KiB | same |
-
Must bound waiting relay frames before enqueueing, by count and cumulative received-string length; both
e2eandclient-goneshare one FIFO and one in-flight operation across reconnects. Overflow synchronously closes the relay connection and clears its queue and transient state; never skip a transport frame and continue its Noise session (rationale). -
At most one pairing, one connection, and one established session per relay client; a replacement disposes its predecessor, whatever identity it belonged to (rationale). Pending pairings expire on the pairing TTL, pending connections on the challenge TTL.
-
The session cap is checked at promotion and nowhere else, after the presence proof and the ACL conjunction have both succeeded (rationale). A Client static already holding a session replaces its own atomically; any other identity at the cap gets the fixed-size
burrow-busyand evicts no other entry. Pending caps and the token bucket stay active at the cap. -
A Burrow-global token bucket gates the WebCrypto an accepted
initbuys, on the Burrow's own clock, and answers a refused frame with nothing — as do refusals by shape, size, or a pending cap (rationale). -
A message is processed only for its exact pending ID and expected step: unknown IDs are dropped without decryption, established frames decrypt only at their session's next nonce, and the first invalid ciphertext destroys its session (rationale).
-
Rejected frames perform no WebCrypto operation and allocate no entry.
MAX_RELAY_TO_BURROW_FRAME_LENGTHis measured on the received string beforeJSON.parse(a non-string payload is dropped) and given to the socket implementation'smaxPayloadwhere it takes one (rationale); the wire guard then bounds every routing value,clientIdfirst, before the ciphertext scan — handshake messages at 65,535 bytes, application payloads at 1 MiB, each measured before base64 decoding. -
One reaper owns every deadline, over absolute timestamps: invitation expiry, pairing TTL, challenge TTL, idle timeout. It runs on every
init, every local decision, every relay lifecycle event, and a timer armed for the soonest deadline — re-armed when that instant moves earlier, cleared onstop()(rationale). An expiry emits an outcome only where a transport cipher exists and someone is owed one:Expired Answer Pending pairing invitation-expiredPending connection (its challenge is now dead) presence-rejectedIdle established session nothing Pending pairing evicted at its cap superseded(rationale)Pending connection evicted at its cap nothing (rationale) -
The idle deadline moves only on a successfully decrypted Client→Burrow transport message, keepalive or application data; never on Burrow output, a failed decrypt, a relay envelope, a socket ping, or any unauthenticated frame (rationale). The Client keepalives on
E2E_KEEPALIVE_INTERVAL_MSand runs the same deadline against its own last send, so a session the Burrow reaped ends on both sides (pocket-app.md). -
Every expiry or outcome disposes remote-control attachments without killing terminal sessions, erases Noise state and keys, and removes the entry before accepting replacement work.
client-gonedisposes that client's state; losing the Burrow's own relay socket disposes everything, invitations included (rationale).
Source of truth: lib/src/remote/burrow/burrow-runtime.ts, and TokenBucket in
remote-lib-common/src/security/token-bucket.ts — the same primitive the Relay
admits Burrow enrollment with (relay.md). Pinned by
lib/src/remote/burrow/burrow-bounds.test.ts,
relay/test/malicious-relay.test.mjs and
remote-lib-common/test/token-bucket.test.mjs.
The direct path adds no layer to this model. A WebRTC data channel replaces
the Relay as the carrier of an already-authorized session; every rule above
holds unchanged, because nothing about what is carried changes.
remote-api.md -> "Direct path" owns the design and is not
restated here: that the channel carries transport messages of the session
promoted at Connection on that Split's own two CipherStates,
that every signal rides inside the ciphertext, that nothing is offered before
promotion, and that one peer connection per session is closed by every path
that ends one, are its rules. What this model adds is what is underneath them.
- DTLS beneath is transport hygiene this model does not rely on. It protects nothing the Noise session does not already protect, and the fingerprints in an SDP are authentic for exactly one reason — that SDP arrived inside the session. A DTLS peer is never an authenticated one.
- Never an ICE server. Both ends pass an empty list. (rationale)
- What the channel may buffer is this side's bound, not the implementation's, in both directions (remote-api.md -> "Direct path"): the same pair of numbers holds a sender's queue and a receiver's, and overrunning either disposes the session.
The listener is UDP on every interface a candidate names, for the life of an
attempt. The standalone Burrow's addon binds one socket on the unspecified
address and advertises each routable interface at that port; a browser binds per
interface. Either way the host answers UDP from anyone who can route to it on
any of those networks. (rationale)
Two parsers sit behind it and both are attack surface: before DTLS, ICE's
own STUN parser, which answers a binding request only under this attempt's
ufrag and password (RFC 8445 requires 24 and 128 bits of randomness), both
freshly generated and reaching the peer only inside the session; after DTLS,
the peer implementation's TLS stack — the browser's on the phone, the addon's
on a standalone Burrow (docs/specs/security-supply-chain.md). A memory-safety
bug in either is reachable by any stranger on those networks, and nothing above
it mitigates that.
A channel lost after a session has switched is burrow loss, and that is accepted: both ends end the session rather than resume on the Relay. (rationale) The Relay still sees that the session exists and whether each end is online; it no longer sees the traffic (Residual metadata).
Source of truth: remote-lib-common/src/security/direct-path.ts (the signals,
their guard, and DirectCutover), lib/src/remote/direct/direct-peer.ts
(DirectPeer), DirectEndpoint in
lib/src/remote/direct/direct-endpoint.ts (the attempt, the peer, and the
switch, created at promotion by BurrowRuntime.#promoteConnection in
lib/src/remote/burrow/burrow-runtime.ts and PocketClient.#directEndpoint in
lib/src/remote/client/pocket-client.ts). The audited rows are
docs/specs/security-remote.md -> "Direct path".
- Exactly one suite:
Noise_IK_25519_ChaChaPoly_SHA256, Noise revision 34. No generic pattern API, cipher negotiation, protocol-name override, or caller-selectable suite.IKonly: pre-message<- s, then-> e, es, s, ssand<- e, ee, se. - No plaintext path, feature flag, negotiated downgrade, or legacy frame
discriminant —
scripts/e2e-lint.mjs(pnpm lint:e2e) refuses each textually, andscripts/e2e-lint-selftest.mjsproves them load-bearing. - Prologues are canonical and length-prefixed (
lengthPrefixedConcat), each binding its own ceremony's identifiers so a transcript is useless against another Burrow, id, or ceremony (relay.md -> E2E framing owns the field order). Application authentication binds to Noise's final handshake hash — no parallel transcript, exporter, KDF, or nonce scheme. Sessions use the twoCipherStates fromSplit, each from nonce zero, with empty associated data; routing metadata is never authenticated application content. No rekey: sessions expire on inactivity. - X25519 stays WebCrypto-only (
generateKey/deriveBits/importKey), never a JavaScript curve (rationale). An X25519 rejection and an all-zero shared secret are one terminal handshake failure, and the handshake refuses every later call rather than resuming on half-mixed state. SHA-256 and HMAC are WebCrypto; HKDF is Noise's own HMAC construction (section 4.3), never WebCrypto HKDF. - ChaChaPoly is bundled from an exactly pinned
@noble/ciphersrelease (rationale). The module header records the pin, the published audit, and what changed in the chacha path between the audited and the pinned release; a version bump rewrites that note in the same commit. - Every message — handshake and transport — is capped at 65,535 bytes on
write and read, the tag counted. The 96-bit nonce is
00000000 || little_endian_u64(n)with2^64-1reserved, so counter exhaustion is a hard error, never a wrap, and a failed decrypt does not advance the counter (rationale). - Any failure ends the session: authentication or decryption failure, replay, gap, reordering, version mismatch, or counter exhaustion. Relay errors stay generic availability errors and never trigger a fallback.
- Conformance is proven against an independent implementation (rationale): the vendored Cacophony vector matched byte for byte through both handshake messages, every transport message both ways, and the handshake hash, plus the RFC 7748 and RFC 8439 vectors. No expected value may come from the production state machine.
- The only test hook is ephemeral-key injection; production callers never pass it.
Source of truth: remote-lib-common/src/security/noise.ts,
remote-lib-common/src/security/noise-transport.ts, pinned by
remote-lib-common/test/noise.test.mjs against the attributed vector in
remote-lib-common/test/vectors/.
Each Burrow mints one permanent Noise static at enrollment, before the request
and never in it: noiseStaticPrivateKey (PKCS#8, base64url) and
noiseStaticPublicKey (raw 32 bytes, base64url) ride in the enrollment record,
landing exactly where burrowToken does (docs/specs/security-remote.md -> "Credentials at rest").
The Burrow's local label rides there too, reaching a Client only inside an
encrypted outcome.
- A runtime that cannot mint one does not enroll, and the mint runs before
the exchange, since a successful
POST /api/burrow/enrollis not undoable by the Burrow (rationale). - Both halves or neither.
isEnrollmentrejects a single half, a malformed encoding, or a wrong decoded length, and accepts a record from before the fields existed. - A Burrow missing one mints it at start, persisting before the Burrow runs (rationale).
- Whatever consumes the static checks that the halves correspond
(
deriveNoiseStaticPublicKey), and a mismatch keeps the Burrow down, loudly (rationale). An enrollment carrying no usable static reads as un-enrolled and the Settings dialog offers enrollment again — the entire Burrow-state version. BurrowRuntimeimports the private half nonextractably, never re-exports it, and the PKCS#8 in the state file is the only copy that leaves WebCrypto.
X25519 is probed, not assumed. probeNoiseSupport runs one generateKey
and one deriveBits, and every rejection — a missing WebCrypto included — is
false, never a throw (rationale). Runtimes are gated, not degraded:
Pocket runs the same probe before sign-in, setup, pairing, or connection and
shows a fixed upgrade requirement on false, performing no remote operation
(pocket-app.md).
Source of truth: mintNoiseStaticKeyPair / importNoiseStaticPrivateKey /
deriveNoiseStaticPublicKey / isNoiseStaticMaterial / probeNoiseSupport in
remote-lib-common/src/security/noise.ts, isEnrollment / performEnrollment
in lib/src/remote/burrow/enrollment.ts,
BurrowService.#enrolledWithNoiseStatic in lib/src/host/remote/service.ts.
Never treat browser storage as permanent. An iOS browser tab is the weakest, an Android tab is generally durable, and an installed PWA is the preferred mode on both (rationale).
Loss is expected, and recovery is a re-run of the normal flow: scan a fresh
setup code, generate a new per-Burrow static, pair again, optionally revoke the old
record (revokedAt). Nothing is compromised — the lost key authorized
nothing without its paired passkey.
The checklist an auditor or a change reviewer verifies against, each property
established above and pinned by
remote-lib-common/test/security-guarantees.test.mjs:
- Adding a new passkey does not grant Burrow access.
- Compromising the Relay does not let it create an authorized Client.
- Compromising the Relay reveals no pairing decision, Burrow label, remote-api message, terminal byte, or notification text.
- Passkey synchronization does not automatically create trusted Clients.
- Every trusted Client must be explicitly paired with every Burrow.
- Every connection requires fresh user presence, single-use and bound to that connection's own transcript.
- Every access decision is ultimately made by the Burrow.
Never claim this model for paid SaaS before an independent cryptographic review of the Noise integration, the WebAuthn channel binding, key storage, and the push construction. Self-hosting is the shipped deployment and carries no such claim.
No traffic-analysis resistance, per-Burrow unlinkability, or metadata anonymity
is claimed. The Relay still observes account and passkey authentication data,
IPs, Burrow IDs and online state, routing relationships, every session's reauth
exchange, push endpoints, timing, ciphertext sizes, and volume — the last
three only while the Relay is carrying the session, since a session that has
switched to the direct path leaves it the fact of the session
and each end's liveness and nothing else. Two leaks follow and are accepted
rather than closed (rationale): Client→Burrow timing exposes inter-keystroke
timing on a relayed session while keystroke values stay encrypted, ending at
the switch — which in exchange shows each paired peer the other's addresses,
until then known only to the Relay — and one
PushSubscription per worker scope lets a shared endpoint correlate every
deliveryId one Pocket profile registers across Burrows. A push carries no
counter, so a Relay that kept an envelope can re-deliver it
(Push sealing).
Onboarding changes with security surface are staged in the
selfhost-onboarding scope (relay.md ## Future).
Two properties of the shipped Pocket client are observable only on a real iOS
device, and both are load-bearing: the selected Client-static storage format
surviving an app and phone restart, and getUserMedia working inside a Home Screen web app (without
it the install has only the paste field).
The Relay pushing revocations to Burrows. Today BurrowAcl.revokeClient /
revokePasskey have no callers and no relay frame carries a revocation, so
revoking is hand-editing state (relay.md, Guardrails) — and
BurrowService hands the BurrowRuntime one ACL snapshot for its whole
lifetime, so restarting the Burrow is the entire lever: it reloads the ACL
and, by dropping the relay socket, ends every established session. Editing
alone changes nothing that is running.