Skip to content

Latest commit

 

History

History
625 lines (541 loc) · 35.8 KB

File metadata and controls

625 lines (541 loc) · 35.8 KB

Remote Security Model

See docs/specs/glossary.md for 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.

Goals

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).

Trust Model

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.

Passkeys

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.

Client statics

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.

Burrow Authorization

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).

Presence proofs

Use one verifier for pairing and connection.

  • The WebAuthn challenge is derived, not random. presenceChallenge(binding, relayNonce) is base64url SHA-256(lengthPrefixedConcat(domain, kind, binding fields in declared order, relayNonce)) under dormouse/presence/v1. One encoding rule: a base64url field is hashed as the bytes it encodes and everything else as UTF-8 — decoded are connectionId, burrowChallenge, handshakeHash, and the nonce; text are the domain, the kind, burrowId, and passkeyCredentialId. Relay mints, Burrow recomputes, one builder. isPresenceBinding takes 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/begin takes 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 to finish, is a 400 (rationale). finish consumes 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.
  • PresenceProofV1 travels 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.

Pairing

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. setupQr mints 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 carries burrowId, 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: live until a valid Noise message 1 decrypts against it (reserved), which then always ends consumed; an un-scanned one ends expired by TTL or dropped when 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 as rs. Both handshake payloads are empty; Split yields the pairing channel, and no ACL, delivery ID, or resumable state exists yet.
  • Reverse two-digit confirmation. Pocket samples a uniform code 0099 (rejection sampling) and sends it with its PresenceProofV1 and 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 BurrowAclRecord binding burrowId, accountId, passkeyCredentialId, passkeyPublicKeyHash, the Client static IK authenticated — never one the payload merely claimed — and a fresh deliveryId, then sends PairingOutcomeV1: success carries the Burrow static public key, the local label, the paired passkey identifiers and hash, and the deliveryId; denial carries only user-denied, confirmation-mismatch, presence-rejected, invitation-expired, superseded, or burrow-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-error without 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.

Connection

  • IK against the pinned Burrow static. Fresh 16-byte connection ID; Client initiator with its paired per-Burrow static, rs the 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 PresenceProofV1 against the binding it built from its own burrowId, connection ID, challenge and handshake hash, then requires one active BurrowAclRecord holding all four of accountId, passkeyCredentialId, passkeyPublicKeyHash, and the IK-authenticated Client static.
  • Then ConnectionOutcomeV1: success carries the Burrow label; denial carries only pairing-required, presence-rejected, protocol-rejected, burrow-busy, or burrow-error. Every ACL miss is pairing-required — individual ACL and presence failures are logged owner-locally and never returned. Success promotes the two CipherStates into the established session; every terminal decision sends exactly one outcome and clears pending state; failures before Split yield 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.

Push sealing

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/ciphers the 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 CryptoKey rather than the key. There is no group key.
  • The Relay forwards exactly { burrowId, v, salt, ct }, burrowId taken 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's burrowId.
  • 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, because userVisibleOnly makes 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.

Burrow bounds

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 e2e and client-gone share 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-busy and 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 init buys, 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_LENGTH is measured on the received string before JSON.parse (a non-string payload is dropped) and given to the socket implementation's maxPayload where it takes one (rationale); the wire guard then bounds every routing value, clientId first, 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 on stop() (rationale). An expiry emits an outcome only where a transport cipher exists and someone is owed one:

    Expired Answer
    Pending pairing invitation-expired
    Pending connection (its challenge is now dead) presence-rejected
    Idle 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_MS and 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-gone disposes 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.

Direct path

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".

Noise suite

  • Exactly one suite: Noise_IK_25519_ChaChaPoly_SHA256, Noise revision 34. No generic pattern API, cipher negotiation, protocol-name override, or caller-selectable suite. IK only: pre-message <- s, then -> e, es, s, ss and <- e, ee, se.
  • No plaintext path, feature flag, negotiated downgrade, or legacy frame discriminantscripts/e2e-lint.mjs (pnpm lint:e2e) refuses each textually, and scripts/e2e-lint-selftest.mjs proves 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 two CipherStates from Split, 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/ciphers release (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) with 2^64-1 reserved, 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/.

Burrow identity

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/enroll is not undoable by the Burrow (rationale).
  • Both halves or neither. isEnrollment rejects 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.
  • BurrowRuntime imports 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.

Client static loss

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.

Security Guarantees

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.

Residual metadata

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).

Future

Onboarding changes with security surface are staged in the selfhost-onboarding scope (relay.md ## Future).

Device verification

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).

Revocation propagation

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.