diff --git a/.gitignore b/.gitignore index 667dafc..10ef6d3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ node_modules/ /*.js.map /build *.tgz +review-* \ No newline at end of file diff --git a/README.md b/README.md index 2143bdd..abc43ab 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@ npm install @dotex/saferpc ![Safe RPC](banner.png) - **Full docs and rationale:** -- [Quickstart](./spec/getting-started.md) · [API](./spec/api.md) · [Wire Protocol](./spec/protocol.md) · [Security](./spec/security.md) · [Transports](./spec/integrations.md) +- [Quickstart](./spec/getting-started.md) · [API](./spec/api.md) · [Wire Protocol](./spec/protocol.md) · [Security](./spec/security.md) · [Assessment](./spec/assessment.md) · [Transports](./spec/integrations.md) ## Highlights - **Typed procedures** with Zod input/output validation - **End-to-end encryption.** X25519 ECDH, XSalsa20-Poly1305 AEAD, HKDF-SHA-256, with forward secrecy by design -- **Lazy handshake** on the first call. Transparent auto-retry when the session drops +- **Lazy handshake** on the first call. Lazy re-handshake on the next call when the session drops — failures surface with a typed code, never silently resent - **Three auth modes:** pre-shared secret, asymmetric (Ed25519 / ECDSA / JWT / cert / multifactor), or both for defense-in-depth - **Synchronous** `client()` and `server()`. Runs in Node.js, browsers, Service Workers, React Native, Vercel Edge, Cloudflare Workers, Deno Deploy - **Tiny surface.** `@noble/*` crypto, `@msgpack/msgpack`, `zod`, and nothing else @@ -65,7 +65,7 @@ const { api, destroy: stopClient } = client(clientChannel, { auth }); const { message } = await api.greet({ name: "World" }); // input & output typed ``` -`client()` and `server()` are synchronous. No top-level `await`. The handshake runs lazily on the first procedure call. If the session drops, the next call retries once with a fresh handshake. +`client()` and `server()` are synchronous. No top-level `await`. The handshake runs lazily on the first procedure call. If the session drops, the first sent call to hit its reply-timeout surfaces `RPCAbortedError("TIMEOUT")` and resets the session — the next call re-handshakes lazily. A call that provably never left surfaces a plain `RPCError` and resets nothing: never-left is a transport event, the keys are fine, retrying is safe. The client never silently resends either way (that would double-execute non-idempotent handlers). Transport death alone doesn't touch the session: a reconnecting adapter (see `@dotex/saferpc/channels`) lets in-flight calls complete across a socket blip. Per-call `AbortSignal` (`api.thing(input, { signal })`) cancels waiting without tearing anything down. Because `rpc` carries `Context`, procedures can be authored in any file with a fully-typed `ctx`, and `server()` requires a `context()` that returns the type your procedures expect. @@ -107,15 +107,20 @@ Built-in helpers cover Ed25519, ECDSA P-256, JWT, certificate-based, and multifa ## Errors ```typescript -import { RPCError, RemoteRPCError } from "@dotex/saferpc"; +import { RPCError, RPCAbortedError, RemoteRPCError } from "@dotex/saferpc"; try { await api.greet({ name: "World" }); } catch (err) { if (err instanceof RemoteRPCError) { // The remote peer threw. err.code / err.message / err.data come from there. + } else if (err instanceof RPCAbortedError) { + // The request WAS sent but the outcome is unknown (TIMEOUT, ABORTED, + // SESSION) — it may have executed. Reconcile state before retrying. } else if (err instanceof RPCError) { - // Local failure: TIMEOUT, SESSION, HANDSHAKE, INPUT_VALIDATION, ... + // Local failure, the request never left the process: CHANNEL, ABORTED, + // SESSION, HANDSHAKE, CLIENT, ... — safe to retry as-is. + // (validation errors like INPUT_VALIDATION run server-side and arrive as RemoteRPCError) } else { throw err; } @@ -128,7 +133,7 @@ try { src/ common.ts : shared types, crypto, msgpack, procedure builder server.ts : resilient handshake server - client.ts : lazy handshake client with auto-retry + client.ts : lazy handshake client (no auto-retry) auth/ index.ts : combined re-exports (deriveSessionSecret + client + server) client.ts : Ed25519, ECDSA, JWT client helpers @@ -143,7 +148,10 @@ import { server } from "@dotex/saferpc/server"; import { client } from "@dotex/saferpc/client"; import { saferpc, RPCError } from "@dotex/saferpc/common"; // Auth helpers: combined or split per side -import { createEd25519ClientAuth, createEd25519ServerAuth } from "@dotex/saferpc/auth"; +import { + createEd25519ClientAuth, + createEd25519ServerAuth, +} from "@dotex/saferpc/auth"; import { createEd25519ClientAuth } from "@dotex/saferpc/auth/client"; import { createEd25519ServerAuth } from "@dotex/saferpc/auth/server"; ``` @@ -154,7 +162,7 @@ Node.js 18+, modern browsers, Service / Web / Shared Workers, React Native, Verc ## Project status -`0.x` with a stable wire protocol (`saferpc-v1` HKDF info, `saferpc-hs-{hello,reply}-v1` transcript prefixes). Test coverage for handshake attacks, replay, tampering, type confusion, prototype pollution, middleware misuse, and DoS limits lives in `test/security/`. A 1.0 release will lock the public API surface. +`0.x` with a stable wire protocol (`saferpc-v1` HKDF info, `saferpc-hs-{hello,reply}-v1` transcript prefixes). Test coverage for handshake attacks, replay, tampering, type confusion, prototype pollution, middleware misuse, and DoS limits lives in `test/security/`. An internal line-by-line security review — including the honest list of residual risks and open issues — is published in [spec/assessment.md](./spec/assessment.md). A 1.0 release will lock the public API surface. ## Releasing diff --git a/handshake-continuity-question.md b/handshake-continuity-question.md new file mode 100644 index 0000000..43175e7 --- /dev/null +++ b/handshake-continuity-question.md @@ -0,0 +1,116 @@ +# Design review request: session-continuity property in a lazy handshake + +I'm the author of an open-source end-to-end-encrypted RPC library. I'd like a +design critique of one lifecycle property in my handshake state machine. No code +needed — this is a protocol-design question about which state transition should +carry an authentication guarantee. + +## System under review + +A peer-to-peer RPC library. Two endpoints share a byte channel. Payloads are +encrypted at the application layer (X25519 ECDH → HKDF-SHA-256 → XSalsa20-Poly1305 +AEAD), independent of whatever transport sits underneath. + +The handshake is **one round-trip and lazy** — nothing goes on the wire until the +first RPC call: + +1. The initiator sends a `hello`: + `{ epoch, pub_i, nonce_i, signature? }` + where `epoch` is a per-attempt counter, `pub_i` is a fresh ephemeral X25519 + public key, `nonce_i` is random, and `signature` (optional) covers the + **hello transcript**: + `transcript = DOMAIN_TAG || epoch || pub_i || nonce_i`. + +2. The responder derives the session key: + `key = HKDF(ikm = ECDH(priv_r, pub_i), salt = PSK, info = ...)`, + computes an HMAC proof over `(pub_r, pub_i, nonce_i)`, and replies with + `{ pub_r, proof, epoch, signature? }`. + +3. The initiator recomputes the same key, checks the proof, and is ready. + +4. The responder does **not** consider the session live yet. It becomes live only + when the **first encrypted frame decrypts and authenticates** under the new + key. Producing a valid AEAD frame is the implicit proof that the sender + actually holds the key material. + +Two authentication modes, independently or together: + +- **Shared-secret mode:** a pre-shared key (PSK) is folded into HKDF. Nothing in + the `hello` itself is signed; possession of the PSK is proven only in step 4. +- **Signature mode:** the initiator signs the hello transcript. The verifier is + **stateless** (e.g. Ed25519 / ECDSA over the transcript bytes) — it has no + server-contributed value in the transcript, so it depends only on + initiator-chosen fields (`epoch`, `pub_i`, `nonce_i`). + +## The property I want to hold + +> An established, live session must only be **superseded** by a counterparty that +> proves possession of the private key material (`priv_i` or the PSK). A +> **duplicate or stale `hello`** — the same bytes the library already processed +> once — must not be able to retire the live session. + +Rationale: a duplicate `hello` carries no new proof of key possession. Whoever +re-sends it does not thereby demonstrate they can participate in the session, so +it should not be allowed to end the working one. This is a **liveness / +continuity** property, not confidentiality. + +## Current design and where I think it's wrong + +Today the responder treats every incoming `hello` as a fresh handshake *attempt* +on attempt-local state, and — importantly — the live session keeps serving while +the attempt runs. The old session is retired and replaced at the **publish step**, +which happens **right after the signature verifies and the key is derived** (i.e. +after step 2), **before** the new session is confirmed by step 4. + +My concern: in signature mode the verifier is stateless over initiator-only +fields, so a **byte-for-byte duplicate `hello` verifies again**. That reaches the +publish step and retires the live session — yet the new session can never reach +step 4, because the party re-sending the duplicate does not hold `priv_i` and so +cannot derive the key or produce a valid encrypted frame. Net effect: a repeated +`hello` ends the working session and replaces it with one that is never +confirmed. That's a continuity regression, and I reproduced it: after a duplicate +`hello`, the next legitimate call on the original session fails with a timeout. + +The same reasoning applies in shared-secret mode: nothing in the `hello` proves +PSK possession, so a well-formed `hello` reaches publish and supersedes the live +session there too. + +## Proposed fix: promote on first authenticated frame ("make-before-break") + +Keep **both** session keys alive concurrently: + +- The current (old) key stays live and keeps decrypting ongoing traffic. +- A new `hello` derives a **candidate** key but does **not** retire the old one. +- Incoming encrypted frames are tried against the current key first. +- The candidate is promoted — and the old key retired — **only when the first + frame decrypts and authenticates under the candidate key** (step 4 applied to + the candidate). +- Unconfirmed candidates expire on a timer, leaving the live session untouched. + +The claim is that step 4 is the real authentication of the counterparty: +producing a valid AEAD frame under the candidate key requires either `priv_i` +(for the ECDH) or the PSK, neither of which a party re-sending a captured/stale +`hello` possesses. So a duplicate `hello` can create a candidate that never +confirms, but can never retire the live session. + +## Questions + +1. Does "promote only on the first authenticated frame under the candidate key" + fully establish the continuity property in **both** modes (shared-secret and + stateless-signature)? Where is the reasoning load-bearing, and where could it + leak? + +2. What does make-before-break **not** cover? (e.g. resource cost of holding + candidate state; concurrent candidates; interaction with the per-session + nonce-dedup window that is cleared on key change.) + +3. Is adding a **responder-contributed freshness value** to the hello transcript + (so signatures aren't replayable at all) strictly better than, or complementary + to, make-before-break? What round-trip cost does that impose on a design whose + whole point is a single lazy round-trip, and is there a way to get freshness + without a second round-trip? + +4. Is there a cleaner framing of the underlying rule — something like "a session + transition that ends a live session must be gated on proof-of-key-possession, + never on a signature over counterparty-chosen fields alone"? Does that + generalize, or are there transitions where it's too strong? diff --git a/handshake-continuity-review.md b/handshake-continuity-review.md new file mode 100644 index 0000000..2480efb --- /dev/null +++ b/handshake-continuity-review.md @@ -0,0 +1,136 @@ +# Session continuity in the saferpc handshake — problem, external review, and where we landed + +## The setup + +saferpc gives two peers an encrypted RPC channel over any byte transport. The +handshake is deliberately minimal: one round-trip, and it doesn't happen until +the application actually makes its first call. The initiator sends a hello (an +ephemeral public key, a nonce, an epoch counter, and — optionally — a signature), +the responder derives a session key from an X25519 exchange folded together with +a pre-shared secret, proves it knows the secret with an HMAC, and replies. From +that point every message is encrypted. + +One detail matters for everything below: the responder does not treat the new +session as *live* the moment the maths checks out. It waits for the first +encrypted frame that actually decrypts under the new key. Producing that frame is +the real proof that the other side holds the key — anyone can send bytes that +*look* like a handshake, but only the genuine peer can send bytes that decrypt. + +## The problem + +A server that already has a working session should be hard to knock off it. The +question we care about is: **can a stale or duplicated hello — literally the same +bytes the server already handled once — end a live session?** It shouldn't, +because re-sending an old hello proves nothing new about who you are or whether +you can actually talk. + +The way the code worked, it could. The server ran each incoming hello as a fresh +attempt, kept the old session serving while the attempt ran (good), but then +retired and replaced the old session *the moment the signature verified and the +key was derived* — before waiting for that first real encrypted frame. And the +signature, in signature mode, only covers fields the initiator chose. It carries +nothing from the server, so a byte-for-byte copy of an old hello verifies again, +every time. + +Put those together and you get an ugly outcome: replay an old hello, the server +happily retires the perfectly good session and stands up a new one — except the +new one can never come alive, because whoever replayed the hello doesn't hold the +private key and can't send that confirming frame. The working session is gone; +nothing usable replaces it. + +We didn't argue about this in the abstract — we reproduced it. Capture one +legitimate hello, feed it back into an established session, and the very next +call on that session times out. The session was killed by a recording of an old +message. + +## The proposed fix + +The instinct is "make before break." Don't throw away the old key when a new +hello shows up. Keep both: the old session keeps serving, the new hello only +produces a *candidate* key sitting off to the side. Promote the candidate — and +only then retire the old key — when the first frame arrives that decrypts under +the candidate. That's the same "first real frame confirms it" rule the protocol +already uses for a fresh session, just applied to the replacement of an existing +one. + +The point is that decrypting a frame under the candidate key requires actually +holding the private key material, which a replayed hello doesn't. So a duplicate +hello can create a candidate that quietly expires, but it can never take down the +live session. + +## What the external review added + +I put the question to a second model (framed purely as a protocol-design review, +no attack language, or its safety filter chokes on the vocabulary). It agreed +with the direction and, more usefully, pushed on the assumptions underneath it. + +Its sharpest point: make-before-break only holds if the *server's* ephemeral key +is fresh on every attempt. If the server reused a static key, a replayed hello +would derive the *same* key as the live session — and then replayed traffic would +decrypt under the "candidate" and promote it. That turns a mere nuisance into a +full replay of recorded session traffic. So "the server's key is fresh per hello" +isn't an implementation detail; it's the load-bearing assumption the whole +property rests on, and it deserves to be written down as a protocol invariant, not +left to chance in the code. + +It also flagged three things worth keeping: + +- **A brute-force angle on weak secrets.** The server hands out its HMAC proof to + any well-formed hello, and that proof is computed from a key that mixes in the + shared secret. Someone can send their own hello, collect the proof, and grind + guesses at the secret offline. Against a proper random 32-byte key this is + hopeless (you'd be brute-forcing 256 bits). Against a secret someone derived + from a password, it's real. That's a documentation duty: the secret is a key, + not a passphrase. + +- **In-flight frames during a legitimate swap.** When a real re-handshake does + promote a new key, messages still in flight under the old key die. WireGuard + keeps the previous key around, decrypt-only, for a short grace period to cover + exactly this. Cheap to add while we're in here. + +- **Flooding hellos to starve a real reconnect.** If an attacker spams hellos, + they can keep bumping the "latest attempt" and crowd out a legitimate peer's + reconnect. Denial of service is explicitly outside saferpc's threat model, so we + won't chase it, but make-before-break raises the stakes a little and it's worth + naming honestly in the assessment. + +The review also offered a clean way to state the underlying rule, which is worth +quoting because it's the whole thing in one sentence: + +> A transition that destroys authenticated state must be authenticated at least +> as strongly as the state it destroys. A signature over the sender's own chosen +> fields proves identity, not participation. Creating state can be gated on +> identity; destroying it must be gated on proof that you actually hold the key. + +That's the same failure family as forged TCP resets and Wi-Fi deauth frames — an +unauthenticated message tearing down an authenticated connection — and the fix is +always the same shape: make the teardown prove itself. + +## Where we actually landed + +The good news is that the scariest item — the static-key replay — doesn't apply +to us. The server already generates a fresh ephemeral key inside each hello +handler; a duplicate hello produces a different key exchange and therefore a +different session key, so replayed traffic can't confirm anything. The review +raised a valid general risk; checking it against our actual code showed it was +already closed. But that's exactly why it now goes into the spec as a stated +invariant: one refactor moving that key back to a shared scope would silently +reopen the hole, and nobody would notice until it was a headline. + +So the plan is: + +1. Move the server to make-before-break: keep the live key serving, promote a + candidate only when a frame decrypts under it, and keep the old key around + decrypt-only for a short grace window. +2. Write down "the server's ephemeral key is fresh per attempt" as a protocol + invariant, so it can't be refactored away by accident. +3. Document that the shared secret must be a real random key, not a password. +4. Note the hello-flood-versus-reconnect trade-off in the assessment as a known, + accepted residual. +5. Add a continuity regression test: a duplicated hello must leave the live + session untouched, while a genuine reconnect (a real new key) still succeeds. + +The short version: the bug was that the server ended a good session on the +strength of a signature that could be replayed. The fix is to make ending a +session require proof that can't be — the same proof we already demand to start +one. diff --git a/index.md b/index.md index b773a78..95306ac 100644 --- a/index.md +++ b/index.md @@ -9,6 +9,7 @@ ## Learn - [Security & Auth](spec/security.md) +- [Security Assessment](spec/assessment.md) - [Transports & Integrations](spec/integrations.md) ## Reference @@ -45,7 +46,7 @@ No HTTP. No JSON. No middleware stack. Encryption is the default because the des ## What problems it solves -**The trusted-transport assumption.** TLS protects the wire between two specific endpoints. Once a message hits a proxy, a service worker, a content script, or a postMessage bridge, TLS is gone and the payload is plaintext to anyone in that hop. Safe RPC encrypts payloads at the *application* layer, so the hop count between endpoints stops mattering. Only the two endpoints can read anything. +**The trusted-transport assumption.** TLS protects the wire between two specific endpoints. Once a message hits a proxy, a service worker, a content script, or a postMessage bridge, TLS is gone and the payload is plaintext to anyone in that hop. Safe RPC encrypts payloads at the _application_ layer, so the hop count between endpoints stops mattering. Only the two endpoints can read anything. **The handwritten message bus.** Every team that ships a browser extension or an iframe-embedded widget eventually grows its own RPC layer: a request-id map, a `Promise` registry, a timeout sweeper, an error-coercion path, type definitions kept in sync by hand. Safe RPC is that code, written once. @@ -66,7 +67,7 @@ No HTTP. No JSON. No middleware stack. Encryption is the default because the des 2. On the first call, a handshake runs automatically. Ephemeral X25519 keys are exchanged and a session key is derived using the secret as HKDF salt. 3. The server proves it knows the secret with an HMAC over the transcript. The client proves it implicitly by producing valid ciphertext. 4. All calls are encrypted with XSalsa20-Poly1305 AEAD under the session key. -5. If the session drops, the client resets and re-handshakes transparently. The pending call retries once. +5. If the session drops, the first sent call that times out surfaces `RPCAbortedError("TIMEOUT")` and resets the session; the next call re-handshakes lazily. A call that never left surfaces a plain `RPCError("CHANNEL")` without touching the session. There is no silent resend; the caller decides whether to retry. No certificates. No token refresh. No auth middleware. A shared secret and a channel. @@ -76,17 +77,17 @@ See the [Getting Started](spec/getting-started.md) guide to set it up in under f tRPC is excellent. It solved type-safe RPC for the HTTP world, and for a Next.js app calling its own backend, tRPC is the right answer. Safe RPC is for the cases tRPC's HTTP assumption rules out. -| | tRPC (+ plugins) | Safe RPC | -|---|---|---| -| **Transport** | HTTP, WebSocket via adapter | Any bidirectional byte channel, same API | -| **Encryption** | TLS at the edge; plaintext after that | E2E AEAD on every message, every hop | -| **Roles** | Server / client (asymmetric) | Peer / peer; either side can serve | -| **Auth** | `trpc-shield`, middleware per-procedure, enforced after parse | Bound to the handshake; no session, no calls | -| **Edge runtimes** | Mostly works; init can be async | Synchronous init, no top-level `await` | -| **Browser extension** | Possible with a custom link and plugins | Drop-in: content script ↔ background ↔ popup | -| **WebRTC / iframes** | Not the target use case | First-class | -| **Dependencies** | Adapters per transport, plugins per concern | One package: `@noble/*`, `@msgpack/msgpack`, `zod` | -| **Wire format** | JSON | msgpack inside an AEAD envelope | +| | tRPC (+ plugins) | Safe RPC | +| --------------------- | ------------------------------------------------------------- | -------------------------------------------------- | +| **Transport** | HTTP, WebSocket via adapter | Any bidirectional byte channel, same API | +| **Encryption** | TLS at the edge; plaintext after that | E2E AEAD on every message, every hop | +| **Roles** | Server / client (asymmetric) | Peer / peer; either side can serve | +| **Auth** | `trpc-shield`, middleware per-procedure, enforced after parse | Bound to the handshake; no session, no calls | +| **Edge runtimes** | Mostly works; init can be async | Synchronous init, no top-level `await` | +| **Browser extension** | Possible with a custom link and plugins | Drop-in: content script ↔ background ↔ popup | +| **WebRTC / iframes** | Not the target use case | First-class | +| **Dependencies** | Adapters per transport, plugins per concern | One package: `@noble/*`, `@msgpack/msgpack`, `zod` | +| **Wire format** | JSON | msgpack inside an AEAD envelope | The plugin route works, but it stacks. `@trpc/server` plus `@trpc/client` plus a WebSocket link plus a custom transformer plus `trpc-shield` for auth plus a logger plus a rate limiter plus something to encrypt the payload (which does not exist as a polished plugin, so you write it). Each plugin has its own release cadence and its own opinions. Safe RPC ships the whole thing as one surface because it is the same problem viewed from a different angle. @@ -116,7 +117,7 @@ Encryption is the difference between trusting every box on the path and not need **Worker communication.** Web Workers, SharedWorkers, and Service Workers communicate through MessagePorts. Safe RPC provides a structured, validated API layer instead of ad-hoc message passing. -**Microservices over WebSocket.** Two services need a persistent bidirectional connection. REST falls apart. Safe RPC over WebSocket gives typed calls in both directions, encrypted, with auto-retry. No API gateway, no TLS proxy. +**Microservices over WebSocket.** Two services need a persistent bidirectional connection. REST falls apart. Safe RPC over WebSocket gives typed calls in both directions, encrypted, with lazy re-handshake on reconnect. No API gateway, no TLS proxy. **Electron / Tauri IPC.** Main process and renderer need a secure channel. Safe RPC works over any IPC mechanism that can carry binary data. diff --git a/jsr.json b/jsr.json index d3b6106..a10b1c7 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@dotex/saferpc", - "version": "0.6.1", + "version": "0.7.0", "exports": { ".": "./src/index.ts", "./common": "./src/common.ts", @@ -8,7 +8,8 @@ "./auth/client": "./src/auth/client.ts", "./auth/server": "./src/auth/server.ts", "./client": "./src/client.ts", - "./server": "./src/server.ts" + "./server": "./src/server.ts", + "./channels": "./src/channels/index.ts" }, "imports": { "@msgpack/msgpack": "npm:@msgpack/msgpack@^3.1.3", diff --git a/package.json b/package.json index fdf63f6..b089631 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dotex/saferpc", - "version": "0.6.1", + "version": "0.7.0", "description": "Safe RPC for any two counterparties over any bidirectional channel", "type": "module", "files": [ @@ -92,6 +92,11 @@ "types": "./esm/server.d.ts", "import": "./esm/server.js", "require": "./cjs/server.js" + }, + "./channels": { + "types": "./esm/channels/index.d.ts", + "import": "./esm/channels/index.js", + "require": "./cjs/channels/index.js" } }, "sideEffects": false, diff --git a/scripts/gen-vectors.mjs b/scripts/gen-vectors.mjs new file mode 100644 index 0000000..1e7d3f3 --- /dev/null +++ b/scripts/gen-vectors.mjs @@ -0,0 +1,97 @@ +// Generate known-answer test vectors for spec/protocol.md from the +// reference implementation. Deterministic fixed inputs -> hex outputs. +// Run: node scripts/gen-vectors.mjs (requires node >= 22 w/ strip-types? no — +// we import from noble directly + replicate the exact derivation calls) + +import { x25519 } from "@noble/curves/ed25519.js"; +import { hkdf } from "@noble/hashes/hkdf.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { hmac } from "@noble/hashes/hmac.js"; +import { xsalsa20poly1305 } from "@noble/ciphers/salsa.js"; +import { concatBytes } from "@noble/ciphers/utils.js"; +import { encode } from "@msgpack/msgpack"; + +const hex = (b) => Buffer.from(b).toString("hex"); +const fromPattern = (start, len = 32) => + new Uint8Array(Array.from({ length: len }, (_, i) => (start + i) & 0xff)); + +// ── Fixed inputs ── +const c_priv = fromPattern(0x01); // 0x01..0x20 +const s_priv = fromPattern(0x41); // 0x41..0x60 +const c_nonce = fromPattern(0x81); // 0x81..0xa0 +const secret = fromPattern(0xc1); // 0xc1..0xe0 +const epoch = 1; + +const c_pub = x25519.getPublicKey(c_priv); +const s_pub = x25519.getPublicKey(s_priv); + +// raw shared: both sides must agree +const raw_c = x25519.getSharedSecret(c_priv, s_pub); +const raw_s = x25519.getSharedSecret(s_priv, c_pub); +if (hex(raw_c) !== hex(raw_s)) throw new Error("ECDH mismatch"); + +const KDF_INFO = new TextEncoder().encode("saferpc-v1"); +const EMPTY_SECRET = new Uint8Array(32); + +// session keys (exact call from src/common.ts deriveSessionKey) +const session_key_psk = hkdf(sha256, raw_c, secret, KDF_INFO, 32); +const session_key_nopsk = hkdf(sha256, raw_c, EMPTY_SECRET, KDF_INFO, 32); + +// proof (exact call from src/common.ts computeProof) +const proof = hmac(sha256, session_key_psk, concatBytes(s_pub, c_pub, c_nonce)); + +// transcripts (exact layout from src/common.ts) +const epochBytes = new Uint8Array(4); +new DataView(epochBytes.buffer).setUint32(0, epoch, false); +const HELLO_MAGIC = new TextEncoder().encode("saferpc-hs-hello-v1\0"); +const REPLY_MAGIC = new TextEncoder().encode("saferpc-hs-reply-v1\0"); +const hello_transcript = concatBytes(HELLO_MAGIC, epochBytes, c_pub, c_nonce); +const reply_transcript = concatBytes( + REPLY_MAGIC, + epochBytes, + c_pub, + c_nonce, + s_pub, +); + +// deriveSessionSecret helper (exact call from src/common.ts) +const dss = hkdf( + sha256, + secret, + new TextEncoder().encode("session-abc123"), + new TextEncoder().encode("saferpc-session-v1"), + 32, +); + +// encrypted frame KAT: fixed msg nonce, request {t:1,id:"1",p:"ping"} +const msg_nonce = fromPattern(0x11, 24); // 0x11..0x28 +const plaintext = encode({ t: 1, id: "1", p: "ping" }); // default codec fine: no ext, no bigints +const ct = xsalsa20poly1305(session_key_psk, msg_nonce).encrypt(plaintext); +const frame = concatBytes(new Uint8Array([0x01]), msg_nonce, ct); + +const out = { + inputs: { + c_priv: hex(c_priv), + s_priv: hex(s_priv), + c_nonce: hex(c_nonce), + secret: hex(secret), + epoch, + }, + derived: { + c_pub: hex(c_pub), + s_pub: hex(s_pub), + raw_shared: hex(raw_c), + session_key_psk: hex(session_key_psk), + session_key_empty_secret: hex(session_key_nopsk), + proof: hex(proof), + hello_transcript: hex(hello_transcript), + reply_transcript: hex(reply_transcript), + deriveSessionSecret_session_abc123: hex(dss), + }, + encrypted_frame: { + msg_nonce: hex(msg_nonce), + plaintext_msgpack: hex(plaintext), + frame: hex(frame), + }, +}; +console.log(JSON.stringify(out, null, 2)); diff --git a/spec-channel-lifecycle.md b/spec-channel-lifecycle.md new file mode 100644 index 0000000..ddf3936 --- /dev/null +++ b/spec-channel-lifecycle.md @@ -0,0 +1,394 @@ +# Spec: channel lifecycle v2 — session survives transport death, core owns the send queue + +Status: ready to implement. Supersedes v1 (2026-07-09) after CTO review of +PR #2 (2026-07-10). Target: `src/client.ts`, `src/common.ts`, +`src/channels/`, spec docs, tests. Ships within the unreleased 0.7.0. + +Decisions locked with CTO 2026-07-10: + +1. **`abortPending` is removed entirely** (v1 kept it in simple form). The + per-call `AbortSignal` covers the use case: an app that wants "reject all + in-flight calls" holds one `AbortController` and passes its signal to every + call — `ctl.abort()` is `abortPending()` with none of the API surface. The + method was historically the transport hook (`ws.onclose → abortPending`), + which is exactly the antipattern this work kills; removing it removes the + footgun physically. +2. **New error class `RPCAbortedError extends RPCError`** — the caller must + know deterministically whether the request reached the wire. + `RPCAbortedError` = "the request was handed to a live transport; outcome + UNKNOWN — it may have executed, check before retrying". Plain `RPCError` + (local) = "the request provably never left this process; retry freely". + `RemoteRPCError` (unchanged) = "the handler ran and returned an error". + Details and the DX rationale in §5. +3. **Channels own no queue — queueing inside a transport adapter is an + antipattern.** The `Channel` contract flips: `send` MUST throw (or reject, + for async adapters) when it cannot hand the frame to a live transport + right now. The client core owns the outbound queue and retries unsent + frames until a new **`sendTimeout`** (default 3 000 ms) expires. The + channel's only jobs: move frames, and try to stay available + (auto-reconnect stays in `wsChannel`). +4. **Unchanged from v1:** lazy reconnect policy (the client core does not + react to channel recovery; pending calls wait under their own timers, a + lost server session heals via `timeout → reset → lazy re-handshake`); + the `Channel` interface shape `{ send, receive }`; the two-lever timeout + model (global `timeout` 30 s + per-call `AbortSignal.timeout(ms)`); no + auto-retry of frames that reached the wire (F1). + +This document is self-contained; v1 content still valid is restated. + +--- + +## 1. The problem in one paragraph + +Two gaps remain after the v1 implementation (543d4f9). First, a caller +catching an error today cannot tell whether the request reached the server: +`TIMEOUT` is documented as unknown-outcome, `CHANNEL` as never-left, but the +send/queue split lived inside the adapter — a frame sitting in `wsChannel`'s +private queue when the call timed out was reported `TIMEOUT` (unknown) even +though it provably never left. The retry decision — the single most important +thing an RPC error must support — required reading docs and trusting adapter +internals. Second, the adapter-owned queue duplicates state the core already +has (the pending map bounds it, the call timers race it) and puts the +"never-left" bookkeeping in the one place the core cannot see. Moving the +queue into the core makes the sent/unsent boundary a fact the core *knows* +rather than infers, and shrinks the adapter contract to one line: send or +throw. + +## 2. The property we want + +> A Safe RPC session is bound to key material, not to a transport instance. +> A call's outcome is decided by exactly two events: a reply that decrypts, +> or a terminal local event (timeout, abort, destroy). Every rejection states +> on which side of the wire the request died: `RPCAbortedError` = it left, +> outcome unknown; plain local `RPCError` = it never left, retry freely. + +The **sent boundary** is defined as: `channel.send(frame)` returned without +throwing (sync adapters) or its promise resolved (async adapters). Before +that point the core holds the only copy of the frame and can discard it with +certainty; after that point the frame's fate is unknowable and it is never +resent by any layer. + +## 3. Design part I — the channel contract (revised) and `src/channels/` + +### 3.1 Contract (goes into `common.ts` `Channel` jsdoc + integrations.md) + +- `send(frame)` MUST throw synchronously (or reject, if it returns a + promise) when it cannot hand the frame to a live transport **now**. No + internal queues, no buffering, no retry — a channel that accepts a frame it + cannot deliver lies to the core about the sent boundary. +- A channel SHOULD try to stay available: reopen its transport eagerly when + it dies, hold it open as long as possible. Availability is the channel's + job; delivery bookkeeping is the core's. +- `receive(cb)` unchanged: register a frame callback, return unsubscribe. + +### 3.2 `wsChannel(source, opts?)` — reconnecting client adapter (revised) + +```ts +export interface WsChannelOptions { + /** Reconnect backoff. First retry is immediate; then exponential + * from `backoffMin` (default 250) to `backoffMax` (default 5000) ms, + * full jitter. Retries forever until close(). */ + backoffMin?: number; + backoffMax?: number; + /** Observability only. Never affects behavior. */ + onDown?: (err?: unknown) => void; + onUp?: () => void; +} + +export function wsChannel( + source: string | (() => WebSocket), + opts?: WsChannelOptions, +): Channel & { close: () => void }; +``` + +Diff against the v1 implementation: + +- **The queue is deleted** (`maxQueue` option gone, drop-oldest policy gone, + flush-on-open gone). `send` while `readyState !== OPEN` — or after + `close()` — **throws synchronously**. This also covers the F4 browser trap + (CLOSED silently drops, CONNECTING throws): the adapter checks readyState + itself and throws one typed error for every not-OPEN state. +- Everything else stays: owns the socket lifecycle, eager reconnect with + backoff until `close()`, `binaryType = "arraybuffer"`, `receive` callbacks + survive socket replacement, `onDown`/`onUp` observability, `close()` stops + the loop and closes the socket. + +### 3.3 `socketChannel(ws)` — plain single-socket adapter (revised) + +One socket, no reconnect (server side / caller-managed sockets). Change: +`send` must check `readyState` and throw when not OPEN instead of delegating +to `ws.send`'s platform-dependent behavior. `ws.on("close", ...)` server +wiring stays as is — the server core is untouched. + +### 3.4 Handshake frames + +The hello goes through the same core outbound queue (§4). If the channel is +down, the hello is retried like any frame; the attempt is bounded by +`handshakeTimeout` (5 s), which fires first and **removes the queued hello**. +This deletes v1's accepted residual (a stale hello flushing after handshake +timeout) — the core owns the queue, so it can revoke frames; an adapter +queue couldn't. + +## 4. Design part II — core outbound queue + `sendTimeout` + +New `ClientOptions` field: + +```ts +/** How long a frame may wait for a live channel before the call fails + * with a definite "never sent" error. Default 3_000 ms. */ +sendTimeout?: number; +``` + +Send path in `sendRequest` (and the hello path in `ensureHandshake`): + +1. Try `channel.send(encrypted)` immediately. Success (no throw / resolved + promise) → the call is **sent**: it waits for reply-or-timeout exactly as + today. +2. On sync throw or async rejection → the frame enters the **outbound + queue** with its call context. The call's global timer keeps running. +3. A retry tick (fixed 250 ms interval, running only while the queue is + non-empty) attempts queued frames **in order**; first throw stops the + tick's pass (head-of-line: if the channel is down for one frame it is + down for all). A frame that sends transitions its call to *sent*. +4. Terminal events on a **still-queued** frame remove it from the queue and + reject the call with a **plain** `RPCError` — the frame provably never + left: + - `sendTimeout` expiry (per-frame, counted from enqueue) → code + `CHANNEL`, message "not sent within sendTimeout". + - global `timeout` fires first (only possible when `timeout` is + configured below `sendTimeout`) → code `CHANNEL`, plain class — + same definite never-left code as sendTimeout expiry. + - per-call signal abort → code `ABORTED`, plain class. + - `destroy()` → code `SESSION`, plain class. + - session reset (epoch bump, §6) → code `CHANNEL`, plain class — the + frame is encrypted under zeroed keys and can never succeed. +5. Terminal events on a **sent** call reject with `RPCAbortedError` (§5). + +Bounding: the queue needs no own limit — `maxPending` (256) already bounds +in-flight calls, and at most one hello can be queued per handshake attempt. + +Defaults sanity: `sendTimeout` (10 s) < `timeout` (30 s), so with default +config an unsent frame normally fails via the sendTimeout expiry. But the +`CHANNEL` code does not depend on which timer fires first: if the global +`timeout` beats `sendTimeout` (custom config), the still-queued frame is +removed and rejected with the same plain `RPCError("CHANNEL")`. Plain +`TIMEOUT` therefore never occurs — the `TIMEOUT` code always means "sent, +no reply" and always rides the aborted class. + +The queue holds encrypted frames (encryption happens at call time, as +today); the epoch captured at call time (`sentEpoch`) identifies frames +staled by a reset. + +## 5. Design part III — error taxonomy: `RPCAbortedError` + +### 5.1 Shape + +```ts +// common.ts +export class RPCAbortedError extends RPCError {} +``` + +No new fields. The class carries the one bit that decides retry safety; the +existing `code` string keeps naming the trigger. Constructor signature +identical to `RPCError` (code, message, data?, options?). + +### 5.2 The full local-error table + +| class | code | trigger | wire status | +|---|---|---|---| +| `RPCAbortedError` | `TIMEOUT` | global timeout, frame was sent | UNKNOWN — check, then retry | +| `RPCAbortedError` | `ABORTED` | signal fired, frame was sent | UNKNOWN — check, then retry | +| `RPCAbortedError` | `SESSION` | `destroy()`, frame was sent | UNKNOWN — check, then retry | +| `RPCError` | `CHANNEL` | sendTimeout or global timeout expired while still queued / channel closed / reset staled a queued frame | never left — retry freely | +| `RPCError` | `ABORTED` | signal fired before send (incl. during handshake wait, pre-aborted signal) | never left — retry freely | +| `RPCError` | `SESSION` | `destroy()` before send / call on closed client | never left | +| `RPCError` | `CLIENT` / `HANDSHAKE` | guardrails / handshake failure | never left | +| `RemoteRPCError` | server-defined | handler ran and threw | executed | + +Invariant, stated once in api.md and enforced by tests: **class = which side +of the wire the request died on; code = what killed it.** The same code can +appear in both classes (`ABORTED`, `SESSION`) — that is by design, the +trigger and the retry-safety are orthogonal axes. `TIMEOUT` appears only +on the aborted class: a still-queued frame that runs out of time fails +with the definite `CHANNEL` code, whichever timer fired. + +Caller-facing catch block, the whole point of the feature: + +```ts +try { + await api.transfer(input, { signal }); +} catch (e) { + if (e instanceof RemoteRPCError) { + // executed, server said no + } else if (e instanceof RPCAbortedError) { + // may have executed — reconcile state before retrying + } else { + // never reached the server — safe to resend as-is + } +} +``` + +### 5.3 DX rationale (variants considered, per review ask) + +- **Code-only (`code === "ABORTED"`), no subclass** — rejected: no + `instanceof` narrowing, and it conflates trigger with retry-safety + (timeout-after-send and timeout-before-send would need distinct invented + codes; stringly-typed checks spread through consumer code). +- **Boolean field on `RPCError` (`err.sent`)** — rejected: carries the bit + but is invisible at the catch site, not discoverable from types, easy to + ignore; a flag does not force the three-way decision the way the class + hierarchy does. +- **Subclass with a dedicated `reason` field and a single fixed code** — + rejected: `reason` duplicates the existing `code` machinery; two parallel + trigger vocabularies is worse than one. +- **Chosen: bare subclass, codes preserved across the boundary.** One + `instanceof` = the retry decision with type narrowing; `code`/`message`/ + `cause` = diagnostics, unchanged semantics; zero new fields; symmetric + codes make logs readable ("ABORTED before send" vs "ABORTED after send" is + the class name in the stack trace). + +`RemoteRPCError extends RPCError` already exists; `RPCAbortedError` is a +parallel branch. `instanceof RPCError` still catches everything — existing +consumer code keeps working. + +## 6. Auto-reset predicate (revised) + +Today: reset on `code === "TIMEOUT" || code === "CHANNEL"` while `ready`. +New predicate: **reset only on `RPCAbortedError` with code `TIMEOUT`** — +"the request went out and the server never answered" is the one signal that +the session may be desynced (server restarted, session dropped: the reply +can't come). Everything else must NOT reset: + +- plain `CHANNEL` (never left) — the transport was down; that is not a + session event, the keys are fine. Note this loses nothing: the old + CHANNEL-reset could only heal if a re-handshake could send, and if send + throws for 10 s the hello can't leave either; the first *sent* call that + times out still resets. The wedge case in the current comment (restarted + server silently dropping TAG_MSG over a sync transport) sends fine and + fails by reply-timeout → still resets. +- `ABORTED` (either class) — caller-local decision, existing rule. +- `SESSION`/`CLIENT`/`HANDSHAKE`/`RemoteRPCError` — existing rule. + +`reset()` itself gains one duty: reject **queued unsent** frames plain +`CHANNEL` (§4.4) — they are ciphertext under zeroed keys. Sent-pending calls +stay untouched, exactly as today (their replies can't decrypt post-reset; +they fail by reply-timeout → `RPCAbortedError("TIMEOUT")`, which is the +correct classification — they did leave). + +`destroy()` splits the pending map by the sent flag: sent → +`RPCAbortedError("SESSION", ...)`, queued → plain `RPCError("SESSION", ...)`. +Hello-waiters were never sent as calls → plain. + +## 7. What deliberately does NOT change + +- **`Channel` interface shape** — `{ send, receive }`. Only the prose + contract flips (§3.1). Async `send` stays supported (the core already + handles a returned promise). +- **Server core** — nothing. +- **No auto-retry of anything past the sent boundary (F1)** — the core + retries only frames it still exclusively owns; a frame written to a live + transport is spent. +- **Per-call `AbortSignal` semantics** (v1 §5) — all of it: reject-not- + cancel-handshake, listener hygiene, no reset on abort. Only the rejection + class now depends on the sent flag. +- **Handshake/crypto/wire format** — untouched. +- **Two-lever timeout model** — global `timeout` + `AbortSignal.timeout()`. + `sendTimeout` is not a third caller lever; it is the definite/unknown + boundary inside the machine. + +## 8. Spec/doc changes + +- **api.md**: delete `abortPending` (§ returns, § failure handling); add + `RPCAbortedError` + the §5.2 table + the catch-block idiom; add + `sendTimeout` to `ClientOptions`; error-code table gets a "class" column; + migration note "abortPending → shared AbortController". +- **protocol.md**: delete the `### abortPending` section (it still describes + pre-0.7.0 zero-keys semantics — stale either way); §Failure handling: + state the sent-boundary rule (§2), the queue-in-core design, the revised + reset predicate; update the checklist line that still says abortPending + returns the client to `idle`. +- **integrations.md**: adapter contract section replaced by §3.1 (send or + throw, no queues, stay available); `wsChannel`/`socketChannel` docs lose + the queue paragraphs. +- **assessment.md**: remove the stale-hello residual (fixed by §3.4) and the + drop-oldest availability trade (queue no longer exists); add: head-of-line + blocking on the core retry tick is bounded by `sendTimeout`. +- **common.ts**: `Channel` jsdoc = §3.1 contract in three sentences; + `RPCAbortedError` jsdoc = the invariant sentence from §5.2. + +## 9. Migration / breaking notes (vs 543d4f9, all pre-release) + +- `abortPending` removed from the client return type. Replacement: shared + `AbortController` passed per call. +- `wsChannel` `maxQueue` option removed; `send` now throws while down. +- New `ClientOptions.sendTimeout` (default 3 000 ms). +- `ClientOptions.timeout` default raised from 10 000 to 30 000 ms. The old + short default doubled as a UX budget when a timeout auto-healed via + reset+retry; without auto-retry the per-call UX cap belongs to + `AbortSignal.timeout(ms)`. Pin `timeout: 10_000` to keep the old cadence. +- `CHANNEL` no longer triggers auto-reset; reply-timeout now surfaces as + `RPCAbortedError` (still `code === "TIMEOUT"`, so code-based consumer + checks keep working). +- Tests asserting `abortPending` (transport-lifecycle F3 block, + channel-lifecycle §4 case, session-continuity usage) are rewritten against + the shared-controller idiom or dropped. + +## 10. Tests + +`test/e2e/channel-lifecycle.test.ts` (rework; `faultChannel` helper now +implements the §3.1 contract — `send` throws while down): + +1. **Call issued while channel is down completes after recovery, no + re-handshake.** Establish, `goDown()`, call (frame queues in core), + `goUp()` within `sendTimeout`. Resolves; TAG_HELLO count == 1. +2. **Pending call survives a gap after send.** Send, `goDown()`, `goUp()`, + deliver reply. Resolves. (Reply-or-timeout, reply branch.) +3. **Lazy heal on lost server session.** First call fails + `RPCAbortedError("TIMEOUT")`; second call re-handshakes and succeeds; + TAG_HELLO == 2. +4. **sendTimeout: definite failure.** `goDown()`, call, stay down past + `sendTimeout` → plain `RPCError("CHANNEL")`, NOT RPCAbortedError; session + not reset (next call after `goUp()` succeeds, TAG_HELLO still 1). +5. **Class split on abort.** (a) abort while frame queued → plain + `ABORTED`; (b) abort after send → `RPCAbortedError("ABORTED")` + cause; + late reply dropped; no reset either way. +6. **Class split on destroy.** One call sent, one queued (channel down); + `destroy()` → sent rejects `RPCAbortedError("SESSION")`, queued rejects + plain `RPCError("SESSION")`. +7. **Reset predicate regression.** Reply-timeout (sent) → next call + re-handshakes (reset happened). Plain CHANNEL (never sent) → next call + does NOT re-handshake. +8. **Stale hello is revoked.** Channel down, first call queues a hello, + `handshakeTimeout` fires → attempt fails; `goUp()` → queue does not flush + the dead hello (assert no TAG_HELLO frame from the failed attempt reaches + the server); next call sends a fresh hello. +9. **Shared-controller idiom replaces abortPending.** Two calls with one + controller's signal; `ctl.abort()` rejects both (`ABORTED`, class per + sent status); a third call succeeds without re-handshake. + +`test/e2e/ws-channel.test.ts` (rework): + +10. **Contract: send throws while down and after close().** Kill socket + server-side; `send` throws until reconnect completes; after `close()` + throws forever. +11. **Reconnect + core retry end-to-end.** Kill socket; call during the gap + resolves after the adapter reconnects (core tick re-sends); `onDown`/ + `onUp` fired once each; TAG_HELLO == 1. + +Keep neutral naming/comments, as with `session-continuity.test.ts`. + +## 11. Open questions for the implementer + +1. Retry tick granularity: fixed 250 ms is specced (not configurable — avoid + knob creep; `sendTimeout` is the caller-visible contract). Revisit only + if a sync-transport test shows the 250 ms floor hurting. +2. `RPCAbortedError` export surface: export from the package root alongside + `RPCError`/`RemoteRPCError` (it is part of the public catch idiom). +3. Grep Enclave for `abortPending` consumers before merging the removal — + the WS-reconnect branch there was the known caller; it migrates to + `wsChannel` + shared controller. +4. Async-send adapters: a rejection arriving *after* the sent boundary was + already counted (promise resolved) is impossible by contract; a rejection + is always pre-boundary and re-enqueues the frame at the head of the + queue. State this in the `Channel` jsdoc so adapter authors reject + rather than resolve-then-error. diff --git a/spec-make-before-break.md b/spec-make-before-break.md new file mode 100644 index 0000000..7196286 --- /dev/null +++ b/spec-make-before-break.md @@ -0,0 +1,398 @@ +# Spec: make-before-break session replacement (saferpc server) + +Status: ready to implement. Target: `src/server.ts` + four spec docs + one test +file. Companion background (prose, already shared): `handshake-continuity-review.md`. + +This document is self-contained. An implementer should be able to work from it +without the discussion that produced it. + +--- + +## 1. The problem in one paragraph + +The server retires a live session too early. When a hello arrives it runs as an +attempt on attempt-local state, and the live session keeps serving *during* the +attempt — but at the publish step the code zeroes the old session key and installs +the new one immediately, before the new session has been confirmed by a real +encrypted frame. In signature mode the hello signature only covers +initiator-chosen fields (epoch, pub, nonce) with no server contribution, so a +byte-for-byte duplicate hello verifies again and reaches publish. Result: a +replayed hello ends a working session and replaces it with one that never comes +alive (whoever replayed the hello can't produce the confirming frame). Reproduced: +inject a captured hello into an established session, the next call times out. + +The exact line today (`src/server.ts`, publish block ~`:549`): + +```ts +if (attemptEpoch !== myAttempt || destroyed) return; +clearHsTimer(); +if (sessionKey !== null) zero(sessionKey); // ← old session dies HERE +epoch++; +seenClear(); +sessionKey = localSessionKey; // ← before confirmation +encrypt = createEncryptor(sessionKey); +decrypt = createDecryptor(sessionKey); +authData = localAuthData; +state = "pending"; +``` + +## 2. The property we want + +> A live session may only be replaced by a counterparty that proves possession of +> the key material (the initiator's ephemeral private key, and the PSK if +> configured). A duplicate or stale hello — bytes the server already processed — +> must never retire the live session. + +Underlying rule, worth stating in the spec: *a transition that destroys +authenticated state must be authenticated at least as strongly as the state it +destroys. Creating state is gated on identity; destroying it is gated on proof of +key possession.* A hello (identity) may create a candidate; only a confirming +frame (key possession) may retire the live session. + +## 3. Design: two independent key slots + +Replace the single module-level session with two slots that live side by side. + +- **live** — the confirmed session. Serves all traffic: `decrypt` inbound, + `encrypt` outbound. May be null (no session yet). +- **candidate** — a session derived from a successful hello attempt but *not yet + confirmed*. Used only to *try* decrypting inbound frames. Never used to encrypt + a reply until it is promoted. May be null. + +The confirmation rule that already exists for a fresh session ("first frame that +decrypts under the new key makes it real") now also governs *replacement*: + +- A hello attempt that passes all checks installs a **candidate** and sends the + handshake reply. It does **not** touch `live`. +- On an inbound `TAG_MSG`, try `live.decrypt` first; on Poly1305 failure, try + `candidate.decrypt`. + - Decrypts under **live** → normal request handling, as today. + - Decrypts under **candidate** → **promote**: candidate becomes live, old live + is zeroed, candidate slot cleared. Then handle the request under the new live. + - Decrypts under neither → silent drop, as today. + +This unifies bootstrap and rekey: at bootstrap `live` is null and the first hello +installs a candidate that the first client frame promotes; at rekey `live` is +non-null and keeps serving until the candidate is confirmed. Same code path. + +### 3.1 State model + +Derive `state` from the slots rather than tracking it separately, or keep the +enum but redefine it: + +- `waiting` — `live == null && candidate == null`. +- `pending` — `candidate != null` (regardless of whether `live` is null). A hello + has been answered; we're waiting for the confirming frame. **If `live != null` + it keeps serving throughout** — that is the whole point. +- `ready` — `live != null && candidate == null`. + +Note the change in meaning: `pending` no longer implies "no working session". A +rekey attempt on a healthy session is `pending` with `live` still serving. + +### 3.2 Hello handling — install candidate, don't touch live + +In the publish block, replace the swap-in-place with a candidate install. Keep +everything above it (attempt-local ephemeral keys, verify, ECDH, secret, proof, +sign) exactly as it is — those already run on attempt-local state and are correct. + +```ts +// FINAL publish guard — unchanged. +if (attemptEpoch !== myAttempt || destroyed) return; + +// Install as CANDIDATE. Do NOT touch the live session. +// If a previous unconfirmed candidate exists, zero and replace it +// (latest attempt wins; see §6 residual on hello-flood). +clearCandidateTimer(); +if (candidateKey !== null) zero(candidateKey); +candidateEpoch++; // see §3.4 +candidateKey = localSessionKey; +localSessionKey = null; // ownership transferred +candidateDecrypt = createDecryptor(candidateKey); +candidateAuthData = localAuthData; +// candidateEncrypt is created on promotion, not now — we never encrypt +// under an unconfirmed key. + +// Confirmation timer applies to the CANDIDATE only. On expiry, drop the +// candidate; the live session is untouched. +const myCandEpoch = candidateEpoch; +candidateTimer = setTimeout(function onCandidateTimeout() { + if (candidateEpoch !== myCandEpoch || destroyed) return; + dropCandidate(); // zero candidateKey, clear slot + if (onError !== null) { + onError(new RPCError("HANDSHAKE", "Handshake timeout")); + } +}, hsTimeout); + +// send handshake reply (unchanged) +await channel.send(reply); +if (candidateEpoch !== myCandEpoch || destroyed) return; +``` + +`dropCandidate()`: zero `candidateKey`, null the candidate slot fields, clear +`candidateTimer`. Does not touch `live` or the seen-nonce window. + +### 3.3 TAG_MSG handling — trial order + promotion + +Current handler decrypts with the single `decrypt`. New handler tries live then +candidate. The seen-nonce pre-check (§3.5) stays before decrypt. + +**Gate change (mandatory).** Today's handler opens with +`if (tag === TAG_MSG && decrypt !== null && encrypt !== null)`. In this model a +bootstrap has `live == null` (no `liveDecrypt`/`liveEncrypt`) and +`candidateEncrypt` does not exist by design, so the old gate would reject the +very frame that confirms a bootstrap and the session would never go live. +Replace the gate — and the internal re-check `if (decrypt === null || encrypt +=== null) return;` — with a condition keyed on **decrypt availability of either +slot**: `liveDecrypt !== null || candidateDecrypt !== null`. + +```ts +if (tag === TAG_MSG && (liveDecrypt !== null || candidateDecrypt !== null)) { + if (data.length > maxBytes) return; + + // seen-nonce pre-check applies to the LIVE window only (see §3.5) + const nKey = data.length >= 1 + NONCE_LEN + ? nonceKey(data.subarray(1, 1 + NONCE_LEN)) : null; + if (nKey !== null && seenHas(nKey)) return; + + (async function handleRequest() { + let raw: unknown = undefined; + let decryptedUnder: "live" | "candidate" | null = null; + + if (liveDecrypt !== null) { + try { raw = liveDecrypt(data); decryptedUnder = "live"; } catch {} + } + if (decryptedUnder === null && candidateDecrypt !== null) { + try { raw = candidateDecrypt(data); decryptedUnder = "candidate"; } catch {} + } + if (decryptedUnder === null) return; // neither key — silent drop + + // Promotion (if any) MUST happen before reqEpoch is captured — it + // advances `epoch` (§3.4). Capturing reqEpoch before this would make + // the response guard drop the reply to this very frame. See below. + if (decryptedUnder === "candidate") { + promoteCandidate(); // §3.4 — advances epoch + } + + // Capture the response-guard epoch AFTER promotion, not at frame arrival. + const reqEpoch = epoch; + + // record nonce in the (now-current) live window AFTER successful decrypt + if (nKey !== null) seenAdd(nKey); + + // ... existing request handling, unchanged, using liveEncrypt for the + // response, guarded at the end by `if (epoch !== reqEpoch ...) return;` + })(); +} +``` + +Important ordering details: + +- Try **live first** so steady-state traffic pays only one decrypt. Candidate is + tried only when a frame fails under live (a genuine rekey confirmation, or junk). +- **`reqEpoch` is captured AFTER decrypt + promotion, not at frame arrival.** + This is the one non-obvious correctness point. Today `const reqEpoch = epoch` + is captured synchronously at the top of the handler, which is safe only because + `epoch` advances at *publish* (before the frame arrives). Here `epoch` advances + inside `promoteCandidate()`, i.e. *while processing the confirming frame*. If + `reqEpoch` were captured before promotion, the final response guard + (`epoch !== reqEpoch`) would drop the reply to the first call after every rekey + and every bootstrap — reproducing the exact symptom of the bug this spec fixes. + The confirming frame is not an in-flight leftover; it *is* the promoter. +- The nonce is recorded in the seen window **after** a successful decrypt, and + after promotion, so it lands in the correct (post-promotion) live window. +- Do the sync decrypt + promotion **before** the first `await` in request + handling, so back-to-back frames can't both trigger a promotion race. + +### 3.4 promoteCandidate() + +```ts +function promoteCandidate(): void { + clearCandidateTimer(); + if (liveKey !== null) zero(liveKey); // retire old live + liveKey = candidateKey; + liveEncrypt = createEncryptor(liveKey); + liveDecrypt = candidateDecrypt; // reuse the decryptor we just used + liveAuthData = candidateAuthData; + epoch++; // response-guard epoch advances + seenClear(); // new key → old nonces irrelevant + // clear candidate slot (ownership moved to live; don't zero liveKey) + candidateKey = null; + candidateDecrypt = null; + candidateAuthData = null; + state = "ready"; +} +``` + +**Three counters, three distinct jobs — keep all of them.** The two new ones +**complement** `attemptEpoch`; they do not replace it. Removing `attemptEpoch` +breaks D1's cancellation of competing attempts. + +- **`attemptEpoch`** (exists today, DO NOT remove) — bumped on **every incoming + hello**, before any `await`. Guards the three mid-attempt suspension points in + `handleHello` (after `verify`, after `secret()`, after `sign`) so a stale + suspended attempt abandons all writes when a newer hello arrives. This is the + D1 mechanism; §3.2's publish guard still reads `attemptEpoch !== myAttempt`. +- **`candidateEpoch`** (new) — bumped **only when a candidate is installed** (in + the publish block). Guards the candidate confirmation timer. It must be + separate from `attemptEpoch`: if the timer were guarded on `attemptEpoch`, a + later hello that bumps `attemptEpoch` and then *fails validation* (never + installs a candidate) would disarm the *existing* candidate's timeout → + an immortal unconfirmed candidate wedging the slot. +- **`epoch`** (exists today) — now advances on every **promotion** instead of at + publish. In-flight responses from the previous live session self-drop at the + existing response guard (`epoch !== reqEpoch`). Keep the guard; note the + capture-timing fix in §3.3. + +### 3.5 seen-nonce window + +The window belongs to the **live** key. Rules: + +- Pre-check and record against the live window only (candidate has no window — + it's confirmed by a single frame; a replayed hello can't produce a valid + candidate frame, so there's nothing to dedup there). +- `seenClear()` on promotion (already done inside `promoteCandidate`). +- No change to the ring/set structure or the `replayWindow` option. + +### 3.6 resetHandshake / destroy + +- **`resetHandshake()` loses its only caller.** Today it is invoked solely from + the `hsTimer` callback; in this model the candidate timer calls + `dropCandidate()` instead, and a confirming-frame timeout no longer tears down + a live session. So `resetHandshake()` becomes dead code. Either delete it, or + keep it deliberately for a future explicit-teardown path and say so in a + comment — do not leave it looking live. If kept, it must clear **both** slots + (zero `liveKey` and `candidateKey`, both timers, `seenClear()`, + `state="waiting"`) and must not regenerate any module-level ephemeral (there is + none — ephemerals are attempt-local, §4). +- `dropCandidate()`: zero `candidateKey`, null the candidate slot fields, clear + `candidateTimer`. Leaves `live` and the seen window untouched. +- `destroy()`: zero both slots, clear both timers, unsubscribe. + +## 4. Invariant to lock (do not skip) + +The whole property rests on the server's ephemeral key being **fresh per hello +attempt**. It already is — `src/server.ts:407`: + +```ts +const myPriv = x25519.utils.randomSecretKey(); // inside handleHello() +const myPub = x25519.getPublicKey(myPriv); +``` + +If this key were ever moved back to module scope (as it was pre-D1), a duplicate +hello would derive the *same* key as the live session, and replayed traffic could +decrypt under the "candidate" and promote it — turning the continuity bug into a +full session-traffic replay. Add an implementation-checklist bullet to +`protocol.md` stating the ephemeral pair is generated per attempt and never held +at module scope, so a refactor can't silently reopen this. + +## 5. Optional follow-up (decide, don't default): previous-key grace + +WireGuard keeps the just-retired key decrypt-only for a short grace window so +messages already in flight under it aren't lost when a rekey promotes. + +In saferpc the client is the sole handshake initiator and the server only +*responds*, so at a legitimate rekey the client has already moved to the new key; +inbound frames under the old live key are unlikely. This makes previous-key grace +lower value here than in WireGuard's symmetric case. **Recommendation:** ship §3 +(live + candidate) first — it fully closes the continuity bug — and treat a third +`previous` slot as a separate, optional change only if a concrete in-flight-loss +case shows up during legit rekey. If added: a `previous` slot, decrypt-only, with +its own short grace timer, tried after candidate; never promotes, never encrypts. +Not required for the property in §2. + +## 6. Out of scope (name, don't fix here) + +- **Weak-secret proof oracle.** The server returns its HMAC proof to any + well-formed hello, and the proof derives from a key salted with the PSK. Anyone + who completes an ECDH can grind PSK guesses offline against the proof. Infeasible + against a random 32-byte key (2²⁵⁶); real against a password-derived secret. + Pre-existing, not introduced here. → **doc only** (§7, security.md): the secret + must be a CSPRNG key, not a passphrase; if password-derived, run it through a + slow KDF (scrypt/argon2) first. +- **Hello-flood starving a legit reconnect.** Latest-hello-wins means a flood of + hellos keeps overwriting the candidate slot, so a legitimate peer's rekey + candidate can be evicted before its confirming frame lands. DoS is explicitly + outside saferpc's threat model. → **assessment.md**: name it as an accepted + residual; note the cheap partial mitigation (drop byte-identical duplicate + hellos via a small recent-transcript-hash cache before signature verification) + as a possible future hardening, not shipped now. + +## 7. Spec/doc changes + +- **protocol.md** + - §Handshake step 10 + §Re-handshake: the live session is replaced on the + *first confirming frame under the candidate key*, not at publish. Publish + installs a candidate; promotion retires the old live. + - State machine (server): redefine `pending` as "candidate present, live may + still be serving"; promotion edge is "first TAG_MSG decrypts under candidate". + - Implementation checklist: add the §4 ephemeral-per-attempt invariant; update + the "server accepts new hellos in any state" bullet to "a hello installs a + candidate and never retires the live session before confirmation". + - Add the §2 underlying rule as a short normative note. +- **security.md**: §Replay within a session unchanged; add the §6 weak-secret + paragraph near the `secret` requirements. +- **assessment.md**: risk #3 (unauthenticated teardown) → note that duplicate/ + stale hello can no longer retire a live session in *either* mode (was "residual + in PSK-only"); add the §6 hello-flood residual. +- **api.md**: no option changes. If `state` is observable anywhere, sync its + described meaning. + +## 8. Tests — `test/security/session-continuity.test.ts` + +Use neutral naming/comments (no attack vocabulary; the test suite is read by +tooling that false-positives on it). + +1. **Duplicate hello leaves the live session intact.** Establish a PSK session, + capture the client hello, re-inject it into the server. Assert: subsequent + calls succeed, and the client performed exactly one handshake (count TAG_HELLO + frames == 1). Regression for the core bug. +2. **Same, signature mode.** With Ed25519 verify configured, re-inject the + captured signed hello. Assert the live session survives (this is the case the + old code broke even with verify configured). +3. **Genuine rekey still works — and the FIRST call after it gets a reply.** Force + the client to a fresh handshake (real new ephemeral key), then make a call and + **await its result**. This is the regression that catches the §3.3 reqEpoch + capture-timing bug: if `reqEpoch` is captured before promotion, this first + post-rekey call times out. The test MUST assert the resolved value, not just + that a handshake happened. +4. **Candidate expiry.** Install a candidate (hello) that is never confirmed; + after `handshakeTimeout` assert the candidate is dropped and the prior live + session (if any) still serves. +5. **Promotion clears the replay window.** Non-trivial to write: clients use + random nonces, so an old captured frame won't decrypt under the new key and + you can't just replay bytes. Options: (a) assert the *observable* effect — + after a rekey, normal traffic continues to be accepted (the window didn't + carry stale entries that would false-reject); or (b) drive it with a seeded/ + stubbed RNG and a hand-assembled frame that reuses a nonce under the new key. + Flag this in the test so the implementer doesn't burn an hour on "why won't it + reproduce". Prefer (a) unless you specifically want to pin the clear. + +Verified assumption (no test needed beyond #1, but note it): the server's +handshake reply to a duplicate hello reaches the legitimate client on the shared +channel and does **not** confuse it — `client.ts:347` guards +`if (tag === TAG_HELLO && state === "handshaking")`, so an unsolicited reply in +`ready`/`idle` is silently dropped. Test #1 covers this behaviorally. + +Keep the existing `deferred-reset.test.ts` D1 tests green (they assert the weaker +"garbage hello during attempt doesn't reset" property, which make-before-break +also satisfies). + +## 9. Open questions for the implementer + +1. `state` enum vs derived: keep the three-value enum with redefined meaning + (§3.1) or compute it from slot nullness? Enum is less churn; make sure every + site that reads `state` still means the right thing (esp. the pending→ready + promotion check now lives in the TAG_MSG decrypt branch). +2. Response epoch guard: the confirming frame's `reqEpoch` is captured AFTER + promotion (§3.3), so its own reply survives the guard. Responses to requests + that were genuinely in flight under the *pre-promotion* live session still + drop when promotion advances `epoch` — acceptable, same as today's + re-handshake behavior. Confirm no path captures `reqEpoch` before the + decrypt/promotion step. +3. Does any caller rely on the old side effect that a hello *immediately* moved + state to `pending` (e.g. tests, onError timing)? Grep before changing. +4. Stale docstrings: the file-header lifecycle comment and the D1 comment + ("torn down only when the attempt fully validates and publishes") describe the + old publish-retires-live behavior. Update them together with protocol.md, or + they become false descriptions of the code. +``` diff --git a/spec/api.md b/spec/api.md index 46c34a9..ac827f2 100644 --- a/spec/api.md +++ b/spec/api.md @@ -8,7 +8,7 @@ Reference for every exported symbol. End-to-end walkthrough lives in [Getting St // Root entry: everything import { saferpc, chain, server, client, - RPCError, RemoteRPCError, + RPCError, RPCAbortedError, RemoteRPCError, deriveSessionSecret, } from "@dotex/saferpc"; @@ -45,7 +45,9 @@ Initialise once, binding the handler context type `TCtx` (mirrors tRPC's fully-typed `ctx`. ```typescript -interface Context { user: { id: string } | null } +interface Context { + user: { id: string } | null; +} // rpc IS the procedure builder; rpc.router / rpc.middleware hang off it. export const rpc = saferpc(); @@ -83,12 +85,12 @@ Every method is immutable and chainable. The handler may be **sync or async**. ` ### Method semantics -| Method | Effect | Notes | -|--------|--------|-------| -| `.use(mw)` | Adds middleware that can extend context | `mw` may be a plain (non-`async`) function but must **return** `next()` (called exactly once). `next(extra)` merges `extra` into ctx — and its type flows into every downstream step. | -| `.input(schema)` | Validates & parses input with Zod | Handler receives `z.output`; callers send `z.input`. On failure throws `RPCError("INPUT_VALIDATION", ...)`. | -| `.output(schema)` | Validates & parses output with Zod | Handler returns `z.input` (pre-transform); callers observe `z.output`. On failure throws `RPCError("OUTPUT_VALIDATION", ...)`. Runs *after* handler. | -| `.handler(fn)` | Terminates the builder | `fn` may be **sync or async**. Returns a frozen `Procedure`. Without `.output()`, the caller-facing output is inferred from `fn`'s (awaited) return. | +| Method | Effect | Notes | +| ----------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `.use(mw)` | Adds middleware that can extend context | `mw` may be a plain (non-`async`) function but must **return** `next()` (called exactly once). `next(extra)` merges `extra` into ctx — and its type flows into every downstream step. | +| `.input(schema)` | Validates & parses input with Zod | Handler receives `z.output`; callers send `z.input`. On failure throws `RPCError("INPUT_VALIDATION", ...)`. | +| `.output(schema)` | Validates & parses output with Zod | Handler returns `z.input` (pre-transform); callers observe `z.output`. On failure throws `RPCError("OUTPUT_VALIDATION", ...)`. Runs _after_ handler. | +| `.handler(fn)` | Terminates the builder | `fn` may be **sync or async**. Returns a frozen `Procedure`. Without `.output()`, the caller-facing output is inferred from `fn`'s (awaited) return. | `schema` is anything with a `.safeParse()` method (a Zod schema in practice). @@ -113,8 +115,9 @@ interface Procedure { type Router = Record; // Extract a procedure's caller-facing types: -type inferInput

= P extends Procedure ? I : never; -type inferOutput

= P extends Procedure ? O : never; +type inferInput

= P extends Procedure ? I : never; +type inferOutput

= + P extends Procedure ? O : never; // Also exported — RouterContext: the base context a whole router requires // (the intersection of its procedures' base contexts). server() uses it. @@ -138,16 +141,19 @@ Subscribes to `channel` and serves the router. Returns synchronously. The option ### `ServerOptions` -| Field | Type | Default | Required | -|-------|------|---------|----------| -| `auth` | `AuthOptions` | — | ✅ | -| `context` | `(ctx: { auth?: Ctx }) => TCtx \| Promise` | — | ✅ when `TCtx` (the router's base context) is non-empty, else — | -| `handshakeTimeout` | `number` (ms) | `5000` | — | -| `maxMessageBytes` | `number` | `1_048_576` | — | -| `onError` | `(err: unknown) => void` | — | — | +| Field | Type | Default | Required | +| ------------------ | ------------------------------------------------ | ----------- | --------------------------------------------------------------- | +| `auth` | `AuthOptions` | — | ✅ | +| `context` | `(ctx: { auth?: Ctx }) => TCtx \| Promise` | — | ✅ when `TCtx` (the router's base context) is non-empty, else — | +| `handshakeTimeout` | `number` (ms) | `5000` | — | +| `maxMessageBytes` | `number` | `1_048_576` | — | +| `replayWindow` | `number` | `4096` | — | +| `onError` | `(err: unknown) => void` | — | — | `context` runs per request and must return the router's base context `TCtx`. The `auth` argument carries whatever `auth.verify` returned for the current session. When the base context is empty, `context` is optional and the request context falls back to the verified auth data (or `{}` if none). +`replayWindow` is the number of recently-seen AEAD nonces the server remembers per session so it can drop replayed request frames (in-session replay defense). FIFO-evicted: a replay older than the last `replayWindow` accepted messages executes again, so the window is narrowed to N, not closed. Cleared on every re-handshake. Set `0` to disable. + `onError` fires on handshake failures and non-fatal internal errors. The server does **not** destroy itself on a failed handshake — it resets and accepts the next hello. ### `AuthOptions` @@ -167,11 +173,11 @@ type VerifyResult = { auth?: Ctx } | void; Set at least one of `secret` or asymmetric (`sign` / `verify`). Configuring neither throws a `TypeError` at construction. -| Field | Called | Notes | -|-------|--------|-------| -| `secret` | Per handshake attempt | Returned bytes must be ≥ 32. Empty secret used when `secret` is omitted but asymmetric auth is configured. | -| `sign` | Per handshake attempt, if set | Signature payload, ≤ 32 KiB. | -| `verify` | Per handshake attempt, if set | Throw to reject. Returned `auth` is bound to the resulting session. | +| Field | Called | Notes | +| -------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `secret` | Per handshake attempt | Returned bytes must be ≥ 32. Empty secret used when `secret` is omitted but asymmetric auth is configured. | +| `sign` | Per handshake attempt, if set | Signature payload, ≤ 32 KiB. | +| `verify` | Per handshake attempt, if set | Throw to reject. Returned `auth` is bound to the resulting session. | Returned `auth` data is sanitized (poison keys stripped) before reaching `context`. @@ -180,13 +186,15 @@ Returned `auth` data is sanitized (poison keys stripped) before reaching `contex ``` waiting → pending → ready ↑ | | - └─────────┴────────┘ (timeout / new hello / explicit destroy) + └─────────┴────────┘ (candidate timeout / new hello / explicit destroy) ``` -- `waiting`: accepting hellos, no session. -- `pending`: hello processed, reply sent. Transitions to `ready` only on successful decrypt of the first `TAG_MSG`. -- `ready`: session confirmed. Routes RPCs. -- A new hello in any state resets the server and starts over. +State is described by two key slots — a **live** session and a validated-but-unconfirmed **candidate** (make-before-break): + +- `waiting`: no live session, no candidate. Accepting hellos. +- `pending`: a validated hello has installed a candidate; the reply is sent. **If a live session already exists it keeps serving throughout.** The candidate is promoted only on successful decrypt of the first `TAG_MSG` under the candidate key. +- `ready`: a live session is confirmed, no candidate pending. Routes RPCs. +- A new hello in any state opens an attempt and, if it validates, installs a candidate — it does **not** reset or displace the live session. A replayed or forged hello can at most create a candidate that expires unconfirmed. - `destroy()` is permanent: zeros all keys, unsubscribes from the channel, drops references. --- @@ -197,36 +205,66 @@ waiting → pending → ready function client( channel: Channel, options: ClientOptions, -): { api: Client; destroy: () => void }; +): { + api: Client; + destroy: () => void; +}; ``` Returns synchronously. The handshake stays lazy: it starts on the first `api` call. ### `ClientOptions` -| Field | Type | Default | Required | -|-------|------|---------|----------| -| `auth` | `AuthOptions` | — | ✅ | -| `timeout` | `number` (ms) | `10_000` | — | -| `maxPending` | `number` | `256` | — | -| `handshakeTimeout` | `number` (ms) | `5000` | — | -| `maxMessageBytes` | `number` | `1_048_576` | — | +| Field | Type | Default | Required | +| ------------------ | ------------- | ----------- | -------- | +| `auth` | `AuthOptions` | — | ✅ | +| `timeout` | `number` (ms) | `30_000` | — | +| `maxPending` | `number` | `256` | — | +| `handshakeTimeout` | `number` (ms) | `5000` | — | +| `sendTimeout` | `number` (ms) | `3_000` | — | +| `maxMessageBytes` | `number` | `1_048_576` | — | `maxPending` caps concurrent in-flight calls. Past the cap, calls reject with `RPCError("CLIENT", "Too many pending requests")`. -`timeout` is per call. On timeout the client throws `RPCError("TIMEOUT", "Timed out: ")` and triggers an auto-retry. +`timeout` applies to every call. On timeout the client throws `RPCAbortedError("TIMEOUT", "Timed out: ")` if the frame had already been sent (outcome unknown — do not blind-resend; the session resets and the next call re-handshakes), or plain `RPCError("CHANNEL")` if the frame had not yet left the process (retry freely; no reset). Set `timeout` generously — it is the safety net, not a UX budget; shorten a single call with [`AbortSignal.timeout`](#calloptions--per-call-abort) instead. + +`sendTimeout` is the maximum time a frame spends in the core outbound queue waiting for a live channel before the call fails with a definite `RPCError("CHANNEL")` (never sent — retry freely). Default 3 000 ms — fail fast: the error is provably "never left", so retrying at the call site is always safe, and a frame that could not leave for 3 s is usually stale anyway. Raise it for transports with long reconnect cycles. Not a caller-facing UX knob; it is the boundary between the definite and unknown failure paths. ### `Client` ```typescript type Client = { [K in keyof T & string]: T[K] extends Procedure - ? (input: TInput) => Promise - : (input: unknown) => Promise; + ? (input: TInput, opts?: CallOptions) => Promise + : (input: unknown, opts?: CallOptions) => Promise; }; ``` -Each procedure maps to a call whose argument and result are inferred from that procedure. Pass `typeof appRouter` as the type argument — `client(...)` — to get full end-to-end inference. A loose `Router` collapses to `(input: unknown) => Promise`, so untyped usage keeps working. +Each procedure maps to a call whose argument and result are inferred from that procedure. Pass `typeof appRouter` as the type argument — `client(...)` — to get full end-to-end inference. A loose `Router` collapses to `(input: unknown, opts?: CallOptions) => Promise`, so untyped usage keeps working. + +### `CallOptions` — per-call abort + +```typescript +interface CallOptions { + signal?: AbortSignal; +} + +const ac = new AbortController(); +const p = api.getProfile({ id: "u_1" }, { signal: ac.signal }); +ac.abort(); // p rejects with RPCError("ABORTED"), signal.reason on .cause + +// a shorter budget for ONE call — the platform's timeout-signal: +await api.getPrice(input, { signal: AbortSignal.timeout(500) }); +``` + +Fetch-style. Aborting rejects **that call** with code `ABORTED`. The class depends on the sent boundary: + +- An already-aborted signal rejects immediately — nothing is sent and no handshake is triggered → plain `RPCError("ABORTED")`, retry freely. +- Aborting while the call waits on a shared handshake rejects the call only; the handshake keeps running for other callers and for the next call → plain `RPCError("ABORTED")`, retry freely. +- Aborting after the frame was already sent → `RPCAbortedError("ABORTED")`; `signal.reason` on `.cause`. Outcome on the server is **UNKNOWN** — the handler may have run. Do not blind-resend a non-idempotent call. +- The session is never touched: `ABORTED` does not trigger the reset path, and a reply arriving after the abort is silently dropped. + +There is deliberately **no per-call `timeout` field**: the two-lever model is a *global* timeout (the client-level `timeout` option — the safety net that heals a dead session) plus a *local* abort (`AbortSignal.timeout(ms)` for a shorter single-call budget — gives `ABORTED`, session untouched). A signal can only shrink a call's budget, not extend it past the global timer; procedures slower than the global timeout mean the global value is too small — raise `ClientOptions.timeout` (this is also why the default is a generous 30 s), don't look for a per-call escape hatch. ### Client lifecycle @@ -243,9 +281,13 @@ idle → handshaking → ready --- -## Auto-retry +## Failure handling (no auto-retry) + +As of 0.7.0 a call that fails as `RPCAbortedError("TIMEOUT")` — the frame was sent and no reply arrived — resets the session (zeros the key, returns to `idle`) but is **not** resent. The error surfaces so the caller, the only party that knows whether the procedure is idempotent, decides. Resending after a sent-frame timeout would silently double-execute non-idempotent handlers. A call whose frame was still in the core outbound queue when a terminal event fired (`RPCError("CHANNEL")` — either timer — or plain `RPCError("ABORTED")`) provably never left — the session is not reset. `RemoteRPCError` (server answered) and guardrail errors (`CLIENT`) never reset the session. The reset alone keeps a desynced peer from wedging future calls: the next call re-handshakes lazily. Concurrent failures share one re-handshake via an epoch counter. Full state-machine and wire-level semantics in [Protocol § Failure handling](protocol.md#failure-handling-no-auto-retry). -A call that fails on a `ready` session with a local `TIMEOUT` or send error triggers a single retry: the client zeros its session key, returns to `idle`, runs a fresh handshake, and resends the request **exactly once**. `RemoteRPCError` (server returned an error) is **not** retried — the server is alive and answered. Concurrent failures share one re-handshake via an epoch counter, so there are no retry storms. Full state-machine and wire-level semantics in [Protocol § Auto-retry semantics](protocol.md#auto-retry-semantics). +As of 0.7.0 **transport death is not a session event**. The session is bound to key material, not to a transport instance; when a socket dies, the client does nothing — keys are kept, pending calls keep waiting under their own timers. A call's outcome is decided by exactly two events: a reply that decrypts, or the call's own timeout. If the adapter's `send` throws (transport down), the core holds the frame in its outbound queue and retries until `sendTimeout` — see [Integrations § adapter contract](integrations.md#adapter-contract-send-or-throw-no-queues-stay-available) and the shipped `@dotex/saferpc/channels`. If the server lost its session with the socket, the first sent call that times out triggers the reset above, and the next call re-handshakes lazily. + +**Migration from 0.6.x:** `abortPending` is removed. Replace with a shared `AbortController` whose `signal` is passed to each call: `ctl.abort()` rejects every carrying call, equivalent to the old behavior with more precise class semantics (sent → `RPCAbortedError`, unsent → plain `RPCError`). Also note the `timeout` default rose from `10_000` to `30_000` ms: without auto-retry the short timeout no longer doubles as a UX budget — that moved to per-call `AbortSignal.timeout(ms)`. Code that relied on the 10 s reset-and-heal cadence should pin `timeout: 10_000` explicitly. ## Replay within a session @@ -262,15 +304,15 @@ interface Channel { } ``` -The only transport contract. `receive` returns an unsubscribe function. The channel must: +The only transport contract. `receive` should return an unsubscribe function; returning `void` is tolerated but then `destroy()` cannot detach the listener (a leak on long-lived channels — prefer returning the unsubscribe). The channel must: - Transmit bytes intact (no silent corruption) - Deliver each call to `cb` once, in any order - Allow `send` and `receive` to run concurrently -Dropping, duplicating, or reordering messages is allowed — Safe RPC will time out and retry. Ready-made adapters live in [Integrations](integrations.md). +Dropping, duplicating, or reordering messages is allowed — Safe RPC will time out and surface a typed error (no auto-retry; the caller decides whether to resend). For transports that can die (sockets), the adapter owns liveness: reopen immediately, hold open, throw from `send` while down so the core can queue and retry the frame — see [Integrations § adapter contract](integrations.md#adapter-contract-send-or-throw-no-queues-stay-available). Ready-made adapters live in [Integrations](integrations.md) and ship as code in `@dotex/saferpc/channels`. -> Within a single session the protocol assumes the `TAG_HELLO` reply arrives before any `TAG_MSG` sent under the resulting session key. Transports that can reorder *across* the hello/reply boundary (multi-path links, fan-out buses) will hang the handshake until the timeout fires. `TAG_MSG`-to-`TAG_MSG` reordering stays safe: every encrypted frame is independently authenticated and the protocol imposes no ordering on application messages. +> Within a single session the protocol assumes the `TAG_HELLO` reply arrives before any `TAG_MSG` sent under the resulting session key. Transports that can reorder _across_ the hello/reply boundary (multi-path links, fan-out buses) will hang the handshake until the timeout fires. `TAG_MSG`-to-`TAG_MSG` reordering stays safe: every encrypted frame is independently authenticated and the protocol imposes no ordering on application messages. --- @@ -284,24 +326,32 @@ class RPCError extends Error { } class RemoteRPCError extends RPCError {} +class RPCAbortedError extends RPCError {} ``` -- `RPCError` is thrown for **local** failures: timeout, session destroyed, handshake failure, validation failure, channel error. -- `RemoteRPCError` is thrown when the remote peer's handler returned an error. The `code`, `message`, and `data` come from the remote side and are **untrusted strings** — sanitize before logging at warn/error level, or before showing them to a user. - -### Standard local error codes - -| Code | Thrown when | -|------|-------------| -| `TIMEOUT` | RPC call exceeded `timeout` ms | -| `SESSION` | `destroy()` called or session closed | -| `CLIENT` | Client-side guardrail tripped (e.g., `maxPending` exceeded) | -| `HANDSHAKE` | Handshake failed or timed out, auth payload malformed | -| `INPUT_VALIDATION` | `.input(schema)` rejected the input | -| `OUTPUT_VALIDATION` | `.output(schema)` rejected the handler output | -| `INVALID_DATA` | Wire-level data rejected by `sanitize()` | -| `INTERNAL` | Defensive: should not be reachable | -| `MIDDLEWARE` | Middleware misuse (`next()` called twice, bad `extra` arg) | +- `RPCError` is thrown for **local** failures where the request provably never left the process: `sendTimeout` expired, guardrail errors, session destroyed before send. +- `RPCAbortedError extends RPCError` is thrown for **local** failures where the frame **had already been sent** — the request may have executed on the server. **Invariant: class = which side of the wire the request died on; code = what killed it.** The same code (`ABORTED`, `SESSION`) can appear in both classes; `TIMEOUT` appears only on `RPCAbortedError` — a still-queued frame that runs out of time (either timer) fails with the definite `CHANNEL` code instead. +- `RemoteRPCError extends RPCError` is thrown when the remote peer's handler returned an error. The `code`, `message`, and `data` come from the remote side and are **untrusted strings** — sanitize before logging at warn/error level, or before showing them to a user. + +### Standard error codes + +| Class | Code | Thrown when | +| ----------------- | ------------------- | ----------------------------------------------------------------------------------------------------- | +| `RPCAbortedError` | `TIMEOUT` | Global timeout; frame was already sent — outcome unknown | +| `RPCAbortedError` | `ABORTED` | Per-call signal fired; frame was already sent — outcome unknown. `signal.reason` on `.cause` | +| `RPCAbortedError` | `SESSION` | `destroy()` called; frame was already sent — outcome unknown | +| `RPCError` | `CHANNEL` | `sendTimeout` or global `timeout` expired while still queued, channel closed, or reset staled a queued frame — never left | +| `RPCError` | `ABORTED` | Signal fired before frame was sent (pre-aborted signal, or abort during handshake wait) — never left | +| `RPCError` | `SESSION` | `destroy()` before send, or call on a closed client — never left | +| `RPCError` | `CLIENT` | Client-side guardrail tripped (e.g., `maxPending` exceeded) | +| `RPCError` | `HANDSHAKE` | Handshake failed or timed out, auth payload malformed | +| `RemoteRPCError` | `INPUT_VALIDATION` | `.input(schema)` rejected the input (server-side) | +| `RemoteRPCError` | `OUTPUT_VALIDATION` | `.output(schema)` rejected the handler output (server-side) | +| `RPCError` | `INVALID_DATA` | Wire-level data rejected by `sanitize()` | +| `RPCError` | `INTERNAL` | Defensive: should not be reachable | +| `RPCError` | `MIDDLEWARE` | Middleware misuse (`next()` called twice, bad `extra` arg) | + +`INPUT_VALIDATION`, `OUTPUT_VALIDATION`, `MIDDLEWARE`, and `NOT_FOUND` are raised **server-side** and always arrive as `RemoteRPCError`. `CHANNEL` is purely client-local. `ABORTED` and `SESSION` appear in both `RPCAbortedError` (frame sent) and `RPCError` (frame unsent); `TIMEOUT` only in `RPCAbortedError` — the class is the retry-safety signal. Handlers may throw `RPCError(...)` with any code; those codes surface as `RemoteRPCError.code` on the client. @@ -312,9 +362,11 @@ try { await api.getProfile({ id: "u_1" }); } catch (err) { if (err instanceof RemoteRPCError) { - // handler threw on the other side: err.code, err.message, err.data + // handler ran and returned an error: err.code, err.message, err.data + } else if (err instanceof RPCAbortedError) { + // request left the process — outcome unknown, reconcile before retrying } else if (err instanceof RPCError) { - // local failure: TIMEOUT, SESSION, etc. + // request never reached the server — retry freely } else { throw err; } @@ -385,13 +437,13 @@ import { // Also re-exported from "@dotex/saferpc" and "@dotex/saferpc/auth". ``` -| Helper | Returns | Notes | -|--------|---------|-------| -| `createJWTClientAuth({ getToken })` | `{ sign }` | Embeds `{ jwt, ts, th }` where `th` is `SHA-256(transcript)`. | -| `createEd25519ClientAuth({ privateKey, deviceId })` | `{ sign }` | Signs the transcript via `@noble/curves` (no WebCrypto needed). | -| `createECDSAClientAuth({ privateKey, identifier })` | `{ sign }` | WebCrypto P-256, `privateKey` is a `CryptoKey`. | -| `generateEd25519Keypair()` | `{ privateKey: Uint8Array, publicKey: Uint8Array }` | Pure JS, works everywhere. | -| `generateECDSAKeypair()` | `{ privateKey: CryptoKey, publicKey: CryptoKey }` | Non-extractable. | +| Helper | Returns | Notes | +| --------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------- | +| `createJWTClientAuth({ getToken })` | `{ sign }` | Embeds `{ jwt, ts, th }` where `th` is `SHA-256(transcript)`. | +| `createEd25519ClientAuth({ privateKey, deviceId })` | `{ sign }` | Signs the transcript via `@noble/curves` (no WebCrypto needed). | +| `createECDSAClientAuth({ privateKey, identifier })` | `{ sign }` | WebCrypto P-256, `privateKey` is a `CryptoKey`. | +| `generateEd25519Keypair()` | `{ privateKey: Uint8Array, publicKey: Uint8Array }` | Pure JS, works everywhere. | +| `generateECDSAKeypair()` | `{ privateKey: CryptoKey, publicKey: CryptoKey }` | Non-extractable. | ### Server-side @@ -406,13 +458,13 @@ import { // Also re-exported from "@dotex/saferpc" and "@dotex/saferpc/auth". ``` -| Helper | Use | -|--------|-----| -| `createJWTServerAuth({ verifyToken, maxAge? })` | Verifies JWT + timestamp (symmetric skew check) + transcript digest. Returns the `verifyToken` result as `auth`. | -| `createEd25519ServerAuth({ getPublicKey, validateDevice? })` | Verifies Ed25519 signature against a device's 32-byte public key. | -| `createECDSAServerAuth({ getPublicKey, validateEntity? })` | Verifies ECDSA P-256 signature via WebCrypto. | -| `createCertificateServerAuth({ verifyCertificate, validateSubject? })` | Verifies a presented certificate chain + ECDSA P-256 signature. | -| `createMultifactorServerAuth({ primary, secondary, combineAuth? })` | Composes two verifiers; both must pass. | +| Helper | Use | +| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `createJWTServerAuth({ verifyToken, maxAge? })` | Verifies JWT + timestamp (symmetric skew check) + transcript digest. Returns the `verifyToken` result as `auth`. | +| `createEd25519ServerAuth({ getPublicKey, validateDevice? })` | Verifies Ed25519 signature against a device's 32-byte public key. | +| `createECDSAServerAuth({ getPublicKey, validateEntity? })` | Verifies ECDSA P-256 signature via WebCrypto. | +| `createCertificateServerAuth({ verifyCertificate, validateSubject? })` | Verifies a presented certificate chain + ECDSA P-256 signature. | +| `createMultifactorServerAuth({ primary, secondary, combineAuth? })` | Composes two verifiers; both must pass. | Auth payloads decode through the hardened msgpack codec: extension types rejected, prototype-pollution keys stripped, recursion depth capped. Returned `auth` data is sanitized before reaching `context`. @@ -422,19 +474,19 @@ Auth payloads decode through the hardened msgpack codec: extension types rejecte ```typescript import { - NONCE_LEN, // 24: XSalsa20-Poly1305 message nonce - KEY_LEN, // 32: symmetric key / X25519 key / hello nonce - TAG_HELLO, // 0x00 - TAG_MSG, // 0x01 - MAX_MSG_BYTES, // 1_048_576 - MAX_HELLO_BYTES, // 65_536 - MAX_AUTH_BYTES, // 32_768 - MAX_DEPTH, // 32: max `sanitize()` recursion depth - HANDSHAKE_TIMEOUT,// 5000 - EMPTY_SECRET, // Uint8Array(32) of zeros: internal "no secret" sentinel + NONCE_LEN, // 24: XSalsa20-Poly1305 message nonce + KEY_LEN, // 32: symmetric key / X25519 key / hello nonce + TAG_HELLO, // 0x00 + TAG_MSG, // 0x01 + MAX_MSG_BYTES, // 1_048_576 + MAX_HELLO_BYTES, // 65_536 + MAX_AUTH_BYTES, // 32_768 + MAX_DEPTH, // 32: max `sanitize()` recursion depth + HANDSHAKE_TIMEOUT, // 5000 + EMPTY_SECRET, // Uint8Array(32) of zeros: internal "no secret" sentinel // Type guards - isPlainBytes, // exact-prototype Uint8Array check for wire data - isEmptySecret, // constant-time check for the 32-zero secret sentinel + isPlainBytes, // exact-prototype Uint8Array check for wire data + isEmptySecret, // constant-time check for the 32-zero secret sentinel } from "@dotex/saferpc"; ``` @@ -448,7 +500,9 @@ Call `destroy()` when you are done with a session. ```typescript const { destroy: destroyServer } = server(router, channel, { auth }); -const { api, destroy: destroyClient } = client(channel, { auth }); +const { api, destroy: destroyClient } = client(channel, { + auth, +}); // later destroyClient(); // rejects pending calls, zeros keys, unsubscribes diff --git a/spec/assessment.md b/spec/assessment.md new file mode 100644 index 0000000..2c10b7b --- /dev/null +++ b/spec/assessment.md @@ -0,0 +1,78 @@ +# Security assessment + +An internal line-by-line review of the implementation against the [Protocol](protocol.md) and [Security](security.md) specs, published as-is. We would rather ship an honest list of residual risks than a clean-looking page. Threat model and configuration guidance live in [Security](security.md); this page is about how well the code holds up against them. + +**Reviewed version:** 0.7.0 · **Date:** 2026-07 · **Reviewer:** internal (Dotex), independent of the original author + +## Method + +- Full read of `src/` (client, server, common, auth helpers) against every normative statement in `spec/protocol.md` and `spec/security.md`. +- Full test suite run (266 tests, including `test/security/`: handshake attacks, replay, tampering, type confusion, prototype pollution, DoS limits, deferred reset, replay window, session continuity). +- Instrumented probes for behavioral claims that the suite does not pin (execution counts, handshake counts under fault injection). + +An internal review is not a third-party audit. It catches spec/code drift and design-level issues; it does not replace external cryptographic review. If you need the latter for a deployment, treat this page as the starting inventory. + +## What was verified and holds + +Checked line-by-line against the spec, no findings: + +- **Handshake ordering.** `verify` runs before any session key material is derived on both sides; a failed verification never leaks ECDH artifacts. Every `await` in the handshake path is followed by an epoch + destroyed guard; module-level state is published only inside a synchronous block under a final guard. +- **Transcripts.** Domain-separated magic prefixes, big-endian uint32 epoch, field order — byte-for-byte per spec. An active MITM cannot substitute either ephemeral key without invalidating the corresponding signature. +- **Key derivation and proof.** `HKDF(ikm=raw ECDH, salt=secret, info="saferpc-v1")`; `HMAC(session_key, s_pub‖c_pub‖c_nonce)`; proof compared in constant time. `deriveSessionSecret` matches its spec formula. +- **Low-order X25519 points.** Rejected by `@noble/curves`, with a regression test (`test/security/f002-low-order-x25519-pubkey.test.ts`) pinning both halves of the contract: the library throws, and a forged hello carrying a low-order `pub` aborts the handshake before any session state exists. +- **Memory hygiene.** Ephemeral keys and shared secrets zeroed in try/finally; in-flight handshake attempts own copies of key material, so a concurrent reset cannot corrupt a derivation. An application `secret()` returning 32 zero bytes is refused at runtime. +- **Sanitization.** msgpack extension types rejected (including the hard-coded Timestamp), prototype-pollution keys stripped, non-plain objects rejected, recursion capped, inbound `bin` fields normalized to plain `Uint8Array` at the channel boundary. +- **Silent-drop policy.** Bad tags, oversized frames, Poly1305 failures, and malformed RPC shapes are dropped without feedback, as specified. +- **Constant-time comparisons** everywhere the spec requires them, including the JWT helper's transcript digest check. + +## Residual risks + +Ordered by how much they should influence a deployment decision. "By design" means the trade-off is deliberate and documented; "fixed in 0.7.0" means the 0.7.0 release closed it (see the per-risk status). + +### 1. Replay within a session — narrowed in 0.7.0, residual by design + +Per-message nonces are random, not counter-derived. An attacker who can inject into a live channel can replay a captured ciphertext; without a dedup window the receiver executes it again. Full discussion in [Security § Replay within a session](security.md#replay-within-a-session). + +Status: **fixed (narrowed) in 0.7.0.** The server keeps a bounded seen-nonce set (`replayWindow`, default 4096) and silently drops any frame whose nonce it has already accepted in the current session — the nonce is recorded only after Poly1305 verifies, and the set is cleared on re-handshake, with no wire change. Residual by design: a replay older than the last `replayWindow` accepted messages still executes (the window is narrowed to N, not closed), so non-idempotent handlers on very long sessions should still carry idempotency keys. Counter-based nonces (which would close it fully) require directional keys and are deferred to a future protocol version. + +### 2. Auto-retry double-execution — fixed in 0.7.0 + +Before 0.7.0 the client's transparent retry fired on **any** local error, including the `CLIENT` backpressure error, and — more fundamentally — resent a call after a `TIMEOUT` whose outcome is unknown. An instrumented probe reproduced a single backpressure error on a healthy session tearing the session down and re-executing every in-flight call, callers observing clean success while handlers ran twice. + +Status: **fixed in 0.7.0.** The auto-retry is removed entirely. A `TIMEOUT` or `CHANNEL` send error resets the session but is **not** resent — the error surfaces with its typed code and the caller decides whether to retry (a `TIMEOUT` is never blind-resent because the request may have executed). Guardrail (`CLIENT`) and `RemoteRPCError` never reset the session. See [Protocol § Failure handling](protocol.md#failure-handling-no-auto-retry). + +### 3. Unauthenticated session teardown — fixed in 0.7.0 (both modes, make-before-break) + +Before 0.7.0 a server in any state that received a `TAG_HELLO` reset its session **before** validating the hello, so a single injected garbage hello (≤ 64 KiB, no authentication needed) tore down an established session — even when `auth.verify` was configured and would reject the attacker. An interim fix (deferred reset, D1) closed the _garbage_-hello case by validating before publishing, but a **byte-for-byte replayed valid hello** still reached publish and displaced the live session with one that could never come alive — reproduced empirically: injecting a captured hello into an established session made the next call time out. In PSK-only mode a well-formed forged hello did the same. + +Status: **fixed in 0.7.0 (make-before-break).** A validated hello is now installed as a _candidate_ that runs alongside the live session; the live key is retired only when a `TAG_MSG` decrypts under the candidate — proof the counterparty holds the key material. Because the server's ephemeral key is fresh per attempt, a duplicate or forged hello derives a different candidate key than the live session, so its replayed traffic can never decrypt under the candidate. A replayed or forged hello can therefore at most create a candidate that expires unconfirmed; it can no longer displace a live session in **either** mode. Regression tests: `test/security/session-continuity.test.ts` (duplicate PSK hello, duplicate signed hello, genuine-rekey first-call reply, candidate expiry). The general rule this enforces — *a transition that destroys authenticated state must be authenticated no weaker than the state it destroys* — is stated in [Protocol § Re-handshake](protocol.md#re-handshake). + +Residual by design (denial of service, out of threat model): each hello still costs the server an ECDH plus an optional `verify`, and because the latest hello wins the candidate slot, a **flood** of hellos can keep overwriting the candidate and starve a legitimate peer's reconnect before its confirming frame lands. This does not touch an already-live session (make-before-break protects it), but it can delay a genuine _re_-handshake. Rate-limit hellos in your channel adapter on exposed transports (on `BroadcastChannel` / `postMessage`, any same-origin code can still force handshake work). A cheap partial mitigation — dropping byte-identical duplicate hellos via a small recent-transcript-hash cache before signature verification — is possible but not shipped. Do not put Safe RPC on a shared-origin bus you do not fully control. + +### 3b. Weak-secret proof oracle — documented, pre-existing + +On any well-formed hello the server returns `proof = HMAC(HKDF(raw, salt=secret), …)` before the client authenticates. An attacker who completes an ECDH with their own ephemeral knows `raw` and receives the proof, leaving the `secret` as the only unknown — which they can then brute-force **offline**. Infeasible against a random 32-byte key (2²⁵⁶); a fast dictionary attack against a password-derived secret. This is a property of the scheme (PSK as HKDF salt, server-first proof), not a code defect, and is unchanged by make-before-break. Mitigation is documentation: the `secret` must be a CSPRNG key, not a passphrase — see [Security § The secret is a key, not a password](security.md#the-secret-is-a-key-not-a-password). + +### 3c. Core outbound queue — head-of-line blocking bounded by `sendTimeout` + +The client core now owns the outbound queue; adapters have no internal queues. A frame that fails to send (the adapter threw) enters the core queue and is retried every 250 ms. The queue is head-of-line: if the channel is down, no later frame can pass a stuck earlier one. The bound is `sendTimeout` (default 10 s) — every queued frame fails with a definite `RPCError("CHANNEL")` if a live channel does not appear within that window. The stale-queued-hello residual (a timed-out hello flushing after handshake timeout) is gone: the core revokes queued hellos when the handshake attempt expires. + +### 4. No server-side concurrency cap — documented, application concern + +The server has no per-request timeout and no cap on concurrent handler executions (consistent with tRPC/oRPC). An authenticated client can accumulate hanging closures by calling a slow procedure repeatedly. Bound concurrency in your handlers or in front of the server if your peers are not fully trusted. + +### 5. One session key for both directions — design note + +Traffic in both directions is encrypted under the same session key; there are no directional keys (`k_c2s` / `k_s2c`). A reflected frame therefore decrypts successfully and is rejected one layer up, by the message-type field (`t: 1` requests vs `t: 2` responses). This holds, and the shape check is covered by tests — but it is a weaker line of defense than directional keys, and a port that forgets the `t` check loses reflection protection entirely. Flagged for a future protocol version. + +### 6. JWT mode is bearer-token auth — by design, documented + +The transcript digest binds a captured auth payload to its handshake, so it cannot be replayed into a new one. It does not protect against theft of the JWT itself: anyone holding the token can open fresh handshakes until it expires. Documented in [Security § JWT](security.md#jwt-bearer-token-transcript-bound); combine with a PSK or a signature mode when this matters. + +### 7. The curve dependency pin is load-bearing — documented, pinned by test + +Low-order point rejection is delegated to `@noble/curves`. A future dependency release that relaxed the check would re-open the MITM attack against asymmetric-only deployments described in [Security § Ephemeral key validity](security.md#ephemeral-key-validity). The regression test exists precisely so this cannot happen silently — do not remove it, and re-run the suite on every dependency bump. + +## Reporting a vulnerability + +Found something not on this list? Report it privately via [GitHub Security Advisories](https://github.com/dotexorg/saferpc/security/advisories/new) rather than a public issue. We will credit reporters in the release notes unless asked otherwise. diff --git a/spec/getting-started.md b/spec/getting-started.md index d1e02de..fc87617 100644 --- a/spec/getting-started.md +++ b/spec/getting-started.md @@ -8,7 +8,7 @@ Install, define a router, configure auth, attach a channel, call functions. npm install @dotex/saferpc ``` -The only peer dependency is `zod` (or any library exposing `.safeParse()`). +`zod` ships as a bundled dependency (along with `@noble/*` and `@msgpack/msgpack`) — nothing else to install. Schema objects passed to `.input()`/`.output()` only need a `.safeParse()` method, so any zod-compatible library works there, but the `zod` dependency ships regardless. ## Define a router @@ -56,7 +56,9 @@ import { server, type Channel } from "@dotex/saferpc"; import { WebSocketServer, type WebSocket } from "ws"; import { appRouter } from "./router.js"; -const secret = new Uint8Array([/* 32 bytes from your generator */]); +const secret = new Uint8Array([ + /* 32 bytes from your generator */ +]); function wsChannel(ws: WebSocket): Channel { return { @@ -90,16 +92,18 @@ wss.on("connection", (ws) => { import { client, type Channel } from "@dotex/saferpc"; import type { AppRouter } from "./router"; -const secret = new Uint8Array([/* same 32 bytes as the server */]); +const secret = new Uint8Array([ + /* same 32 bytes as the server */ +]); function wsChannel(url: string): Channel { const ws = new WebSocket(url); ws.binaryType = "arraybuffer"; - + const ready = new Promise((resolve) => ws.addEventListener("open", () => resolve(), { once: true }), ); - + return { async send(data) { await ready; @@ -123,7 +127,7 @@ const { message } = await api.greet({ name: "World" }); console.log(message); // "Hello, World!" ``` -That is the whole loop. The handshake runs on the first call, every payload is XSalsa20-Poly1305 AEAD over the WS, and the client retries once if the session drops. +That is the whole loop. The handshake runs on the first call, every payload is XSalsa20-Poly1305 AEAD over the WS, and if the session drops the client re-handshakes on the next call — the failed call surfaces a typed error (it is never silently resent). ## What just happened @@ -135,20 +139,24 @@ That is the whole loop. The handshake runs on the first call, every payload is X ## Error handling -Two error classes: +Three error classes: -- `RPCError`: local failure (timeout, session lost, validation error, handshake failure). +- `RPCError`: local failure where the request provably never left (frame expired in the outbound queue, validation error, handshake failure, session destroyed before send). Safe to retry. +- `RPCAbortedError` (subclass of `RPCError`): the request was sent and the outcome is unknown — it may have executed on the server. Retry only idempotent procedures. - `RemoteRPCError`: error returned from the remote peer (`code`, `message`, `data`). ```typescript -import { RPCError, RemoteRPCError } from "@dotex/saferpc"; +import { RPCError, RPCAbortedError, RemoteRPCError } from "@dotex/saferpc"; try { await api.greet({ name: "World" }); } catch (err) { if (err instanceof RemoteRPCError) { if (err.code === "UNAUTHORIZED") await refreshCredentials(); + } else if (err instanceof RPCAbortedError) { + // Sent, no reply — may have executed. Retry only if idempotent. } else if (err instanceof RPCError) { + // Provably never left — retrying cannot double-execute. if (err.code === "HANDSHAKE") console.warn("auth mismatch?"); } else { throw err; @@ -253,7 +261,7 @@ All built-in helpers (Ed25519, ECDSA, JWT, certificate, multifactor) bind their ### Both (defense-in-depth) -Combine a pre-shared secret and asymmetric when you need session binding *and* individual revocation. +Combine a pre-shared secret and asymmetric when you need session binding _and_ individual revocation. ```typescript const auth = { @@ -269,7 +277,7 @@ const auth = { **Asymmetric** when one side is untrusted or there is no shared secret: public web clients, mobile apps, IoT devices. Per-device revocation. -**Both** when you want session binding *and* per-device identity: regulated environments, high-value systems. +**Both** when you want session binding _and_ per-device identity: regulated environments, high-value systems. The full trade-off breakdown lives in [Security](security.md). diff --git a/spec/integrations.md b/spec/integrations.md index 7e4b9ea..6a0e73c 100644 --- a/spec/integrations.md +++ b/spec/integrations.md @@ -17,37 +17,21 @@ Bidirectional byte streams. Each connection maps to one Safe RPC session. ### WebSocket -The most common case: browser or service talking to a server over WS. - -```typescript -function wsChannel(ws: WebSocket): Channel { - return { - send(data) { - ws.send(data); - }, - receive(cb) { - const handler = (e: MessageEvent) => { - if (e.data instanceof ArrayBuffer) cb(new Uint8Array(e.data)); - }; - ws.addEventListener("message", handler); - return () => ws.removeEventListener("message", handler); - }, - }; -} -``` - -Make sure `ws.binaryType = "arraybuffer"` on the browser side. +The most common case: browser or service talking to a server over WS. Ships +as code — `@dotex/saferpc/channels` — because WS is the one transport with a +lifecycle trap (see [the adapter contract](#adapter-contract-send-or-throw-no-queues-stay-available) below). ```typescript // Server (Node.js, ws package) import { WebSocketServer } from "ws"; import { server } from "@dotex/saferpc"; +import { socketChannel } from "@dotex/saferpc/channels"; const serverSecret = crypto.getRandomValues(new Uint8Array(32)); const wss = new WebSocketServer({ port: 8080 }); wss.on("connection", (ws) => { - const { destroy } = server(router, wsChannel(ws), { + const { destroy } = server(router, socketChannel(ws), { auth: { secret: () => serverSecret }, onError: console.error, }); @@ -55,18 +39,37 @@ wss.on("connection", (ws) => { }); // Client (browser) -const ws = new WebSocket("ws://localhost:8080"); -ws.binaryType = "arraybuffer"; -await new Promise((r) => (ws.onopen = r)); +import { client } from "@dotex/saferpc"; +import { wsChannel } from "@dotex/saferpc/channels"; -const { api } = client(wsChannel(ws), { +const channel = wsChannel("ws://localhost:8080"); +const { api } = client(channel, { auth: { secret: () => serverSecret }, }); const user = await api.getUser({ id: "123" }); +// on app shutdown: channel.close() ``` -A WebSocket carries one logical Safe RPC session per connection. Reconnect = new handshake. +`wsChannel(source, opts?)` **owns the socket lifecycle**: it connects +immediately, and when the socket closes it reconnects at once and keeps the +transport open as long as possible (exponential backoff, forever, until +`close()`). While the socket is down `send` throws synchronously — the core +catches that, queues the frame, and retries on its own tick. Transport errors +are surfaced through the optional `onDown`/`onUp` observability hooks. +`source` is a URL string (uses the global `WebSocket`) or a factory `() => ws` +for the `ws` package / custom construction. + +`socketChannel(ws)` is the plain single-socket wrapper: no reconnect, no +queue. The server uses it because a server cannot reconnect a client's +socket — there, connection death IS session death, and `ws.on("close", +destroy)` stays right. + +With a per-connection `server()` (the wiring above) the client's kept session +is useless after a reconnect — the first call times out and the client +re-handshakes lazily. Keeping the session pays off when the server side +outlives the socket (a long-lived session binding that re-attaches to each +new connection): then calls in flight across a socket blip just complete. ### TCP socket (Node.js) @@ -319,8 +322,16 @@ function waitDCOpen(dc: RTCDataChannel): Promise { return new Promise((resolve, reject) => { if (dc.readyState === "open") return resolve(); dc.addEventListener("open", () => resolve(), { once: true }); - dc.addEventListener("error", () => reject(new Error("data channel error")), { once: true }); - dc.addEventListener("close", () => reject(new Error("data channel closed")), { once: true }); + dc.addEventListener( + "error", + () => reject(new Error("data channel error")), + { once: true }, + ); + dc.addEventListener( + "close", + () => reject(new Error("data channel closed")), + { once: true }, + ); }); } ``` @@ -354,13 +365,16 @@ pc.addEventListener("datachannel", (e) => { #### Symmetric peer -WebRTC peers are symmetric — nothing says one side is the server. To expose a router *and* call the other side's, open two channels on the same `RTCPeerConnection`: one where you serve, one where you call. They share the underlying DTLS/SCTP transport. +WebRTC peers are symmetric — nothing says one side is the server. To expose a router _and_ call the other side's, open two channels on the same `RTCPeerConnection`: one where you serve, one where you call. They share the underlying DTLS/SCTP transport. ```typescript function joinPeer(pc: RTCPeerConnection) { // Serve our router on a channel we open const outbound = pc.createDataChannel("peer", { ordered: true }); - const serving = server(router, webRTCChannel(outbound), { auth, onError: console.error }); + const serving = server(router, webRTCChannel(outbound), { + auth, + onError: console.error, + }); // Call the peer's router on the channel they open const calling = new Promise>((resolve) => { @@ -372,7 +386,10 @@ function joinPeer(pc: RTCPeerConnection) { return { api: () => calling, - close: () => { serving.destroy(); pc.close(); }, + close: () => { + serving.destroy(); + pc.close(); + }, }; } ``` @@ -425,6 +442,33 @@ The rules are the same as everywhere else: 1. `send` accepts `Uint8Array` and gets it to the other side. 2. `receive(cb)` calls `cb` with each incoming `Uint8Array`. It returns an unsubscribe function. -3. The transport is allowed to drop, duplicate, or reorder messages. Safe RPC will time out and retry. It will not behave correctly if your transport silently corrupts bytes. Wrap it in something that fails noisily if you cannot trust it. - -That is the whole API surface. Encryption, framing, retry, key management: all on the Safe RPC side. Your adapter does not need to care. +3. The transport is allowed to drop, duplicate, or reorder messages. Safe RPC will time out and surface a typed error (it does not auto-retry — the caller decides whether to resend). It will not behave correctly if your transport silently corrupts bytes. Wrap it in something that fails noisily if you cannot trust it. +4. If the transport can die (sockets), the adapter owns liveness — see the [adapter contract](#adapter-contract-send-or-throw-no-queues-stay-available) below. In brief: throw from `send` when the transport is down (the core queues and retries), reconnect eagerly, never queue inside the adapter. + +That is the whole API surface. Encryption, framing, key management: all on the Safe RPC side. Your adapter does not need to care. + +### Adapter contract: send or throw, no queues, stay available + +The client core owns the outbound queue. The adapter's job is simpler: + +- **`send` MUST throw synchronously** (or reject, for async adapters) when it + cannot hand the frame to a live transport right now. No internal queues, no + buffering, no retry — accepting a frame the adapter cannot deliver lies to + the core about the sent boundary. +- **No queues inside adapters.** Queueing in a transport adapter is an + antipattern: it duplicates state the core already owns and moves the + "never-left" bookkeeping to a place the core cannot see. The core retries + frames it gets back via throw; it cannot know about frames an adapter + accepted and then lost. +- **Stay available.** When the transport closes, reconnect at once and keep + it open as long as possible (backoff, forever, until explicit `close()`). + The channel's job is availability; delivery bookkeeping is the core's. + +`wsChannel` in `@dotex/saferpc/channels` implements this contract; use it as +the reference. Two WS-specific traps it absorbs, for anyone writing their +own: browser `WebSocket.send()` on a **CLOSED** socket silently drops the +frame (it only throws while `CONNECTING`) — without a `readyState` check, +every failure degrades into a stacked RPC + handshake timeout (~35 s with +defaults) surfacing as a misleading `HANDSHAKE "Handshake timeout"`; and a +socket in a transient non-OPEN state must be detected by checking +`readyState`, not by catching the send exception. diff --git a/spec/protocol.md b/spec/protocol.md index 71a7be3..aa3f8fe 100644 --- a/spec/protocol.md +++ b/spec/protocol.md @@ -12,42 +12,46 @@ Design constraints, in order: 2. **Lazy.** No work happens until the application makes a call. `client()` and `server()` return synchronously. 3. **Resilient.** Either side can fail and re-handshake without coordination from the application. 4. **Transport-agnostic.** The protocol must work over any byte-pipe: duplex socket, message pair, broadcast bus. -5. **No long-lived state in the protocol.** Secret rotation, key revocation, replay caches: application concerns. +5. **No long-lived state in the protocol.** Secret rotation, key revocation: application concerns. The one exception is the optional bounded seen-nonce set (see Replay protection) — per-session, bounded, and dropped with the session. Non-goals: streaming RPCs in-protocol, multiplexing over a single channel, formal session tickets, ordering guarantees stronger than what the transport provides. ## Primitives -| Primitive | Algorithm | Notes | -|-----------|-----------|-------| -| Key exchange | X25519 | 32-byte keys | -| Symmetric encryption | XSalsa20-Poly1305 (AEAD) | 24-byte nonce, 32-byte key, 16-byte tag | -| Hash | SHA-256 | — | -| Key derivation | HKDF-Extract+Expand-SHA-256 | RFC 5869 | -| MAC | HMAC-SHA-256 | RFC 2104 | -| Serialization | msgpack | All extension types **disabled** (see Sanitization) | +| Primitive | Algorithm | Notes | +| -------------------- | --------------------------- | --------------------------------------------------- | +| Key exchange | X25519 | 32-byte keys | +| Symmetric encryption | XSalsa20-Poly1305 (AEAD) | 24-byte nonce, 32-byte key, 16-byte tag | +| Hash | SHA-256 | — | +| Key derivation | HKDF-Extract+Expand-SHA-256 | RFC 5869 | +| MAC | HMAC-SHA-256 | RFC 2104 | +| Serialization | msgpack | All extension types **disabled** (see Sanitization) | All wire numbers are network-byte-order (big-endian) unless explicitly noted. ## Constant reference -| Name | Value | Purpose | -|------|-------|---------| -| `NONCE_LEN` | 24 | XSalsa20-Poly1305 nonce length (per encrypted message) | -| `KEY_LEN` | 32 | Symmetric session key length, X25519 key length, **client hello nonce length** | -| `TAG_HELLO` | `0x00` | First byte of a handshake frame | -| `TAG_MSG` | `0x01` | First byte of an encrypted RPC frame | -| `MAX_HELLO_BYTES` | 65,536 | Max size of a handshake frame (post-tag) | -| `MAX_AUTH_BYTES` | 32,768 | Max size of the optional `auth` payload | -| `MAX_MSG_BYTES` | 1,048,576 | Max size of an encrypted RPC frame (configurable) | -| `HANDSHAKE_TIMEOUT_MS` | 5,000 | Default timeout for completing the handshake | -| `RPC_TIMEOUT_MS` | 10,000 | Default per-call timeout (client side) | -| `MAX_PENDING` | 256 | Default maximum in-flight RPCs per client | -| `KDF_INFO` | UTF-8 bytes of `"saferpc-v1"` | HKDF info parameter for session key | -| `PSK_DERIVE_INFO` | UTF-8 bytes of `"saferpc-session-v1"` | HKDF info for `deriveSessionSecret` helper | -| `TRANSCRIPT_HELLO_MAGIC` | UTF-8 bytes of `"saferpc-hs-hello-v1\0"` (20 bytes) | Domain separation for client transcript | -| `TRANSCRIPT_REPLY_MAGIC` | UTF-8 bytes of `"saferpc-hs-reply-v1\0"` (20 bytes) | Domain separation for server transcript | -| `EMPTY_SECRET` | 32 zero bytes | HKDF salt when no secret is configured (asymmetric-only mode) | +| Name | Value | Purpose | +| ------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------ | +| `NONCE_LEN` | 24 | XSalsa20-Poly1305 nonce length (per encrypted message) | +| `KEY_LEN` | 32 | Symmetric session key length, X25519 key length, **client hello nonce length** | +| `TAG_HELLO` | `0x00` | First byte of a handshake frame | +| `TAG_MSG` | `0x01` | First byte of an encrypted RPC frame | +| `MAX_HELLO_BYTES` | 65,536 | Max size of a handshake frame, **tag byte included** | +| `MAX_AUTH_BYTES` | 32,768 | Max size of the optional `auth` payload | +| `MAX_MSG_BYTES` | 1,048,576 | Max size of an encrypted RPC frame (configurable) | +| `HANDSHAKE_TIMEOUT_MS` | 5,000 | Default timeout for completing the handshake | +| `RPC_TIMEOUT_MS` | 30,000 | Default per-call timeout (client side) | +| `SEND_TIMEOUT_MS` | 3,000 | Default outbound-queue deadline for an unsent frame (client side) | +| `MAX_PENDING` | 256 | Default maximum in-flight RPCs per client | +| `MAX_ID_LEN` | 64 | Max request `id` length accepted by the server | +| `PROOF_LEN` | 32 | Length of the HMAC proof in the reply | +| `REPLAY_WINDOW` | 4,096 | Default seen-nonce set capacity (see Replay protection) | +| `KDF_INFO` | UTF-8 bytes of `"saferpc-v1"` | HKDF info parameter for session key | +| `PSK_DERIVE_INFO` | UTF-8 bytes of `"saferpc-session-v1"` | HKDF info for `deriveSessionSecret` helper | +| `TRANSCRIPT_HELLO_MAGIC` | UTF-8 bytes of `"saferpc-hs-hello-v1\0"` (20 bytes) | Domain separation for client transcript | +| `TRANSCRIPT_REPLY_MAGIC` | UTF-8 bytes of `"saferpc-hs-reply-v1\0"` (20 bytes) | Domain separation for server transcript | +| `EMPTY_SECRET` | 32 zero bytes | HKDF salt when no secret is configured (asymmetric-only mode) | ## Frame format @@ -59,6 +63,8 @@ frame := tag (1 byte) || payload (...) Two tag values are defined. Implementations **must** drop frames with any other tag. +All frame-size limits in this document are compared against the **full frame length, tag byte included**: a hello frame is dropped when `len(frame) > MAX_HELLO_BYTES`, an RPC frame when `len(frame) > MAX_MSG_BYTES`. (The effective payload maximum is therefore one byte less than the constant.) Ports must use the same comparison or byte-exact conformance tests at the boundary will disagree. + ### `TAG_HELLO = 0x00` The payload is a msgpack-encoded map. The frame is sent in both handshake directions; the map's shape differs by direction. @@ -85,7 +91,7 @@ The payload is a msgpack-encoded map. The frame is sent in both handshake direct } ``` -A frame whose payload is longer than `MAX_HELLO_BYTES` **must** be dropped without state change. A frame that fails msgpack decoding, or decodes to anything other than a map with the required fields, **must** cause the receiving side to reset its handshake state (and call `onError` if observed by the server). +A frame longer than `MAX_HELLO_BYTES` (tag included) **must** be dropped without state change. A frame that fails msgpack decoding, or decodes to anything other than a map with the required fields, **must** fail the handshake _attempt_ it belongs to (and call `onError` if observed by the server). On the server an invalid hello **must not** disturb an established session — see Re-handshake. ### `TAG_MSG = 0x01` @@ -111,11 +117,11 @@ The handshake is one round-trip initiated by the client, and it is **lazy** - no ```mermaid sequenceDiagram Client->>Server: TAG_HELLO + { pub, nonce, epoch, auth? } - Note right of Server: verify auth (if configured)
derive session key
compute proof
state → pending + Note right of Server: verify auth (if configured)
derive session key
compute proof
install CANDIDATE (live untouched) Server->>Client: TAG_HELLO + { pub, proof, epoch, auth? } Note left of Client: verify auth (if configured)
derive session key
verify proof
state → ready Client->>Server: TAG_MSG + encrypted RPC - Note right of Server: decrypt OK ⇒ state → ready + Note right of Server: decrypt under candidate ⇒ promote
(retire old live key) Server->>Client: TAG_MSG + encrypted response ``` @@ -125,7 +131,7 @@ The client generates: - A fresh X25519 keypair `(c_priv, c_pub)`. - A fresh 32-byte random nonce `c_nonce`. -- The next epoch value (start at 1; increment on every handshake attempt; wrap modulo 2³²). +- The next epoch value: start at 1, increment on every handshake attempt. Epochs are unsigned 32-bit; values outside `0..2^32-1` are invalid on the wire and **must** be rejected. The counter does not wrap — an implementation that exhausts it (2³² handshake attempts in one client lifetime) must fail the handshake with a terminal error rather than reuse epoch values. Recreating the client resets the counter safely: epochs only disambiguate attempts within one client instance. If asymmetric `sign` is configured, the client computes the **hello transcript**: @@ -147,13 +153,17 @@ The client then sends: ### Step 2: server processes hello +The server processes the hello as an **attempt**, on state local to that attempt — a fresh ephemeral pair `(s_priv, s_pub)` generated for this hello. An established session, if any, keeps serving while the attempt runs and is **not** replaced at step 10 — the attempt is installed as a _candidate_ that is promoted only when a frame decrypts under it (step 4, make-before-break). A failure at any step discards the attempt's local state and leaves the established session untouched. + +> **Invariant (load-bearing).** `(s_priv, s_pub)` is generated fresh per attempt and is never held at module/connection scope. A duplicate hello therefore derives a _different_ `raw` and a _different_ candidate key than the live session, so replayed traffic can never decrypt under the candidate. Moving this pair to a shared scope would silently turn the duplicate-hello nuisance below into a full session-traffic replay. See [make-before-break](#re-handshake). + 1. Verify frame length and tag. -2. Decode msgpack, sanitize, check shape. -3. If `verify` is configured: require `auth`, build hello transcript, call `verify(auth, transcript)`. On failure, reset handshake state. +2. Decode msgpack, sanitize, check shape. Validate `epoch` is an integer in `0..2^32-1`. +3. If `verify` is configured: require `auth` (`1..MAX_AUTH_BYTES` bytes), build hello transcript, call `verify(auth, transcript)`. On failure, discard the attempt. 4. Compute ECDH shared secret: `raw = X25519(s_priv, c_pub)`. -5. Call `secret()` if configured. If fewer than `KEY_LEN` bytes, fail. If not configured, use `EMPTY_SECRET`. +5. Call `secret()` if configured. If fewer than `KEY_LEN` bytes, or equal to `EMPTY_SECRET`, fail. If not configured, use `EMPTY_SECRET`. 6. Derive session key: `session_key = HKDF(SHA-256, IKM=raw, salt=psk, info=KDF_INFO, L=KEY_LEN)`. -7. Zero `raw` and PSK bytes. +7. Zero `raw`. **Do not zero or mutate the secret bytes** — the application owns that buffer, and callers legitimately return the same buffer on every handshake (`secret: () => sharedSecret`). 8. Compute proof: `proof = HMAC-SHA-256(session_key, s_pub || c_pub || c_nonce)`. 9. If `sign` is configured, build **reply transcript** and sign it: @@ -166,14 +176,14 @@ reply_transcript := s_pub ``` -10. Set encryptor/decryptor, transition state to `pending`. +10. **Install the candidate atomically**: install `session_key`/decryptor and the verified auth data into the _candidate_ slot, replacing any prior unconfirmed candidate (latest attempt wins). **Do not touch the live session** — its key keeps serving. The candidate is decrypt-only; its encryptor is created on promotion, never before, since the server never encrypts under an unconfirmed key. This install must be a single synchronous block guarded by the attempt's epoch, so a concurrent newer hello cannot interleave. Arm a confirmation timer for the candidate. 11. Send: ``` 0x00 || msgpack({ pub: s_pub, proof: proof, epoch: epoch, auth: signed? }) ``` -The server **does not** transition to `ready` yet. It does so on the first `TAG_MSG` whose Poly1305 tag verifies under the freshly-derived session key, regardless of whether the decrypted payload is a well-formed RPC request. Producing a valid AEAD frame is the implicit proof; the inner shape is checked afterwards and may be silently dropped without rolling state back. +The live session is **not** replaced yet. Replacement happens on the first `TAG_MSG` whose Poly1305 tag verifies under the candidate key, regardless of whether the decrypted payload is a well-formed RPC request. Producing a valid AEAD frame under the candidate is the implicit proof that the counterparty holds the key material; only then is the old live key retired (step 4). The inner shape is checked afterwards and may be silently dropped without rolling state back. ### Step 3: client processes reply @@ -190,11 +200,23 @@ The server **does not** transition to `ready` yet. It does so on the first `TAG_ ### Step 4: first encrypted message -The client encrypts and sends its first RPC request. On the server, successful AEAD verification of the first `TAG_MSG` (Poly1305 tag passes under the freshly-derived session key) is the implicit proof that the client knows the secret, and the server transitions from `pending` to `ready`. The inner RPC payload is validated separately. A junk payload that nonetheless decrypts cleanly still confirms the session; it is just dropped without producing a response. +The client encrypts and sends its first RPC request. On the server, inbound `TAG_MSG` frames are trial-decrypted **live key first, then candidate key**. Successful AEAD verification under the candidate (Poly1305 tag passes) is the implicit proof that the client knows the secret; the server then **promotes** the candidate — retires the old live key, installs the candidate as the new live session (create its encryptor), advances the response-guard epoch, and clears the replay window. A bootstrap (no prior live session) is the same path with an empty live slot. The inner RPC payload is validated separately. A junk payload that nonetheless decrypts cleanly still confirms and promotes the session; it is just dropped without producing a response. + +The response-guard epoch is captured **after** promotion, so the reply to this first confirming frame is not dropped by the guard — the confirming frame is the promoter, not an in-flight leftover from the retired session. ### Re-handshake -A server in **any** state that receives a `TAG_HELLO` resets its handshake state and processes the new hello. The client side does the same on reset. This is how transparent recovery works: a dead session triggers a new hello, the server resets, and the new session is established without application-layer coordination. +A server in **any** state that receives a `TAG_HELLO` opens a new handshake _attempt_ and processes it — this is how transparent recovery works: a client whose session died sends a fresh hello and gets a fresh session without application-layer coordination. + +The established session, if one exists, **must survive until a frame decrypts under the new candidate** — i.e. until the counterparty proves it holds the new key material. A validated attempt installs a candidate (step 10); the live key is retired only at promotion (step 4). An invalid hello — malformed, oversized auth, failed `verify`, bad secret — discards only the attempt and never reaches candidate install. + +**Underlying rule (normative).** A transition that _destroys_ authenticated state must be authenticated at least as strongly as the state it destroys. A hello proves at most the sender's chosen identity; it may _create_ a candidate. Retiring a live session is _destruction_ and must be gated on proof of key possession — a frame that decrypts under the candidate. (This is the same failure family as forged TCP resets and Wi-Fi deauth: an unauthenticated message tearing down an authenticated connection.) + +This make-before-break ordering is load-bearing and closes the displacement hole in **both** modes: neither a byte-for-byte replayed hello nor a well-formed forged hello (secret-only mode) can retire a live session, because neither can produce a frame that decrypts under the fresh-per-attempt candidate key. A duplicate/forged hello can at most create a candidate that expires unconfirmed on its timer. + +Residual (accepted, out of threat model): because the latest hello wins the candidate slot, a _flood_ of hellos can keep overwriting the candidate and starve a legitimate peer's reconnect before its confirming frame lands. This is a denial-of-service concern, explicitly outside saferpc's threat model; rate-limit hellos at the transport layer if it matters for your deployment. A cheap partial mitigation (drop byte-identical duplicate hellos via a small recent-transcript-hash cache before signature verification) is possible but not mandated. + +Each incoming hello **must** invalidate any prior in-flight attempt (bump the attempt counter/epoch before any `await`-equivalent suspension), so stale suspended attempts detect the change and abandon all writes. A separate counter guards the candidate confirmation timer, bumped only when a candidate is installed — so a later hello that bumps the attempt counter but then fails validation cannot disarm an existing candidate's timeout. ## Key derivation @@ -268,6 +290,23 @@ message = sanitize(msgpack_decode(plaintext)) A 24-byte random nonce gives 192 bits of entropy. Collisions are negligible for any realistic message volume. Safe RPC does **not** use a counter. The trade-off: slightly higher nonce size in exchange for stateless encoding and tolerance for out-of-order or duplicated transport delivery. +> **Directionality.** Both directions encrypt under the **same** session key. With random nonces this is safe for confidentiality (no keystream reuse in practice), but it means a captured frame decrypts on _either_ side. The only thing preventing a reflected client request from being accepted by that same client is the message-type check (`t: 1` vs `t: 2`, below). That check is therefore **mandatory, security-relevant, and must run before any other processing of the decrypted payload**. A port that treats it as optional shape validation loses reflection protection entirely. (A future protocol revision may introduce directional keys; any such change bumps the version strings.) + +### Replay protection (seen-nonce set) + +Random nonces make accidental collision negligible but do nothing against deliberate replay: an attacker who can inject into a live channel can re-deliver a captured `TAG_MSG` frame and it will decrypt and execute again. The seen-nonce set narrows this window. + +The **server** (the side that executes requests) keeps a per-session set of the AEAD nonces it has already accepted, with capacity `REPLAY_WINDOW` (default 4,096; `0` disables the mechanism). Exact semantics — all four rules are load-bearing and a port must implement all of them: + +1. **Check before decrypt.** On an inbound `TAG_MSG`, if the 24-byte nonce is in the set, drop the frame silently. (Cheap exact-replay rejection, no AEAD work.) +2. **Insert only after successful Poly1305 verification.** A frame that fails to decrypt must **not** add its nonce to the set. Otherwise an attacker who cannot forge ciphertexts can still flood arbitrary nonces, forcing eviction churn and re-opening the window for entries evicted early. +3. **Bounded, FIFO eviction.** When the set holds `REPLAY_WINDOW` entries, inserting a new nonce evicts the oldest. The honest consequence: a replay older than the last `REPLAY_WINDOW` accepted messages executes again — the window is _narrowed_, not closed. Non-idempotent procedures still want an application-level idempotency key. +4. **Cleared on re-handshake.** A new session key makes old nonces unreplayable (AEAD fails under the new key); keeping them wastes the budget. The set's lifetime is the session key's lifetime. + +The client does not need a seen-nonce set for security: response `id`s are matched against the pending-call table and each `id` is used once, so a replayed response is dropped at the RPC layer. A client-side set is permitted but adds nothing. + +A transport-duplicated frame (duplication is allowed by the channel contract) is absorbed by rule 1 exactly like an attacker replay: the request executes once, the duplicate is dropped. + ## RPC message format After decryption, an RPC message is a msgpack-encoded map. Two kinds. @@ -277,12 +316,18 @@ After decryption, an RPC message is a msgpack-encoded map. Two kinds. ``` { t: 1, - id: string, // non-empty, unique within this client session - p: string, // procedure name + id: string, // non-empty, ≤ MAX_ID_LEN, unique within this client session + p: string, // non-empty procedure name i: any, // input (validated against procedure's .input schema) } ``` +Nuances a port must reproduce: + +- The server **must** drop requests whose `id` is empty or longer than `MAX_ID_LEN` (64), and requests whose `p` is missing, non-string, or empty. Drop means silent drop — no response frame. +- When the caller supplies no input, the `i` key is **omitted entirely**, never encoded as nil. msgpack has no `undefined`; encoding an absent input as nil would make an "optional" input schema on the server observe an explicit null instead of an absent value. An absent key must decode back to the language's absent-value representation. +- The reference client generates `id`s from a monotonically increasing per-client counter. This is not normative — any scheme works — but `id`s **must never be reused** within a client instance: uniqueness is what makes response matching (and the client's replay immunity) sound. + ### Response (server → client) ``` @@ -307,11 +352,15 @@ After decryption, an RPC message is a msgpack-encoded map. Two kinds. The error map's fields: -| Field | Meaning | -|-------|---------| -| `c` | Error code. Strings like `"INPUT_VALIDATION"`, `"NOT_FOUND"`, `"UNAUTHORIZED"`, or any application-defined string. | -| `m` | Human-readable message. Untrusted from the receiver's perspective. | -| `d` | Optional structured data, sanitized before transmission. | +| Field | Meaning | +| ----- | ------------------------------------------------------------------------------------------------------------------ | +| `c` | Error code. Strings like `"INPUT_VALIDATION"`, `"NOT_FOUND"`, `"UNAUTHORIZED"`, or any application-defined string. | +| `m` | Human-readable message. Untrusted from the receiver's perspective. | +| `d` | Optional structured data, sanitized before transmission. | + +The receiving client must treat every error field as hostile and coerce defensively: if `e` is not a map, surface code `"UNKNOWN"` with an empty message; if `c` is not a non-empty string, use `"UNKNOWN"`; if `m` is not a string, use `""`. Never `String()`-coerce arbitrary values into codes — a stringified `undefined`/object makes a misleading code. + +On the server, a handler throwing the implementation's typed RPC error maps to `{ c: code, m: message, d: sanitize(data) }`. Any other thrown value maps to `{ c: "INTERNAL", m: "Internal error", d: null }` — internal error details **must not** leak to the peer. If sanitizing `d` itself fails, the response is not sent at all (the client times out); handler error `data` must be a plain-data tree. Messages with wrong `t`, missing/empty `id`, missing/empty `p`, or any unexpected type **must** be dropped silently. The protocol has no provision for "bad message" responses. Those would be useful only to an attacker enumerating implementation behavior. @@ -319,17 +368,32 @@ Messages with wrong `t`, missing/empty `id`, missing/empty `p`, or any unexpecte ### Server +State is described by two key slots — the **live** session (serves traffic) and +a **candidate** (a validated-but-unconfirmed attempt). `waiting` = neither set, +`pending` = candidate set (live may still be serving), `ready` = live set with +no candidate. + ``` [waiting] - │ receive TAG_HELLO (good) + │ TAG_HELLO → attempt validates (steps 1-9) → install candidate ▼ -[pending] ──── 1st valid TAG_MSG ───────► [ready] - │ ▲ - │ │ receive TAG_HELLO - │ timeout │ - │ error │ - ▼ │ -[waiting] ◄──────┘ (resetHandshake — accept new handshake even from ready) +[pending] ── 1st TAG_MSG decrypts under candidate → promote ──► [ready] + │ │ + │ candidate timeout / attempt error │ TAG_HELLO → validates + │ (candidate dropped; live, if any, untouched) ▼ → install candidate + ▼ [pending] (live still serving) +[waiting or ready] │ +(ready if a live session exists) │ 1st TAG_MSG under candidate + ▼ → promote (retire old live) + [ready] (new session) + +Make-before-break: a hello in ANY state opens an attempt on attempt-local +state and, if it validates, installs a CANDIDATE. The live session (if any) +keeps serving and is retired ONLY when a frame decrypts under the candidate +— proof the counterparty holds the key material. A failed / timed-out attempt +or an unconfirmed candidate that expires changes nothing about the live +session. A byte-for-byte replayed or forged hello can therefore at most create +a candidate that expires; it can never displace the live session. destroy() ⇒ [destroyed], terminal ``` @@ -344,25 +408,39 @@ destroy() ⇒ [destroyed], terminal │ reply OK + proof OK ▼ [ready] - │ call timeout / send error + │ RPCAbortedError(TIMEOUT) — sent-call reply timeout ▼ -[idle] (auto-reset, retry once on next call) +[idle] (auto-reset, NO resend; next call re-handshakes) destroy() ⇒ [closed], terminal ``` -The client uses an **epoch counter** to coordinate concurrent failure-and-retry. When multiple calls fail at once, only the first one increments the epoch and resets; the rest see the new epoch and join the in-progress handshake. +The client uses an **epoch counter** to coordinate concurrent failure-and-reset. When multiple calls fail at once, only the first one increments the epoch and resets; the rest see the new epoch and join the in-progress handshake. + +## Failure handling (no auto-retry) + +As of 0.7.0 the client does **not** auto-retry. When a sent call times out with `RPCAbortedError("TIMEOUT")` on a `ready` session — **and only then**: + +1. If `epoch === sentEpoch` (no other call has already reset), call `reset()`: zero the session key, drop encryptor/decryptor, state ← `idle`. The failed call is **not** resent. +2. The error surfaces to the caller with its typed code. The caller — the only party that knows whether the procedure is idempotent — decides whether to retry. + +**Sent boundary.** A call's retry safety is determined by whether its frame reached a live transport. `channel.send` returning without throwing (sync) or its promise resolving (async) is the sent boundary. Before that point, the core holds the only copy of the frame; terminal events (timeout, abort, destroy, `sendTimeout`) reject with a plain `RPCError` — the frame provably never left and the caller may retry freely. After the sent boundary, terminal events reject with `RPCAbortedError` — outcome unknown. + +**Core outbound queue.** When `channel.send` throws, the frame enters the core outbound queue. A retry tick (every 250 ms, running only while the queue is non-empty) attempts queued frames in order; the first throw stops the pass (head-of-line: if the channel is down, no later frame bypasses a stuck one). A frame transitions to *sent* the first time `send` succeeds; it then waits for reply-or-timeout only. `sendTimeout` (default 3 000 ms, counted from enqueue) is the per-frame deadline; expiry rejects the call with plain `RPCError("CHANNEL")` — the frame provably never left. + +**Why no resend.** A sent-frame `TIMEOUT` does not prove the server did not execute the request, only that no response arrived in time. Resending would silently execute a non-idempotent handler twice. Unsent frames (`RPCError`) are safe to retry; the library defers that choice to the caller in both cases. + +**Why still reset (without resending).** A desynced peer — e.g. a restarted server that silently drops `TAG_MSG` over a synchronous transport — would otherwise wedge every future call. Reset keeps healing lazy: the failed call surfaces its error; the _next_ call re-handshakes. The trade-off: `reset()` nulls `decrypt`, so replies to other in-flight sent calls on the same session are dropped and those calls also fail; size `timeout` to the slowest procedure (default 30 s) and shorten individual calls with `AbortSignal.timeout` — an abort never resets. + +Calls that received a `RemoteRPCError` (the server responded with `ok: false`) are **not** reset or retried. The server is alive and gave a real answer. -## Auto-retry semantics +**Transport death is not a session event (0.7.0).** The session is bound to key material, not to a transport instance. When the transport dies the client does nothing: keys are kept, state stays `ready`, pending calls keep waiting under their own timers. A call's outcome is decided by exactly two events — a reply that decrypts, or the call's own timeout. Transport liveness is the adapter's job (reconnect eagerly, hold the transport open, throw from `send` while down); the core retries frames the adapter rejected until `sendTimeout` expires — a definite `RPCError("CHANNEL")` if the transport stays down. A frame written to a live transport has unknown outcome and is never resent by any layer. If the peer lost its session together with the transport, the call hits `RPCAbortedError("TIMEOUT")` and the reset path above heals lazily. Per-call `signal` aborts are client-local and never touch the session — `ABORTED` is outside the reset trigger set. -When a call fails with a local `TIMEOUT` or send error on a `ready` session: +Local guardrail errors **must not** trigger the reset path either. The `CLIENT` backpressure error (`maxPending` exceeded), id-counter exhaustion, and any other error that does not indicate a dead session leave the session exactly as it was: resetting a healthy session on a guardrail error would tear down the encryption state for every in-flight call, force them into timeout, and re-execute their handlers — double execution with no attacker involved. The reset trigger set is exactly: `RPCAbortedError` with code `TIMEOUT` — a sent call whose reply never arrived. Nothing else. + +Concurrent failures share one re-handshake via the epoch counter, so there are no reset storms. -1. If `epoch === sentEpoch` (no other call has already reset), call `reset()`: zero the session key, drop encryptor/decryptor, state ← `idle`. -2. Call `ensureHandshake()`. If `state === handshaking`, await the existing promise; otherwise start a new handshake. -3. Once `ready`, resend the original request **once**. -4. If that also fails, surface the error. No further retries. -Calls that received a `RemoteRPCError` (the server responded with `ok: false`) are **not** retried. The server is alive and gave a real answer. ## Sanitization @@ -387,7 +465,7 @@ When `auth.verify` is configured on the server, the value it returns is the veri - Stores it on the server session. - Passes it as `{ auth: verified }` to the `context` factory on every request. -- Discards it on any reset (timeout, new hello, destroy). +- Discards it when the session it belongs to ends: replaced by a successful re-handshake (the new session carries the new attempt's verified auth), pending-session timeout, or destroy. ``` server.verify(hello.auth, hello_transcript) @@ -405,20 +483,22 @@ Clients can also configure `verify`. On the client side the return value is unus ## Failure modes -| Failure | Server response | Client response | -|---------|----------------|-----------------| -| Bad frame tag | Drop silently | Drop silently | -| Frame > max size | Drop silently | Drop silently | -| msgpack decode error | Reset, `onError` | Fail handshake | -| Sanitization failure | Reset, `onError` | Fail handshake | -| Bad secret / missing secret bytes | Fail handshake (`HANDSHAKE`), reset | Fail handshake (`HANDSHAKE`) | -| `verify` throws | Fail handshake, reset | Fail handshake | -| `sign` returns invalid payload | Fail handshake | Fail handshake | -| Proof mismatch (client) | — | Fail handshake | -| Poly1305 mismatch (post-handshake) | Drop frame silently | Drop frame silently | -| Stale reply (`epoch` mismatch) | — | Drop reply silently | -| Stale request (after server reset) | Drop response (server-side guard) | Eventually times out, retries | -| RPC handler throws non-`RPCError` | Send `{ c: "INTERNAL", m: "Internal error" }` | Surface as `RemoteRPCError` | +| Failure | Server response | Client response | +| --------------------------------------------- | --------------------------------------------- | --------------------------------------------------------- | +| Bad frame tag | Drop silently | Drop silently | +| Frame > max size | Drop silently | Drop silently | +| msgpack decode error (hello) | Discard attempt, `onError`; session survives | Fail handshake | +| Sanitization failure (hello) | Discard attempt, `onError`; session survives | Fail handshake | +| Bad secret / missing secret bytes | Discard attempt (`HANDSHAKE`), `onError` | Fail handshake (`HANDSHAKE`) | +| `verify` throws | Discard attempt, `onError`; session survives | Fail handshake | +| `sign` returns invalid payload | Discard attempt | Fail handshake | +| Proof mismatch (client) | — | Fail handshake | +| Poly1305 mismatch (post-handshake) | Drop frame silently | Drop frame silently | +| Replayed `TAG_MSG` nonce (within window) | Drop frame silently | Optional (see Replay protection) | +| Stale reply (`epoch` mismatch) | — | Drop reply silently | +| Stale request (after session replaced) | Drop response (epoch guard) | Times out; error surfaces, caller decides (no auto-retry) | +| RPC handler throws non-`RPCError` | Send `{ c: "INTERNAL", m: "Internal error" }` | Surface as `RemoteRPCError` | +| Local guardrail (`maxPending`, id exhaustion) | — | Reject that call only; session untouched, **no retry** | Silent drops are deliberate. Any feedback at the wire level gives an attacker probing material. @@ -441,15 +521,25 @@ A new-language port that ticks every item is conformant: - [ ] Frames are bounded by `MAX_HELLO_BYTES` / `MAX_MSG_BYTES`. - [ ] Hello transcript and reply transcript are built from the exact byte sequences shown. - [ ] Auth is processed **before** any session key is materialized; failed auth never leaks session state. -- [ ] Server transitions to `ready` only on first valid decrypt, not on sending the reply. -- [ ] Epoch counter increments per handshake attempt on both sides and is echoed in the reply. -- [ ] The epoch counter is bumped for **every** incoming hello, including ones that arrive while a previous attempt is still suspended at an `await`. In-flight stale attempts detect themselves via the epoch guard and abandon all writes. -- [ ] Every `await` in the handshake path is followed by an epoch + destroyed guard before any module-level state is written. Module-level publishes happen under a final guard inside a synchronous block. +- [ ] The server's ephemeral pair `(s_priv, s_pub)` is generated **fresh per hello attempt** and never held at module/connection scope. This is load-bearing for make-before-break: it guarantees a duplicate hello derives a different candidate key than the live session, so replayed traffic can never decrypt under the candidate and force a promotion. +- [ ] A validated attempt is installed as a **candidate**, not swapped into the live session. The live key is retired only when a `TAG_MSG` decrypts under the candidate key (make-before-break). Inbound frames are trial-decrypted live-first, then candidate. +- [ ] The response-guard epoch is captured **after** promotion (not at frame arrival), so the reply to the confirming frame is not dropped by the guard. +- [ ] Client epoch increments per handshake attempt (and per session reset) and is echoed verbatim in the reply; it is validated as a uint32 on the wire and never wraps (exhaustion is a terminal client error). The server does NOT bump a single mirror counter — under make-before-break it keeps three internal counters: `attemptEpoch` (per incoming hello; invalidates older attempt coroutines), `candidateEpoch` (per candidate install; guards the confirmation timer), and `epoch` (per promotion; guards TAG_MSG responses). +- [ ] The attempt counter is bumped for **every** incoming hello, including ones that arrive while a previous attempt is still suspended at an `await`. In-flight stale attempts detect themselves via the guard and abandon all writes. +- [ ] Every `await` in the handshake path is followed by an attempt + destroyed guard before any session state is written. The candidate install happens under a final guard inside a single synchronous block. +- [ ] A separate counter guards the candidate confirmation timer, bumped only when a candidate is installed — so a later hello that bumps the attempt counter but then fails validation cannot disarm an existing candidate's timeout. +- [ ] Hello processing runs on **attempt-local** state; an established session keeps serving during the attempt and is retired only on promotion. An invalid hello (malformed, oversized, failed `verify`, bad secret), a byte-for-byte replayed hello, or a well-formed forged hello never disturbs an established session — at most it creates a candidate that expires unconfirmed. - [ ] Secret bytes equal to `EMPTY_SECRET` (32 zero bytes) are rejected at runtime when `auth.secret` is configured. - [ ] The X25519 raw shared secret is zeroed in a try/finally so a thrown `psk()` does not leak it. - [ ] Ephemeral private keys captured for the duration of an `await` are owned by the in-flight attempt (copied, not aliased), so a concurrent reset that zeroes the live buffer does not corrupt the in-flight derivation. -- [ ] Server accepts new hellos in any state (including `ready`); doing so resets the current session before processing. -- [ ] Client auto-retries exactly once per call on `TIMEOUT` / send error; never on `RemoteRPCError`. +- [ ] Server accepts new hellos in any state (including `ready`). +- [ ] Client does **not** auto-retry. On a sent-call reply timeout (`RPCAbortedError("TIMEOUT")`) while `ready` it resets the session (zeros the key, state → `idle`) **without resending**, then surfaces the error; the caller decides whether to retry. It never resets on `RemoteRPCError`, guardrail errors (`maxPending`, counter exhaustion), or unsent-frame failures — those reject the call and leave the session intact. +- [ ] The application's secret buffer is never mutated or zeroed by the protocol; only protocol-owned copies and derived material are zeroed. +- [ ] Request `id` is validated: non-empty string, ≤ `MAX_ID_LEN`; `p` non-empty string; violations are silent drops. +- [ ] Absent call input omits the `i` key entirely (never nil). +- [ ] The `t` type check on decrypted messages runs before any other processing — it is the reflection defense for the shared bidirectional key, not cosmetic validation. +- [ ] Remote error fields are coerced defensively (`c` non-empty string else `UNKNOWN`, `m` string else empty); non-`RPCError` handler throws map to `INTERNAL` with no detail leakage. +- [ ] Seen-nonce set (when enabled): membership check before decrypt, insert only after AEAD verification, FIFO eviction at capacity, cleared on re-handshake. - [ ] Ephemeral keys, raw shared secrets, and session keys are zeroed on reset and destroy. - [ ] The proof is verified in constant time. - [ ] No information is sent back to a peer that sends a malformed frame. @@ -458,4 +548,77 @@ When every item holds and the test vectors below pass, two implementations inter ## Test vectors -The reference implementation's `test/security` and `test/unit` directories contain canonical fixtures: known `(c_priv, c_pub, c_nonce, s_priv, s_pub, secret)` inputs and the resulting `session_key` and `proof`. Use those to validate a port at the byte level before running end-to-end interop tests over a real channel. +Known-answer vectors generated from the reference implementation (pinned by `test/unit/vectors.test.ts`; regenerate with `node scripts/gen-vectors.mjs`). A port that reproduces every value below byte-for-byte derives wire-compatible sessions. All values are lowercase hex. + +### Inputs + +``` +c_priv = 0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20 +s_priv = 4142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f60 +c_nonce = 8182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0 +secret = c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0 +epoch = 1 +``` + +### Derived values + +``` +c_pub = X25519_pub(c_priv) + = 07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c +s_pub = X25519_pub(s_priv) + = 64b101b1d0be5a8704bd078f9895001fc03e8e9f9522f188dd128d9846d48466 +raw_shared = X25519(c_priv, s_pub) = X25519(s_priv, c_pub) + = 26c2c17fdb82161cb21ad16e721315355b64d1763119b10bfc962530dc7cc163 + +session_key = HKDF(SHA-256, ikm=raw_shared, salt=secret, info="saferpc-v1", L=32) + = 26cfff1fd363520e6adc49c5f0647197d6bf84063ba7d977be53abe5a09e4df1 + +# asymmetric-only mode: same inputs, salt = EMPTY_SECRET (32 zero bytes) +session_key_empty_secret + = 09f21f20ea6205029a057330916649c6d92ca421067b2249358a4f7d8d79ba68 + +proof = HMAC-SHA-256(session_key, s_pub || c_pub || c_nonce) + = 1d55f7b3d9eda8cb8a30a269197139afe10fd4557f426698513de175a41cd0b3 +``` + +### Transcripts (epoch = 1) + +``` +hello_transcript = "saferpc-hs-hello-v1\0" || be32(1) || c_pub || c_nonce += 736166657270632d68732d68656c6c6f2d763100 00000001 + 07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c + 8182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0 + +reply_transcript = "saferpc-hs-reply-v1\0" || be32(1) || c_pub || c_nonce || s_pub += 736166657270632d68732d7265706c792d763100 00000001 + 07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c + 8182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0 + 64b101b1d0be5a8704bd078f9895001fc03e8e9f9522f188dd128d9846d48466 +``` + +(Line breaks are for readability; the transcript is one contiguous byte string.) + +### `deriveSessionSecret` helper + +``` +deriveSessionSecret("session-abc123", secret) += HKDF(SHA-256, ikm=secret, salt=utf8("session-abc123"), info="saferpc-session-v1", L=32) += e90487157dafebc492bf80cb1b0dc9818b220ee2fbbce3304ed4fc0a181e02db +``` + +### Encrypted frame + +Request `{ t: 1, id: "1", p: "ping" }` under `session_key` (the PSK-mode key above) with a fixed message nonce: + +``` +msg_nonce = 1112131415161718191a1b1c1d1e1f202122232425262728 +plaintext = msgpack({t:1, id:"1", p:"ping"}) + = 83a17401a26964a131a170a470696e67 +frame = 0x01 || msg_nonce || XSalsa20-Poly1305(session_key, msg_nonce, plaintext) + = 011112131415161718191a1b1c1d1e1f202122232425262728 + d6305197fd685b58024ba4e38d269a78d4afe8373d476fe52d04f1d3ed9aa51e +``` + +A port must decrypt this frame to the plaintext above, and its own encryption of the same plaintext under the same key and nonce must produce the identical frame. (In real operation the nonce is random per message — the fixed nonce exists only for this vector.) + +Two caveats for the frame vector. msgpack map-key order follows encoding order — the vector encodes keys as `t`, `id`, `p`; match that order when reproducing the exact bytes (the protocol itself does not require canonical key order, only this vector does). And byte-equality of ciphertext requires the same AEAD construction: XSalsa20-Poly1305 as in NaCl `secretbox`, no associated data. diff --git a/spec/security.md b/spec/security.md index f2423d4..e222dd4 100644 --- a/spec/security.md +++ b/spec/security.md @@ -1,6 +1,6 @@ # Security -Safe RPC treats the transport as hostile. This page covers what that buys you, how to configure auth so those guarantees hold, and what is *not* covered. Wire-level mechanics (frame layout, handshake steps, state machines, key derivation) live in [Protocol](protocol.md). +Safe RPC treats the transport as hostile. This page covers what that buys you, how to configure auth so those guarantees hold, and what is _not_ covered. Wire-level mechanics (frame layout, handshake steps, state machines, key derivation) live in [Protocol](protocol.md). How well the implementation holds up against this model — what was verified and the honest list of residual risks — lives in [Assessment](assessment.md). ## Threat model @@ -19,7 +19,7 @@ Safe RPC does **not** protect against: ## Why authentication is required -A bare ephemeral X25519 exchange produces a shared key, but says nothing about *who* the key was shared with. An active attacker on the transport can run the exchange separately with each side, hold two different session keys, and sit between the peers rewriting traffic in both directions. Each side sees a clean handshake; neither sees the other. This is the textbook MITM-on-DH attack and the reason `auth` is not optional. +A bare ephemeral X25519 exchange produces a shared key, but says nothing about _who_ the key was shared with. An active attacker on the transport can run the exchange separately with each side, hold two different session keys, and sit between the peers rewriting traffic in both directions. Each side sees a clean handshake; neither sees the other. This is the textbook MITM-on-DH attack and the reason `auth` is not optional. The `auth` callbacks close that gap by binding the ephemeral keys to something the attacker does not have: @@ -50,21 +50,21 @@ When the key is already in memory and the math is synchronous, return `Promise.r ## Security properties -| Property | Mechanism | -|----------|-----------| -| Confidentiality | XSalsa20-Poly1305 AEAD per message | -| Authentication (session) | Secret mixed into HKDF + optional asymmetric signatures | -| Server identity | HMAC proof in handshake reply (+ optional signature) | -| Client identity | Implicit (wrong PSK ⇒ invalid ciphertext) + optional signature | -| Forward secrecy | Fresh ephemeral X25519 keys per session | -| Replay across handshakes | Random nonce + epoch counter + transcript-bound signatures | -| Replay across peers | Domain-separated transcript prefixes | -| Replay within a session | Random 24-byte nonces per message (probabilistic) | -| Stale responses | Epoch counter echoed in reply | -| Prototype pollution | `sanitize()` strips `__proto__`, `constructor`, `prototype` | -| Type confusion | msgpack extension types disabled (including Timestamp); inbound `bin` fields require exact `Uint8Array` prototype | -| Memory hygiene | Ephemeral keys zeroed on reset/destroy | -| Plaintext lifetime | Returned `Uint8Array` fields alias the encrypted payload (msgpack `bin` is zero-copy); copy them out if you need to zero them yourself | +| Property | Mechanism | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| Confidentiality | XSalsa20-Poly1305 AEAD per message | +| Authentication (session) | Secret mixed into HKDF + optional asymmetric signatures | +| Server identity | HMAC proof in handshake reply (+ optional signature) | +| Client identity | Implicit (wrong PSK ⇒ invalid ciphertext) + optional signature | +| Forward secrecy | Fresh ephemeral X25519 keys per session | +| Replay across handshakes | Random nonce + epoch counter + transcript-bound signatures | +| Replay across peers | Domain-separated transcript prefixes | +| Replay within a session | Random 24-byte nonces + bounded seen-nonce set (server, default 4096) | +| Stale responses | Epoch counter echoed in reply | +| Prototype pollution | `sanitize()` strips `__proto__`, `constructor`, `prototype` | +| Type confusion | msgpack extension types disabled (including Timestamp); inbound `bin` fields require exact `Uint8Array` prototype | +| Memory hygiene | Ephemeral keys zeroed on reset/destroy | +| Plaintext lifetime | Returned `Uint8Array` fields alias the encrypted payload (msgpack `bin` is zero-copy); copy them out if you need to zero them yourself | ## Authentication modes @@ -113,19 +113,19 @@ auth: { } ``` -Use when you want session binding *and* identity proof. An attacker must now compromise two independent things (the derivation secret and the device key) and still cannot read past sessions because of forward secrecy. +Use when you want session binding _and_ identity proof. An attacker must now compromise two independent things (the derivation secret and the device key) and still cannot read past sessions because of forward secrecy. ### Comparison -| Property | Session-derived secret | Asymmetric | -|----------|----------------------|------------| -| Identity granularity | Per session | Per key/device | -| Revocation | Rotate root secret (affects all) | Revoke individual keys | -| Compromise blast radius | All sessions sharing the root | The compromised device only | -| Forward secrecy | Ephemeral ECDH | Ephemeral ECDH | -| Replay protection | Epoch + nonce + key binding | Transcript bound | -| Cost | Low (HMAC only) | Higher (signature ops) | -| Complexity | Simple | More moving parts | +| Property | Session-derived secret | Asymmetric | +| ----------------------- | -------------------------------- | --------------------------- | +| Identity granularity | Per session | Per key/device | +| Revocation | Rotate root secret (affects all) | Revoke individual keys | +| Compromise blast radius | All sessions sharing the root | The compromised device only | +| Forward secrecy | Ephemeral ECDH | Ephemeral ECDH | +| Replay protection | Epoch + nonce + key binding | Transcript bound | +| Cost | Low (HMAC only) | Higher (signature ops) | +| Complexity | Simple | More moving parts | Forward secrecy comes from the ephemeral X25519 exchange in either mode. Even if a long-term secret leaks, past session ciphertexts remain unreadable. The ephemeral private keys were zeroed when the session ended. @@ -214,6 +214,35 @@ auth: { The unsafe list shares one pattern: the attacker can reproduce the derivation, either because the input is guessable or because the secret material lives in the wrong place. +### The secret is a key, not a password + +The `secret` **must** be a high-entropy cryptographic key (e.g. `randomBytes(32)` from a CSPRNG), not a password or passphrase. The 32-byte minimum length is a floor, not a guarantee of strength: 32 bytes of a human-chosen or dictionary-derived value still has only password-level entropy. + +The reason is a proof oracle inherent to the handshake. On any well-formed hello the server derives `session_key = HKDF(raw, salt=secret)` and returns `proof = HMAC(session_key, s_pub‖c_pub‖c_nonce)` — before the client has proven anything. An attacker who sends their own hello with an ephemeral pair they generated knows `raw` (X25519 is symmetric: `X25519(attacker_priv, s_pub) == X25519(s_priv, attacker_pub)`), and receives `s_pub` and `proof`. That leaves the secret as the only unknown in the proof, so the attacker can grind candidate secrets **offline**, at billions of guesses per second, with no further traffic to the server: + +``` +for guess in candidates: # offline, no network + k = HKDF(raw, salt=guess) + if HMAC(k, s_pub‖c_pub‖c_nonce) == proof: # found it +``` + +Against a random 32-byte key this is 2²⁵⁶ work — infeasible. Against a password-derived secret it is a fast dictionary attack. This is a property of the scheme (PSK as HKDF salt with a server-first proof), not a fixable bug; defending weak passwords would require a PAKE, which saferpc is not. + +```typescript +// ✅ Real random key from a CSPRNG +import { randomBytes } from "@noble/ciphers/utils.js"; +const key = randomBytes(32); +auth: { secret: () => key } + +// ❌ Password / passphrase used directly — offline-bruteforceable via the proof +auth: { secret: () => new TextEncoder().encode("correct horse battery staple") } + +// ⚠️ If you MUST start from a human password, stretch it with a slow KDF first +// (scrypt / argon2) — this raises the per-guess cost but does not make a +// weak password strong; prefer a real random key wherever possible. +auth: { secret: async () => await scrypt(password, salt, { N: 2**17, r: 8, p: 1, dkLen: 32 }) } +``` + ## Built-in signature helpers Safe RPC ships ready-made helpers for the common cases. Each one binds its proof to the handshake transcript that Safe RPC passes in. @@ -257,7 +286,7 @@ Uses `@noble/curves` so it works in every JS runtime. No dependency on WebCrypto ```typescript const clientAuth = createECDSAClientAuth({ - privateKey: ecdsaPrivateKey, // CryptoKey (can be non-extractable) + privateKey: ecdsaPrivateKey, // CryptoKey (can be non-extractable) identifier: "device-123", }); @@ -315,30 +344,36 @@ The client embeds `{ primary, secondary }`: two pre-encoded sub-payloads. ## Replay within a session -Safe RPC uses random 24-byte nonces (not counters) for XSalsa20-Poly1305. The collision probability is negligible. But **a captured ciphertext can be replayed by an attacker who can inject into a live channel**. The replayed message will decrypt and execute again. +Safe RPC uses random 24-byte nonces (not counters) for XSalsa20-Poly1305. The collision probability is negligible. A captured ciphertext could otherwise be replayed by an attacker who can inject into a live channel, and the replayed message would decrypt and execute again. -For non-idempotent operations, add an idempotency key inside the procedure input, or keep a request-ID set on the server keyed by the verified principal. +As of 0.7.0 the server keeps a **bounded seen-nonce set** per session (`replayWindow`, default 4096): it records the nonce of every frame that passes Poly1305 and silently drops any later frame carrying a nonce it has already seen. This closes the replay window for the last `replayWindow` messages of a session, with no wire change and no ordering requirement (so lossy / reordering transports stay supported). The set is cleared on every re-handshake, and only the server needs it — the client already matches responses to a monotonic request `id` that is never reused. -This is the only known replay window in the protocol. A counter-based scheme would close it, but it would also require strict transport ordering, and several supported transports (BroadcastChannel, lossy WebRTC, multi-path links) cannot promise that. +The window is **narrowed to N, not closed**: a replay older than the last `replayWindow` accepted messages still executes. For non-idempotent operations on long-lived sessions, still add an idempotency key inside the procedure input, or keep a request-ID set keyed by the verified principal. Set `replayWindow: 0` to disable the defense. Counter-based nonces would close the window fully but require strict transport ordering and directional keys (keystream reuse otherwise on the single shared key); that is deferred to a future protocol version. ## Recommended configurations **Public web app (browser ↔ server):** asymmetric auth. No shared secrets in the bundle. ```typescript -auth: { sign: async (t) => signWithSessionJWT(t) } +auth: { + sign: async (t) => signWithSessionJWT(t); +} ``` **Mobile app ↔ backend:** device certificates or platform attestation. ```typescript -auth: { sign: async (t) => getDeviceAttestation(t) } +auth: { + sign: async (t) => getDeviceAttestation(t); +} ``` **Microservices (server ↔ server):** session-derived secret from a service-mesh identity. ```typescript -auth: { secret: async () => deriveSessionSecret(await serviceToken(), clusterSecret) } +auth: { + secret: async () => deriveSessionSecret(await serviceToken(), clusterSecret); +} ``` **High-security environment:** both secret and asymmetric, with hardware key storage on at least one side. @@ -353,13 +388,13 @@ auth: { ## Constants and limits -| Constant | Value | Notes | -|----------|-------|-------| -| `NONCE_LEN` | 24 | XSalsa20-Poly1305 per-message nonce | -| `KEY_LEN` | 32 | Symmetric key, X25519 pub/priv, and the client hello nonce | -| `MAX_HELLO_BYTES` | 65,536 | Sized for typical signature payloads | -| `MAX_AUTH_BYTES` | 32,768 | Hard cap on `auth` payload inside a hello/reply | -| `MAX_MSG_BYTES` | 1,048,576 | Per encrypted RPC frame (configurable) | -| `HANDSHAKE_TIMEOUT` | 5,000 ms | Default | -| Secret minimum | 32 bytes | Validated when `secret()` returns | -| Encryption nonce | 24 bytes | Random per message | +| Constant | Value | Notes | +| ------------------- | --------- | ---------------------------------------------------------- | +| `NONCE_LEN` | 24 | XSalsa20-Poly1305 per-message nonce | +| `KEY_LEN` | 32 | Symmetric key, X25519 pub/priv, and the client hello nonce | +| `MAX_HELLO_BYTES` | 65,536 | Sized for typical signature payloads | +| `MAX_AUTH_BYTES` | 32,768 | Hard cap on `auth` payload inside a hello/reply | +| `MAX_MSG_BYTES` | 1,048,576 | Per encrypted RPC frame (configurable) | +| `HANDSHAKE_TIMEOUT` | 5,000 ms | Default | +| Secret minimum | 32 bytes | Validated when `secret()` returns | +| Encryption nonce | 24 bytes | Random per message | diff --git a/src/channels/index.ts b/src/channels/index.ts new file mode 100644 index 0000000..2995325 --- /dev/null +++ b/src/channels/index.ts @@ -0,0 +1,6 @@ +export { + wsChannel, + socketChannel, + type WsChannelOptions, + type WebSocketLike, +} from "./ws.ts"; diff --git a/src/channels/ws.ts b/src/channels/ws.ts new file mode 100644 index 0000000..539ec36 --- /dev/null +++ b/src/channels/ws.ts @@ -0,0 +1,263 @@ +/** + * drpc/channels/ws — WebSocket channel adapters. + * + * Two adapters, one contract (see `Channel` in common.ts): + * + * - `wsChannel(source)` — reconnecting client adapter. OWNS the socket + * lifecycle: when the socket closes it immediately creates a new one and + * keeps retrying (exponential backoff, forever) until `close()`. While the + * transport is down, `send` throws synchronously — the core outbound queue + * owns retry; the adapter's job is availability, not delivery bookkeeping. + * `onDown`/`onUp` hooks report transitions for observability only. + * + * - `socketChannel(ws)` — plain single-socket adapter, no lifecycle + * ownership. This is the server-side wrapper (a server cannot reconnect a + * client's socket) and the choice when the caller manages the socket. + * `send` checks `readyState` and throws when not OPEN (browser CLOSED + * silently drops — that silent drop is the trap this check closes). + */ + +import type { Channel } from "../common.ts"; + +/** WebSocket.readyState OPEN — identical in the DOM and the `ws` package. */ +const WS_OPEN = 1; + +/** + * Minimal structural WebSocket. Covers the browser/Node global `WebSocket` + * and the `ws` package without depending on DOM types. + */ +export interface WebSocketLike { + binaryType: string; + readyState: number; + send(data: Uint8Array): void; + close(code?: number, reason?: string): void; + addEventListener(type: string, listener: (ev: never) => void): void; + removeEventListener(type: string, listener: (ev: never) => void): void; +} + +export interface WsChannelOptions { + /** + * Reconnect backoff. The first retry is immediate; subsequent retries are + * exponential from `backoffMin` (default 250 ms) to `backoffMax` + * (default 5000 ms) with full jitter. Retries forever until `close()`. + */ + backoffMin?: number; + backoffMax?: number; + /** Observability only — never affects behavior. */ + onDown?: (err?: unknown) => void; + onUp?: () => void; +} + +interface MessageEventLike { + data?: unknown; +} + +function toBytes(data: unknown): Uint8Array | null { + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (data instanceof Uint8Array) return data; // includes Node Buffer + return null; +} + +/** + * Reconnecting WebSocket channel (client side). + * + * @param source a URL string (uses `globalThis.WebSocket`) or a factory + * returning a fresh socket per connection attempt (use this with the `ws` + * package or custom construction). A factory throw on the FIRST attempt + * propagates to the caller (misconfiguration surfaces immediately); later + * throws are treated as a failed attempt and retried with backoff. + */ +export function wsChannel( + source: string | (() => WebSocketLike), + opts: WsChannelOptions = {}, +): Channel & { close: () => void } { + const backoffMin = opts.backoffMin !== undefined ? opts.backoffMin : 250; + const backoffMax = opts.backoffMax !== undefined ? opts.backoffMax : 5000; + + const factory: () => WebSocketLike = + typeof source === "function" + ? source + : function fromUrl() { + const Ctor = ( + globalThis as { WebSocket?: new (url: string) => WebSocketLike } + ).WebSocket; + if (typeof Ctor !== "function") { + throw new TypeError( + "wsChannel: no global WebSocket constructor; pass a factory", + ); + } + return new Ctor(source); + }; + + const callbacks = new Set<(data: Uint8Array) => void>(); + let sock: WebSocketLike | null = null; + let closed = false; + /** True after onOpen (socket usable) until the next down event. */ + let up = false; + /** Consecutive failed attempts since the socket was last open. */ + let attempts = 0; + let retryTimer: ReturnType | null = null; + + function scheduleReconnect(): void { + if (closed || retryTimer !== null) return; + const cap = Math.min( + backoffMax, + backoffMin * 2 ** Math.max(0, attempts - 1), + ); + const delay = attempts === 0 ? 0 : Math.random() * cap; + attempts++; + retryTimer = setTimeout(function onRetry() { + retryTimer = null; + connect(false); + }, delay); + } + + function connect(initial: boolean): void { + if (closed) return; + let s: WebSocketLike; + try { + s = factory(); + } catch (err: unknown) { + if (initial) throw err; // bad URL / misconfig — surface immediately + scheduleReconnect(); + return; + } + sock = s; + try { + s.binaryType = "arraybuffer"; + } catch { + // some implementations restrict when binaryType may be set + } + + let lastErr: unknown = undefined; + + function onMessage(ev: MessageEventLike): void { + const bytes = toBytes( + ev !== null && typeof ev === "object" ? ev.data : null, + ); + if (bytes === null) return; + for (const cb of callbacks) cb(bytes); + } + function onError(ev: unknown): void { + // Recorded for onDown; the close event drives the actual transition. + // Registering a listener also keeps the `ws` package from throwing + // on an unhandled 'error' event. + lastErr = ev; + } + function onOpen(): void { + if (closed || s !== sock) return; + attempts = 0; + up = true; + if (opts.onUp !== undefined) { + try { + opts.onUp(); + } catch { + // observability hook must not break the channel + } + } + } + function onClose(): void { + s.removeEventListener("message", onMessage as (ev: never) => void); + s.removeEventListener("error", onError as (ev: never) => void); + s.removeEventListener("open", onOpen as (ev: never) => void); + s.removeEventListener("close", onClose as (ev: never) => void); + if (closed || s !== sock) return; + sock = null; + if (up) { + up = false; + if (opts.onDown !== undefined) { + try { + opts.onDown(lastErr); + } catch { + // observability hook must not break the channel + } + } + } + scheduleReconnect(); + } + + s.addEventListener("message", onMessage as (ev: never) => void); + s.addEventListener("error", onError as (ev: never) => void); + s.addEventListener("open", onOpen as (ev: never) => void); + s.addEventListener("close", onClose as (ev: never) => void); + } + + connect(true); + + return { + send(data: Uint8Array): void { + if (closed) { + throw new Error("wsChannel: channel closed"); + } + const s = sock; + if (s === null || !up || s.readyState !== WS_OPEN) { + throw new Error("wsChannel: no open socket"); + } + s.send(data); // a sync throw here is a real send failure — propagate + }, + receive(cb: (data: Uint8Array) => void): () => void { + callbacks.add(cb); + return function unsubscribe() { + callbacks.delete(cb); + }; + }, + close(): void { + if (closed) return; + closed = true; + if (retryTimer !== null) { + clearTimeout(retryTimer); + retryTimer = null; + } + callbacks.clear(); + const s = sock; + sock = null; + up = false; + if (s !== null) { + try { + s.close(); + } catch { + // already dead + } + } + }, + }; +} + +/** + * Plain single-socket channel. No reconnect — the caller owns the socket + * lifecycle. Use on the server side + * (`wss.on("connection", sock => server(router, socketChannel(sock), ...))`, + * with `sock.on("close", destroy)`) or anywhere the socket is managed + * externally. + * + * `send` checks `readyState` and throws when not OPEN (browser CLOSED silently + * drops — that silent drop is the trap this check closes). + */ +export function socketChannel(ws: WebSocketLike): Channel { + try { + ws.binaryType = "arraybuffer"; + } catch { + // some implementations restrict when binaryType may be set + } + return { + send(data: Uint8Array): void { + if (ws.readyState !== WS_OPEN) { + throw new Error("socketChannel: socket not open"); + } + ws.send(data); + }, + receive(cb: (data: Uint8Array) => void): () => void { + function handler(ev: MessageEventLike): void { + const bytes = toBytes( + ev !== null && typeof ev === "object" ? ev.data : null, + ); + if (bytes === null) return; + cb(bytes); + } + ws.addEventListener("message", handler as (ev: never) => void); + return function unsubscribe() { + ws.removeEventListener("message", handler as (ev: never) => void); + }; + }, + }; +} diff --git a/src/client.ts b/src/client.ts index f296ed3..be5474d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,10 +1,23 @@ /** - * drpc/client — Lazy RPC client with auto-retry + * drpc/client — Lazy RPC client (no auto-retry) * - * LIFECYCLE: Handshake triggers lazily on first RPC call. On session - * failure (timeout/send error), resets and retries once with a fresh - * handshake — transparent to the caller. Concurrent calls coordinate - * via epoch to avoid redundant resets. + * LIFECYCLE: Handshake triggers lazily on first RPC call. The session + * auto-resets only on RPCAbortedError(TIMEOUT) — a sent request that got + * no reply — but the failed call is NOT resent; the error surfaces to the + * caller, who alone knows whether the procedure is idempotent. The reset + * only ensures the NEXT call lazily re-handshakes. Concurrent calls + * coordinate via epoch to avoid redundant resets. + * + * Transport death is NOT a session event: the session is bound to key + * material, not to a transport instance. Channel liveness (reconnect) is + * the adapter's job — see the Channel jsdoc in common.ts and the shipped + * adapters in channels/. Delivery bookkeeping is OURS: a frame whose + * `channel.send` throws enters the core outbound queue and is retried + * until `sendTimeout`; the sent boundary (send returned / promise + * resolved) decides every rejection's class — `RPCAbortedError` = the + * request left, outcome UNKNOWN; plain local `RPCError` = it provably + * never left, safe to resend. Per-call AbortSignal cancels waiting + * without touching the session. */ import { randomBytes } from "@noble/ciphers/utils.js"; @@ -36,6 +49,7 @@ import { buildHelloTranscript, buildReplyTranscript, RPCError, + RPCAbortedError, type Router, type Procedure, type Channel, @@ -46,7 +60,11 @@ import { const PROOF_LEN = 32; const MAX_PENDING = 256; -const DEFAULT_TIMEOUT = 10_000; +const DEFAULT_TIMEOUT = 30_000; +const DEFAULT_SEND_TIMEOUT = 3_000; +// Outbound retry tick. Internal, not a caller lever — `sendTimeout` is the +// contract; the tick only bounds how fast a revived channel is noticed. +const SEND_RETRY_MS = 250; const MAX_KNOWN_PROCEDURES = 1024; // ─── Client types ───────────────────────────────────────── @@ -63,6 +81,22 @@ export class RemoteRPCError extends RPCError { } } +/** + * Per-call options, fetch-style. Passed as the optional second argument of + * every generated method. + */ +export interface CallOptions { + /** + * Abort THIS call (code `ABORTED`, the signal's reason on `.cause`). + * Client-local: the session is never touched; a shared in-progress + * handshake continues for other callers. The rejection's class tells + * you whether the request had already left: `RPCAbortedError` = sent, + * outcome on the server UNKNOWN; plain `RPCError` = provably never + * sent, safe to resend. + */ + signal?: AbortSignal; +} + /** * A procedure's call signature. The input argument is optional when it * isn't actually required to call the procedure — no `.input()` schema was @@ -71,10 +105,10 @@ export class RemoteRPCError extends RPCError { * Otherwise the argument is mandatory. */ type ClientMethod = unknown extends TInput - ? (input?: TInput) => Promise + ? (input?: TInput, opts?: CallOptions) => Promise : undefined extends TInput - ? (input?: TInput) => Promise - : (input: TInput) => Promise; + ? (input?: TInput, opts?: CallOptions) => Promise + : (input: TInput, opts?: CallOptions) => Promise; /** * The caller-facing API for a router, inferred end-to-end. Each procedure @@ -91,7 +125,7 @@ export type Client = { unknown > ? ClientMethod - : (input?: unknown) => Promise; + : (input?: unknown, opts?: CallOptions) => Promise; }; export interface ClientOptions { @@ -100,10 +134,25 @@ export interface ClientOptions { * auth (`sign`/`verify`) MUST be configured. */ auth: AuthOptions; - /** Per-RPC-call timeout. Default: 10000ms. */ + /** + * Per-RPC-call timeout, client-wide. Default: 30000ms. Set it generously + * — it is the safety net that heals a dead session (a TIMEOUT resets and + * the next call re-handshakes). For a SHORTER budget on a single call, + * pass `{ signal: AbortSignal.timeout(ms) }` — that rejects with ABORTED + * and does not touch the session. + */ timeout?: number; /** Max concurrent pending RPC calls. Default: 256. */ maxPending?: number; + /** + * How long (ms) a frame may wait for a live channel before the call + * fails with a definite never-sent error (plain `RPCError("CHANNEL")`). + * A `send` that throws does not fail the call — the frame enters the + * core outbound queue and is retried until this expires. An unsent + * frame always fails with the definite `CHANNEL` code — even when the + * global `timeout` fires first. Default: 3000ms. + */ + sendTimeout?: number; /** * Max time (ms) to complete the handshake from when the client hello * is sent. Triggered lazily by the first RPC call, or on retry after @@ -118,7 +167,10 @@ export interface ClientOptions { export function client( channel: Channel, opts: ClientOptions, -): { api: Client; destroy: () => void } { +): { + api: Client; + destroy: () => void; +} { if (typeof channel.send !== "function") { throw new TypeError("client() channel.send must be a function"); } @@ -129,8 +181,24 @@ export function client( validateAuthConfig(opts.auth); const auth = opts.auth; const timeout = opts.timeout !== undefined ? opts.timeout : DEFAULT_TIMEOUT; + if ( + typeof timeout !== "number" || + !Number.isFinite(timeout) || + timeout <= 0 + ) { + throw new TypeError("client() timeout must be a number > 0 ms"); + } const maxPending = opts.maxPending !== undefined ? opts.maxPending : MAX_PENDING; + const sendTimeout = + opts.sendTimeout !== undefined ? opts.sendTimeout : DEFAULT_SEND_TIMEOUT; + if ( + typeof sendTimeout !== "number" || + !Number.isFinite(sendTimeout) || + sendTimeout < 0 + ) { + throw new TypeError("client() sendTimeout must be a number ≥ 0 ms"); + } const hsTimeout = opts.handshakeTimeout !== undefined ? opts.handshakeTimeout @@ -152,10 +220,13 @@ export function client( // ready: session key established. RPC calls go through. // closed: destroyed. All calls throw. // - // RETRY: On handshake failure, state → idle. Next call retries. - // AUTO-RESET: On RPC timeout or send error while ready, zeros crypto - // and goes idle. Pending calls keep their timers — they retry - // individually when they time out. Epoch prevents redundant resets. + // On handshake failure, state → idle; the next call re-handshakes. + // AUTO-RESET: Only an RPCAbortedError(TIMEOUT) — a sent request with no + // reply — while ready zeros crypto and goes idle WITHOUT resending. + // Plain RPCError (frame never left: CHANNEL, unsent TIMEOUT) never + // resets — transport failure is not a session event. Other pending + // calls keep their timers; the next call re-handshakes lazily. Epoch + // prevents redundant resets. Guardrail (CLIENT) errors never reset. let state: "idle" | "handshaking" | "ready" | "closed" = "idle"; // Ephemeral keys — regenerated per handshake attempt @@ -175,17 +246,233 @@ export function client( let handshakeReject: ((err: unknown) => void) | null = null; let hsTimer: ReturnType | null = null; - // Pending RPC responses - const pending = new Map< - string, - { - resolve: (v: unknown) => void; - reject: (e: unknown) => void; - timer: ReturnType; - } - >(); + // Pending RPC responses. `sent` is the wire boundary: true once + // `channel.send` returned without throwing (or its promise was handed + // off — optimistic, see enqueue comment). It decides the class of every + // terminal rejection: sent → RPCAbortedError (outcome UNKNOWN), unsent → + // plain RPCError (provably never left). + interface PendingEntry { + resolve: (v: unknown) => void; + reject: (e: unknown) => void; + timer: ReturnType; + sent: boolean; + } + const pending = new Map(); let counter = 0; + // ── Outbound queue: frames whose send failed, retried until sendTimeout ─ + // The channel contract (common.ts) forbids adapter queues — the core is + // the only owner of undelivered frames, so "never left" is a fact it + // knows, not an inference. Frames are already encrypted; `epoch` stales + // them on reset (ciphertext under a zeroed key can never succeed). + type OutboundEntry = + | { kind: "hello"; frame: Uint8Array; epoch: number } + | { + kind: "msg"; + frame: Uint8Array; + id: string; + prop: string; + epoch: number; + expiresAt: number; + cause?: unknown; + }; + const outbound: OutboundEntry[] = []; + let flushTimer: ReturnType | null = null; + // True while an async `send` of a queued frame is unresolved — blocks + // concurrent flush passes so order is preserved. + let flushInflight = false; + + function isThenable(v: unknown): v is Promise { + return ( + v !== null && + typeof v === "object" && + typeof (v as { then?: unknown }).then === "function" + ); + } + + function startFlushTimer(): void { + if (flushTimer === null) { + flushTimer = setInterval(flushOutbound, SEND_RETRY_MS); + } + } + + function stopFlushTimerIfIdle(): void { + if (flushTimer !== null && outbound.length === 0 && !flushInflight) { + clearInterval(flushTimer); + flushTimer = null; + } + } + + function enqueueMsg( + id: string, + prop: string, + frame: Uint8Array, + cause?: unknown, + ): void { + outbound.push({ + kind: "msg", + frame, + id, + prop, + epoch, + expiresAt: Date.now() + sendTimeout, + cause, + }); + startFlushTimer(); + } + + function removeOutboundMsg( + id: string, + ): (OutboundEntry & { kind: "msg" }) | undefined { + let removed: (OutboundEntry & { kind: "msg" }) | undefined; + for (let i = 0; i < outbound.length; i++) { + const e = outbound[i]; + if (e !== undefined && e.kind === "msg" && e.id === id) { + outbound.splice(i, 1); + removed = e; + break; + } + } + stopFlushTimerIfIdle(); + return removed; + } + + // Reject a still-queued call with a plain (never-left) error and drop + // its frame. Used by expiry, reset staling, and destroy. + function failQueued(e: OutboundEntry & { kind: "msg" }, err: RPCError): void { + const p = pending.get(e.id); + if (p === undefined) return; + pending.delete(e.id); + clearTimeout(p.timer); + p.reject(err); + } + + // Drop entries that can no longer be sent meaningfully: + // • hellos of a dead attempt (state/epoch mismatch) — silently; this is + // what revokes a stale hello after handshakeTimeout, no residual flush; + // • msgs whose call already settled — silently; + // • msgs staled by a reset (epoch mismatch) — plain CHANNEL (belt for + // reset()'s own purge); + // • msgs past their sendTimeout — plain CHANNEL, the definite failure. + function dropStaleAndExpired(): void { + const now = Date.now(); + for (let i = outbound.length - 1; i >= 0; i--) { + const e = outbound[i]; + if (e === undefined) continue; + if (e.kind === "hello") { + if (state !== "handshaking" || epoch !== e.epoch) { + outbound.splice(i, 1); + } + continue; + } + const p = pending.get(e.id); + if (p === undefined || p.sent) { + outbound.splice(i, 1); + continue; + } + if (epoch !== e.epoch) { + outbound.splice(i, 1); + failQueued( + e, + new RPCError( + "CHANNEL", + "Session reset before send: " + e.prop, + undefined, + { cause: e.cause }, + ), + ); + continue; + } + if (now >= e.expiresAt) { + outbound.splice(i, 1); + failQueued( + e, + new RPCError( + "CHANNEL", + "Not sent within sendTimeout: " + e.prop, + undefined, + { cause: e.cause }, + ), + ); + } + } + } + + function markSent(e: OutboundEntry): void { + if (e.kind !== "msg") return; + const p = pending.get(e.id); + if (p !== undefined) p.sent = true; + } + + function flushOutbound(): void { + if (flushInflight) return; + dropStaleAndExpired(); + while (outbound.length > 0) { + const e = outbound[0]; + if (e === undefined) break; + let res: unknown; + try { + res = channel.send(e.frame) as unknown; + } catch (err: unknown) { + // Channel still down — head-of-line: if it can't take this frame + // it can't take the next. Keep the freshest cause for diagnostics. + if (e.kind === "msg") e.cause = err; + break; + } + if (isThenable(res)) { + // Optimistic sent: an unconfirmed frame counts as "left" — a false + // "unknown outcome" is safe, a false "never left" is not. Rolled + // back on rejection. + outbound.shift(); + markSent(e); + flushInflight = true; + res.then( + function onFlushSent() { + flushInflight = false; + flushOutbound(); + }, + function onFlushFail(err: unknown) { + flushInflight = false; + if (e.kind === "msg") { + const p = pending.get(e.id); + if (p !== undefined && p.sent) { + p.sent = false; + if (state === "ready" && epoch === e.epoch) { + e.cause = err; + outbound.unshift(e); + startFlushTimer(); + } else { + // The session was reset (or replaced) while this frame's + // async send was in flight. The rejection proves it never + // left, and its ciphertext is under the zeroed key — it + // can never succeed. Fail NOW (reset invalidates the + // queue); resurrecting it would retry dead ciphertext. + failQueued( + e, + new RPCError( + "CHANNEL", + "Session reset before send: " + e.prop, + undefined, + { cause: err }, + ), + ); + } + } + } else if (state === "handshaking" && epoch === e.epoch) { + outbound.unshift(e); + startFlushTimer(); + } + stopFlushTimerIfIdle(); + }, + ); + return; + } + outbound.shift(); + markSent(e); + } + stopFlushTimerIfIdle(); + } + const knownProcedures = new Set(); function clearHsTimer(): void { @@ -303,9 +590,16 @@ export function client( zero(helloPayload); try { await channel.send(hello); - } catch (err: unknown) { + } catch { if (state !== "handshaking" || epoch !== currentEpoch) return; - failHandshake(err); + // Channel down — not a handshake failure. Queue the hello and let + // the flush tick retry; the attempt stays bounded by hsTimer. If + // the attempt dies first, the queued hello is revoked by the + // state/epoch check in dropStaleAndExpired — it never flushes + // late. The send error is intentionally not surfaced: + // transport-down is the queue's normal input, not a failure. + outbound.push({ kind: "hello", frame: hello, epoch: currentEpoch }); + startFlushTimer(); } })().catch(function onProduceError(err: unknown) { if (state !== "handshaking" || epoch !== currentEpoch) return; @@ -475,7 +769,13 @@ export function client( zero(nonce); } })().catch(function onReplyError(err: unknown) { - if (state === "closed" || epoch !== currentEpoch) return; + // Only fail the handshake if we're STILL actively handshaking this + // attempt. A reply coroutine that rejects after the attempt already + // completed (state "ready") or was reset ("idle") is a stale or + // MITM-injected frame carrying the current epoch — drop it silently + // instead of tearing down the session a concurrent valid reply just + // established. (Client-side analog of the server's D1 deferred reset.) + if (state !== "handshaking" || epoch !== currentEpoch) return; failHandshake(err); }); return; @@ -531,13 +831,59 @@ export function client( } }); + // ── Per-call abort ─────────────────────────────────────── + + function abortError(prop: string, reason: unknown): RPCError { + return new RPCError("ABORTED", "Call aborted: " + prop, undefined, { + cause: reason, + }); + } + + /** + * Await `p`, but reject early with ABORTED if the signal fires. The + * underlying promise is left running — a shared handshake must complete + * for its other awaiters (it already carries a noop catch, so a later + * rejection cannot surface as unhandled). + */ + function raceAbort( + p: Promise, + signal: AbortSignal, + prop: string, + ): Promise { + return new Promise(function raceExec(resolve, reject) { + function onAbort(): void { + reject(abortError(prop, signal.reason)); + } + signal.addEventListener("abort", onAbort, { once: true }); + p.then( + function onOk(v: T) { + signal.removeEventListener("abort", onAbort); + resolve(v); + }, + function onErr(e: unknown) { + signal.removeEventListener("abort", onAbort); + reject(e); + }, + ); + }); + } + // ── Send a single RPC request, returns promise for the response ── - function sendRequest(prop: string, input: unknown): Promise { + function sendRequest( + prop: string, + input: unknown, + signal?: AbortSignal, + ): Promise { const enc = encrypt; if (state === "closed" || enc === null) { return Promise.reject(new RPCError("SESSION", "Session destroyed")); } + if (signal !== undefined && signal.aborted) { + // Covers the microtask gap between the handshake race resolving and + // this call — a listener added to an already-aborted signal never fires. + return Promise.reject(abortError(prop, signal.reason)); + } if (pending.size >= maxPending) { return Promise.reject( new RPCError("CLIENT", "Too many pending requests"), @@ -553,6 +899,10 @@ export function client( ); } const id = String(++counter); + // The frame below is encrypted under THIS epoch's key. Captured so the + // async-send rollback can tell a live session from one that was reset + // (and possibly re-established) while the send promise was in flight. + const sendEpoch = epoch; // Omit `i` entirely for `undefined` input rather than encoding it — // msgpack has no `undefined` primitive and would round-trip it as // `null`, which a `.optional()` (as opposed to `.nullish()`) Zod schema @@ -562,18 +912,127 @@ export function client( const encrypted = enc(req); return new Promise(function rpcExec(res, rej) { - const timer = setTimeout(function onRpcTimeout() { - pending.delete(id); - rej(new RPCError("TIMEOUT", "Timed out: " + prop)); - }, timeout); + // Listener hygiene: every settle path (resolve, reject, timeout, + // abort, send error) detaches the abort listener, or long-lived + // signals reused across calls would accumulate closures. + let onAbort: (() => void) | null = null; + function settle(): void { + if (onAbort !== null && signal !== undefined) { + signal.removeEventListener("abort", onAbort); + onAbort = null; + } + } - pending.set(id, { resolve: res, reject: rej, timer }); + const entry: PendingEntry = { + resolve: function resolveSettled(v: unknown) { + settle(); + res(v); + }, + reject: function rejectSettled(e: unknown) { + settle(); + rej(e); + }, + timer: setTimeout(function onRpcTimeout() { + pending.delete(id); + settle(); + if (entry.sent) { + // The request left; a reply never came. Outcome UNKNOWN — + // this is the one signal that the session may be desynced, + // and the only trigger of the auto-reset (proxy catch). + rej(new RPCAbortedError("TIMEOUT", "Timed out: " + prop)); + } else { + // Global timer beat sendTimeout (timeout < sendTimeout + // config) while the frame was still queued — it never left, + // so the definite CHANNEL code applies regardless of which + // timer fired first. Plain TIMEOUT thus never occurs: the + // TIMEOUT code always means "sent, no reply" (aborted class). + const q = removeOutboundMsg(id); + rej( + new RPCError( + "CHANNEL", + "Not sent within timeout: " + prop, + undefined, + { cause: q !== undefined ? q.cause : undefined }, + ), + ); + } + }, timeout), + sent: false, + }; + pending.set(id, entry); + + if (signal !== undefined) { + onAbort = function onCallAbort() { + onAbort = null; // {once:true} already removed the listener + if (pending.get(id) !== entry) return; + pending.delete(id); + clearTimeout(entry.timer); + if (entry.sent) { + // The request may have executed on the server — same UNKNOWN + // outcome as a timeout. A late reply finds no pending entry + // and is silently dropped. The session is not touched. + rej( + new RPCAbortedError( + "ABORTED", + "Call aborted: " + prop, + undefined, + { cause: signal.reason }, + ), + ); + } else { + // Still in the outbound queue — provably never left. + removeOutboundMsg(id); + rej(abortError(prop, signal.reason)); + } + }; + signal.addEventListener("abort", onAbort, { once: true }); + } - Promise.resolve(channel.send(encrypted)).catch(function onSendError(err) { - pending.delete(id); - clearTimeout(timer); - rej(err); - }); + // Send now if the line is clear; queue behind earlier frames + // otherwise (a non-empty queue means the channel was down a tick + // ago — jumping it would reorder for no gain). A failed send does + // NOT reject the call: the frame provably never left, the core + // owns it and retries until sendTimeout. Async sends are counted + // sent optimistically (a false "unknown outcome" is safe, a false + // "never left" is not) and rolled back to the queue on rejection. + if (outbound.length > 0 || flushInflight) { + enqueueMsg(id, prop, encrypted); + } else { + try { + const sent = channel.send(encrypted) as unknown; + if (isThenable(sent)) { + entry.sent = true; + sent.catch(function onAsyncSendFail(err: unknown) { + if (pending.get(id) !== entry || !entry.sent) return; + entry.sent = false; + if (state === "ready" && epoch === sendEpoch) { + enqueueMsg(id, prop, encrypted, err); + } else { + // Reset (or reset + re-handshake) happened while the send + // promise was pending. `enqueueMsg` stamps the CURRENT + // epoch, which would smuggle old-key ciphertext past the + // staleness check — under a new session it can never + // decrypt. The rejection proves the frame never left: + // fail plain CHANNEL immediately. + pending.delete(id); + clearTimeout(entry.timer); + entry.reject( + new RPCError( + "CHANNEL", + "Session reset before send: " + prop, + undefined, + { cause: err }, + ), + ); + } + }); + } else { + entry.sent = true; + } + } catch (err: unknown) { + enqueueMsg(id, prop, encrypted, err); + } + } }); } @@ -584,12 +1043,27 @@ export function client( if (typeof prop !== "string") return undefined; if (prop === "then") return undefined; - return async function call(input: unknown): Promise { + return async function call( + input: unknown, + callOpts?: CallOptions, + ): Promise { if (state === "closed") { throw new RPCError("SESSION", "Session destroyed"); } - await ensureHandshake(); + const signal = callOpts !== undefined ? callOpts.signal : undefined; + if (signal !== undefined && signal.aborted) { + // Already aborted: nothing is sent, no handshake is triggered. + throw abortError(prop, signal.reason); + } + + if (signal !== undefined) { + // Abort rejects THIS call only; the handshake itself is shared + // state and keeps running for other callers / the next call. + await raceAbort(ensureHandshake(), signal, prop); + } else { + await ensureHandshake(); + } if (knownProcedures.size < MAX_KNOWN_PROCEDURES) { knownProcedures.add(prop); @@ -599,22 +1073,34 @@ export function client( const sentEpoch = epoch; try { - return await sendRequest(prop, input); + return await sendRequest(prop, input, signal); } catch (err: unknown) { - // If session was ready and the call failed (timeout, send error), - // the server likely died. Auto-reset and retry ONCE with a fresh - // handshake — transparent to the caller. - if ((state as any) === "closed") throw err; // @TODO: Invistigae error - // Don't retry RemoteRPCError — the server responded, session is fine - if (err instanceof RemoteRPCError) throw err; - - // Only reset if still in the same session. If epoch moved on, - // another call already reset — just ride the new handshake. - if (epoch === sentEpoch) { + // No auto-retry. An RPCAbortedError leaves the outcome UNKNOWN + // (the request may have executed — auto-resending it is a silent + // double-execution hazard for non-idempotent handlers); a plain + // local error means the request provably never left. Either way + // the error surfaces and the CALLER decides whether to retry — + // it alone knows if the procedure is idempotent. + // + // We still reset the session (but do NOT resend) on exactly one + // trigger: a reply-timeout on a SENT request — "it went out and + // the server never answered" is the one signal the session may + // be desynced (e.g. a restarted server silently dropping + // TAG_MSG over a live transport); without the reset every + // future call would wedge. A never-left CHANNEL failure is a + // transport event, not a session event — the keys are fine, + // and if send throws, a re-handshake's hello couldn't leave + // either. ABORTED is a caller-local decision; guardrail + // (CLIENT) and SESSION errors must not tear down a good key. + if ( + state === "ready" && + epoch === sentEpoch && + err instanceof RPCAbortedError && + err.code === "TIMEOUT" + ) { reset(); } - await ensureHandshake(); - return sendRequest(prop, input); + throw err; } }; }, @@ -637,17 +1123,42 @@ export function client( }); // ── Auto-reset ───────────────────────────────────────────── - // Called when a call's sendRequest fails (timeout or send error). - // Only zeros crypto and returns to idle — pending calls are left - // untouched. They'll time out naturally and retry individually - // through the same catch → epoch-check → ensureHandshake path. - // If another call already reset (epoch advanced), latecomers skip - // this and just join the in-progress handshake. + // Called on a reply-timeout of a SENT request (the one desync signal). + // Zeros crypto and returns to idle. Sent pending calls are left + // untouched — they'll time out naturally and retry individually + // through the same catch → epoch-check → ensureHandshake path. Queued + // UNSENT frames are failed immediately: they are ciphertext under the + // zeroed key and can never succeed — plain CHANNEL, the definite + // never-left error. If another call already reset (epoch advanced), + // latecomers skip this and just join the in-progress handshake. function reset(): void { if (state !== "ready") return; zeroKeys(); state = "idle"; + // Advance the epoch so anything encrypted under the zeroed key is + // epoch-stale from this point on. This is what arms the belt in + // dropStaleAndExpired for frames that were IN FLIGHT during the reset + // (shifted out of `outbound`, so the purge below can't see them) and + // get rolled back by an async-send rejection afterwards. + epoch++; + for (let i = outbound.length - 1; i >= 0; i--) { + const e = outbound[i]; + if (e === undefined) continue; + outbound.splice(i, 1); + if (e.kind === "msg") { + failQueued( + e, + new RPCError( + "CHANNEL", + "Session reset before send: " + e.prop, + undefined, + { cause: e.cause }, + ), + ); + } + } + stopFlushTimerIfIdle(); } // ── Destroy ─────────────────────────────────────────────── @@ -665,10 +1176,27 @@ export function client( handshakePromise = null; handshakeResolve = null; handshakeReject = null; + // Hello-waiters were never sent as calls — plain, retryable against + // a fresh client. rej(new RPCError("SESSION", "Session destroyed")); } - rejectPending(new RPCError("SESSION", "Session destroyed")); + // Split the pending map by the sent boundary: still-queued frames + // provably never left → plain; everything else was handed to the + // transport → outcome unknown → RPCAbortedError. + for (let i = outbound.length - 1; i >= 0; i--) { + const e = outbound[i]; + if (e === undefined) continue; + outbound.splice(i, 1); + if (e.kind === "msg") { + failQueued(e, new RPCError("SESSION", "Session destroyed")); + } + } + if (flushTimer !== null) { + clearInterval(flushTimer); + flushTimer = null; + } + rejectPending(new RPCAbortedError("SESSION", "Session destroyed")); } return { api, destroy }; diff --git a/src/common.ts b/src/common.ts index 0f240fb..edf72bb 100644 --- a/src/common.ts +++ b/src/common.ts @@ -386,19 +386,37 @@ export class RPCError extends Error { public readonly code: string; public readonly data: unknown; - constructor(code: string, message: string, data?: unknown) { + constructor( + code: string, + message: string, + data?: unknown, + options?: { cause?: unknown }, + ) { if (typeof code !== "string" || code.length === 0) { throw new TypeError("RPCError: code must be a non-empty string"); } if (typeof message !== "string") { throw new TypeError("RPCError: message must be a string"); } - super(message); + super(message, options); this.code = code; this.data = data !== undefined ? data : null; } } +/** + * A local failure for a request that DID reach the wire — the outcome on + * the server is UNKNOWN: it may have executed, check before retrying. + * + * Invariant: the class says which side of the wire the request died on, + * `code` says what killed it. `RPCAbortedError` carries `TIMEOUT` (sent, + * no reply), `ABORTED` (signal fired after send) or `SESSION` (`destroy()` + * with the call in flight). The same codes on a plain local `RPCError` + * mean the request provably never left this process — safe to resend + * as-is. `RemoteRPCError` means the handler ran and returned an error. + */ +export class RPCAbortedError extends RPCError {} + // ─── Types ─────────────────────────────────────────────── export type Ctx = Record; @@ -463,6 +481,21 @@ export type RouterContext = UnionToIntersection< }[keyof T] >; +/** + * The transport contract: move `Uint8Array` frames both ways. + * + * Adapter contract: `send` MUST throw synchronously (or reject, if it + * returns a promise) when it cannot hand the frame to a live transport + * right now — no internal queues, no buffering, no retry; a channel that + * accepts a frame it cannot deliver lies to the core about the sent + * boundary. A channel SHOULD try to stay available (reopen its transport + * eagerly when it dies, hold it open as long as possible) — availability + * is the channel's job, delivery bookkeeping is the core's. An async + * `send` must reject, never resolve-then-error: a resolved promise is + * counted as "frame left the process" and is never resent by any layer. + * Shipped reference implementations: `@dotex/saferpc/channels` + * (`wsChannel`, `socketChannel`). + */ export interface Channel { send(data: Uint8Array): void | Promise; receive(cb: (data: Uint8Array) => void): (() => void) | void; diff --git a/src/index.ts b/src/index.ts index 4774907..b3ee565 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ export { EMPTY_SECRET, // Error RPCError, + RPCAbortedError, // Procedure builder + typed entry point saferpc, chain, @@ -65,6 +66,7 @@ export { RemoteRPCError, type Client, type ClientOptions, + type CallOptions, } from "./client.ts"; export { server, diff --git a/src/server.ts b/src/server.ts index e1212f8..8492911 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,16 @@ /** * drpc/server — Resilient RPC server * - * LIFECYCLE: Survives handshake failures and re-handshakes. Resets to - * waiting on timeout, failure, or new hello (even in ready state). - * Only explicit destroy() is permanent. + * LIFECYCLE: Survives handshake failures and re-handshakes (make-before-break). + * A new hello opens a handshake ATTEMPT on attempt-local state; a fully + * validated attempt is installed as a CANDIDATE that runs alongside the live + * session. The live session keeps serving and is retired ONLY when a frame + * decrypts under the candidate key — proof the counterparty holds the key + * material. A garbage / unauthenticated / replayed hello therefore cannot + * displace an established session: it can at most create a candidate that + * expires unconfirmed. An unconfirmed candidate is dropped on its confirmation + * timeout, leaving the live session intact. Only explicit destroy() is + * permanent. */ import { @@ -12,6 +19,7 @@ import { TAG_HELLO, TAG_MSG, KEY_LEN, + NONCE_LEN, MAX_HELLO_BYTES, MAX_MSG_BYTES, MAX_AUTH_BYTES, @@ -42,6 +50,7 @@ import { } from "./common.ts"; const MAX_ID_LEN = 64; +const DEFAULT_REPLAY_WINDOW = 4096; // ─── Server types ───────────────────────────────────────── @@ -70,16 +79,30 @@ export interface ServerOptionsBase { /** * Max time (ms) to complete a handshake AFTER a client hello arrives. * The server waits indefinitely for a client to connect — this timeout - * only governs the exchange once a hello is received. - * On timeout the server resets to waiting (does NOT destroy). + * only governs the exchange once a hello is received. The budget covers + * the WHOLE attempt: the async auth callbacks (`verify`/`secret`/`sign`) + * and the wait for the first frame that decrypts under the candidate. + * On timeout the unconfirmed candidate is dropped; the live session, + * if any, keeps serving (make-before-break). Never destroys. * Default: 5000ms. */ handshakeTimeout?: number; maxMessageBytes?: number; + /** + * Size of the per-session replay window: how many recently-seen AEAD + * nonces the server remembers so it can drop duplicate (replayed) + * request frames within a single session. FIFO-evicted — a replay older + * than the last `replayWindow` accepted messages still executes, so this + * narrows the window to N rather than closing it. Cleared on every + * re-handshake. `0` disables the defense. Default: 4096. + */ + replayWindow?: number; /** * Called on handshake failures and non-fatal internal errors. - * The server does NOT destroy on handshake failure — it resets to - * waiting and accepts the next hello. Use this for logging/monitoring. + * The server does NOT destroy on handshake failure — a failed attempt + * simply installs no candidate; an established live session (if any) + * is untouched, and the next hello is accepted as usual. Use this for + * logging/monitoring. */ onError?: (err: unknown) => void; } @@ -187,22 +210,23 @@ function execute( } // ─── Server ─────────────────────────────────────────────── -// RESILIENT HANDSHAKE: The server survives handshake failures. +// RESILIENT HANDSHAKE (make-before-break): The server survives handshake +// failures AND handshake replays. State is described by two key slots: // -// States: -// waiting — accepting hellos, no session yet -// pending — hello reply sent, waiting for first valid encrypted msg -// ready — session established (first valid TAG_MSG decrypted) +// waiting — no live session, no candidate. Accepting hellos. +// pending — a validated hello installed a CANDIDATE; reply sent. If a +// live session exists it keeps serving throughout. +// ready — live session confirmed, no candidate pending. // // Transitions: -// waiting → pending: hello processed, reply sent -// pending → ready: first valid TAG_MSG decrypted (session confirmed) -// pending → waiting: timeout OR new hello arrives (client retry) -// ready → waiting: new hello arrives (client re-handshaking) +// hello validates → install candidate (live, if any, untouched) +// TAG_MSG decrypts under candidate → promote (retire old live key) +// candidate timeout / attempt error → drop candidate only; live intact // -// On handshake failure or timeout, the server resets with fresh -// ephemeral keys and waits for the next hello. An epoch counter -// guards against stale async operations from previous attempts. +// A failed, replayed, or forged hello can at most create a candidate that +// expires unconfirmed — it can never displace the live session. Epoch +// counters guard against stale async operations from previous attempts +// (see the three-counter comment inside server()). export function server( router: T, @@ -247,60 +271,166 @@ export function server( } const maxBytes = opts.maxMessageBytes !== undefined ? opts.maxMessageBytes : MAX_MSG_BYTES; + const replayWindow = + opts.replayWindow !== undefined ? opts.replayWindow : DEFAULT_REPLAY_WINDOW; + if ( + typeof replayWindow !== "number" || + !Number.isInteger(replayWindow) || + replayWindow < 0 + ) { + throw new TypeError("server() replayWindow must be an integer ≥ 0"); + } const onError = opts.onError ?? null; - let state: "waiting" | "pending" | "ready" = "waiting"; + // There is no explicit `state` enum: the session state is fully described + // by the two key slots — waiting = neither set, pending = candidate set + // (live may still be serving), ready = live set with no candidate. The + // TAG_MSG handler keys on slot nullness directly. + // Three counters, three jobs (make-before-break): + // `epoch` — advances on every PROMOTION; TAG_MSG responses are + // guarded by it so a re-handshake drops stale in-flight + // replies. + // `attemptEpoch` — advances on every incoming hello (D1); concurrent + // handshake attempts self-cancel at their await guards + // WITHOUT touching the live session. + // `candidateEpoch`— advances only when a candidate is INSTALLED; guards + // the candidate confirmation timer so a later hello that + // bumps `attemptEpoch` but fails validation cannot disarm + // an existing candidate's timeout. let epoch = 0; - let privateKey = x25519.utils.randomSecretKey(); - let publicKey = x25519.getPublicKey(privateKey); - let sessionKey: Uint8Array | null = null; - let encrypt: ((data: unknown) => Uint8Array) | null = null; - let decrypt: ((payload: Uint8Array) => unknown) | null = null; - // Verified auth data from auth.verify (server-only). Bound to - // the current session; cleared on every reset so stale auth data - // never leaks across handshake attempts. - let authData: Ctx | null = null; + let attemptEpoch = 0; + let candidateEpoch = 0; + // Server ephemeral keys are attempt-local (generated per hello inside the + // handshake coroutine) and never held at module scope — a failed attempt + // cannot corrupt an established session's state. + + // ── LIVE slot ── the confirmed session. Serves all traffic (encrypt out, + // decrypt in). May be null before the first handshake completes. + let liveKey: Uint8Array | null = null; + let liveEncrypt: ((data: unknown) => Uint8Array) | null = null; + let liveDecrypt: ((payload: Uint8Array) => unknown) | null = null; + // Verified auth data from auth.verify (server-only), bound to the live + // session. Promoted from the candidate; cleared on teardown. + let liveAuthData: Ctx | null = null; + + // ── CANDIDATE slot ── a session proven by a hello attempt but NOT yet + // confirmed. Used only to TRY decrypting inbound frames; never encrypts. + // Make-before-break: installing a candidate does not touch the live + // session — the live key is retired only when a frame decrypts under the + // candidate (proof the counterparty holds the key material). + let candidateKey: Uint8Array | null = null; + let candidateDecrypt: ((payload: Uint8Array) => unknown) | null = null; + let candidateAuthData: Ctx | null = null; + let destroyed = false; - let hsTimer: ReturnType | null = null; + // Confirmation timer for the pending candidate. On expiry the candidate is + // dropped; the live session (if any) is untouched. + let candidateTimer: ReturnType | null = null; + + // ── D2: bounded seen-nonce set (in-session replay defense) ──────── + // Ring buffer of the last `replayWindow` accepted nonce keys + a Set for + // O(1) membership. Nonces are inserted ONLY after Poly1305 verifies (see + // the TAG_MSG handler), so an attacker who cannot forge ciphertexts + // cannot pump the set and force eviction churn. Lifetime is tied to the + // session key: cleared on every reset / re-handshake. + const seenSet: Set | null = replayWindow > 0 ? new Set() : null; + let seenRing: string[] = []; + let seenHead = 0; + + function nonceKey(nonce: Uint8Array): string { + let s = ""; + for (let i = 0; i < nonce.length; i++) { + s += String.fromCharCode(nonce[i]!); + } + return s; + } + function seenHas(key: string): boolean { + return seenSet !== null && seenSet.has(key); + } + function seenAdd(key: string): void { + if (seenSet === null || seenSet.has(key)) return; + if (seenRing.length >= replayWindow) { + const evicted = seenRing[seenHead]; + seenRing[seenHead] = key; + seenHead = (seenHead + 1) % replayWindow; + if (evicted !== undefined) seenSet.delete(evicted); + } else { + seenRing.push(key); + } + seenSet.add(key); + } + function seenClear(): void { + if (seenSet !== null) { + seenSet.clear(); + seenRing = []; + seenHead = 0; + } + } - function clearHsTimer(): void { - if (hsTimer !== null) { - clearTimeout(hsTimer); - hsTimer = null; + function clearCandidateTimer(): void { + if (candidateTimer !== null) { + clearTimeout(candidateTimer); + candidateTimer = null; } } - /** Zero current keys, regenerate fresh ephemeral pair, return to waiting. */ - function resetHandshake(): void { - clearHsTimer(); + /** + * Promote the pending candidate to the live session. Called ONLY from the + * TAG_MSG handler, when a frame decrypts under the candidate key — that + * ciphertext is proof the counterparty holds the key material, which is + * exactly the authority required to retire the old live session + * (make-before-break). Advances `epoch` so in-flight responses from the + * retired session self-drop at the response guard, and clears the replay + * window (new key → old nonces are irrelevant). + */ + function promoteCandidate(): void { + if (candidateKey === null) return; // defensive — never expected + clearCandidateTimer(); + if (liveKey !== null) zero(liveKey); + liveKey = candidateKey; + liveEncrypt = createEncryptor(liveKey); + liveDecrypt = candidateDecrypt; + liveAuthData = candidateAuthData; epoch++; - if (sessionKey !== null) { - zero(sessionKey); - sessionKey = null; + seenClear(); + candidateKey = null; // ownership moved to liveKey — do NOT zero it + candidateDecrypt = null; + candidateAuthData = null; + } + + /** + * Drop the pending candidate without touching the live session. Called on + * candidate confirmation timeout. If a live session exists it keeps serving + * (make-before-break); otherwise we fall back to waiting. + */ + function dropCandidate(): void { + clearCandidateTimer(); + if (candidateKey !== null) { + zero(candidateKey); + candidateKey = null; } - encrypt = null; - decrypt = null; - authData = null; - zero(privateKey); - zero(publicKey); - privateKey = x25519.utils.randomSecretKey(); - publicKey = x25519.getPublicKey(privateKey); - state = "waiting"; + candidateDecrypt = null; + candidateAuthData = null; } function destroy(): void { if (destroyed) return; destroyed = true; - clearHsTimer(); - zero(privateKey); - zero(publicKey); - if (sessionKey !== null) { - zero(sessionKey); - sessionKey = null; + clearCandidateTimer(); + if (liveKey !== null) { + zero(liveKey); + liveKey = null; } - encrypt = null; - decrypt = null; - authData = null; + if (candidateKey !== null) { + zero(candidateKey); + candidateKey = null; + } + liveEncrypt = null; + liveDecrypt = null; + liveAuthData = null; + candidateDecrypt = null; + candidateAuthData = null; + seenClear(); if (unsubscribe !== null) { unsubscribe(); unsubscribe = null; @@ -315,36 +445,46 @@ export function server( const data = toPlainBytes(raw); const tag = data[0]; - // Every hello — regardless of current state — is a new attempt. - // Reset unconditionally so the epoch is bumped even when a previous - // attempt is still suspended at an `await`; in-flight stale coroutines - // will detect the mismatch via the epoch guard and bail. + // Make-before-break: a hello opens a handshake ATTEMPT. The live session + // (if any) keeps serving on its own key throughout. A fully validated + // attempt is installed as a CANDIDATE (below), NOT swapped in; the live + // key is retired only when a frame decrypts under the candidate. A garbage + // or bad-signature hello therefore cannot displace an established session. + // `attemptEpoch` bumps per hello so a newer attempt cancels this one at + // its await guards without touching the session or the candidate. if (tag === TAG_HELLO) { if (data.length > MAX_HELLO_BYTES) return; - resetHandshake(); - - const myEpoch = epoch; - - // Handshake timeout covers hello processing + waiting for - // first encrypted message (session confirmation). - hsTimer = setTimeout(function onHsTimeout() { - if (epoch !== myEpoch || destroyed) return; - resetHandshake(); + attemptEpoch++; + const myAttempt = attemptEpoch; + + // The hsTimeout budget starts NOW, at hello receipt — not after the + // async auth callbacks. A slow/hung `auth.verify` / `auth.secret` / + // `auth.sign` must not stretch the attempt: on expiry the flag kills + // the attempt at its await guards (a pending await itself cannot be + // cancelled, but nothing past it can install a candidate or reply). + // Whatever budget validation leaves over goes to the candidate + // confirmation timer below, so hello → first-decrypted-frame is + // bounded by ONE hsTimeout total. + const attemptStart = Date.now(); + let attemptExpired = false; + const attemptTimer = setTimeout(function onAttemptTimeout() { + attemptExpired = true; + if (attemptEpoch !== myAttempt || destroyed) return; if (onError !== null) { onError(new RPCError("HANDSHAKE", "Handshake timeout")); } }, hsTimeout); (async function handleHello() { - // Snapshot ephemeral keys by value. resetHandshake() may zero the - // live buffers in place while we await; owning a copy means our - // derivation is correct regardless of races. - const myPriv = privateKey.slice(); - const myPub = publicKey.slice(); + // Attempt-local ephemeral pair — never published to module scope + // until the final synchronous publish, so a failed attempt leaves + // the established session untouched. + const myPriv = x25519.utils.randomSecretKey(); + const myPub = x25519.getPublicKey(myPriv); // Local accumulators — only published to module-level state under - // the FINAL epoch guard below. Cleaned up in finally on any exit. + // the FINAL attempt guard below. Cleaned up in finally on any exit. let rawShared: Uint8Array | null = null; let localSessionKey: Uint8Array | null = null; let localProof: Uint8Array | null = null; @@ -404,7 +544,9 @@ export function server( nonce, ); const verifyResult = await auth.verify(helloAuth, transcript); - if (epoch !== myEpoch || destroyed) return; + if (attemptEpoch !== myAttempt || destroyed || attemptExpired) { + return; + } if (verifyResult && typeof verifyResult === "object") { const a = (verifyResult as { auth?: unknown }).auth; if (a !== undefined) { @@ -428,7 +570,9 @@ export function server( const secretBytes = auth.secret !== undefined ? await auth.secret() : EMPTY_SECRET; - if (epoch !== myEpoch || destroyed) return; + if (attemptEpoch !== myAttempt || destroyed || attemptExpired) { + return; + } if ( !(secretBytes instanceof Uint8Array) || @@ -455,8 +599,8 @@ export function server( // If `sign` is configured, sign over the canonical reply // transcript (which binds BOTH ephemeral pubs) so the client // can authenticate the server beyond what the secret alone provides. - // Computed BEFORE state transition so a failure here cleanly - // resets the handshake. + // Computed BEFORE the candidate install below, so a failure here + // installs nothing and leaves the live session untouched. if (auth.sign !== undefined) { const replyTranscript = buildReplyTranscript( clientEpoch, @@ -465,7 +609,9 @@ export function server( myPub, ); const signed = await auth.sign(replyTranscript); - if (epoch !== myEpoch || destroyed) return; + if (attemptEpoch !== myAttempt || destroyed || attemptExpired) { + return; + } if ( !(signed instanceof Uint8Array) || signed.length === 0 || @@ -479,17 +625,48 @@ export function server( localServerAuth = signed; } - // FINAL epoch guard. The block below is fully synchronous, so the - // module-level publishes (sessionKey, encrypt, decrypt, authData, - // state) cannot race against an incoming hello. - if (epoch !== myEpoch || destroyed) return; + // FINAL install guard. The block below is fully synchronous: it + // installs the newly-proven session as a CANDIDATE without racing a + // newer attempt or a request. + if (attemptEpoch !== myAttempt || destroyed || attemptExpired) { + return; + } + + // Validation finished within budget — the attempt timer's job is + // done; the candidate timer takes over with the REMAINING budget. + clearTimeout(attemptTimer); + const remainingBudget = Math.max( + 1, + hsTimeout - (Date.now() - attemptStart), + ); - sessionKey = localSessionKey; + // Make-before-break: install as CANDIDATE, do NOT touch the live + // session. The live key (if any) keeps serving until a frame + // decrypts under this candidate (see promoteCandidate). A newer + // unconfirmed candidate replaces an older one (latest wins). The + // candidate is decrypt-only — its encryptor is created on promotion, + // never before, since we never encrypt under an unconfirmed key. + clearCandidateTimer(); + if (candidateKey !== null) zero(candidateKey); + candidateEpoch++; + candidateKey = localSessionKey; localSessionKey = null; // ownership transferred — skip finally zero - encrypt = createEncryptor(sessionKey); - decrypt = createDecryptor(sessionKey); - authData = localAuthData; - state = "pending"; + candidateDecrypt = createDecryptor(candidateKey); + candidateAuthData = localAuthData; + + // Arm the confirmation timer for this candidate. On expiry the + // candidate is dropped and the live session is untouched. Keyed on + // `candidateEpoch` so a later install (or a validated newer + // candidate) cancels this timer cleanly, while a later hello that + // merely bumps `attemptEpoch` and then fails cannot disarm it. + const myCandEpoch = candidateEpoch; + candidateTimer = setTimeout(function onCandidateTimeout() { + if (candidateEpoch !== myCandEpoch || destroyed) return; + dropCandidate(); + if (onError !== null) { + onError(new RPCError("HANDSHAKE", "Handshake timeout")); + } + }, remainingBudget); const replyMsg: Record = { pub: myPub, @@ -505,11 +682,15 @@ export function server( localProof = null; await channel.send(reply); - if (epoch !== myEpoch || destroyed) return; + if (candidateEpoch !== myCandEpoch || destroyed) return; - // Timer continues running — waiting for first valid TAG_MSG - // to transition pending → ready. Total budget = hsTimeout. + // Timer continues running — waiting for first valid TAG_MSG that + // decrypts under the candidate to promote it. Total budget = + // hsTimeout. } finally { + // Harmless double-clear on the success path; on every failure / + // stale-attempt path this stops a pending spurious timeout report. + clearTimeout(attemptTimer); if (rawShared !== null) zero(rawShared); if (localSessionKey !== null) zero(localSessionKey); if (localProof !== null) zero(localProof); @@ -518,8 +699,9 @@ export function server( zero(myPub); } })().catch(function onHsError(err: unknown) { - if (epoch !== myEpoch || destroyed) return; - resetHandshake(); + // The attempt failed. Under D1 the live session (if any) was never + // touched, so there is nothing to reset — only report the failure. + if (attemptEpoch !== myAttempt || destroyed) return; if (onError !== null) { onError( err instanceof RPCError @@ -531,29 +713,63 @@ export function server( return; } - if (tag === TAG_MSG && decrypt !== null && encrypt !== null) { + if ( + tag === TAG_MSG && + (liveDecrypt !== null || candidateDecrypt !== null) + ) { if (data.length > maxBytes) return; - const reqEpoch = epoch; + // D2: cheap replay reject BEFORE decrypt, against the LIVE replay + // window. The AEAD nonce is the NONCE_LEN bytes right after the tag. If + // a frame with this nonce was already accepted in the current session, + // it is a duplicate/replay — drop it silently. The membership record + // itself is only written AFTER Poly1305 verifies (below), so unforgeable + // frames can never pollute the window. + const nKey = + data.length >= 1 + NONCE_LEN + ? nonceKey(data.subarray(1, 1 + NONCE_LEN)) + : null; + if (nKey !== null && seenHas(nKey)) return; (async function handleRequest() { - if (decrypt === null || encrypt === null) return; - - let raw: unknown; - try { - raw = decrypt(data); - } catch { - return; // poly1305 failure → silently drop + // Trial decrypt: LIVE first (steady-state cost = one decrypt), then + // CANDIDATE. A frame that decrypts under the candidate is proof the + // counterparty holds the key material — the authority required to + // retire the live session (make-before-break). + let raw: unknown = undefined; + let decryptedUnder: "live" | "candidate" | null = null; + if (liveDecrypt !== null) { + try { + raw = liveDecrypt(data); + decryptedUnder = "live"; + } catch { + /* fall through to candidate */ + } } - - // First valid decrypt confirms the session. - // The client proved it has the correct sessionKey (which - // requires the correct secret) by producing a valid ciphertext. - if (state === "pending") { - clearHsTimer(); - state = "ready"; + if (decryptedUnder === null && candidateDecrypt !== null) { + try { + raw = candidateDecrypt(data); + decryptedUnder = "candidate"; + } catch { + /* neither key */ + } + } + if (decryptedUnder === null) { + return; // poly1305 failure → silently drop (nonce NOT recorded) } + // Promotion advances `epoch`; capture reqEpoch AFTER it so the reply + // to THIS confirming frame survives the response guard below. The + // confirming frame is not an in-flight leftover — it is the promoter. + if (decryptedUnder === "candidate") promoteCandidate(); + const reqEpoch = epoch; + + // Poly1305 verified — record the nonce in the (now-current) live + // window so a later duplicate of this exact frame is rejected. + // Synchronous (runs before any await), so back-to-back duplicates + // cannot both slip through. + if (nKey !== null) seenAdd(nKey); + if (typeof raw !== "object" || raw === null) return; const msg = raw as Record; @@ -584,7 +800,7 @@ export function server( // does not race against an in-flight handler. The session is // bound to one handshake; if it resets, the response is dropped // by the epoch guard below. - const ctxArg = authData !== null ? { auth: authData } : {}; + const ctxArg = liveAuthData !== null ? { auth: liveAuthData } : {}; let ctx: Ctx; if (opts.context !== undefined) { // Return type is widened to `unknown` on the loose impl @@ -625,12 +841,12 @@ export function server( } } - // Epoch guard: if a reset/re-handshake happened while the - // handler was running, this response belongs to a dead session. + // Epoch guard: if a promotion/re-handshake happened while the + // handler was running, this response belongs to a superseded session. // Drop it — the client already timed out and retried. if (epoch !== reqEpoch || destroyed) return; - const enc = encrypt; + const enc = liveEncrypt; if (enc === null) return; await channel.send(enc(res)); })().catch(function onSendError(err: unknown) { diff --git a/test/e2e/channel-lifecycle.test.ts b/test/e2e/channel-lifecycle.test.ts new file mode 100644 index 0000000..832262c --- /dev/null +++ b/test/e2e/channel-lifecycle.test.ts @@ -0,0 +1,509 @@ +/** + * Channel lifecycle v2 — the session is bound to key material, not to a + * transport instance. The core owns the outbound queue; channels implement + * the send-or-throw contract. RPCAbortedError vs plain RPCError reflects + * which side of the wire the request died on (spec §10, tests 1-9). + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { + chain, + client, + server, + RPCError, + RPCAbortedError, + TAG_HELLO, + type CallOptions, + type Channel, + type Router, +} from "../../src/index.ts"; +import { createFaultChannelPair } from "../helpers/channels.ts"; + +type LooseApi = Record< + string, + (input?: unknown, opts?: CallOptions) => Promise +>; + +function deferred(): { promise: Promise; resolve: (v: T) => void } { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +/** + * Wrap a channel to count outbound hello frames that were successfully + * delivered (send() returned without throwing). Frames rejected by a down + * channel are not counted — they enter the core queue and may be retried. + */ +function countingChannel(ch: Channel): { ch: Channel; hellos: () => number } { + let n = 0; + return { + ch: { + send(data) { + const result = ch.send(data) as void | Promise; + // Counted only when send did not throw (synchronous contract). + if (data[0] === TAG_HELLO) n++; + return result; + }, + receive: (cb) => ch.receive(cb), + }, + hellos: () => n, + }; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe("channel lifecycle / core queue + send-or-throw contract", () => { + // ── Test 1: call issued while channel is down completes after recovery ── + it("a call issued while the channel is down completes after recovery, no re-handshake", async () => { + const psk = randomBytes(32); + const { a, b, link } = createFaultChannelPair(); + const router: Router = { + echo: chain().handler(async ({ input }) => input), + }; + const srv = server(router, b, { auth: { secret: () => psk } }); + const wrapped = countingChannel(a); + const { api, destroy } = client(wrapped.ch, { + auth: { secret: () => psk }, + timeout: 2000, + sendTimeout: 500, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); + + link.goDown(); + const p = api.echo!("during-gap"); // send throws → frame enters core queue + await sleep(30); + link.goUp(); // core retry tick re-sends within 250 ms + + expect(await p).toBe("during-gap"); + expect(wrapped.hellos()).toBe(1); // session survived — no re-handshake + } finally { + destroy(); + srv.destroy(); + } + }); + + // ── Test 2: pending call survives a gap that opens after send ── + it("a pending call survives a channel gap that opens after the frame is sent", async () => { + const psk = randomBytes(32); + const { a, b, link } = createFaultChannelPair(); + const gate = deferred(); + const router: Router = { + gated: chain().handler(async () => gate.promise), + }; + const srv = server(router, b, { auth: { secret: () => psk } }); + const { api, destroy } = client(a, { + auth: { secret: () => psk }, + timeout: 2000, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + const p = api.gated!(); // request delivered; handler awaiting gate + await sleep(30); + link.goDown(); // channel goes down after frame was sent + await sleep(20); + link.goUp(); // recover before handler resolves + gate.resolve("late"); // reply goes out on a live channel + + expect(await p).toBe("late"); // reply-or-timeout, reply branch + } finally { + destroy(); + srv.destroy(); + } + }); + + // ── Test 3: lazy heal on lost server session ── + it("a lost server session heals lazily: first call is RPCAbortedError(TIMEOUT), second re-handshakes", async () => { + const psk = randomBytes(32); + const { a, b } = createFaultChannelPair(); + const router: Router = { + echo: chain().handler(async ({ input }) => input), + }; + let srv = server(router, b, { auth: { secret: () => psk } }); + const wrapped = countingChannel(a); + const { api, destroy } = client(wrapped.ch, { + auth: { secret: () => psk }, + timeout: 300, + sendTimeout: 500, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); + + srv.destroy(); // server session gone + srv = server(router, b, { auth: { secret: () => psk } }); + + // Frame is SENT but the new server cannot decrypt → no reply → TIMEOUT + const err = await api.echo!("stale").catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCAbortedError); + expect((err as RPCAbortedError).code).toBe("TIMEOUT"); + + // Next call lazily re-handshakes and succeeds + expect(await api.echo!("fresh")).toBe("fresh"); + expect(wrapped.hellos()).toBe(2); + } finally { + destroy(); + srv.destroy(); + } + }); + + // ── Test 4: sendTimeout → definite failure, session NOT reset ── + it("sendTimeout: definite failure is plain RPCError(CHANNEL), session not reset", async () => { + const psk = randomBytes(32); + const { a, b, link } = createFaultChannelPair(); + const router: Router = { + echo: chain().handler(async ({ input }) => input), + }; + const srv = server(router, b, { auth: { secret: () => psk } }); + const wrapped = countingChannel(a); + const { api, destroy } = client(wrapped.ch, { + auth: { secret: () => psk }, + timeout: 2000, + sendTimeout: 150, // expires before global timeout + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); + + // Frame queues, retries until sendTimeout, expires → definite CHANNEL + link.goDown(); + const err = await api.echo!("x").catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCError); + expect(err).not.toBeInstanceOf(RPCAbortedError); + expect((err as RPCError).code).toBe("CHANNEL"); + + link.goUp(); + // Session NOT reset: next call succeeds without re-handshake + expect(await api.echo!("after")).toBe("after"); + expect(wrapped.hellos()).toBe(1); // still only the original hello + } finally { + destroy(); + srv.destroy(); + } + }); + + // ── Test 4b: global timeout beats sendTimeout → still definite CHANNEL ── + it("global timeout on a still-queued frame is plain RPCError(CHANNEL), not the aborted class", async () => { + const psk = randomBytes(32); + const { a, b, link } = createFaultChannelPair(); + const router: Router = { + echo: chain().handler(async ({ input }) => input), + }; + const srv = server(router, b, { auth: { secret: () => psk } }); + const wrapped = countingChannel(a); + const { api, destroy } = client(wrapped.ch, { + auth: { secret: () => psk }, + timeout: 150, // fires while the frame is still queued + sendTimeout: 5000, // deliberately above timeout — misconfig-proof path + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); + + // Frame queues; the global timer expires first. The frame provably + // never left → same definite CHANNEL code as sendTimeout expiry, + // and plain class — TIMEOUT is reserved for sent-no-reply. + link.goDown(); + const err = await api.echo!("x").catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCError); + expect(err).not.toBeInstanceOf(RPCAbortedError); + expect((err as RPCError).code).toBe("CHANNEL"); + + link.goUp(); + // Session NOT reset: next call succeeds without re-handshake + expect(await api.echo!("after")).toBe("after"); + expect(wrapped.hellos()).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + }); + + // ── Test 5: class split on abort ── + it("abort while frame queued → plain RPCError(ABORTED); abort after send → RPCAbortedError(ABORTED)", async () => { + const psk = randomBytes(32); + const { a, b, link } = createFaultChannelPair(); + const gate = deferred(); + const router: Router = { + echo: chain().handler(async ({ input }) => input), + gated: chain().handler(async () => gate.promise), + }; + const srv = server(router, b, { auth: { secret: () => psk } }); + const { api, destroy } = client(a, { + auth: { secret: () => psk }, + timeout: 5000, + sendTimeout: 500, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api.echo!("warm")).toBe("warm"); + + // (a) abort while frame is still queued → plain RPCError("ABORTED") + link.goDown(); + const ac1 = new AbortController(); + const pA = api.echo!("queued", { signal: ac1.signal }); + await sleep(20); + ac1.abort("cancelled"); + const errA = (await pA.catch((e: unknown) => e)) as RPCError; + expect(errA).toBeInstanceOf(RPCError); + expect(errA).not.toBeInstanceOf(RPCAbortedError); + expect(errA.code).toBe("ABORTED"); + link.goUp(); + await sleep(10); + + // (b) abort after send → RPCAbortedError("ABORTED") + cause + const ac2 = new AbortController(); + const pB = api.gated!(undefined, { signal: ac2.signal }); + await sleep(30); // frame sent; handler is running on server + ac2.abort("user-cancelled"); + const errB = (await pB.catch((e: unknown) => e)) as RPCAbortedError; + expect(errB).toBeInstanceOf(RPCAbortedError); + expect(errB.code).toBe("ABORTED"); + expect(errB.cause).toBeDefined(); // signal.reason travels on .cause + + gate.resolve("late"); // late reply → no pending entry → silent drop + await sleep(20); + + // Session untouched — no reset on ABORTED + expect(await api.echo!("after")).toBe("after"); + } finally { + destroy(); + srv.destroy(); + } + }); + + // ── Test 6: class split on destroy ── + it("destroy: sent call → RPCAbortedError(SESSION); queued call → plain RPCError(SESSION)", async () => { + const psk = randomBytes(32); + const { a, b, link } = createFaultChannelPair(); + const gate = deferred(); + const router: Router = { + echo: chain().handler(async ({ input }) => input), + gated: chain().handler(async () => gate.promise), + }; + const srv = server(router, b, { auth: { secret: () => psk } }); + let destroyed = false; + const { api, destroy } = client(a, { + auth: { secret: () => psk }, + timeout: 5000, + sendTimeout: 500, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api.echo!("warm")).toBe("warm"); + + // Call 1: frame sent (gated handler keeps reply pending) + const p1 = api.gated!(); + await sleep(30); // frame is in-flight on server + + // Call 2: channel down → frame queues in core + link.goDown(); + const p2 = api.echo!("queued"); + await sleep(10); + + destroy(); + destroyed = true; + + const [e1, e2] = await Promise.all([ + p1.catch((e: unknown) => e), + p2.catch((e: unknown) => e), + ]); + + // Sent call → outcome unknown → RPCAbortedError + expect(e1).toBeInstanceOf(RPCAbortedError); + expect((e1 as RPCAbortedError).code).toBe("SESSION"); + + // Queued call → provably never left → plain RPCError + expect(e2).toBeInstanceOf(RPCError); + expect(e2).not.toBeInstanceOf(RPCAbortedError); + expect((e2 as RPCError).code).toBe("SESSION"); + } finally { + if (!destroyed) destroy(); + srv.destroy(); + gate.resolve("done"); + } + }); + + // ── Test 7: reset predicate regression ── + it("reset predicate: RPCAbortedError(TIMEOUT) resets; plain RPCError(CHANNEL) does not", async () => { + const psk = randomBytes(32); + const router: Router = { + echo: chain().handler(async ({ input }) => input), + }; + + // (a) Reply-timeout on a SENT request → next call re-handshakes + { + const { a, b } = createFaultChannelPair(); + let srv = server(router, b, { auth: { secret: () => psk } }); + const wrapped = countingChannel(a); + const { api, destroy } = client(wrapped.ch, { + auth: { secret: () => psk }, + timeout: 300, + sendTimeout: 500, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); + + // Server gone → frame sent but no reply → RPCAbortedError(TIMEOUT) → reset + srv.destroy(); + srv = server(router, b, { auth: { secret: () => psk } }); + const err = await api.echo!("x").catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCAbortedError); + expect((err as RPCAbortedError).code).toBe("TIMEOUT"); + + // Reset happened: next call re-handshakes + expect(await api.echo!("fresh")).toBe("fresh"); + expect(wrapped.hellos()).toBe(2); + } finally { + destroy(); + srv.destroy(); + } + } + + // (b) Plain CHANNEL (frame never sent) → next call does NOT re-handshake + { + const { a, b, link } = createFaultChannelPair(); + const srv = server(router, b, { auth: { secret: () => psk } }); + const wrapped = countingChannel(a); + const { api, destroy } = client(wrapped.ch, { + auth: { secret: () => psk }, + timeout: 5000, + sendTimeout: 150, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); + + // Channel down → frame queues → sendTimeout → plain RPCError(CHANNEL) → no reset + link.goDown(); + const err = await api.echo!("x").catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCError); + expect(err).not.toBeInstanceOf(RPCAbortedError); + expect((err as RPCError).code).toBe("CHANNEL"); + + link.goUp(); + // Session NOT reset: next call succeeds without re-handshake + expect(await api.echo!("after")).toBe("after"); + expect(wrapped.hellos()).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + } + }); + + // ── Test 8: stale hello is revoked after handshakeTimeout ── + it("stale hello is revoked after handshakeTimeout: goUp does not flush the dead hello", async () => { + const psk = randomBytes(32); + const { a, b, link } = createFaultChannelPair(); + const router: Router = { + echo: chain().handler(async ({ input }) => input), + }; + + // Count hellos that actually arrive at the server + let serverHellos = 0; + const serverB: Channel = { + send: (data) => b.send(data), + receive(cb) { + return b.receive((data) => { + if (data[0] === TAG_HELLO) serverHellos++; + cb(data); + }); + }, + }; + + const srv = server(router, serverB, { auth: { secret: () => psk } }); + const { api, destroy } = client(a, { + auth: { secret: () => psk }, + timeout: 2000, + sendTimeout: 500, + handshakeTimeout: 100, // minimum allowed; fires quickly + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + // Channel down before any call — hello queues in core instead of sending. + // Attach .catch() immediately so the rejection (at handshakeTimeout) is + // never unhandled during the sleep below. + link.goDown(); + const p = api.echo!("x").catch(() => {}); // triggers handshake; hello enters core queue + + // Wait beyond handshakeTimeout; the attempt is abandoned and epoch advances + await sleep(150); + + link.goUp(); // core retry tick fires; dropStaleAndExpired revokes the stale hello + await sleep(300); // one full retry tick (250 ms) plus margin + + // The dead hello must NOT have reached the server + expect(serverHellos).toBe(0); + await p; // catch already ran; just drain the promise + + // Fresh call: new handshake succeeds with a clean hello + expect(await api.echo!("fresh")).toBe("fresh"); + expect(serverHellos).toBe(1); // only the fresh hello + } finally { + destroy(); + srv.destroy(); + } + }); + + // ── Test 9: shared-controller idiom replaces abortPending ── + it("shared AbortController aborts two calls (class per sent status); third call succeeds", async () => { + const psk = randomBytes(32); + const { a, b, link } = createFaultChannelPair(); + const gate = deferred(); + const router: Router = { + echo: chain().handler(async ({ input }) => input), + gated: chain().handler(async () => gate.promise), + }; + const srv = server(router, b, { auth: { secret: () => psk } }); + const wrapped = countingChannel(a); + const { api, destroy } = client(wrapped.ch, { + auth: { secret: () => psk }, + timeout: 5000, + sendTimeout: 500, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api.echo!("warm")).toBe("warm"); + expect(wrapped.hellos()).toBe(1); + + const ctl = new AbortController(); + + // Call 1: gated → frame sent, awaiting reply + const p1 = api.gated!(undefined, { signal: ctl.signal }); + await sleep(30); // frame reaches server + + // Call 2: channel down → frame queues in core + link.goDown(); + const p2 = api.echo!("two", { signal: ctl.signal }); + await sleep(20); + + ctl.abort("bulk-cancel"); + + const [e1, e2] = await Promise.all([ + p1.catch((e: unknown) => e), + p2.catch((e: unknown) => e), + ]); + + // Call 1 was sent → RPCAbortedError("ABORTED") + expect(e1).toBeInstanceOf(RPCAbortedError); + expect((e1 as RPCAbortedError).code).toBe("ABORTED"); + + // Call 2 was queued → plain RPCError("ABORTED") + expect(e2).toBeInstanceOf(RPCError); + expect(e2).not.toBeInstanceOf(RPCAbortedError); + expect((e2 as RPCError).code).toBe("ABORTED"); + + gate.resolve("late"); // late reply silently dropped + link.goUp(); + await sleep(20); + + // Session untouched: third call succeeds without re-handshake + expect(await api.echo!("three")).toBe("three"); + expect(wrapped.hellos()).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + }); +}); diff --git a/test/e2e/reset-rollback.test.ts b/test/e2e/reset-rollback.test.ts new file mode 100644 index 0000000..abc2872 --- /dev/null +++ b/test/e2e/reset-rollback.test.ts @@ -0,0 +1,211 @@ +/** + * Optimistic-sent rollback × reset() interaction. + * + * An async Channel.send counts as "sent" optimistically and is rolled back + * to the outbound queue on rejection. If a reset() ran while the send + * promise was in flight, the rollback must NOT resurrect the frame: its + * ciphertext is under the zeroed key and can never succeed. The rejection + * proves the frame never left, so the call must fail immediately with a + * plain RPCError("CHANNEL") — not retry dead ciphertext, and never smuggle + * old-key bytes past the epoch staleness check. + * + * Covers both rollback paths: + * - direct-send path (sendRequest's onAsyncSendFail) + * - flush path (flushOutbound's onFlushFail) + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { + chain, + client, + server, + RPCError, + RPCAbortedError, + type CallOptions, + type Channel, + type Router, +} from "../../src/index.ts"; + +type LooseApi = Record< + string, + (input?: unknown, opts?: CallOptions) => Promise +>; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +interface Captured { + data: Uint8Array; + resolve: () => void; + reject: (e: unknown) => void; +} + +/** + * In-memory pair whose client→server side can switch behavior: + * - "sync" — deliver immediately (send returns undefined) + * - "throw" — throw synchronously (channel-down contract) + * - "capture" — return a pending Promise the test settles manually; + * the frame is NOT delivered (in-flight async send) + * The server→client side always delivers synchronously. + */ +function controllableChannelPair(): { + a: Channel; + b: Channel; + ctl: { + setMode: (m: "sync" | "throw" | "capture") => void; + captured: Captured[]; + sends: () => number; + }; +} { + let aCb: ((data: Uint8Array) => void) | null = null; + let bCb: ((data: Uint8Array) => void) | null = null; + let mode: "sync" | "throw" | "capture" = "sync"; + const captured: Captured[] = []; + let sends = 0; + + const a: Channel = { + send(data) { + sends++; + if (mode === "throw") throw new Error("channel down"); + if (mode === "capture") { + return new Promise((resolve, reject) => { + captured.push({ data: data.slice(), resolve, reject }); + }); + } + if (bCb) bCb(data); + }, + receive(cb) { + aCb = cb; + return () => { + aCb = null; + }; + }, + }; + const b: Channel = { + send(data) { + if (aCb) aCb(data); + }, + receive(cb) { + bCb = cb; + return () => { + bCb = null; + }; + }, + }; + + return { + a, + b, + ctl: { + setMode: (m) => { + mode = m; + }, + captured, + sends: () => sends, + }, + }; +} + +function makeRouter(): Router { + return { + echo: chain().handler(async ({ input }) => input), + // Never resolves — used to drive a reply-timeout → auto-reset. + gated: chain().handler(async () => new Promise(() => {})), + }; +} + +describe("reset() vs optimistic-sent rollback", () => { + it("direct async send rejected after reset fails plain CHANNEL immediately, frame is not retried", async () => { + const psk = randomBytes(32); + const { a, b, ctl } = controllableChannelPair(); + const srv = server(makeRouter(), b, { auth: { secret: () => psk } }); + const { api, destroy } = client(a, { + auth: { secret: () => psk }, + timeout: 300, + sendTimeout: 5000, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + // Establish the session. + expect(await api["echo"]!("warm")).toBe("warm"); + + // Call A: delivered, server never replies → reply-timeout → reset. + const pA = api["gated"]!(); + await sleep(10); // let A's frame leave on the sync channel + + // Call B: direct async send, promise held pending by the test. + ctl.setMode("capture"); + const pB = api["echo"]!("b"); + + // A times out → RPCAbortedError(TIMEOUT) → auto-reset fires. + const errA = await pA.catch((e: unknown) => e); + expect(errA).toBeInstanceOf(RPCAbortedError); + expect((errA as RPCError).code).toBe("TIMEOUT"); + + // B's in-flight send now rejects — AFTER the reset. + expect(ctl.captured.length).toBe(1); + const sendsBefore = ctl.sends(); + ctl.captured[0]!.reject(new Error("socket died")); + + // B fails NOW with the definite never-left error: plain RPCError, + // not the aborted class, not a late TIMEOUT. + const errB = await pB.catch((e: unknown) => e); + expect(errB).toBeInstanceOf(RPCError); + expect(errB).not.toBeInstanceOf(RPCAbortedError); + expect((errB as RPCError).code).toBe("CHANNEL"); + expect((errB as RPCError).message).toContain("Session reset before send"); + + // The dead frame must not be resurrected: no retry ticks re-send it. + await sleep(600); + expect(ctl.sends()).toBe(sendsBefore); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("queued frame whose flush-send rejects after reset fails plain CHANNEL, not retried under the dead session", async () => { + const psk = randomBytes(32); + const { a, b, ctl } = controllableChannelPair(); + const srv = server(makeRouter(), b, { auth: { secret: () => psk } }); + const { api, destroy } = client(a, { + auth: { secret: () => psk }, + timeout: 800, + sendTimeout: 5000, + }) as unknown as { api: LooseApi; destroy: () => void }; + try { + expect(await api["echo"]!("warm")).toBe("warm"); + + // Call A: delivered, will reply-timeout at ~800 ms. + const pA = api["gated"]!(); + await sleep(10); // let A's frame leave on the sync channel + + // Call B: sync send throws → frame enters the core outbound queue. + ctl.setMode("throw"); + const pB = api["echo"]!("b"); + + // Flush tick (≤250 ms) picks B up as an in-flight async send. + ctl.setMode("capture"); + await sleep(400); + expect(ctl.captured.length).toBe(1); // B left the queue, promise pending + + // A times out → auto-reset. B is in flight: reset can't see it. + const errA = await pA.catch((e: unknown) => e); + expect(errA).toBeInstanceOf(RPCAbortedError); + expect((errA as RPCError).code).toBe("TIMEOUT"); + + const sendsBefore = ctl.sends(); + ctl.captured[0]!.reject(new Error("socket died")); + + const errB = await pB.catch((e: unknown) => e); + expect(errB).toBeInstanceOf(RPCError); + expect(errB).not.toBeInstanceOf(RPCAbortedError); + expect((errB as RPCError).code).toBe("CHANNEL"); + expect((errB as RPCError).message).toContain("Session reset before send"); + + await sleep(600); + expect(ctl.sends()).toBe(sendsBefore); + } finally { + destroy(); + srv.destroy(); + } + }); +}); diff --git a/test/e2e/transport-lifecycle.test.ts b/test/e2e/transport-lifecycle.test.ts new file mode 100644 index 0000000..2774c68 --- /dev/null +++ b/test/e2e/transport-lifecycle.test.ts @@ -0,0 +1,153 @@ +/** + * Transport lifecycle — 0.7.0 retry semantics (F1 / Option A). + * + * The client no longer auto-retries. A lost reply (TIMEOUT, outcome + * unknown) surfaces as RPCAbortedError and resets the session. A send + * failure (CHANNEL, request never left) surfaces as plain RPCError and + * does NOT reset the session. The caller decides whether to retry. + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { + chain, + client, + server, + RPCError, + RPCAbortedError, + type Channel, + type Router, +} from "../../src/index.ts"; +import { + createChannelPair, + createMitmChannelPair, +} from "../helpers/channels.ts"; + +describe("transport lifecycle / retry semantics (F1 Option A)", () => { + it("a lost reply surfaces RPCAbortedError(TIMEOUT) and the handler runs exactly once (no auto-resend)", async () => { + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + let execCount = 0; + const router: Router = { + ping: chain().handler(async () => { + execCount++; + return "pong"; + }), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 300, + handshakeTimeout: 600, + }); + try { + expect(await api.ping({})).toBe("pong"); + expect(execCount).toBe(1); + + // Drop the NEXT server→client reply frame. Both requests and replies + // ride TAG_MSG (0x01) on the wire (t:1/t:2 is inside the ciphertext); + // the transform only sees server→client (AtoB) traffic, and the first + // reply already went out during the call above. + let dropped = false; + mitm.transformAtoB((d) => { + if (!dropped && d[0] === 0x01) { + dropped = true; + return null; + } + return d; + }); + + const timeoutErr = await api.ping({}).catch((e: unknown) => e); + expect(timeoutErr).toBeInstanceOf(RPCAbortedError); + expect((timeoutErr as RPCAbortedError).code).toBe("TIMEOUT"); + // Handler executed once for the lost-reply call; there was NO resend. + expect(execCount).toBe(2); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("a send failure queues and retries until sendTimeout, then surfaces plain RPCError(CHANNEL); session not reset", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + let execCount = 0; + const router: Router = { + ping: chain().handler(async () => { + execCount++; + return "pong"; + }), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + + // Wrap the client channel so send() throws for TAG_MSG once armed. + let failSend = false; + let hellosSent = 0; + const bFail: Channel = { + send(data) { + if (failSend && data[0] === 0x01) throw new Error("socket dead"); + const r = b.send(data); + // Count hellos only on successful delivery (never fails for 0x00) + if (data[0] === 0x00) hellosSent++; + return r; + }, + receive: (cb) => b.receive(cb), + }; + const { api, destroy } = client(bFail, { + auth: { secret: () => psk }, + timeout: 2000, + sendTimeout: 150, // frame expires before global timeout + }); + try { + expect(await api.ping({})).toBe("pong"); + expect(execCount).toBe(1); + expect(hellosSent).toBe(1); + + // With failSend=true, the frame is re-queued and retried by the core + // until sendTimeout (150 ms) expires → definite plain RPCError(CHANNEL). + failSend = true; + const chanErr = await api.ping({}).catch((e: unknown) => e); + expect(chanErr).toBeInstanceOf(RPCError); + expect(chanErr).not.toBeInstanceOf(RPCAbortedError); + expect((chanErr as RPCError).code).toBe("CHANNEL"); + // Request provably never left — handler count unchanged. + expect(execCount).toBe(1); + + // Session NOT reset on CHANNEL: next call succeeds without re-handshake. + failSend = false; + expect(await api.ping({})).toBe("pong"); + expect(hellosSent).toBe(1); // still only the original hello + } finally { + destroy(); + srv.destroy(); + } + }); + + it("a guardrail (RemoteRPCError) is passed through, session not reset", async () => { + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + const router: Router = { + boom: chain().handler(async () => { + throw new RPCError("BUSINESS", "nope"); + }), + ok: chain().handler(async () => "ok"), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + await expect(api.boom({})).rejects.toMatchObject({ code: "BUSINESS" }); + // Remote error must NOT tear down the session: next call needs no + // re-handshake. + expect(await api.ok({})).toBe("ok"); + const helloCount = mitm.state.captures.filter( + (c) => c.dir === "BtoA" && c.data[0] === 0x00, + ).length; + expect(helloCount).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + }); +}); diff --git a/test/e2e/ws-channel.test.ts b/test/e2e/ws-channel.test.ts new file mode 100644 index 0000000..a485567 --- /dev/null +++ b/test/e2e/ws-channel.test.ts @@ -0,0 +1,250 @@ +/** + * E2E for the shipped reconnecting WebSocket adapter (src/channels/ws.ts) + * over a real socket on 127.0.0.1. Validates the v2 adapter contract: + * send() throws while the transport is down and after close(); eager + * reconnect surfaces via onDown/onUp; the core outbound queue re-sends + * during the gap so a call issued while disconnected still resolves. + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { WebSocketServer, WebSocket as WsClient } from "ws"; +import { + chain, + client, + server, + TAG_HELLO, + type Channel, + type Router, +} from "../../src/index.ts"; +import { wsChannel, type WebSocketLike } from "../../src/channels/index.ts"; + +function pickPort(): number { + return 30000 + Math.floor(Math.random() * 10000); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function waitFor(cond: () => boolean, ms = 4000): Promise { + const start = Date.now(); + while (!cond()) { + if (Date.now() - start > ms) throw new Error("waitFor: condition timeout"); + await sleep(10); + } +} + +function toU8(data: unknown): Uint8Array { + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (Buffer.isBuffer(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + return data as Uint8Array; +} + +/** + * Long-lived server binding: one `server()` instance whose channel spans + * connections — each new socket replaces the previous one (SessionDO-style + * wiring). Returns the current-socket handle so tests can kill it. + */ +function startBridgedServer( + router: Router, + psk: Uint8Array, + port: number, +): { + currentSock: () => import("ws").WebSocket | null; + stop: () => Promise; +} { + const wss = new WebSocketServer({ host: "127.0.0.1", port }); + let current: import("ws").WebSocket | null = null; + const cbs = new Set<(data: Uint8Array) => void>(); + wss.on("connection", (sock) => { + current = sock; + sock.on("message", (data) => { + const u8 = toU8(data); + for (const cb of cbs) cb(u8); + }); + }); + const bridge: Channel = { + send(data) { + current?.send(data); + }, + receive(cb) { + cbs.add(cb); + return () => cbs.delete(cb); + }, + }; + const s = server(router, bridge, { auth: { secret: () => psk } }); + return { + currentSock: () => current, + stop: () => + new Promise((resolve) => { + s.destroy(); + current?.terminate(); + wss.close(() => resolve()); + }), + }; +} + +describe("ws channel / reconnecting adapter", () => { + // ── Test 10: contract ──────────────────────────────────────────────────── + + it("contract: send throws while down and after close()", async () => { + const port = pickPort(); + let ups = 0; + let downs = 0; + + // A blockable factory lets the test control when reconnects can land. + // When blockConnect is true the factory throws, which the adapter treats + // as a failed attempt and retries with backoff — keeping sock===null and + // up===false for the duration of the block. + let blockConnect = false; + const wss = new WebSocketServer({ host: "127.0.0.1", port }); + const ch = wsChannel( + () => { + if (blockConnect) throw new Error("connect blocked by test"); + return new WsClient( + `ws://127.0.0.1:${port}`, + ) as unknown as WebSocketLike; + }, + { + backoffMin: 20, + backoffMax: 200, + onDown: () => downs++, + onUp: () => ups++, + }, + ); + try { + await waitFor(() => ups === 1); + + // Block reconnects BEFORE killing the socket so the immediate retry + // (delay=0) hits a factory throw and the adapter stays reliably down. + blockConnect = true; + for (const s of wss.clients) s.terminate(); + await waitFor(() => downs === 1); + + // Factory throws on every retry → sock===null, up===false. + // The adapter MUST throw synchronously when it cannot hand the frame + // to a live transport now. + expect(() => ch.send(new Uint8Array([1]))).toThrow( + "wsChannel: no open socket", + ); + + // Unblock: adapter reconnects on its own backoff schedule. + blockConnect = false; + await waitFor(() => ups === 2); + + // close() is terminal — throws forever regardless of socket state. + ch.close(); + expect(() => ch.send(new Uint8Array([1]))).toThrow( + "wsChannel: channel closed", + ); + await sleep(50); + expect(() => ch.send(new Uint8Array([1]))).toThrow( + "wsChannel: channel closed", + ); + } finally { + ch.close(); // idempotent if already closed + await new Promise((resolve) => wss.close(() => resolve())); + } + }); + + // ── Test 11: reconnect + core retry end-to-end ─────────────────────────── + + it("call issued during a gap resolves after the adapter reconnects; TAG_HELLO == 1", async () => { + const psk = randomBytes(32); + const port = pickPort(); + const router: Router = { + echo: chain().handler(async ({ input }) => input), + }; + const srv = startBridgedServer(router, psk, port); + + let downs = 0; + let ups = 0; + const ch = wsChannel( + () => new WsClient(`ws://127.0.0.1:${port}`) as unknown as WebSocketLike, + { + backoffMin: 20, + backoffMax: 200, + onDown: () => downs++, + onUp: () => ups++, + }, + ); + // Count hellos that actually left the adapter (count after a successful + // ch.send, not before — a throw means the frame never left). + let hellos = 0; + const counting: Channel = { + send(d) { + const result = ch.send(d); // throws if socket not open + if (d[0] === TAG_HELLO) hellos++; // only reached on success + return result; + }, + receive: (cb) => ch.receive(cb), + }; + const { api, destroy } = client(counting, { + auth: { secret: () => psk }, + timeout: 5000, + }) as unknown as { + api: Record Promise>; + destroy: () => void; + }; + try { + expect(await api.echo!("warm")).toBe("warm"); + expect(ups).toBe(1); + + srv.currentSock()!.terminate(); + await waitFor(() => downs === 1); + + // Call during gap (or right after the fast reconnect — either way the + // call resolves and the session is continuous: no second hello). + const p = api.echo!("during-gap"); + expect(await p).toBe("during-gap"); + + expect(downs).toBe(1); + expect(ups).toBe(2); + expect(hellos).toBe(1); // same session — no re-handshake + } finally { + destroy(); + ch.close(); + await srv.stop(); + } + }); + + // ── Updated: close() is terminal ───────────────────────────────────────── + + it("close() is terminal: after close() the core cannot send and surfaces CHANNEL", async () => { + const psk = randomBytes(32); + const port = pickPort(); + const router: Router = { + echo: chain().handler(async ({ input }) => input), + }; + const srv = startBridgedServer(router, psk, port); + let ups = 0; + const ch = wsChannel( + () => new WsClient(`ws://127.0.0.1:${port}`) as unknown as WebSocketLike, + { onUp: () => ups++ }, + ); + // Small sendTimeout so the CHANNEL error arrives within one flush tick + // (~250 ms) rather than waiting the 10 s default. + const { api, destroy } = client(ch, { + auth: { secret: () => psk }, + timeout: 2000, + sendTimeout: 100, + }) as unknown as { + api: Record Promise>; + destroy: () => void; + }; + try { + await waitFor(() => ups === 1); + expect(await api.echo!("warm")).toBe("warm"); + + ch.close(); + // Adapter throws synchronously. The core queues the frame, retries for + // sendTimeout (100 ms), then surfaces plain RPCError("CHANNEL"). + expect(() => ch.send(new Uint8Array([9]))).toThrow(); + await expect(api.echo!("x")).rejects.toMatchObject({ code: "CHANNEL" }); + } finally { + destroy(); + ch.close(); // idempotent + await srv.stop(); + } + }); +}); diff --git a/test/helpers/channels.ts b/test/helpers/channels.ts index f0837f5..dcce29c 100644 --- a/test/helpers/channels.ts +++ b/test/helpers/channels.ts @@ -3,6 +3,9 @@ * - createChannelPair — in-memory two-way pair (synchronous) * - createAsyncChannelPair — same, but send() resolves on next tick * - createMitmChannelPair — middlebox you can stop/tamper/inject through + * - createFaultChannelPair — pair whose link can go down/up; while down + * both endpoints queue their sends (adapter + * lifecycle contract) and flush in order on up * - portChannel — wrap a worker_threads MessagePort * - wsChannel — wrap a `ws` WebSocket */ @@ -169,6 +172,67 @@ export function createMitmChannelPair(): { return { a, b, mitm }; } +export interface FaultLink { + goDown: () => void; + goUp: () => void; + isUp: () => boolean; +} + +/** + * In-memory pair whose link can be taken down and brought back. Implements + * the v2 Channel contract (common.ts `Channel` jsdoc): while the link is + * down, `send` THROWS synchronously — no internal queuing, no buffering. + * The core outbound queue is the sole owner of undelivered frames. On + * `goUp()` the channel becomes available again; the core retry tick will + * re-send any queued frames within its next tick (≤ 250 ms). + */ +export function createFaultChannelPair(): { + a: Channel; + b: Channel; + link: FaultLink; +} { + let aCb: ((data: Uint8Array) => void) | null = null; + let bCb: ((data: Uint8Array) => void) | null = null; + let up = true; + + const a: Channel = { + send(data) { + if (!up) throw new Error("channel down"); + if (bCb) bCb(data); + }, + receive(cb) { + aCb = cb; + return () => { + if (aCb === cb) aCb = null; + }; + }, + }; + const b: Channel = { + send(data) { + if (!up) throw new Error("channel down"); + if (aCb) aCb(data); + }, + receive(cb) { + bCb = cb; + return () => { + if (bCb === cb) bCb = null; + }; + }, + }; + + const link: FaultLink = { + goDown() { + up = false; + }, + goUp() { + up = true; + }, + isUp: () => up, + }; + + return { a, b, link }; +} + /** Wrap a worker_threads MessagePort. */ export function portChannel(port: { postMessage: (data: unknown) => void; diff --git a/test/helpers/protocol.ts b/test/helpers/protocol.ts index 4d8613c..6f57ef9 100644 --- a/test/helpers/protocol.ts +++ b/test/helpers/protocol.ts @@ -68,7 +68,7 @@ export async function manualHandshake( const unsubscribe = channel.receive((data) => { if (data[0] !== TAG_HELLO) return; clearTimeout(t); - unsubscribe(); + unsubscribe?.(); resolve(data); }); }); diff --git a/test/security/deferred-reset.test.ts b/test/security/deferred-reset.test.ts new file mode 100644 index 0000000..a179926 --- /dev/null +++ b/test/security/deferred-reset.test.ts @@ -0,0 +1,162 @@ +/** + * D1 — deferred reset. + * + * A hello opens a handshake ATTEMPT on attempt-local state. The live session + * keeps serving throughout and is torn down only when the attempt fully + * validates and publishes. So a garbage or unauthenticated hello injected by + * a MITM can no longer displace an established session (the pre-0.7.0 server + * reset unconditionally on every incoming hello, before any validation). + * + * Regression signal: under the old behavior these tests re-handshake + * (helloCount === 2) and/or drop the in-flight call; under D1 the session + * survives (helloCount === 1). + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { + chain, + client, + server, + mpEncode, + concatBytes, + x25519, + createEd25519ClientAuth, + createEd25519ServerAuth, + generateEd25519Keypair, + RPCError, + type Router, +} from "../../src/index.ts"; +import { createMitmChannelPair } from "../helpers/channels.ts"; + +const clientHellos = ( + captures: Array<{ dir: "AtoB" | "BtoA"; data: Uint8Array }>, +): number => + captures.filter((c) => c.dir === "BtoA" && c.data[0] === 0x00).length; + +describe("security / D1 deferred reset", () => { + it("a garbage hello does not displace an established PSK session", async () => { + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + const errors: RPCError[] = []; + const router: Router = { + ping: chain().handler(async () => "pong"), + }; + const srv = server(router, a, { + auth: { secret: () => psk }, + onError: (e) => errors.push(e as RPCError), + }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + expect(await api.ping({})).toBe("pong"); + expect(clientHellos(mitm.state.captures)).toBe(1); + + // Inject a garbage hello straight to the server (bad public key). + const garbage = mpEncode({ + pub: new Uint8Array(16), + nonce: randomBytes(32), + epoch: 1, + }); + mitm.injectToA(concatBytes(new Uint8Array([0x00]), garbage)); + await new Promise((r) => setTimeout(r, 40)); + expect(errors.some((e) => e.code === "HANDSHAKE")).toBe(true); + + // Session still serves; the client never had to re-handshake. + expect(await api.ping({})).toBe("pong"); + expect(clientHellos(mitm.state.captures)).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("keeps an in-flight call alive across an injected garbage hello", async () => { + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + const router: Router = { + slow: chain().handler( + async () => + new Promise((r) => setTimeout(() => r("done"), 200)), + ), + ping: chain().handler(async () => "pong"), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 2000, + }); + try { + // Drive the handshake, then start a slow in-flight call. + expect(await api.ping({})).toBe("pong"); + const inflight = api.slow({}); + await new Promise((r) => setTimeout(r, 20)); + + // Attacker injects a garbage hello mid-flight. + const garbage = mpEncode({ + pub: new Uint8Array(16), + nonce: randomBytes(32), + epoch: 2, + }); + mitm.injectToA(concatBytes(new Uint8Array([0x00]), garbage)); + + // The in-flight call still completes — its session was never reset. + expect(await inflight).toBe("done"); + expect(clientHellos(mitm.state.captures)).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("an unauthenticated hello cannot displace a session when verify is configured", async () => { + const { privateKey, publicKey } = await generateEd25519Keypair(); + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + const errors: RPCError[] = []; + const router: Router = { + ping: chain().handler(async () => "pong"), + }; + const srv = server(router, a, { + auth: { + secret: () => psk, + ...createEd25519ServerAuth({ getPublicKey: async () => publicKey }), + }, + onError: (e) => errors.push(e as RPCError), + }); + const { api, destroy } = client(b, { + auth: { + secret: () => psk, + ...createEd25519ClientAuth({ privateKey, deviceId: "dev-A" }), + }, + timeout: 1000, + }); + try { + expect(await api.ping({})).toBe("pong"); + expect(clientHellos(mitm.state.captures)).toBe(1); + + // Shape-valid hello (real x25519 pub, right sizes) but a bogus auth + // payload — reaches auth.verify and fails there, AFTER the pre-verify + // shape checks. Without D1 the session would already be gone by now. + const attackerPriv = x25519.utils.randomSecretKey(); + const attackerPub = x25519.getPublicKey(attackerPriv); + const forged = mpEncode({ + pub: attackerPub, + nonce: randomBytes(32), + epoch: 1, + auth: randomBytes(96), + }); + mitm.injectToA(concatBytes(new Uint8Array([0x00]), forged)); + await new Promise((r) => setTimeout(r, 40)); + expect(errors.length).toBeGreaterThanOrEqual(1); + + // Established session is untouched. + expect(await api.ping({})).toBe("pong"); + expect(clientHellos(mitm.state.captures)).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + }); +}); diff --git a/test/security/dos-attacks.test.ts b/test/security/dos-attacks.test.ts index ffa671b..324de7f 100644 --- a/test/security/dos-attacks.test.ts +++ b/test/security/dos-attacks.test.ts @@ -98,6 +98,56 @@ describe("security / DoS — client backpressure", () => { srv.destroy(); } }); + + // C1 regression: a maxPending overflow is a CLIENT guardrail error, not a + // transport failure. It must NOT trigger a session reset + re-handshake + + // resend of the in-flight calls (the old auto-retry path did, silently + // executing each of the 4 healthy calls twice while the caller saw one + // clean success). Assert exec count == 4 and hello count == 1. + it("maxPending overflow does not reset the session or double-execute", async () => { + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + let execCount = 0; + const router: Router = { + slow: chain().handler(async () => { + execCount++; + return new Promise((r) => setTimeout(() => r("done"), 150)); + }), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 5000, + maxPending: 4, + }); + try { + const inflight: Array> = []; + for (let i = 0; i < 4; i++) inflight.push(api.slow({})); + await Promise.resolve(); + + let blocked: unknown; + try { + await api.slow({}); + } catch (err) { + blocked = err; + } + expect((blocked as RPCError).code).toBe("CLIENT"); + + const all = await Promise.all(inflight); + expect(all).toEqual(["done", "done", "done", "done"]); + + // The 4 healthy calls each ran exactly once... + expect(execCount).toBe(4); + // ...and the good session was never torn down: a single handshake. + const helloCount = mitm.state.captures.filter( + (c) => c.dir === "BtoA" && c.data[0] === 0x00, + ).length; + expect(helloCount).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + }); }); describe("security / DoS — depth bomb input", () => { diff --git a/test/security/hung-auth-timeout.test.ts b/test/security/hung-auth-timeout.test.ts new file mode 100644 index 0000000..4941f58 --- /dev/null +++ b/test/security/hung-auth-timeout.test.ts @@ -0,0 +1,143 @@ +/** + * handshakeTimeout must bound the ENTIRE handshake attempt, starting at + * hello receipt — including the async auth phase (auth.verify / + * auth.secret / auth.sign). A slow or hung auth callback must not stretch + * the attempt: on expiry the server reports a handshake timeout, and a + * late-resolving callback must not install a candidate or send a reply. + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { + chain, + client, + server, + RPCError, + type CallOptions, + type Channel, + type Router, +} from "../../src/index.ts"; +import { createChannelPair } from "../helpers/channels.ts"; + +type LooseApi = Record< + string, + (input?: unknown, opts?: CallOptions) => Promise +>; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const router: Router = { + ping: chain().handler(async () => "pong"), +}; + +/** Count outbound frames on a channel (server replies). */ +function countSends(ch: Channel): { ch: Channel; sends: () => number } { + let n = 0; + return { + ch: { + send(data) { + n++; + return ch.send(data); + }, + receive: (cb) => ch.receive(cb), + }, + sends: () => n, + }; +} + +describe("handshakeTimeout bounds the async auth phase", () => { + it("a slow auth.secret overrunning hsTimeout fails the attempt at hsTimeout; the late result installs nothing", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const wrapped = countSends(b); + const errors: unknown[] = []; + + const srv = server(router, wrapped.ch, { + auth: { + // Resolves at 300 ms — well past the 100 ms server budget. + secret: async () => { + await sleep(300); + return psk; + }, + }, + handshakeTimeout: 100, + onError: (e) => { + errors.push(e); + }, + }); + const { api, destroy } = client(a, { + auth: { secret: () => psk }, + handshakeTimeout: 500, + timeout: 1000, + }) as unknown as { api: LooseApi; destroy: () => void }; + + try { + const pCall = api["ping"]!(); + pCall.catch(() => {}); // settled below; avoid unhandled rejection + + // At 200 ms: budget (100 ms) expired, auth.secret (300 ms) has NOT + // resolved yet — the timeout must have fired independently of it. + await sleep(200); + const timeoutErr = errors.find( + (e) => e instanceof RPCError && e.code === "HANDSHAKE", + ); + expect(timeoutErr).toBeDefined(); + expect((timeoutErr as RPCError).message).toMatch(/timeout/i); + expect(wrapped.sends()).toBe(0); // no reply went out + + // At 400 ms: auth.secret resolved at 300 ms — on a DEAD attempt. + // No candidate, no reply. + await sleep(200); + expect(wrapped.sends()).toBe(0); + + // The client's own handshakeTimeout (500 ms) fails the call. + const err = await pCall.catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCError); + expect((err as RPCError).code).toBe("HANDSHAKE"); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("an auth.secret that never resolves still times out the attempt", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const wrapped = countSends(b); + const errors: unknown[] = []; + + const srv = server(router, wrapped.ch, { + auth: { + secret: () => new Promise(() => {}), // hangs forever + }, + handshakeTimeout: 100, + onError: (e) => { + errors.push(e); + }, + }); + const { api, destroy } = client(a, { + auth: { secret: () => psk }, + handshakeTimeout: 300, + timeout: 1000, + }) as unknown as { api: LooseApi; destroy: () => void }; + + try { + const pCall = api["ping"]!(); + pCall.catch(() => {}); + + await sleep(200); + const timeoutErr = errors.find( + (e) => e instanceof RPCError && e.code === "HANDSHAKE", + ); + expect(timeoutErr).toBeDefined(); + expect((timeoutErr as RPCError).message).toMatch(/timeout/i); + expect(wrapped.sends()).toBe(0); + + const err = await pCall.catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCError); + expect((err as RPCError).code).toBe("HANDSHAKE"); + } finally { + destroy(); + srv.destroy(); + } + }); +}); diff --git a/test/security/proof-attacks.test.ts b/test/security/proof-attacks.test.ts index 380bd0f..5214f4a 100644 --- a/test/security/proof-attacks.test.ts +++ b/test/security/proof-attacks.test.ts @@ -105,7 +105,7 @@ describe("security / handshake proof", () => { } } finally { destroy(); - unsubscribe(); + unsubscribe?.(); } }); diff --git a/test/security/replay-window.test.ts b/test/security/replay-window.test.ts new file mode 100644 index 0000000..dad5681 --- /dev/null +++ b/test/security/replay-window.test.ts @@ -0,0 +1,113 @@ +/** + * D2 — bounded seen-nonce set (in-session replay defense). + * + * The server remembers the last `replayWindow` AEAD nonces it has accepted and + * silently drops any frame whose nonce it has already seen — so a MITM cannot + * replay a captured (valid) request frame to re-run a handler. The record is + * written only after Poly1305 verifies, and the window is FIFO-bounded: a + * replay older than `replayWindow` accepted messages executes again (the + * window is narrowed to N, not closed). `replayWindow: 0` disables it. + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { chain, client, server, type Router } from "../../src/index.ts"; +import { createMitmChannelPair } from "../helpers/channels.ts"; + +const lastRequestFrame = ( + captures: Array<{ dir: "AtoB" | "BtoA"; data: Uint8Array }>, +): Uint8Array | undefined => + captures.filter((c) => c.dir === "BtoA" && c.data[0] === 0x01).pop()?.data; + +describe("security / D2 replay window", () => { + it("drops a replayed request frame (handler runs exactly once)", async () => { + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + let execCount = 0; + const router: Router = { + bump: chain().handler(async () => ({ n: ++execCount })), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + await api.bump({}); + expect(execCount).toBe(1); + + const frame = lastRequestFrame(mitm.state.captures); + expect(frame).toBeDefined(); + + mitm.injectToA(frame!); + await new Promise((r) => setTimeout(r, 40)); + // Replay dropped — no re-execution. + expect(execCount).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("replayWindow: 0 disables the defense (replay re-executes)", async () => { + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + let execCount = 0; + const router: Router = { + bump: chain().handler(async () => ({ n: ++execCount })), + }; + const srv = server(router, a, { + auth: { secret: () => psk }, + replayWindow: 0, + }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + await api.bump({}); + expect(execCount).toBe(1); + + const frame = lastRequestFrame(mitm.state.captures); + mitm.injectToA(frame!); + await new Promise((r) => setTimeout(r, 40)); + expect(execCount).toBe(2); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("a replay older than replayWindow executes again (honest FIFO boundary)", async () => { + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + let execCount = 0; + const router: Router = { + bump: chain().handler(async () => ({ n: ++execCount })), + }; + const srv = server(router, a, { + auth: { secret: () => psk }, + replayWindow: 2, + }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + await api.bump({}); // exec 1 — capture this frame's nonce + const oldest = lastRequestFrame(mitm.state.captures)!; + + // Two more accepted messages evict the first nonce from a window of 2. + await api.bump({}); // exec 2 + await api.bump({}); // exec 3 + const before = execCount; + + mitm.injectToA(oldest); + await new Promise((r) => setTimeout(r, 40)); + // Evicted from the window → accepted and executed again. + expect(execCount).toBe(before + 1); + } finally { + destroy(); + srv.destroy(); + } + }); +}); diff --git a/test/security/session-continuity.test.ts b/test/security/session-continuity.test.ts new file mode 100644 index 0000000..2ea9c27 --- /dev/null +++ b/test/security/session-continuity.test.ts @@ -0,0 +1,174 @@ +/** + * Session continuity (make-before-break). + * + * A validated hello is installed as a CANDIDATE that runs alongside the live + * session. The live key is retired only when a frame decrypts under the + * candidate — proof the counterparty holds the key material. Therefore a + * duplicate/stale hello (bytes the server already processed) can at most + * create a candidate that expires unconfirmed; it can never retire the live + * session. + * + * This is strictly stronger than D1 (deferred-reset.test.ts), which only + * proved that an UNVALIDATED (garbage / bad-signature) hello is harmless. The + * case below replays a BYTE-IDENTICAL VALID hello, which re-verifies and + * reaches the install step — the exact input that displaced the live session + * before make-before-break. + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { + chain, + client, + server, + createEd25519ClientAuth, + createEd25519ServerAuth, + generateEd25519Keypair, + RPCError, + type Router, +} from "../../src/index.ts"; +import { createMitmChannelPair } from "../helpers/channels.ts"; + +// Client → server hellos are captured on the "BtoA" leg with tag 0x00. +const clientHellos = ( + captures: Array<{ dir: "AtoB" | "BtoA"; data: Uint8Array }>, +): Uint8Array[] => + captures + .filter((c) => c.dir === "BtoA" && c.data[0] === 0x00) + .map((c) => c.data); + +describe("security / session continuity (make-before-break)", () => { + it("a duplicate VALID PSK hello does not retire the live session", async () => { + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + const errors: RPCError[] = []; + const router: Router = { ping: chain().handler(async () => "pong") }; + const srv = server(router, a, { + auth: { secret: () => psk }, + onError: (e) => errors.push(e as RPCError), + }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + expect(await api.ping({})).toBe("pong"); + const hellos = clientHellos(mitm.state.captures); + expect(hellos.length).toBe(1); + + // Replay the exact client hello the server already accepted. + mitm.injectToA(hellos[0]!.slice()); + await new Promise((r) => setTimeout(r, 40)); + + // The live session still serves; the client never re-handshook. + expect(await api.ping({})).toBe("pong"); + expect(clientHellos(mitm.state.captures).length).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("a duplicate VALID signed hello does not retire the live session", async () => { + const { privateKey, publicKey } = await generateEd25519Keypair(); + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + const router: Router = { ping: chain().handler(async () => "pong") }; + const srv = server(router, a, { + auth: { + secret: () => psk, + ...createEd25519ServerAuth({ getPublicKey: async () => publicKey }), + }, + }); + const { api, destroy } = client(b, { + auth: { + secret: () => psk, + ...createEd25519ClientAuth({ privateKey, deviceId: "dev-A" }), + }, + timeout: 1000, + }); + try { + expect(await api.ping({})).toBe("pong"); + const hellos = clientHellos(mitm.state.captures); + expect(hellos.length).toBe(1); + + // Replay the exact signed hello — it re-verifies, yet must not displace. + mitm.injectToA(hellos[0]!.slice()); + await new Promise((r) => setTimeout(r, 40)); + + expect(await api.ping({})).toBe("pong"); + expect(clientHellos(mitm.state.captures).length).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("a genuine rekey promotes and the FIRST call after it gets a reply", async () => { + // Regression for the reqEpoch capture-timing bug: promotion advances + // `epoch` while handling the confirming frame, so reqEpoch must be + // captured AFTER promotion or this first post-rekey call times out. + // + // abortPending() is removed in v2. We force a genuine client reset via + // the only v2 reset trigger: a SENT call that times out + // (RPCAbortedError("TIMEOUT")) → auto-reset → idle → re-handshake. + // The mitm drops one server reply to produce that timeout. + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + const router: Router = { ping: chain().handler(async () => "pong") }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 200, // short for test speed + }); + try { + expect(await api.ping({})).toBe("pong"); + + // Drop the next server reply → the call times out (frame was SENT) → + // RPCAbortedError("TIMEOUT") → auto-reset → client goes to idle. + mitm.dropNextAtoB(1); + await expect(api.ping({})).rejects.toMatchObject({ code: "TIMEOUT" }); + + // The FIRST call after the genuine rekey must resolve, not time out. + expect(await api.ping({})).toBe("pong"); + // And the session keeps serving afterwards. + expect(await api.ping({})).toBe("pong"); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("an unconfirmed candidate from a replay expires, leaving the live session intact", async () => { + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + const errors: RPCError[] = []; + const router: Router = { ping: chain().handler(async () => "pong") }; + const srv = server(router, a, { + auth: { secret: () => psk }, + handshakeTimeout: 100, + onError: (e) => errors.push(e as RPCError), + }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + expect(await api.ping({})).toBe("pong"); + const hellos = clientHellos(mitm.state.captures); + + // Replay creates a candidate that no one can confirm (the replayer + // holds no key material). It must expire on the confirmation timer. + mitm.injectToA(hellos[0]!.slice()); + await new Promise((r) => setTimeout(r, 200)); + + // Candidate expiry surfaces as a HANDSHAKE error — proof a candidate + // was created then dropped — while the live session is untouched. + expect(errors.some((e) => e.code === "HANDSHAKE")).toBe(true); + expect(await api.ping({})).toBe("pong"); + expect(clientHellos(mitm.state.captures).length).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + }); +}); diff --git a/test/unit/typing.test.ts b/test/unit/typing.test.ts index 8fdb67e..7b8e4c5 100644 --- a/test/unit/typing.test.ts +++ b/test/unit/typing.test.ts @@ -16,6 +16,7 @@ import { client, server, RPCError, + type CallOptions, type Client, type Router, type Procedure, @@ -162,26 +163,35 @@ describe("Client — end-to-end inference", () => { it("maps each procedure to a typed call", () => { type Api = Client; expectTypeOf().toEqualTypeOf< - (input: { name: string }) => Promise<{ message: string }> + ( + input: { name: string }, + opts?: CallOptions, + ) => Promise<{ message: string }> >(); expectTypeOf().toEqualTypeOf< - (input: { raw: string }) => Promise<{ id: number }> + (input: { raw: string }, opts?: CallOptions) => Promise<{ id: number }> >(); // no `.input()` schema at all → the argument is optional expectTypeOf().toEqualTypeOf< - (input?: unknown) => Promise<{ id: string; name: string }> + ( + input?: unknown, + opts?: CallOptions, + ) => Promise<{ id: string; name: string }> >(); // `.input(z.string().optional())` → `undefined` is a valid input, so // the argument is still optional expectTypeOf().toEqualTypeOf< - (input?: string | undefined) => Promise<{ pong: string }> + ( + input?: string | undefined, + opts?: CallOptions, + ) => Promise<{ pong: string }> >(); }); - it("a loose Router stays callable as (input?: unknown) => Promise", () => { + it("a loose Router stays callable as (input?, opts?) => Promise", () => { type Loose = Client; expectTypeOf().toEqualTypeOf< - (input?: unknown) => Promise + (input?: unknown, opts?: CallOptions) => Promise >(); }); diff --git a/test/unit/vectors.test.ts b/test/unit/vectors.test.ts new file mode 100644 index 0000000..ae8beeb --- /dev/null +++ b/test/unit/vectors.test.ts @@ -0,0 +1,83 @@ +/** + * Known-answer test vectors — canonical fixtures for ports. + * These exact values are published in spec/protocol.md §Test vectors. + * A port that reproduces them byte-for-byte derives compatible sessions. + * DO NOT regenerate casually: a change here means a wire-protocol change. + */ +import { describe, it, expect } from "vitest"; +import { + deriveSessionKey, + computeProof, + buildHelloTranscript, + buildReplyTranscript, + deriveSessionSecret, + createDecryptor, + x25519, + EMPTY_SECRET, +} from "../../src/index.ts"; + +const h = (b: Uint8Array) => Buffer.from(b).toString("hex"); +const fromHex = (s: string) => new Uint8Array(Buffer.from(s, "hex")); +const pat = (start: number, len = 32) => + new Uint8Array(Array.from({ length: len }, (_, i) => (start + i) & 0xff)); + +describe("KAT vectors match src implementation", () => { + const c_priv = pat(0x01); + const s_priv = pat(0x41); + const c_nonce = pat(0x81); + const secret = pat(0xc1); + + const c_pub = x25519.getPublicKey(c_priv); + const s_pub = x25519.getPublicKey(s_priv); + const raw = x25519.getSharedSecret(c_priv, s_pub); + const sk = deriveSessionKey(raw, secret); + + it("pubs / shared / keys / proof", () => { + expect(h(c_pub)).toBe( + "07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c", + ); + expect(h(s_pub)).toBe( + "64b101b1d0be5a8704bd078f9895001fc03e8e9f9522f188dd128d9846d48466", + ); + expect(h(raw)).toBe( + "26c2c17fdb82161cb21ad16e721315355b64d1763119b10bfc962530dc7cc163", + ); + expect(h(sk)).toBe( + "26cfff1fd363520e6adc49c5f0647197d6bf84063ba7d977be53abe5a09e4df1", + ); + expect( + h(deriveSessionKey(x25519.getSharedSecret(s_priv, c_pub), EMPTY_SECRET)), + ).toBe("09f21f20ea6205029a057330916649c6d92ca421067b2249358a4f7d8d79ba68"); + expect(h(computeProof(sk, s_pub, c_pub, c_nonce))).toBe( + "1d55f7b3d9eda8cb8a30a269197139afe10fd4557f426698513de175a41cd0b3", + ); + }); + + it("transcripts", () => { + expect(h(buildHelloTranscript(1, c_pub, c_nonce))).toBe( + "736166657270632d68732d68656c6c6f2d7631000000000107a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c8182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0", + ); + expect(h(buildReplyTranscript(1, c_pub, c_nonce, s_pub))).toBe( + "736166657270632d68732d7265706c792d7631000000000107a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c8182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa064b101b1d0be5a8704bd078f9895001fc03e8e9f9522f188dd128d9846d48466", + ); + }); + + it("deriveSessionSecret", () => { + expect(h(deriveSessionSecret("session-abc123", secret))).toBe( + "e90487157dafebc492bf80cb1b0dc9818b220ee2fbbce3304ed4fc0a181e02db", + ); + }); + + it("encrypted frame decrypts via createDecryptor", () => { + const frame = fromHex( + "011112131415161718191a1b1c1d1e1f202122232425262728d6305197fd685b58024ba4e38d269a78d4afe8373d476fe52d04f1d3ed9aa51e", + ); + const decrypt = createDecryptor( + deriveSessionKey(x25519.getSharedSecret(c_priv, s_pub), pat(0xc1)), + ); + const msg = decrypt(frame) as Record; + expect(msg["t"]).toBe(1); + expect(msg["id"]).toBe("1"); + expect(msg["p"]).toBe("ping"); + }); +});