diff --git a/.gitignore b/.gitignore index 10ef6d3..667dafc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,3 @@ node_modules/ /*.js.map /build *.tgz -review-* \ No newline at end of file diff --git a/README.md b/README.md index abc43ab..f912a86 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ npm install @dotex/saferpc - **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. 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 +- **Three auth modes:** pre-shared secret, asymmetric (Ed25519 / ECDSA / JWT), 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 - **Pure ESM + CJS dual build**, side-effect-free, tree-shakeable @@ -102,7 +102,7 @@ auth: { } ``` -Built-in helpers cover Ed25519, ECDSA P-256, JWT, certificate-based, and multifactor auth. All bind their proof to the handshake transcript, so a captured payload cannot be replayed into a new session. The full threat model lives in [spec/security.md](./spec/security.md). +Built-in helpers cover Ed25519, ECDSA P-256, and JWT auth. All bind their proof to the handshake transcript, so a captured payload cannot be replayed into a new session. Certificate-based and multi-factor schemes are composed from `sign`/`verify` directly — recipes and the full threat model live in [spec/security.md](./spec/security.md). ## Errors @@ -137,7 +137,7 @@ src/ auth/ index.ts : combined re-exports (deriveSessionSecret + client + server) client.ts : Ed25519, ECDSA, JWT client helpers - server.ts : Ed25519, ECDSA, JWT, certificate, multifactor server helpers + server.ts : Ed25519, ECDSA, JWT server helpers index.ts : public entry point ``` @@ -158,7 +158,7 @@ import { createEd25519ServerAuth } from "@dotex/saferpc/auth/server"; ## Compatibility -Node.js 18+, modern browsers, Service / Web / Shared Workers, React Native, Vercel Edge, Cloudflare Workers, Deno Deploy. WebCrypto is required only for the ECDSA and certificate helpers. +Node.js 20.19+ (the `@noble/*` dependencies require `>=20.19.0`; older 18.x / 20.18 fail to load the ESM-only crypto packages under CommonJS), modern browsers, Service / Web / Shared Workers, React Native, Vercel Edge, Cloudflare Workers, Deno Deploy. WebCrypto is required only for the ECDSA helpers. ## Project status @@ -166,13 +166,14 @@ Node.js 18+, modern browsers, Service / Web / Shared Workers, React Native, Verc ## Releasing -One command bumps the version, publishes to npm, and pushes the tag: +Two commands: bump the version, then publish (which also pushes the tag): ```bash npm version patch # or: minor / major / 1.2.3-beta.0 +npm publish ``` -`prepublishOnly` runs lint, tests, and build before publishing. The `postversion` hook then runs `npm publish && git push --follow-tags`. The pushed `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which verifies the version is live on npm and creates a GitHub Release with auto-generated changelog notes since the previous tag. +The `version` hook syncs and stages `jsr.json` into the version commit. `prepublishOnly` runs lint, tests, and build before publishing; `postpublish` runs `git push --follow-tags`. The pushed `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which verifies the version is live on npm and creates a GitHub Release with auto-generated changelog notes since the previous tag. If `npm publish` fails, the tag exists locally but is not pushed. Fix the issue and re-run `npm publish && git push --follow-tags`. To abort, run `git tag -d vX.Y.Z && git reset --hard HEAD~1`. diff --git a/changelog.md b/changelog.md index bd689bc..68c6309 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,7 @@ One file per sync round. Newest first. Each entry is dated and named by the main | Date | What landed | |------|-------------| +| 2026-07-16 | v0.8.0 (0.7 + 0.8 work): no-auto-retry + deferred-reset + replay-window + abortPending, make-before-break + reconnecting ws/socket channels, sendTimeout 10s→3s, port-complete spec+KAT vectors; handshake absolute deadlines + maxPendingHandshakes + epoch-exhaustion guard, normative JWT/Ed25519/ECDSA auth profiles, cert+MFA helpers removed, bigint/cyclic/NaN input validation, all-zero-secret + replay-slot + directionality fixes, middleware fire-and-forget next() fails closed + sync-throw normalized, Node ≥20.19 | --- diff --git a/changelog/2026-07-16-v080-handshake-hardening-auth-profiles-cert-mfa-removed.md b/changelog/2026-07-16-v080-handshake-hardening-auth-profiles-cert-mfa-removed.md new file mode 100644 index 0000000..fa13fd2 --- /dev/null +++ b/changelog/2026-07-16-v080-handshake-hardening-auth-profiles-cert-mfa-removed.md @@ -0,0 +1,176 @@ +# 2026-07-16 — v0.8.0: client/transport rework + handshake hardening, auth profiles, cert/MFA removal + +The 0.7 and 0.8 work, read as one set of changes. 0.7.x was cut in a hurry to +unblock an external review and was never checked against the spec end to end — +treat those tags as pre-release. **0.8.0 is the first release to pass a full +conformance pass against `spec/protocol.md`**; the hardening landed across several +independent review rounds (auth-surface design review, cross-language port audit, +two neutral security passes), every finding re-verified in shipped code and pinned +by a regression test. + +Do **not** assume wire compatibility with pre-0.8.0 tags — only 0.8.0 is +spec-conformant. Test suite grown to 298. + +## Client & transport semantics (0.7) + +Behavioural rework of the client and channel layer — separate from the 0.8 +security pass below. + +- **BREAKING — auto-retry removed.** The client no longer silently re-sends a + call. A sent request that times out rejects with `RPCAbortedError` and leaves + the outcome **UNKNOWN** — the caller decides whether replaying is safe. A plain + local `RPCError` means the request provably never left. +- **BREAKING — deferred reset.** The session resets only on a *sent* call that + gets `RPCAbortedError("TIMEOUT")`; guardrail rejections (`CHANNEL`, `ABORTED`, + queued-frame failures) no longer tear down and re-handshake the session. +- **Replay window.** Server keeps a bounded seen-nonce set (configurable + `replayWindow`, default 4096) so a captured frame can't be replayed within the + window. +- **`abortPending`.** New client API to abort all in-flight calls at once (each + rejects by its sent-status class), on top of per-call `AbortSignal`. +- **Make-before-break session continuity.** A new handshake is negotiated as a + candidate while the live session keeps serving; the old session is only + replaced once the candidate is confirmed, so a re-handshake doesn't drop + in-flight work. Handshake states are resilient across transient channel gaps. +- **Reconnecting channel adapters.** WebSocket and socket adapters reconnect and + the session survives transport loss — a call issued while the channel is down + completes after recovery without a re-handshake. +- **BREAKING — default `sendTimeout` 10s → 3s** for faster failure detection of an + unsent frame. +- **Port-complete spec + first KAT vectors.** `spec/protocol.md`, + `spec/security.md` assessment, and the initial known-answer test vectors landed + here — the groundwork the 0.8.0 conformance pass later verified end to end. + +## Security + +### Handshake + +- **High — candidate phase now has its own absolute deadline.** A candidate + session could be promoted after its confirmation window elapsed. Server stamps + `candidateDeadline` on install and checks it before `promoteCandidate()`; + companion fix drops a double-`onError` report on one failed attempt (`reported` + flag). +- **Synchronous auth callbacks can no longer overrun `handshakeTimeout`.** Timer + + boolean let a synchronous `verify`/`secret`/`sign` win the race (post-`await` + continuation is a microtask, runs before the timer). Now an absolute `Date.now()` + deadline is checked after every auth callback and before candidate install, + session publish, and reply send — both peers. +- **Candidate promotion split from payload decoding.** An AEAD-authenticated frame + with malformed inner msgpack used to fail the whole decrypt — candidate not + promoted, timer not cleared, nonce not recorded — stalling a valid peer to + timeout. AEAD opening (`createAeadOpener`) is now separate from + `decodePlaintext`; candidate is promoted and nonce recorded the moment Poly1305 + verifies, regardless of inner payload shape. +- **`maxPendingHandshakes` caps concurrent in-flight handshakes** so a peer with + never-settling auth callbacks can't accumulate unbounded pending work. +- **Failed handshake-reply send drops the candidate** immediately (when + `candidateEpoch` still matches) instead of lingering to its timer; exactly one + error reported. +- **Low — handshake epoch exhaustion is a terminal client error.** No ceiling + guard on the client epoch counter; past `0xffffffff` every hello would be + silently dropped server-side as `Invalid epoch`, surfacing only as opaque + timeouts. `startHandshake` now rejects with `RPCError("CLIENT", "Handshake epoch + exhausted; destroy and recreate client")`. Unreachable in practice + (~4.3 × 10⁹ handshakes); no wire/reuse impact. + +### Crypto / secrets + +- **Medium-High — all-zero secret rejected at any length.** Guard only covered 32 + bytes. `isEmptySecret` now rejects any-length all-zero buffers; + `deriveSessionSecret` throws `TypeError("secret must not be all-zero")`. + +### Replay / framing + +- **Reflected `t: 2` frames no longer consume replay slots.** A direction guard + runs before the nonce is recorded, closing a replay-window-narrowing reflection. +- Response-framing size limits tightened in the same pass. + +### Input validation + +- **Bigints beyond 64 bits rejected** (`INVALID_DATA`) instead of round-tripping a + corrupted value; `BIGINT_MIN` / `BIGINT_MAX` exported. +- **Cyclic outbound graphs rejected** (`WeakSet` detection) instead of recursing to + a stack overflow; non-cyclic repeated references still rebuilt independently. +- **`NaN` / `Infinity` no longer disable limits.** `maxPending`, + `maxMessageBytes`, JWT `maxAge` validated as finite integers at construction. + `maxMessageBytes: NaN` used to make `len > max` always false — trivially reached + via `Number(process.env.X)`. + +### Middleware + +- **Middleware returning without calling `next()` fails closed** (`RPCError + ("MIDDLEWARE", ...)`) — previously skipped the handler but returned success. + Completion guarded on synchronous throw and bare return. +- **Fire-and-forget `next()` is no longer a supported pattern** — a middleware + that completes while its `next()` promise is still pending fails closed with + `RPCError("MIDDLEWARE", ...)` instead of replying with the middleware's own + value while the handler outcome is silently dropped (inspired by tRPC's + return-next guard — "did you forget to `return next()`?" — which uses a + runtime envelope/marker rather than settlement tracking). A synchronous + downstream throw is normalized into a rejected `next()` promise so a + catch-fallback middleware is not misclassified. The detached downstream + promise is still observed internally, so its rejection can never surface as + an `unhandledRejection` and terminate the process. + +### Auth payloads + +- Auth payloads pass the full `sanitize()` gate and are version-checked; malformed + or unversioned payloads rejected as `UNAUTHORIZED` before reaching app `verify`. + +## Added + +- **Normative auth profiles: JWT, Ed25519, ECDSA (P-256).** Each has a versioned + wire schema (`v` field, unknown version rejected server-side) and published KAT + vectors in `spec/protocol.md` / `test/unit/vectors.test.ts` — cross-language + ports interoperate byte-for-byte. +- **`maxPendingHandshakes`** server option. +- **`BIGINT_MIN` / `BIGINT_MAX`** exports. +- **Port-complete spec + KAT vectors** — 44-item implementation checklist and full + vectors (keys, proof, both transcripts, three auth-profile payloads) cross-checked + spec ↔ tests ↔ implementation. + +## Changed + +- **BREAKING — Node floor `>=20.19.0`.** `package.json` declared a range the build + and `@noble/hashes` 2.x never supported; declaration now matches reality. Node + < 20.19.0 unsupported, not CI-tested. +- Spec/docs realigned with code: JWT credential visibility in the opening frame + called out, asymmetric examples authenticate both directions, `protocol.md` + context-error contract matches `server.ts`, malformed-envelope / + stale-and-unknown-response-id handling pinned, sent-boundary for async adapters + clarified. + +## Removed + +- **BREAKING — certificate + multifactor auth helpers deleted** + (`createCertificateServerAuth`, `createMultifactorServerAuth`) with tests. The + certificate helper was ECDSA verification under a name promising + chain/expiry/revocation it never did (all in the app callback). The multifactor + helper couldn't enforce same-principal binding — default `{ ...primary, + ...secondary }` merge passed two different principals as MFA, and spread order + could let an issuer-signed JWT claim overwrite a cryptographically verified + field. Use JWT/Ed25519/ECDSA profiles or a custom `verify`; `spec/security.md` + documents certificate and two-factor-with-binding recipes. + +## Fixed + +- Nested `undefined` in outbound values no longer diverges from the declared type + contract. +- Reserved route/middleware names can no longer be registered. +- Malformed RPC responses (bad `ok`, absent/garbage `c`/`m`) dropped or coerced + strictly per spec. + +## Dependencies + +- **`puppeteer` moved to `devDependencies`** — was in `optionalDependencies` + (installs by default for consumers), dragging `ws` (high/moderate) and `js-yaml` + (moderate) into production `npm audit`. Production audit now clean. +- `ws` bumped to `^8.21.0`. + +## Verification + +- Full gate green: 39 files, 298 tests; lint, typecheck, ESM+CJS build clean + (Node 22.22.1). +- Every finding re-verified in shipped code (not on commit-message trust) and + mapped to a regression test; KAT vectors byte-identical across spec/tests/impl. +- Full audit trail: `review/0.8/review-release-verdict-0.8.0.md`. diff --git a/package-lock.json b/package-lock.json index 5537391..e5f6e05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,23 +19,21 @@ "@types/node": "^25.6.0", "@types/ws": "^8.18.1", "prettier": "^3.8.3", + "puppeteer": "^24.0.0", "typescript": "6.x.x", "vitest": "^4.1.5", - "ws": "^8.20.0" + "ws": "^8.21.0" }, "engines": { "node": ">=18" - }, - "optionalDependencies": { - "puppeteer": "^24.0.0" } }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -49,28 +47,28 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -79,9 +77,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -106,14 +104,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -164,9 +162,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -177,8 +175,8 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.1.tgz", "integrity": "sha512-zmS4RTK9fbrc++WlAJhxYbfz3IjDeOmkK/CwwbLmk7ydfS9e2CiEeRJHEPvjDVElO/bwXbidwGA37Bsm6LzCnQ==", + "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", @@ -196,9 +194,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -213,9 +211,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -230,9 +228,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -247,9 +245,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -264,9 +262,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -281,16 +279,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -301,16 +296,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -321,16 +313,13 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -341,16 +330,13 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -361,16 +347,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -381,16 +364,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -401,9 +381,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -418,9 +398,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -428,18 +408,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -454,9 +434,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -471,9 +451,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -488,13 +468,13 @@ "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -531,7 +511,7 @@ "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.19.0" @@ -551,6 +531,7 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -674,8 +655,8 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 14" } @@ -684,8 +665,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -694,8 +675,8 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -710,8 +691,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0", - "optional": true + "dev": true, + "license": "Python-2.0" }, "node_modules/assertion-error": { "version": "2.0.1", @@ -727,8 +708,8 @@ "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "tslib": "^2.0.1" }, @@ -740,8 +721,8 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, "license": "Apache-2.0", - "optional": true, "peerDependencies": { "react-native-b4a": "*" }, @@ -755,8 +736,8 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, "license": "Apache-2.0", - "optional": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -770,8 +751,8 @@ "version": "4.7.1", "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -795,8 +776,8 @@ "version": "3.9.1", "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } @@ -805,8 +786,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } @@ -815,8 +796,8 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", + "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" @@ -842,8 +823,8 @@ "version": "2.4.3", "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", + "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-path": "^3.0.0" } @@ -852,8 +833,8 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10.0.0" } @@ -862,8 +843,8 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": "*" } @@ -872,8 +853,8 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=6" } @@ -892,8 +873,8 @@ "version": "14.0.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" @@ -906,8 +887,8 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, "license": "MIT", - "optional": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -916,8 +897,8 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, "license": "ISC", - "optional": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -931,8 +912,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "color-name": "~1.1.4" }, @@ -944,8 +925,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -958,8 +939,8 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -985,8 +966,8 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 14" } @@ -995,8 +976,8 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -1013,8 +994,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", @@ -1038,22 +1019,22 @@ "version": "0.0.1608973", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", - "license": "BSD-3-Clause", - "optional": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -1062,8 +1043,8 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=6" } @@ -1072,8 +1053,8 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -1089,8 +1070,8 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=6" } @@ -1099,8 +1080,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, "license": "BSD-2-Clause", - "optional": true, "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", @@ -1121,8 +1102,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "license": "BSD-2-Clause", - "optional": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -1135,8 +1116,8 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", - "optional": true, "engines": { "node": ">=4.0" } @@ -1155,8 +1136,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -1165,8 +1146,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.7.0" } @@ -1185,8 +1166,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, "license": "BSD-2-Clause", - "optional": true, "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", @@ -1206,15 +1187,15 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "pend": "~1.2.0" } @@ -1256,8 +1237,8 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", - "optional": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -1266,8 +1247,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "pump": "^3.0.0" }, @@ -1282,8 +1263,8 @@ "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", @@ -1297,8 +1278,8 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -1311,8 +1292,8 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -1325,8 +1306,8 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -1342,8 +1323,8 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 12" } @@ -1352,15 +1333,15 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -1369,15 +1350,25 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", - "optional": true, "dependencies": { "argparse": "^2.0.1" }, @@ -1389,8 +1380,8 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/lightningcss": { "version": "1.32.0", @@ -1535,9 +1526,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1559,9 +1547,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1583,9 +1568,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1607,9 +1589,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1669,15 +1648,15 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, "license": "ISC", - "optional": true, "engines": { "node": ">=12" } @@ -1696,20 +1675,20 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -1729,8 +1708,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 0.4.0" } @@ -1750,8 +1729,8 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", - "optional": true, "dependencies": { "wrappy": "1" } @@ -1760,8 +1739,8 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", @@ -1780,8 +1759,8 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" @@ -1794,8 +1773,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "callsites": "^3.0.0" }, @@ -1807,8 +1786,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -1833,20 +1812,20 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -1857,9 +1836,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { @@ -1877,7 +1856,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1905,8 +1884,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.4.0" } @@ -1915,8 +1894,8 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", @@ -1935,15 +1914,15 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -1953,9 +1932,9 @@ "version": "24.43.0", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.43.0.tgz", "integrity": "sha512-DRnMFz+J3s4lFUQcjqKl0/7h0jzlCZuUFU9lNjtKrnMl5WI1RwCaIItpHVu9empuPyUreYueN0sUW3/pnfdqsg==", + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@puppeteer/browsers": "2.13.1", "chromium-bidi": "14.0.0", @@ -1975,8 +1954,8 @@ "version": "24.43.0", "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.0.tgz", "integrity": "sha512-cCRNXsUlhyPoKDz6+TiSpfZpRS3mD6Y1YFKhkdr6ik6TMfuJb7fAtXq9ThUFc4sphxObDk3BuAvdxc1Y6YOnqQ==", + "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@puppeteer/browsers": "2.13.1", "chromium-bidi": "14.0.0", @@ -1994,8 +1973,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -2004,21 +1983,21 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=4" } }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -2027,29 +2006,29 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -2068,8 +2047,8 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -2079,8 +2058,8 @@ "version": "2.8.8", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" @@ -2094,8 +2073,8 @@ "version": "8.0.5", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", @@ -2109,6 +2088,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "optional": true, "engines": { @@ -2143,8 +2123,8 @@ "version": "2.25.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", @@ -2155,8 +2135,8 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2170,8 +2150,8 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2183,8 +2163,8 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" @@ -2198,8 +2178,8 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", @@ -2211,8 +2191,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "streamx": "^2.12.5" } @@ -2221,8 +2201,8 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "b4a": "^1.6.4" } @@ -2245,9 +2225,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -2275,21 +2255,21 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "dev": true, + "license": "0BSD" }, "node_modules/typed-query-selector": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", - "license": "MIT", - "optional": true + "dev": true, + "license": "MIT" }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2303,21 +2283,21 @@ "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -2333,7 +2313,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -2478,8 +2458,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", - "license": "Apache-2.0", - "optional": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/why-is-node-running": { "version": "2.3.0", @@ -2502,8 +2482,8 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -2520,14 +2500,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "optional": true + "dev": true, + "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "devOptional": true, + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2549,8 +2529,8 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "license": "ISC", - "optional": true, "engines": { "node": ">=10" } @@ -2559,8 +2539,8 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -2578,8 +2558,8 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "license": "ISC", - "optional": true, "engines": { "node": ">=12" } @@ -2588,8 +2568,8 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" diff --git a/package.json b/package.json index f453336..ac6dae1 100644 --- a/package.json +++ b/package.json @@ -45,15 +45,13 @@ "@types/node": "^25.6.0", "@types/ws": "^8.18.1", "prettier": "^3.8.3", + "puppeteer": "^24.0.0", "typescript": "6.x.x", "vitest": "^4.1.5", - "ws": "^8.20.0" - }, - "optionalDependencies": { - "puppeteer": "^24.0.0" + "ws": "^8.21.0" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "main": "./cjs/index.js", "types": "./esm/index.d.ts", diff --git a/handshake-continuity-question.md b/review/0.7/handshake-continuity-question.md similarity index 100% rename from handshake-continuity-question.md rename to review/0.7/handshake-continuity-question.md diff --git a/handshake-continuity-review.md b/review/0.7/handshake-continuity-review.md similarity index 100% rename from handshake-continuity-review.md rename to review/0.7/handshake-continuity-review.md diff --git a/review/0.7/review-spec-conformance.md b/review/0.7/review-spec-conformance.md new file mode 100644 index 0000000..3739df8 --- /dev/null +++ b/review/0.7/review-spec-conformance.md @@ -0,0 +1,440 @@ +# Review: spec ↔ implementation conformance (0.6.1) + +Scope: does the library do what README + `spec/*.md` claim. Companion to +`review-transport-lifecycle.md` (F1–F6, transport/lifecycle design). Findings +here are numbered C1–C10. Method: full read of `src/`, all five specs, README; +suite run (245/245 green); one instrumented probe for the main behavioral claim. + +**Overall verdict:** the crypto core, handshake, sanitization, and auth helpers +match the spec closely — ordering of auth-before-key-material, epoch guards +after every await, transcript layout, HKDF/HMAC parameters, zeroing discipline +all check out line-by-line. The gaps are concentrated in one behavioral bug in +the client retry path (C1), one dangerous stale instruction in the normative +protocol doc (C2), and a handful of doc overclaims. + +--- + +## C1 — Auto-retry fires on ANY local error, not just TIMEOUT / send error. Empirically causes silent double execution. 🔴 + +**Spec:** protocol.md §Auto-retry semantics and api.md §Auto-retry both say: +"A call that fails on a `ready` session with a local **`TIMEOUT` or send +error** triggers a single retry." `RemoteRPCError` excluded. + +**Code:** `client.ts` `call()` catch block excludes only `RemoteRPCError` and +`closed`. Everything else — including `RPCError("CLIENT", "Too many pending +requests")` and the counter-exhaustion `CLIENT` error — goes through +`reset()` + re-handshake + resend: + +```ts +if (err instanceof RemoteRPCError) throw err; +if (epoch === sentEpoch) reset(); // ← also reached by CLIENT errors +``` + +**Empirical probe** (in-memory channel, `maxPending: 4`, 4 in-flight slow +calls + 1 overflow call): + +``` +{ helloCount: 2, execCount: 8, results: ["done","done","done","done"] } +``` + +One `CLIENT` backpressure error on a **healthy** session produced: + +1. A second handshake (`helloCount: 2`) — the good session key was zeroed and + the server reset, for no wire-level reason. +2. The server's in-flight responses were dropped by the epoch guard, so all + 4 pending calls timed out and retried: **8 executions for 4 calls**, with + every caller seeing clean success. This is the F1 double-execution hazard + from the transport review, but triggered *locally* by backpressure — no + attacker, no network fault needed. +3. The overflow call itself still failed with `CLIENT` (pending was still + full at retry time), so the reset bought nothing. + +The existing `test/security/dos-attacks.test.ts` backpressure test passes +while masking this: it asserts final values only, and the doubled executions +still eventually resolve `"done"`. + +**Fix:** make the retry predicate match the spec. Retry only when +`err instanceof RPCError && err.code === "TIMEOUT"` or when the rejection came +from `channel.send` (tag send-path rejections with a distinct code, e.g. +`CHANNEL`, instead of re-throwing raw). Never reset on `CLIENT`. Add an +exec-count assertion to the backpressure test. Note: if F1 from the transport +review is adopted (drop retry-on-timeout entirely), this collapses into the +same change. + +## C2 — protocol.md instructs ports to zero the application's PSK buffer. Code and security.md say the opposite. 🔴 (doc, but normative) + +protocol.md §Handshake step 7: **"Zero `raw` and PSK bytes."** + +Code (both `client.ts` and `server.ts`): + +```ts +// The caller owns the secret buffer's lifecycle — do NOT mutate it. +// A `() => sharedSecret` pattern would break on the next handshake. +``` + +security.md agrees with the code: "Safe RPC reads it during HKDF and never +mutates it." + +protocol.md declares itself normative ("this document is the contract — the +code follows it"). A conformant port following step 7 literally would zero the +app's secret buffer after the first handshake and break every subsequent +handshake for the common `secret: () => staticBuffer` pattern — and it would +do so silently on the peer's side (proof mismatch / handshake failures). +Fix protocol.md: "Zero `raw`. Do NOT zero the secret bytes — the application +owns that buffer." + +## C3 — Epoch: spec says "wrap modulo 2³²", code throws instead 🟡 + +protocol.md step 1: "increment on every handshake attempt; **wrap modulo +2³²**." Code: `epoch++` unbounded. At `epoch > 0xffffffff`: + +- sign-mode client: `encodeEpoch` throws `INVALID_DATA` inside + `buildHelloTranscript` → handshake fails forever (no wrap, no recovery). +- secret-only client: epoch goes on the wire unvalidated; the **server** + rejects it (`Invalid epoch`) → silent reset loop until client timeout. + +4 billion handshake attempts is theoretical for a browser tab, less so for a +long-lived server-to-server client auto-retrying against a dead peer for +months. Cheapest fix is code-side wrap (`epoch = (epoch + 1) >>> 0` with a +skip of 0 if you want to preserve "starts at 1"), or change the spec to +"error out" and document the ceiling. + +## C4 — Send errors surface raw, spec promises RPCError 🟡 + +api.md §Errors: "`RPCError` is thrown for local failures: timeout, session +destroyed, handshake failure, validation failure, **channel error**." + +Code: `sendRequest`'s `channel.send` rejection is re-thrown as-is. On the +first attempt it's swallowed by the retry; but the *second* attempt's send +failure surfaces the adapter's raw error (a bare `Error`, a DOMException, +whatever the transport throws) to the caller. Callers following the documented +`instanceof RPCError` pattern will hit their `else { throw err }` branch on +what is by the spec's own taxonomy a local failure. Wrap send-path rejections +in `RPCError("CHANNEL", ..., { cause })` — which also gives C1 its clean +retry predicate. + +## C5 — Client-side error-handling docs list `INPUT_VALIDATION` as a local failure 🟡 + +README quick-start catch comment: `// Local failure: TIMEOUT, SESSION, +HANDSHAKE, INPUT_VALIDATION, ...`. Same framing in api.md's "Standard local +error codes" table without noting the side. + +Input validation runs on the **server**. The client never validates locally, +so `INPUT_VALIDATION` always arrives wrapped as `RemoteRPCError` — the *other* +branch of the documented pattern. Anyone writing +`if (err instanceof RPCError && err.code === "INPUT_VALIDATION")` after +`instanceof RemoteRPCError` handled the first branch will have dead code. +One-line fix in README; api.md table could gain a "side" column +(`INPUT_VALIDATION` / `OUTPUT_VALIDATION` / `MIDDLEWARE` / `NOT_FOUND` are +server-local, surface remotely). + +## C6 — protocol.md "Test vectors" section overclaims 🟡 + +"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`." + +They don't. `derive-proof.test.ts` and friends are property-based over +`randomBytes` (determinism, divergence, length). There is not a single fixed +known-answer vector in the repo. A porter reading this section will go looking +for fixtures that don't exist. Either add one KAT file (a real deliverable — +one fixed input set, expected `session_key`, `proof`, and a canonical +encrypted frame) or soften the section to "derive vectors from the reference +implementation". + +## C7 — `getting-started.md`: "The only peer dependency is zod" — zod is a hard dependency 🟡 + +package.json puts `zod` in `dependencies` (along with `@msgpack/msgpack`, +`@noble/*`). There are no `peerDependencies` at all. So (a) "peer dependency" +is the wrong term, and (b) "or any library exposing `.safeParse()`" is only +true for the schema *objects* you pass in — you cannot swap the dependency +out; zod ships regardless. README's phrasing ("`@noble/*` crypto, +`@msgpack/msgpack`, `zod`, and nothing else") is the accurate one. Align +getting-started with it. (If duck-typed schemas are a real design goal, zod +could genuinely become an optional peer — `common.ts` only uses `ZodType` as a +type and calls `.safeParse()`.) + +## C8 — Leftover in published source: `@TODO: Invistigae error` 🟢 + +`client.ts` retry path: + +```ts +if ((state as any) === "closed") throw err; // @TODO: Invistigae error +``` + +The `as any` is masking a real question — TypeScript narrowed `state` to a +type that "can't" be `closed`, but the await points in the try block mean it +can. The cast is correct at runtime; the TODO (and typo) shipped in `files: +["src"]` to npm. Investigate, replace with a comment explaining *why* the +narrow is wrong, drop the `any`. + +## C9 — `Channel.receive` return type: docs stricter than code 🟢 + +README, api.md, integrations.md all show `receive(cb): () => void` ("returns +unsubscribe"). `common.ts` accepts `(() => void) | void` and both sides handle +the void case (`unsubscribe?.()`). Harmless leniency, but the spec is the +contract for adapter authors — either document that void is tolerated (leak +warning: without an unsubscribe, `destroy()` can't detach the listener) or +tighten the type. + +## C10 — Frame-size boundary off-by-one vs constant table 🟢 + +protocol.md's constant table: `MAX_HELLO_BYTES` = "Max size of a handshake +frame **(post-tag)**". Code compares the *whole* frame including the tag byte +(`data.length > MAX_HELLO_BYTES`), so the effective post-tag maximum is +65,535. Same pattern for `MAX_MSG_BYTES` (there the spec text "len(frame) ≤ +MAX_MSG_BYTES" matches the code — it's the hello table annotation that's off). +Cosmetic for the reference implementation; matters for byte-exact port +conformance tests. Fix the table annotation. + +--- + +## What was checked and found conformant (no findings) + +- **Handshake ordering:** verify-before-key-material on both sides; epoch + + destroyed guard after every await; final publish under a synchronous block. + Matches the implementation checklist item-for-item. +- **Transcripts:** magic prefixes, big-endian uint32 epoch, field order — + byte-for-byte per spec. Domain separation hello/reply present. +- **KDF/proof:** `HKDF(ikm=raw, salt=psk, info="saferpc-v1")`, + `HMAC(session_key, s_pub‖c_pub‖c_nonce)`, constant-time compare. Matches. +- **`deriveSessionSecret`:** ikm=secret, salt=sessionId, + info="saferpc-session-v1". Matches spec formula. +- **Sanitization:** depth 32, ext types rejected incl. Timestamp -1, poison + keys stripped, non-plain objects rejected, null-proto rebuild, `bin` + normalization at the channel boundary (`toPlainBytes`) on both sides. +- **State machines:** server waiting→pending→ready with ready-on-first-valid- + decrypt (junk-that-decrypts confirms, matches spec); hello-in-any-state + resets with epoch bump before any await. Client idle→handshaking→ready with + shared handshake promise. +- **Zeroing:** ephemeral keys/shared secrets zeroed in try/finally; in-flight + copies owned (`.slice()`) so concurrent resets can't corrupt derivations; + all-zero secret refused; low-order X25519 pubkeys covered by noble + f002 + regression test. +- **Silent-drop policy:** bad tag, oversized frames, poly1305 failure, + malformed RPC shape — all dropped without feedback, per spec. +- **Packaging:** package.json `exports` matches every import path claimed in + README/api.md; jsr.json version synced at 0.6.1; ESM+CJS dual build wiring + present (`rewriteRelativeImportExtensions` handles the `.ts` specifiers). +- **Auth helpers:** JWT symmetric skew + constant-time transcript digest, + Ed25519/ECDSA/cert/multifactor decode through hardened codec with strict + field validation — as documented. (JWT payload also carries `v: 1`, not + mentioned in the docs; server ignores it. Not worth a finding.) + +## Suggested priority + +1. **C1** — behavioral bug, silent double execution from a local guardrail; + fold into the F1 retry-semantics decision. +2. **C2** — one-line spec fix, but it's a port-breaking instruction in the + normative doc. +3. **C4** — small code change that also gives C1 its clean predicate. +4. C5, C6, C7 — doc honesty fixes, 15 minutes total. +5. C3, C8, C9, C10 — housekeeping. + +--- + +# Design sketches (agreed 2026-07-06) + +Two fixes agreed against the residual-risk list in `spec/assessment.md`. +Sketches are implementation-ready; both are wire-compatible (no protocol +version bump). + +## D1 — Deferred reset (assessment risk #3, unauthenticated teardown) + +**Decision:** option 1 — do not tear down the established session until the +incoming hello has fully validated. Make-before-break (dual-session) was +considered and rejected for now: bigger state-machine change, and deferred +reset already covers the deployments that have `verify` configured. + +**Current order** (`server.ts`, `onMessage`, TAG_HELLO branch): + +``` +length check → resetHandshake() → [async] shape check → verify → ECDH → … +``` + +A garbage hello kills the live session before any validation — even when +`auth.verify` is configured and would reject it. + +**Target order:** + +1. Length check as today. +2. **Do not call `resetHandshake()`.** Generate a *local* fresh ephemeral + pair for this attempt (`hsPriv`, `hsPub`) — do not touch the module-level + keys. Bump a separate `attemptEpoch` (or reuse `epoch` but without the + destructive zeroing) so stale in-flight attempts still self-detect. +3. Run the whole existing coroutine on local state: shape check, epoch + validation, `verify`, ECDH, `secret()`, key derivation, proof, `sign`. + Any failure: discard locals, call `onError` — **the active session is + untouched and keeps serving**. +4. Only in the final synchronous publish block (which already exists for the + epoch-guard pattern): zero the old session key, swap in the new + `sessionKey`/`encrypt`/`decrypt`/`authData`, regenerate module-level + ephemerals, state → `pending`, send the reply. + +**What this buys:** with `verify` configured, an attacker without a valid +signature can no longer displace an established session at all. In PSK-only +mode a well-formed hello still displaces (nothing to authenticate at hello +time) — accepted as residual; noted in assessment #3. + +**Details to not miss:** + +- The handshake timer must now be armed for the *attempt*, not by resetting + the session; on attempt timeout, discard attempt-locals only. +- Two hellos racing: second attempt bumps `attemptEpoch`, first one bails at + its guards — same pattern as today, just against attempt-locals. +- `resetHandshake()` remains for its other callers (post-publish failures, + timeout of a `pending` session). +- Spec: protocol.md "Re-handshake" section and the state-machine diagram + need one edit each ("a hello opens a handshake attempt; the active session + is replaced only when the attempt succeeds"). The implementation-checklist + bullet about bumping the epoch for every incoming hello changes meaning: + the *attempt* epoch bumps; the session is not torn down. +- Tests: inject a garbage hello and a bad-signature hello into a `ready` + session with `verify` configured → assert in-flight and subsequent calls + still succeed with **zero** re-handshakes (count TAG_HELLO frames). + +## D2 — Bounded seen-nonce set (assessment risk #1, in-session replay) + +**Decision:** close the replay window with a per-session bounded set of +already-seen AEAD nonces, keeping random nonces (no wire change, no ordering +requirement, transport duplication still tolerated). Counter nonces were +considered and parked: with a single bidirectional session key they require +direction separation (keystream reuse otherwise) plus an anti-replay window, +i.e. a protocol v2 — batch with directional keys if ever done. + +"Set of nonces already seen this session" is the right mental model, plus +three load-bearing details: + +1. **Record only after successful Poly1305 verification.** Membership check + *before* decrypt (cheap reject of exact replays); insert *after* the AEAD + tag verifies. Never insert nonces from frames that fail to decrypt — + otherwise an attacker who cannot forge ciphertexts can still pump + arbitrary nonces into the set, forcing eviction churn and re-opening the + window for the entries evicted early. +2. **Bound it with FIFO eviction.** Unbounded Set on a long-lived session is + a slow memory leak (1M messages × 24 B ≈ 24 MB per session, plus Set + overhead). Structure: ring buffer of the last `N` nonces + a `Set` keyed + by the nonce bytes (latin1/hex string) for O(1) lookup; when full, evict + oldest from both. Default `N` = 4096 (≈ a few hundred KB worst case), + configurable via option (e.g. `replayWindow: number`, `0` = off). + Honest semantics, must be documented: a replay older than the last `N` + messages still executes — the window is *narrowed to N*, not closed. +3. **Clear on reset/re-handshake.** New session key makes old nonces + irrelevant; keeping them only wastes the budget. Tie the set's lifetime + to the session key's. + +**Where:** server side is the one that matters — a replayed *request* +re-executes a handler. The client is already replay-immune at the RPC layer +(response ids are matched against `pending` and ids are never reused), so a +client-side set is optional symmetry, not security. + +**Ordering with D1/session confirmation:** the pending→ready promotion on +first valid decrypt happens *after* the seen-check — a replayed frame from a +previous session cannot confirm anything (different key, fails AEAD), so no +interaction. + +**Spec/docs:** protocol.md "Encryption" + "Non-goals" (replay caches are +currently declared an application concern — now partially in-protocol), +security.md "Replay within a session", assessment.md risk #1 status. +Re-run the double-execution probe from C1 as a regression: with the set +enabled, an injected duplicate of a captured request frame must produce +exactly one execution. + +**Tests:** capture a valid encrypted request frame, re-inject it — assert +exec count 1 and silent drop; re-inject after `N+1` newer messages — assert +it executes (documents the honest boundary); assert memory bound (set size +never exceeds `N`). + +--- + +# Work plan — 0.7.0 (consolidated 2026-07-06) + +Everything open, in implementation order. Sources: this review (C*), +`review-transport-lifecycle.md` (F*), design sketches above (D*), +`spec/protocol.md` implementation checklist (now normative). + +## ⚠ Decision needed first: retry-on-TIMEOUT — F1 vs current spec + +Two positions are on record and they conflict: + +- **F1 / transport review + prior discussion:** on `TIMEOUT`, drop without + retry ("better to drop") — a timeout does not prove non-execution, so + auto-retry risks double execution; keep only the session reset so the next + call gets a fresh handshake. +- **spec/protocol.md as of today:** auto-retry once on `TIMEOUT` or send + error stays (with an explicit note about the double-execution trade-off); + guardrail errors excluded. + +Pick one before touching client.ts — it changes both the code and the +"Auto-retry semantics" section + checklist bullet in protocol.md: + +- Option A (F1): retry only on **send error** (request provably never left); + `TIMEOUT` → reset session, surface the error, no resend. +- Option B (spec as written): retry on `TIMEOUT` + send error, never on + guardrails. Keeps current UX, keeps the documented double-exec window. + +Either way the C1 guardrail exclusion applies. + +## Code (src/) + +1. **C4** — wrap `channel.send` rejections in `RPCError("CHANNEL", …, + { cause })`. Do this first: it gives the retry predicate a typed trigger. +2. **C1 (+F1 decision)** — rewrite the retry predicate in `client.ts` per + the decision above. Remove the `(state as any)` cast + `@TODO: Invistigae` + while in there (**C8**). +3. **D1** — deferred reset in `server.ts` (sketch above). +4. **D2** — seen-nonce set in `server.ts` + `replayWindow` option (sketch + above). New `ServerOptions` field → api.md table row. +5. **F3** (if still planned) — `abortPending` on the client: reject pending + + abort in-progress handshake + zeroKeys + state=idle (not closed); + return `{ api, abortPending, destroy }`. Exists locally at Sergiy's, + unpublished — reconcile with this branch before it drifts. + +## Tests (test/) + +6. **C1 regression** — maxPending overflow on a healthy session: assert + exec count stays 4, hello count stays 1, blocked call rejects `CLIENT`. + Extend `dos-attacks.test.ts` with exec/hello counters (current version + masks the bug by asserting final values only). +7. **D1 tests** — garbage hello and bad-signature hello (with `verify` + configured) injected into a `ready` session: in-flight + subsequent calls + succeed, zero re-handshakes. +8. **D2 tests** — replayed frame: exec count 1, silent drop; replay older + than window: executes (honest boundary); set size bounded; cleared on + re-handshake. +9. **Retry-semantics test** matching the F1 decision (whichever option). + +## Docs (spec/, README) + +10. **protocol.md** — only if F1 Option A: rewrite Auto-retry section + + failure-modes row + checklist bullet. Otherwise no changes (already + normative for D1/D2/C1). +11. **api.md** — `replayWindow` option row; auto-retry paragraph sync with + F1 decision; fix `Channel.receive` return type or document void + tolerance (**C9**); error-code table: mark INPUT_VALIDATION / + OUTPUT_VALIDATION / MIDDLEWARE / NOT_FOUND as server-side, surfacing + remotely (**C5**). +12. **README** — quick-start catch comment: drop INPUT_VALIDATION from the + "local failure" list (**C5**). +13. **getting-started.md** — "only peer dependency is zod" → zod is a + regular dependency (**C7**). +14. **integrations.md** — wsChannel: add readyState guard / note that + browser `ws.send` on a CLOSED socket drops silently (**F4**). +15. **security.md** — "Replay within a session" → describe the seen-nonce + window; **assessment.md** — flip risk #1/#2/#3 statuses to "fixed in + 0.7.0" where applicable. + +## Release + +16. Bump 0.7.0, publish npm + jsr (`sync:jsr` hook handles jsr.json). + Commit spec + code together — protocol.md currently leads the code on + D1/D2/retry; the gap closes with this release. +17. Post-release regression from the consumer side: re-run the transport + probe (`/tmp/saferpc-transport-test/test.mjs`) against the published + version — reply-lost case must show 1 execution per call under the new + retry semantics; then bump the Enclave repos. + +Not in scope for 0.7.0 (parked): directional keys + counter nonces + +anti-replay window = protocol v2 (closes reflection at the crypto layer and +replay fully); make-before-break dual-session (D1's bigger sibling); +bounded `seen`-set on the client (adds nothing, see D2). diff --git a/review/0.7/review-transport-lifecycle.md b/review/0.7/review-transport-lifecycle.md new file mode 100644 index 0000000..7f485b9 --- /dev/null +++ b/review/0.7/review-transport-lifecycle.md @@ -0,0 +1,176 @@ +# SafeRPC design review — transport lifecycle, retry, error taxonomy + +Scope: `src/client.ts` / `src/server.ts` / `src/common.ts` @ `a6ffecb` (0.6.1), +`spec/protocol.md`, `spec/security.md`, `spec/integrations.md`. +Trigger: the Enclave WS-reconnect work. Empirical base: an in-memory +channel with fault injection run against the published 0.6.0 build +(same lifecycle code as 0.6.1); results quoted inline as [B]/[C]/[D]. + +The stated goal (per CTO): *create the client api once; keep using it no +matter how often the handshake re-runs or the channel dies.* + +## Verdict on the goal + +The design is ~90% there already, and the remaining 10% is not "add +machinery" — it is "delete the retry and give the channel a failure +vocabulary". Specifically: + +- Lazy handshake, epoch discipline, zeroization, the client state machine, + and the server's any-hello-resets resilience are sound. Nothing below + touches the crypto or the handshake. +- The only paths that force recreating a client today are `destroy()` and + the (cosmetic) id-counter exhaustion. Channel death does NOT require + recreation — it requires a way to *tell* the client about it, which is + what `abortPending` adds. + +## F1 — Remove the auto-retry; keep the auto-reset + +The retry in the api-proxy `catch` conflates two failure classes with +opposite safety properties: + +| class | meaning | retry safety | empirical | +|---|---|---|---| +| send error | request **never left** the client | safe | [B] send threw → transparent retry → success in 1ms, handler ran once | +| RPC timeout | request **may have executed**; reply lost | **unsafe** | [C] reply dropped → auto-retry → handler ran **twice** for one logical call, caller saw one clean SUCCESS | + +[C] is silent at-least-once. For fund-moving handlers (the exact Enclave +use case: `sign`) it is a first-party double-execution mechanism layered +on top of the *documented* attacker-replay window (security.md §"only +known replay window"). The library currently duplicates requests more +reliably than an attacker could. + +**Recommendation (agreed with CTO 06.07: "better to drop"):** + +1. Delete the retry block in the api proxy (`catch` → `reset()` → + `ensureHandshake()` → `sendRequest` re-send). Every failure surfaces to + the caller with a typed code (see F2). The caller decides — it is the + only party that knows whether the procedure is idempotent. +2. **Keep `reset()` on timeout** (reset ≠ retry). Without it a desynced + session wedges permanently: over a sync transport (worker, iframe), + a restarted server has no session, silently drops every TAG_MSG, and + the client — with no channel signal available — would time out on every + call forever. Reset-on-timeout keeps the healing lazy: the failed call + still fails, the *next* call re-handshakes. +3. Document the surviving trade-off of (2): `reset()` nulls `decrypt`, so + replies to *other* concurrent in-flight calls on the same session are + dropped — one slow handler (> `timeout`) can cascade-fail its + neighbours. Mitigations, in order of preference: per-call timeout + override (`api.slowThing(input, { timeout })` or a per-procedure option) + so slow procedures don't share the 10s default; accept and document. + Do NOT fix this by delaying reset — that reopens the wedge in (2). + +This also deletes the `(state as any) === "closed"` cast and the +`// @TODO: Invistigae error` artifact — both live inside the retry block. + +## F2 — Give the channel a failure vocabulary (the "special error") + +Today `sendRequest` propagates the raw `channel.send` rejection to the +caller (`rej(err)` in `onSendError`). Consequences: callers cannot +distinguish "transport down, request never sent, safe to retry" from +arbitrary garbage; and the client core cannot react to transport death +either. + +**Recommendation — three small pieces:** + +1. Export a channel error, one code, minimal: + ```ts + // common.ts + export class ChannelDownError extends RPCError { + constructor(message = "Transport down") { super("CHANNEL_DOWN", message); } + } + ``` + Adapters throw it from `send()` when they *know* the transport is dead + (see F4). The client wraps any other send rejection as + `RPCError("CHANNEL", msg, { cause })` so the caller-facing taxonomy is + closed: `CHANNEL_DOWN` / `CHANNEL` = never sent, retryable; + `TIMEOUT` = **outcome unknown** (may have executed — never blind-retry + a non-idempotent call); `RemoteRPCError` = server answered; + `HANDSHAKE` / `SESSION` as today. + +2. On `ChannelDownError` from send, the client should also `reset()` — + in every real deployment (WS + per-socket server bridge, as in the + Enclave SessionDO) transport death implies the peer's session is gone; + the keys are dead weight. The failed call rejects; the next call + re-handshakes over whatever transport the adapter provides. + +3. **"Fail fast vs wait for the channel to come back" is adapter policy, + not core policy** — and the `Channel` interface already supports both + without any core change, because `send` may return a `Promise`: + - *fail-fast adapter*: `readyState !== OPEN → throw ChannelDownError`; + - *self-healing adapter*: recreate the socket inside `send` and resolve + when flushed — the pending RPC timer keeps running, so the wait + budget is naturally capped by the call timeout; + - *queueing adapter*: hold the promise until reconnect. Same cap. + Keep the core dumb; document the three patterns in `integrations.md`. + (While there: fix integrations.md §"transport is allowed to drop, + duplicate, or reorder — Safe RPC will time out and retry" — after F1 + it times out and *reports*.) + +## F3 — `abortPending` semantics (already being added) + +The missing half of F2: the adapter learns about death from an event +(`ws.onclose`) at a moment when no `send` is in progress — today it has +no way to hand that knowledge to the client, and `rejectPending` is only +reachable via `destroy()`, which is terminal. Required semantics: + +```ts +abortPending(err?: RPCError): void +// 1. no-op when state === "closed" +// 2. reject ALL pending with err ?? new ChannelDownError() (clear timers) +// 3. fail an in-progress handshake with the same error +// (otherwise hello-waiters hang until handshakeTimeout) +// 4. zeroKeys(); state = "idle" — NOT "closed"; client stays usable +``` + +Return `{ api, abortPending, destroy }`. With F1+F2+F3 the full lifecycle +story becomes: *adapter throws/aborts on death → in-flight calls reject +instantly with a retryable code → next call lazily re-handshakes over the +revived transport → caller-visible client object never changes identity.* +That is exactly the stated goal. + +## F4 — Browser-WS trap (documentation, but load-bearing) + +`WebSocket.send()` on a CLOSED socket **silently drops** (it only throws +on CONNECTING). So over the single most common transport, the send-error +path never fires at all: without an adapter-side `readyState` check every +failure degrades into the timeout path — empirically [D]: dead socket → +RPC-timeout + handshake-timeout stacked, ≈15s with defaults, surfacing as +a misleading `HANDSHAKE "Handshake timeout"`. The WS adapter guidance in +`integrations.md` must say: check `readyState` in `send` and throw +`ChannelDownError`; wire `onclose` → `abortPending()`. + +## F5 — Optional: close the documented replay window for ordered-enough transports + +`security.md` accepts request replay ("only known replay window") on the +grounds that a counter scheme needs strict transport ordering. Half-true: +*dedup* does not need ordering. Request ids are already client-monotonic +integers-as-strings; a per-session bounded seen-set on the server +(e.g. LRU of the last 1024 ids, reset per epoch) rejects attacker replays +of captured ciphertexts without constraining reordering or requiring +counters on the wire. One `Set` per session, ~30 lines, no +protocol change (server-side only). Worth doing since the client no +longer duplicates requests itself after F1 — the seen-set then guards a +genuinely attacker-only path. Optional; if declined, keep the current +doc language and rely on application idempotency (Enclave: `device_proof.jti`). + +## F6 — Minor + +- `sendRequest` starts the timeout timer before `send` resolves — an + adapter that waits for reconnect inside `send` (F2.3) spends the call's + own budget. Correct behaviour; document it. +- Counter-exhaustion message says "destroy and recreate client" — at one + call per ms that is ~285k years; harmless, but the message contradicts + the create-once philosophy. Cosmetic. +- After F1, `RemoteRPCError` handling in the proxy simplifies to a plain + passthrough (no retry-exemption needed). + +## Regression fixture + +`/tmp/saferpc-transport-test/test.mjs` (in-memory channel + fault +injection, cases A–D) currently demonstrates [B]/[C]/[D] against 0.6.x. +After F1–F3 land, expected deltas: [C] → caller gets `TIMEOUT`, handler +executes exactly once; [B] → caller gets `CHANNEL`/`CHANNEL_DOWN` +immediately (no hidden retry — app-level retry of an idempotent call is +one line); [D] with a compliant WS adapter → instant `CHANNEL_DOWN` via +`abortPending` instead of 15s of stacked timeouts. Happy to port it into +`test/e2e/` as `transport-lifecycle.test.ts`. diff --git a/review/0.8/auth-api-review-question.md b/review/0.8/auth-api-review-question.md new file mode 100644 index 0000000..f95874e --- /dev/null +++ b/review/0.8/auth-api-review-question.md @@ -0,0 +1,143 @@ +# Design review request: auth helper surface of an RPC library + +I maintain an open-source RPC library (TypeScript, runs in browser / Node / +workers over any byte transport, application-layer encryption). I need a +critique of the **authentication helper API**: its developer experience, and +specifically whether two of the five server-side helpers should exist at all. +One of them has a design gap around principal binding (described below); before +deciding how to address it I want an independent judgement on whether to keep or +remove it. + +No code access needed — everything relevant is inlined. + +## Core auth model + +The handshake is one round-trip: X25519 ECDH → HKDF-SHA-256 → XSalsa20-Poly1305. +Authentication plugs in through a single options object, symmetric on both +sides: + +```typescript +interface AuthOptions { + /** Pre-shared secret mixed into session-key derivation (min 32 bytes). */ + secret?: () => Uint8Array | Promise; + /** Produce a proof over the canonical handshake transcript. + Embedded in hello (client) or reply (server). */ + sign?: (transcript: Uint8Array) => Uint8Array | Promise; + /** Verify the counterparty's proof. Throw to reject. + Server-side: the returned `auth` object becomes the session principal, + exposed to every RPC handler via context. */ + verify?: (proof: Uint8Array, transcript: Uint8Array) => VerifyResult | Promise; +} +``` + +The transcript binds the per-handshake epoch, both ephemeral public keys +(reply direction) and the client nonce, so a signature over it ties the proof +to this specific handshake and prevents reuse of a captured proof in a +different handshake. + +Applications can implement `sign`/`verify` directly — the helpers below are +convenience wrappers. + +## Current helper surface + +Client-side (each returns `{ sign }` to spread into the auth block): + +| Helper | Payload it produces | +| --- | --- | +| `createJWTClientAuth({ getToken })` | `{ jwt, ts, th: SHA-256(transcript) }` | +| `createEd25519ClientAuth({ privateKey, deviceId })` | `{ deviceId, sig: Ed25519(transcript) }` | +| `createECDSAClientAuth({ privateKey, identifier })` | `{ identifier, sig: P-256(transcript) }` (WebCrypto, non-extractable keys) | +| `generateEd25519Keypair()` / `generateECDSAKeypair()` | key generation utilities | + +Server-side (each returns `{ verify }`): + +| Helper | Verifies | +| --- | --- | +| `createJWTServerAuth({ verifyToken, maxAge? })` | JWT via app callback + timestamp skew + transcript digest (const-time) | +| `createEd25519ServerAuth({ getPublicKey, validateDevice? })` | Ed25519 signature over transcript | +| `createECDSAServerAuth({ getPublicKey, validateEntity? })` | P-256 signature over transcript | +| `createCertificateServerAuth({ verifyCertificate, validateSubject? })` | app-supplied chain verification → P-256 signature over transcript with the cert's key | +| `createMultifactorServerAuth({ primary, secondary, combineAuth? })` | composes two verifiers, both must pass | + +Usage: + +```typescript +// client +client(channel, { auth: { ...createEd25519ClientAuth({ privateKey, deviceId }) } }); +// server +server(router, channel, { auth: { ...createEd25519ServerAuth({ getPublicKey }) } }); +``` + +## The two helpers in question + +### 1. `createCertificateServerAuth` + +- **No client counterpart exists.** The application must hand-assemble the + `{ cert, sig }` payload: serialize with the library's exported msgpack + codec, sign the transcript with WebCrypto itself. Even our own unit tests + build the payload by hand. +- The helper delegates the security-critical part — certificate chain + verification — entirely to an app callback + `verifyCertificate(certBytes) → { subject, publicKey }`. What remains inside + the helper is ~30 lines: decode payload, null-checks, one + `crypto.subtle.verify` call over the transcript. +- That remaining signature-over-transcript step is byte-for-byte identical to + what `createECDSAServerAuth` already does. +- We have no known downstream users (the library is young; internal consumers + use Ed25519 and JWT modes). + +### 2. `createMultifactorServerAuth` — a principal-binding design gap + +- **No client counterpart either.** The application calls two sign helpers + itself and wraps them: `mpEncode({ primary: p1, secondary: p2 })`. +- **The gap:** the two factors are verified independently against the same + transcript, then their returned principals are combined with a default + shallow merge `{ ...primaryAuth, ...secondaryAuth, multifactor: true }`. + Nothing in the helper verifies that the two factors resolve to the **same** + principal — because the generic helper does not know the schema of the auth + objects each verifier returns (one returns `{ sub }`, another `{ deviceId }`, + etc.). So a composition where the two factors describe *different* subjects + still yields a single merged principal marked `multifactor: true`. For + bearer-style factors (JWT), whose binding fields are assembled on the client + side rather than signed by the token issuer, the helper cannot itself + guarantee the two factors belong to one identity — that guarantee has to come + from application code that understands the principal schema. +- Because the helper cannot know the schema, "are these two factors the same + principal?" is logic only the application can supply. +- Options if kept: + - (a) drop the default merge, make `combineAuth(primary, secondary)` + **required**, and document that it must assert the two principals match; + - (b) sequential verification: feed the first factor's verified principal + into the second verifier so the second factor can bind to the first + (requires changing the `verify(proof, transcript)` signature across all + helpers); + - (c) both. + +## Questions + +1. **Existence:** for each of the two helpers — keep or delete? Criteria I care + about: does the helper carry real security weight, or does it mostly wrap + trivial plumbing while delegating the hard part to the app; is a server-only + half-helper worse DX than no helper (since the app already has to own the + client side); in a security-focused library, what does a helper whose name + implies it handles X — while the hard part of X lives in an app callback — + signal to an integrating developer. Removing is cheap now (0.x, no known + users); shipping a helper that invites misuse is not. +2. **If multifactor stays:** which option — required `combineAuth`, sequential + verification, or both? Is there a composition design that is safe by default + for a helper that structurally cannot know the principal schema, or is a + documented "write your own `verify` that composes two sub-verifiers and + asserts the binding explicitly" pattern the safer answer than any helper? +3. **DX of the overall auth surface:** is `{ secret?, sign?, verify? }` + + spreadable helpers the right shape? Any rough edges in the helper ergonomics + (naming, config fields, error behavior) from the perspective of a developer + wiring up auth in under an hour? +4. **Cross-language angle:** the wire protocol is being specified for + reimplementation in other languages. Helper payload schemas + (`{ jwt, ts, th }`, `{ deviceId, sig }`, …) become de-facto wire format. + Should helper payloads be part of the normative spec, or explicitly marked + implementation-defined? + +Constraints: pre-1.0, breaking API changes acceptable. Bias toward a minimal +surface — every helper we ship we also have to specify, test thoroughly, and +keep working in browser + Node + workers. diff --git a/review/0.8/review-auth-helpers-fable-2026-07-14.md b/review/0.8/review-auth-helpers-fable-2026-07-14.md new file mode 100644 index 0000000..52c78fc --- /dev/null +++ b/review/0.8/review-auth-helpers-fable-2026-07-14.md @@ -0,0 +1,84 @@ +# Auth Helper Surface — Independent Design Review + +Verified against `src/auth/server.ts`, `src/auth/client.ts`, `src/auth/index.ts`, `src/client.ts`, `src/server.ts`, `src/common.ts`, `spec/security.md`, `spec/api.md`, `spec/protocol.md`, `test/unit/auth-server.test.ts`, `test/security/auth-handshake.test.ts`. All claims below are checked against code. + +--- + +## 1. Verdict per helper + +### `createCertificateServerAuth` — **DELETE** + +**One line:** it is `createECDSAServerAuth` with a different key-lookup callback, wearing a name that promises certificate handling it does not perform. + +Supporting argument: + +- The claim "byte-for-byte identical signature step" is **confirmed**: `src/auth/server.ts:240-246` (certificate) and `src/auth/server.ts:184-190` (ECDSA) both execute `crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, publicKey, sig, transcript)`. The only structural difference is where `publicKey` comes from: `getPublicKey(identifier)` vs `verifyCertificate(cert)`. +- Everything security-relevant — chain building, path validation, expiry, revocation, trust anchors — lives in the app callback `verifyCertificate` (`src/auth/server.ts:204-207`). What the helper owns is `decodeAuthPayload` + two `isPlainBytes` checks + one WebCrypto call (`src/auth/server.ts:218-250`). That is plumbing, not security weight. +- The half-helper problem is real and confirmed: there is no client counterpart in `src/auth/client.ts` (only JWT/Ed25519/ECDSA helpers exist), and both the unit tests (`test/unit/auth-server.test.ts:446-450` — `mpEncode({ cert, sig })` + raw `crypto.subtle.sign`) and the integration test (`test/security/auth-handshake.test.ts:301-313`, with the literal comment "No client helper for certs — application supplies its own `sign`") hand-assemble the payload. If your own test suite has to write the client side by hand, every user will too — and a developer who has already written the client `sign` will find writing the ~25-line server `verify` trivial. +- Misuse signal: in a security-focused library, `createCertificateServerAuth` reads as "the library handles certificates." It doesn't. A developer who plugs in a naive `verifyCertificate` (parse cert, extract key, skip chain validation) gets a helper that happily stamps `{ subject, verified: true }` (`src/auth/server.ts:248`). The name absorbs trust the code doesn't earn. +- Replacement cost: a documented recipe in `spec/security.md` §"Certificate-based" showing a full `sign`/`verify` pair (your integration test at `auth-handshake.test.ts:288-315` is already exactly that recipe). Keep `createECDSAServerAuth` as the pointer for the signature step. + +### `createMultifactorServerAuth` — **DELETE** + +**One line:** a composition helper that structurally cannot enforce the one property that makes composition meaningful — same-principal binding — is a misuse generator, not a convenience. + +Supporting argument: + +- The gap is as described, confirmed at `src/auth/server.ts:299-305`: default combine is `{ ...primaryAuth, ...secondaryAuth, multifactor: true }`. No principal comparison, none possible — the helper sees opaque `Ctx` objects. +- The default merge is worse than the question states. Two additional defects beyond principal binding: + 1. **Spread order silently inverts trust.** `verifyToken` returns the raw token payload as principal (`src/auth/server.ts:105`). Configure `{ primary: ed25519, secondary: jwt }` and a JWT claim named `deviceId` — issuer-signed but attacker-requested — overwrites the *cryptographically verified* `deviceId` from the Ed25519 factor. The developer chose an ordering; the library turned it into a trust decision. + 2. **`multifactor: true` is spoofable in single-factor configs.** Since JWT auth passes token claims through unmodified (`src/auth/server.ts:105`; `sanitize` at `src/server.ts:587` strips only poison keys), a token containing `multifactor: true` sets the same context flag without the helper being involved. The marker an app would gate on is not trustworthy. +- The remaining value is small: decode, two byte-presence checks, short-circuit ordering (verified fail-closed by `test/unit/auth-server.test.ts:679-703`), and the dangerous merge. The safe version of this — decode two sub-payloads, run two verifiers, assert binding against your schema, build one principal — is ~12 lines of app code and *forces* the developer to confront the binding question the helper hides. +- Half-helper again: client side is hand-assembled in every test (`test/security/auth-handshake.test.ts:489-499`, `test/unit/auth-server.test.ts:563-570` — `mpEncode({ primary, secondary })`). +- Pre-1.0, zero known users, minimal-surface bias, and (per Q4 below) every kept helper becomes a normative wire schema you must specify and port. Both criteria columns point the same way. + +--- + +## 2. If multifactor is kept anyway + +**Answer: option (a) only — required `combineAuth`, no default merge — and reject (b).** But the documented-pattern answer dominates both: + +- **(b) sequential verification is wrong for this library.** It changes `AuthOptions.verify`'s signature (`src/common.ts:584-587`) — a core, symmetric, spec'd type (`spec/api.md:159-171`, `spec/protocol.md:162,193`) used by both peers and every port — to serve one helper. And it buys nothing for the motivating case: a JWT cannot bind to a `deviceId` unless the issuer signed that binding into the token. Feeding the first principal into the second verifier doesn't create issuer-signed linkage; the link still has to come from the app's database ("does device X belong to subject Y"), which is exactly a `combineAuth`/app-code check. Signature churn, zero security gain. +- **(a) is honest but weak.** Making `combineAuth` required removes the toxic default, but nothing stops `combineAuth: (p, s) => ({ ...p, ...s })` — the misuse rebuilds itself in one line. A required callback whose contract ("MUST assert both factors resolve to one principal") is enforceable only by documentation is a documented pattern with extra steps. +- Therefore: **the documented pattern is the safer answer.** A "Composing two factors" section in `spec/security.md` with a complete `verify` that (1) decodes `{ primary, secondary }`, (2) calls two sub-verifiers with the same transcript, (3) throws unless `jwtPrincipal.sub` owns `edPrincipal.deviceId` per the app's own store, (4) returns one explicit principal. Your existing integration test (`auth-handshake.test.ts:478-520`) is 80% of this text already — it just needs the binding assertion added, which is precisely the line the helper couldn't write. + +--- + +## 3. DX review of the overall auth surface + +**The core shape is right — keep it.** `{ secret?, sign?, verify? }` is minimal, symmetric across peers, composes secret+asymmetric by construction, and fails closed: `validateAuthConfig` hard-errors when nothing is configured (`src/common.ts:389-405`, called at `src/client.ts:181` and `src/server.ts:267`). Spreadable helpers (`Pick` / `Pick`) compose naturally with `secret:` in the same object literal — the integration tests read exactly how real code will (`auth-handshake.test.ts:284-296`). The all-zero-secret runtime guard (`spec/protocol.md:237`, tested at `auth-handshake.test.ts:395-412`) and hardened `mpDecode` for payloads (`src/auth/server.ts:25-37`) are the good kind of paranoia. A developer wires the Ed25519 happy path in well under an hour from `spec/security.md:265-283`. + +Concrete rough edges: + +1. **Dead `v: 1` version field — spec/code divergence.** All three client helpers emit `v: 1` (`src/auth/client.ts:47`, `:86`, `:124`). No server helper reads it — there is no `payload["v"]` anywhere in `src/auth/server.ts`. Both spec tables omit it (`spec/api.md:442-444`, `spec/security.md:316`). It's an unenforced, undocumented wire byte. Either enforce `v === 1` server-side (mandatory if you take Q4's normative answer) or delete it. *This also contradicts the inlined question, whose payload tables (`{ jwt, ts, th }`, `{ deviceId, sig }`, `{ identifier, sig }`) omit the `v` field the code actually sends.* +2. **Three names for one concept.** The optional pre-verification gate is `validateDevice` (`src/auth/server.ts:117`), `validateEntity` (`:160`), `validateSubject` (`:208`). Pick one field name across configs. +3. **Inconsistent principal shapes.** Ed25519 → `{ deviceId, verified: true }` (`server.ts:150`); ECDSA → `{ identifier, verified: true }` (`:196`); certificate → `{ subject, verified: true }` (`:248`); JWT → raw `verifyToken` result, **no** `verified` marker (`:105`). Switching auth modes forces rewriting the `context` factory, and `verified: true` means "helper ran" in three modes and nothing in the fourth. Either standardize a principal envelope or drop `verified` entirely (it carries no information — an unverified session never reaches `context`). +4. **App-callback exceptions escape untyped.** `verifyCertificate` throws propagate raw — your own test asserts `rejects.toThrow(/bad cert chain/)` rather than an `RPCError` (`test/unit/auth-server.test.ts:488-497`). Same for a throwing `verifyToken` / `getPublicKey`. `onError` consumers get `RPCError("UNAUTHORIZED")` for some rejections and arbitrary `Error`s for others describing the same event. Wrap callback failures in `UNAUTHORIZED` (or `INTERNAL` for lookup infrastructure failures, as already done for a bad resolved key at `server.ts:139`). +5. **Mutual-auth asymmetry has zero helper coverage.** The core supports client-side `verify` (`src/client.ts:712-729`) and server-side `sign` (`src/server.ts:632-650`), but every client helper returns only `sign` and every server helper only `verify`. A developer doing mutual Ed25519 auth writes half of it by hand with no doc calling this out. Cheapest fix: one sentence in `spec/security.md` + reuse note (the server can spread `createEd25519ClientAuth`-style signing — but the names actively fight that reuse, see next point). +6. **`Client`/`Server` in helper names encode direction, not role.** `createEd25519ClientAuth` is really "Ed25519 signer" and `createEd25519ServerAuth` "Ed25519 verifier"; a server proving its identity needs the "Client" helper. If you touch naming pre-1.0, `createEd25519Signer` / `createEd25519Verifier` describes what they return and makes mutual auth read correctly. +7. Minor: `createEd25519ClientAuth` holds a raw seed in closure with no zeroization path or doc note (`src/auth/client.ts:58-92`); the JWT skew rejection message "Stale or future-dated auth" (`server.ts:93`) doesn't say which direction, which costs debugging minutes in clock-skew incidents. + +Note that deleting the two helpers from §1 also deletes rough edges: cert/multifactor account for the untyped-exception case and the worst principal-shape inconsistency. + +--- + +## 4. Cross-language spec question + +**Commit: two-layer answer, and it is already half-implemented in your spec.** The core protocol keeps auth payloads opaque — `spec/protocol.md:146` already says "The signature payload is opaque to the protocol; its length must be in `1..MAX_AUTH_BYTES`." Keep that. Above it, **the payload schema of every helper you ship must be normative**, published as a versioned "standard auth profiles" appendix — not implementation-defined. + +Reasoning: the deployments that motivate cross-language reimplementation are heterogeneous by definition — TS browser client against a Rust or Go server. If helper payloads are implementation-defined, `createEd25519ClientAuth` in TS and the Rust port's Ed25519 verifier have no interop guarantee, which makes the helpers useless in exactly the deployments the spec exists for. "Implementation-defined" is only coherent for auth the application writes end-to-end itself — and the spec already covers that case via the opaque-bytes rule. + +Consequences you must accept with this answer: + +- The `v` field stops being dead weight: profiles are versioned by it, and verifiers MUST reject unknown `v` (fixing rough edge #1). +- Every shipped helper = a wire schema you specify, test with cross-implementation vectors, and maintain forever. This is the strongest independent argument for the §1 deletions: post-deletion you specify three profiles (JWT, Ed25519, ECDSA) instead of five, and the two you'd have dropped are precisely the ones whose schemas (`{ cert, sig }`, `{ primary, secondary }`) delegate their semantics to app callbacks that no spec can pin down. + +--- + +## Accuracy of the inlined question vs code + +- ✅ Multifactor default merge, cert/ECDSA signature-step identity, hand-assembled test payloads, JWT const-time digest check (`server.ts:102`), "~30 lines" for the cert helper (actual body: `server.ts:216-250`) — all confirmed. +- ⚠️ **One divergence:** the payload tables omit the `v: 1` field that all three client helpers actually emit (`src/auth/client.ts:47, 86, 124`) and that no server helper validates. See rough edge #1. +- ⚠️ One nuance: "Even our own unit tests build the payload by hand" understates for multifactor — the *sub*-payloads are built with real client helpers (`test/unit/auth-server.test.ts:563-568`); only the `{ primary, secondary }` wrapper is by hand. Doesn't change the verdict; the wrapper is exactly the missing client half. + +**Bottom line:** delete both helpers, publish two composition recipes in `spec/security.md` (certificate, two-factor with explicit binding assertion), make the three surviving helper payloads normative versioned profiles with enforced `v`, and unify the config-field/principal-shape naming while 0.x still lets you. diff --git a/review/0.8/review-findings-2026-07-14.md b/review/0.8/review-findings-2026-07-14.md new file mode 100644 index 0000000..2786f0f --- /dev/null +++ b/review/0.8/review-findings-2026-07-14.md @@ -0,0 +1,200 @@ +# SafeRPC review findings — 2026-07-14 + +Reviewed `src/` and the current specs: + +- `spec/protocol.md` +- `spec/security.md` +- `spec/api.md` +- `spec/integrations.md` + +No `CLAUDE.md` exists in the current tree or in git history — only +`.claude/commands/release-notes.md`. + +## 1. Multifactor helper does not bind factors to a single principal + +**Priority:** high +**Code:** `src/auth/server.ts:277-300` + +Both factors are verified independently, then their auth data is combined with a +shallow merge: + +```ts +{ + ...primaryAuth, + ...secondaryAuth, + multifactor: true, +} +``` + +As a result, a valid JWT from one user plus a valid device key from a *different* +user produce a combined result like: + +```ts +{ + sub: "user-a", + deviceId: "device-of-user-b", + multifactor: true, +} +``` + +If downstream authorization keys only on `sub`, the system treats this +combination as genuine MFA even though the factors belong to different +principals. + +**Fix:** drop the default merge or make `combineAuth` mandatory. The combination +must check that both factors belong to the same principal. Alternative: feed the +first factor's result into the second factor's verification. + +## 2. Successful AEAD does not confirm the candidate on inner-payload error + +**Priority:** medium +**Code:** `src/common.ts:249-265`, `src/server.ts:741-758` +**Spec:** `spec/protocol.md:186,203` + +`createDecryptor()` does three things in one block: + +1. Poly1305 verification; +2. msgpack decode; +3. sanitize. + +The server treats a failure of any step as a single decrypt failure. So a +correctly authenticated frame with invalid inner msgpack: + +- does not promote the candidate; +- does not clear the candidate timer; +- does not record the nonce in the replay window. + +The spec requires promoting the candidate immediately after a successful AEAD +check, regardless of the inner RPC payload's shape. + +Verified with a dedicated probe: correctly encrypted plaintext `0xc1` passed +Poly1305, but the candidate was not promoted and `handshakeTimeout` fired. + +**Fix:** separate AEAD decrypt from decode/sanitize. Promote the candidate and +record the nonce right after Poly1305 succeeds. Handle the inner-payload error +after that. + +## 3. Synchronous auth callbacks can outrun `handshakeTimeout` + +**Priority:** medium +**Server:** `src/server.ts:469-477,546,611,631-640` +**Client:** `src/client.ts:560-570,708,718` + +Timing is bounded by a timer plus a boolean flag. A synchronous callback blocks +the event loop, and the continuation after `await` runs as a microtask before the +timeout callback. There is no actual-deadline check via `Date.now()`. + +Probe: + +- `handshakeTimeout: 100`; +- synchronous `auth.verify` runs for 160 ms; +- the server still installs the candidate and sends the reply afterward. + +**Fix:** compute an absolute deadline. Check it after every auth callback and +immediately before candidate install, session publish, and reply send. + +## 4. Some protective limits accept `NaN` and `Infinity` + +**Priority:** medium +**Code:** `src/client.ts:191-214`, `src/server.ts:272-273`, `src/auth/server.ts:63-84` + +Not validated: + +- `client.maxPending`; +- client/server `maxMessageBytes`; +- JWT `maxAge`. + +Example consequences: + +```ts +maxMessageBytes: NaN +``` + +The `data.length > maxBytes` check always returns `false`, so the size limit +stops working. + +```ts +maxPending: NaN +``` + +The pending-call cap stops working. + +```ts +maxAge: NaN // or Infinity +``` + +The JWT auth-payload age check stops dropping stale values. + +Such values are easy to get from config read via `Number(process.env.VALUE)`. + +**Fix:** require a finite positive integer for sizes and limits; a finite +non-negative integer for `maxAge`. + +## 5. Puppeteer is in production optional dependencies + +**Priority:** medium +**Code:** `package.json:52-54` + +`puppeteer` is used only in `test/e2e/browser.test.ts` but sits in +`optionalDependencies`. Optional dependencies install for package consumers by +default. + +The current `npm audit --omit=dev` reports, through this branch: + +- `ws@8.20.0` — high and moderate advisories; +- `js-yaml@4.1.1` — moderate advisory. + +Excluding optional dependencies leaves the production audit clean. + +**Fix:** move Puppeteer to `devDependencies`, update the lockfile, and bump `ws` +to the patched version. + +## 6. Failed handshake-reply send leaves the candidate until its timer + +**Priority:** low +**Code:** `src/server.ts:649-684,701-710` + +The candidate is installed before `await channel.send(reply)`. If the send +fails: + +- the candidate stays installed until the remaining budget expires; +- `onError` first gets `Handshake failed`; +- then the candidate timer may additionally report `Handshake timeout`. + +**Fix:** on send failure, drop the candidate if `candidateEpoch` still matches +this handshake attempt. Keep the original transport error as the cause. + +## 7. Middleware can complete without calling `next()` + +**Priority:** low +**Code:** `src/server.ts:174-201` +**Spec:** `spec/api.md:90,403` + +Only a double `next()` call is currently checked. A middleware can return a value +without ever calling `next()`: the handler is skipped but the client gets a +success. + +The API contract requires calling `next()` exactly once. + +**Fix:** after the middleware completes, check `called === true`; otherwise +return `RPCError("MIDDLEWARE", ...)`. + +## Assessment doc needs a sync + +**File:** `spec/assessment.md` + +- Line 42 claims `CHANNEL` resets the session. The current implementation resets + only for a sent call with `RPCAbortedError("TIMEOUT")`. +- Line 58 lists a default `sendTimeout` of 10 seconds; the actual value is 3 + seconds. +- Line 10 lists 266 tests; there are now 280. + +## Checks performed + +- Full test suite: **280/280**. +- Build: passes. +- Lint: passes. +- Low-order X25519 inputs: `@noble/curves` rejects them correctly; the claimed + protection works. +- Two separate behavioral probes confirmed findings #2 and #3. +- The working tree stayed clean after review; temporary probe files were removed. diff --git a/review/0.8/review-porter-audit-sol-2026-07-14.md b/review/0.8/review-porter-audit-sol-2026-07-14.md new file mode 100644 index 0000000..bd6ebb0 --- /dev/null +++ b/review/0.8/review-porter-audit-sol-2026-07-14.md @@ -0,0 +1,149 @@ +# Porter audit — gpt-5.6-sol:high, 2026-07-14 + +**Method:** adversarial clean-context read of `spec/protocol.md` + `spec/security.md` ONLY (no src/, no test/), in the role of a Rust/Go porter. Agent: `porter-audit-sol`. Every finding = a question the porter cannot answer from the text. + +**Verdict (agent's):** ~70% of the implementation determined; crypto/framing portable tomorrow, but parser acceptance, handshake concurrency, replay promotion ordering, async sending, envelope validation, middleware semantics, and several error paths require guessing. + +**Triage status:** #39 FIXED (spec error, same day). **Batches 1–4 triaged + patched 2026-07-14/15** — see per-item resolutions below. Remaining ambiguity closures are recorded after Batch 4; no audit item is currently blocked. + +### Batch 1 resolutions (all patched into protocol.md unless noted) + +- **#2 numeric domain — spec was already correct; empirically confirmed + extended.** `mpEncode` uses `useBigInt64:true`; tested: `Date.now()`→0xcb float64 (decodes as number, NOT BigInt — the earlier probe-note that suggested BigInt used a different codec config). Existing lines 48/569 (ts=float64, uint64→rejected) hold. Added bullets covering negatives/non-integers/NaN/±Inf/−0, the "only big-integer selects 64-bit int family" rule, decoder framing (single top-level value, trailing→throw, dup-keys→last-wins no-throw, invalid-UTF-8→decoder-defined), and universal unknown-field tolerance. +- **#3 parser differentials — empirically pinned.** trailing bytes → THROW ("Extra N byte(s)"); duplicate keys → last-wins, no reject; invalid UTF-8 → no throw, substitutes. All three now normative in the msgpack profile. +- **#4 unknown hello/reply fields — RESOLVED (code correct).** Server/client read known keys by name; unknown fields ignored exactly like request/response. Added universal unknown-field-tolerance bullet. +- **#5 empty auth — RESOLVED (code correct).** No verifier configured ⇒ `auth` never read (ignored); verifier configured ⇒ `auth` required `1..MAX_AUTH_BYTES`, `bin(0)`/absent/oversized fail the attempt. Clarified in the TAG_HELLO frame section. +- **#20 MAX_ID_LEN unit — legalized.** Reference counts UTF-16 code units (JS `String.length`); documented as a resource guard, not a wire invariant; ids are opaque/ASCII-recommended so counts coincide; peers must not depend on the non-ASCII threshold. +- **#37 Ed25519 strictness — pinned.** Reference uses `@noble/curves` default = **ZIP-215** (confirmed `ed25519.ts:150` sets `zip215:true`). Documented as a normative vector-agreement parameter; ports may choose RFC 8032 strict but must document it; not replay-exploitable (transcript binds epoch+nonces). +- **#38 ECDSA high-S — pinned.** Reference uses WebCrypto `crypto.subtle.verify` which accepts high-S; ports MUST NOT add a low-S gate. Not replay-exploitable (transcript binding). + +### Batch 2 resolutions (handshake/state #6–19 — patched 2026-07-14; NO code changes, all code correct) + +- **#6 auth predicate — pinned.** `validateAuthConfig`: valid iff ≥1 of `secret`/`sign`/`verify`; every non-empty subset legal (secret-only, sign-only, verify-only, combos); none ⇒ construction error. Per-direction authentication decided at handshake time, not construction. New "Auth configuration predicate" note. +- **#7 epoch advancement — pinned.** Two deliberate `epoch++`: `startHandshake` (549) AND `reset` (1193). Reference counter doubles as staleness generation → advances on reset too → wire epochs may skip; server must not assume contiguity. Documented at step 1. +- **#8 exhaustion — legalized.** No client-side terminal guard exists; reference never wraps/reuses, and past 2³² the server rejects out-of-range epoch → permanent HANDSHAKE. Ports MAY add explicit terminal error; MUST NOT wrap/reuse. Spec softened from "terminal error" to describe actual behavior. +- **#9 client hello-send failure — RESOLVED (code correct).** Not a failure: hello queued + retried by flush tick, bounded by handshakeTimeout, revoked if attempt dies; error not surfaced. (Contradicts audit's "fails immediately" guess.) New note. +- **#10 server reply-send failure — RESOLVED (code correct).** Candidate dropped immediately (guarded on candidateEpoch), HANDSHAKE via onError with transport error as cause. New note. +- **#11 deadline origins — pinned.** Client: start of attempt (after keygen), `hsDeadline = Date.now()+hsTimeout`. Server: hello arrival (attemptStart); one budget spans validation + confirmation (confirmation timer inherits remaining). New note. +- **#12 verify return shape — pinned + spec corrected.** Server extracts `.auth` MEMBER, sanitizes THAT (not wrapper); absent `.auth` ⇒ success but no principal; non-object `.auth` ⇒ HANDSHAKE. Old wording ("sanitizes the `{auth:...}` object") tightened. +- **#13 no-verify auth — pinned.** context factory gets `{}` (no `auth` key, not `{auth:undefined}`); no factory + auth → principal fields on null-proto ctx; no factory + no auth → empty null-proto ctx. +- **#14 candidate concurrency — pinned (JS single-thread) + porter note.** Three generation counters (attempt/candidate/live-epoch) re-checked after every await; threaded ports must serialize trial-decrypt-and-promote vs candidate swap. New "Concurrency and generation guards" paragraph. +- **#15/#17 replay clearing — pinned.** `seenClear()` called ONLY in `promoteCandidate` (confirmed) → cleared on PROMOTION not install; make-before-break window intact; confirming nonce recorded AFTER clear (`promoteCandidate` then `seenAdd`) so its replay is caught. Rule 4 rewritten. +- **#16 atomic nonce — pinned.** `seenAdd` synchronous before first `await` → back-to-back dupes can't both pass; threaded ports MUST lock check+insert. Added to replay rules. +- **#18 response guard — already covered + reinforced.** `epoch !== reqEpoch || destroyed` drops ordinary responses too; reqEpoch captured at arrival (after this frame's own promotion). Cross-ref to execution pipeline. +- **#19 stray hello — RESOLVED (code correct).** `tag === TAG_HELLO && state === "handshaking"` — hello processed ONLY while handshaking; ignored in idle/ready/closed. Covered by generation-guards note + state machine. + +### Batch 3 resolutions (RPC/pipeline #21–30 — patched 2026-07-15) + +- **#21 ID lifetime — spec corrected.** Runtime counter is never reset by session reset/re-handshake; ids are unique for the entire client instance lifetime. Spec request schema and nuance now say this explicitly. +- **#22 response required shape — code tightened + spec pinned.** Client now requires `d` and `e` outer keys before consuming a pending entry: success requires `e:null`; failure requires `d:null` and a map `e`. Invalid envelopes are silently dropped. Error-map members remain defensively coerced (`c`/`m`), so malformed inner members do not invalidate an otherwise well-formed failure envelope. Added regression cases to `test/security/malformed-response.test.ts`. +- **#23 absent success/error data — pinned.** The reference includes the keys and msgpack encodes JS `undefined` as wire `nil`: no-output success is `{ok:true,d:nil,e:nil}`; an RPCError with absent data carries `e.d:nil`; ordinary failure always has outer `d:nil`. Added to response shape text. +- **#24 protocol error messages — intentionally non-normative.** Wire conformance requires the error code and envelope, not human-readable `m`; exact wording is implementation text. Reference common strings are listed for diagnostics, while schema issue details remain non-normative. +- **#25 typed RPC recognition — pinned.** Server recognizes its typed `RPCError` (including subclasses) and preserves its own `code/message/data`; any other thrown value maps to generic `INTERNAL` with no detail leak. Sanitizing error data can itself abort response production. Added to response/pipeline text. +- **#26 middleware continuation — pinned.** `next(extra?)` exactly once; non-null object extra is shallow-merged into a fresh null-prototype context; `next` returns downstream promise, but middleware's own return value is propagated to the preceding step rather than automatically replaced by downstream result. Added explicit semantics. +- **#27 async middleware races — pinned to reference contract.** `next` must be called before middleware completion; no-call ⇒ `MIDDLEWARE`, second call ⇒ `MIDDLEWARE`. A call made without awaiting/returning its promise is accepted after the call itself occurs; downstream can run concurrently and its result/error is only observed if the continuation promise is observed. Late calls after completion are invalid behavior. Added timing/precedence note. +- **#28 declaration-order composition — pinned.** Steps are nested continuations in declaration order; first declared is outermost, handler innermost. An output schema validates the value returned by the remainder, which may be middleware's replacement return value. Added to pipeline section. +- **#29 oversized outbound RPC — code fixed + spec pinned.** Client checks full encrypted frame length against its local `maxMessageBytes` before creating a pending entry/handing off; rejects `RPCError("CLIENT", "Message exceeds maxMessageBytes")`, does not send or reset. Added DoS regression test. +- **#30 client input sanitize — code fixed + spec pinned.** Client sanitizes supplied input before msgpack encoding, matching the server's plain-data gate; invalid host/ext/deep values fail locally as `INVALID_DATA` and never reach the adapter. Added Date regression test. + +### Batch 3 review follow-up (gpt-5.6-luna:high — patched 2026-07-15) + +- **Late middleware continuation — fixed.** `runMiddleware` now marks completion in `finally`; `next()` invoked after the middleware promise settles returns without launching downstream. Added regression test proving a delayed `next()` cannot invoke the handler after a `MIDDLEWARE` response. +- **Sanitizer primitive gap — fixed.** `sanitize()` now rejects `function` and `symbol` with `RPCError("INVALID_DATA")` before `mpEncode`; added function/symbol regression coverage. +- **Handshake-before-validation — fixed.** The client sanitizes input in the API proxy before `ensureHandshake()`, so an invalid first call emits no hello or RPC frame. `sendRequest()` retains the gate as defense-in-depth. +- **Luna suggestions applied.** Protocol now explicitly states the reference's late-`next()` ignore behavior and function/symbol rejection; `e.d` absence is pinned to `null`, and protocol error-code emission is mandatory rather than merely recommended. `spec/api.md` middleware wording is synchronized. Boundary `== maxMessageBytes` coverage remains an optional follow-up. + +### Batch 4 resolutions (#31–35 — patched 2026-07-15) + +- **#31 queued calls invalidated by reset — pinned.** A reset immediately removes every queued message encrypted under the retired epoch and rejects it with plain `RPCError("CHANNEL")`; the ciphertext is never re-encrypted or carried into the replacement session. Queued stale hellos are silently revoked. +- **#32 async-send terminal race — pinned.** The first event that settles a pending call wins. If timeout/abort/destroy/response already removed the pending entry, a later async-send rejection is ignored: it cannot requeue or settle the call a second time. An unresolved send is rolled back only while the call remains pending and the session/epoch is unchanged. +- **#33 response vs async-send rejection — pinned.** A valid response consumes the pending entry and wins even if the adapter's send promise is unresolved; a later send rejection is a no-op. Conversely, an earlier send rejection rolls back while the call remains pending. +- **#34 simultaneous terminal events — pinned.** Ports serialize event handling; the first observed settling event wins. The error class is selected from the sent boundary at that event. Only a sent-call `RPCAbortedError("TIMEOUT")` triggers reset, subject to the epoch guard; late replies and transport completions are ignored after settlement. +- **#35 reset and shared handshake — pinned.** `reset()` only retires keys and returns to `idle`; it does not start a handshake itself. The next call, or another pending call reaching `ensureHandshake()`, starts or joins one shared lazy handshake. + +### Remaining ambiguity closures (patched 2026-07-15) + +- **CSPRNG, malformed replies, deadlines, response ids — pinned.** Ephemeral keys/nonces require a CSPRNG; a failed RNG aborts the attempt without installing state. A malformed current-attempt reply fails that attempt, a valid stale-epoch reply is silently ignored, and unknown/settled response ids are silently ignored. Handshake deadlines use absolute wall-clock checks with `now >= deadline`; timers are only wakeups. +- **`maxPending` accounting — pinned.** Calls waiting for the shared handshake do not consume slots. A slot is reserved at encrypted-request admission and held across queued/async/sent states until settlement. +- **Sanitization graph semantics — fixed + pinned.** The sanitizer now rejects cyclic outbound graphs as `INVALID_DATA` instead of recursing until a host stack failure. Root depth is 0, map keys do not add depth, and non-cyclic repeated references are allowed. Added regression coverage. +- **JWT timestamp semantics — pinned.** The verifier samples `now` once at the timestamp check; finite fractional millisecond timestamps are accepted, matching the reference's non-integrality check. +- **`secret()` type-failure taxonomy — pinned.** A non-byte-string, too-short, or all-zero `secret()` return fails the handshake with the generic `HANDSHAKE` class on both peers; there is no distinct code for a mistyped application secret. +- **`t`-check vs sanitization ordering — pinned.** Plaintext sanitization runs during decode, before the `t` reflection check; it is side-effect-free, so its position is not security-relevant. What must precede handler dispatch/reply is the `t` check, and on the server AEAD verification (plus the promotion/nonce-record it authorizes) precedes plaintext decode. +- **Numeric option integrality — already normative (confirmed).** `timeout`/`sendTimeout`/`handshakeTimeout` accept finite fractional milliseconds; `maxPending`/`maxMessageBytes`/`replayWindow` require integers. Documented in `api.md`. +- **Typed-error precedence & error-`d` handling — already normative (confirmed).** A stage that throws the typed RPC error keeps its own code over the surrounding stage; a malformed/absent `e.d` is coerced defensively (absent ⇒ nil). No spec change needed. + +--- + +## Blockers + +1. **Document map — normative dependency on `api.md`.** What exact error classes, codes, causes, timeout behavior, constructor validation, and typed-error recognition must a port reproduce? protocol.md says ports mirror those semantics from api.md, while also saying only protocol.md is fully normative. +2. **msgpack profile — incomplete numeric encoding domain.** Negative integers, non-integral values, NaN, infinities, negative zero, integers outside exact IEEE-754 range; "outside the 32-bit range" doesn't define signed/unsigned bounds; float32 vs float64 unstated except JWT `ts`. +3. **msgpack profile / Sanitization — parser differentials.** Duplicate map keys, invalid UTF-8 in `str`, trailing bytes after the first msgpack value — reject, replace, keep-first, keep-last, ignore? Affects envelopes and signed auth profiles. +4. **TAG_HELLO — unknown hello fields.** Ignored, or does "check shape" require an exact field set? Unknown-field tolerance is explicitly normative only for request/response maps. +5. **Empty `auth`.** Is `auth: bin(0)` malformed when no verifier is configured? Frame schema, auth section (`1..MAX_AUTH_BYTES`), and compatibility section disagree. +6. **Valid auth callback combinations.** Which configurations pass construction validation: `sign` alone, `verify` alone, either + `secret`, only `sign+verify`? The predicate is undefined. +7. **Epoch advancement.** Does a timeout reset increment the wire epoch itself, and the next handshake again? Step 1 says once per attempt; checklist says per attempt AND per reset. Ports could send 1,2,… vs 1,3,… +8. **Epoch exhaustion.** What terminal state and typed error; do later calls return the same permanent error? +9. **Client hello send failure.** Queued under sendTimeout? Retried until handshake deadline? Shared handshake fails immediately? Outbound queue described for call frames only. +10. **Async server reply-send rejection.** Does it count as "reply send fails" and drop the candidate? What if confirmation/replacement happens before the rejection is observed? +11. **Handshake deadline origin.** Client: API-call entry / keygen / before sign / hello handoff? Server: transport delivery / before decode / after shape validation? +12. **Verifier return shape.** Principal directly, `{ auth: principal }`, or wrapper with `.auth` extracted? Text alternates. +13. **Session auth without `verify`.** Exact value given to context factory: absent, nil, empty map? +14. **Candidate concurrency linearization.** Candidate decrypt vs timeout vs newer-hello install vs promotion vs destroy in a threaded port; post-decrypt candidate-generation guard unspecified. +15. **Confirmation nonce ordering.** Inserted after promotion clears the old set, or before (and erased)? Checklist requires the malformed confirming frame to consume its nonce; promotion clears the set. Wrong order permits replay of the first request. +16. **Concurrent duplicate frames.** Must nonce acceptance be atomic post-AEAD? Two concurrent copies can both pass check-before-decrypt. +17. **"Cleared on re-handshake" meaning.** Cleared on promotion only, or at candidate install (which reopens replay of the still-serving live session)? +18. **Response-guard capture for ordinary requests.** When is the guard epoch captured? Does candidate installation count as "re-handshake" or only promotion? +19. **TAG_HELLO outside an active client attempt.** What must a client in idle/ready do with a valid/malformed/matching-epoch hello frame? +20. **MAX_ID_LEN unit.** UTF-8 bytes, scalar values, graphemes, or UTF-16 code units? A 64-char non-ASCII id accepted by one port, dropped by another. +21. **ID lifetime.** Unique within one session key or full client instance? Schema and nuance text disagree. +22. **Response required shape.** Must success contain `d` AND `e: nil`; failure `d: nil` + `e`? Are contradictory fields (`ok:true, e:{...}`) accepted? +23. **Absent success/error data.** No-output handler: `d:nil`, omitted, or INVALID_DATA? Typed-error `e.d` omitted or nil? +24. **Protocol error messages.** Exact `m` strings for NOT_FOUND/validation/middleware/etc. — wire-visible; only INTERNAL is fixed. +25. **Typed RPC error recognition.** What constitutes "the implementation's typed RPC error"; what validation before building the failure envelope? +26. **Middleware continuation semantics.** What `next` accepts/returns, context-extension merge, return-value propagation. +27. **Async middleware races.** `next()` unawaited / returns before completion / second call after completion / throw while downstream runs — error precedence, response-or-not. +28. **Declaration-order composition.** Execution topology when multiple schemas interleave with middleware; what an output schema validates when declared before the handler boundary. +29. **Oversized outbound RPC frames.** Local CLIENT/CHANNEL/INVALID_DATA, silent omission, onError, or attempted send? Sent-boundary consequences? +30. **Client input sanitize gate.** Must request input pass the plain-data sanitizer before encoding? +31. **Queued calls invalidated by reset.** Unsent frames under the retired key: immediate CHANNEL, continued retry, re-encrypt later, or wait for timers? +32. **Async-send terminal race.** Call already terminally rejected (timeout/abort) while send in flight; the send later rejects proving never-left — spec says requeue/CHANNEL, but caller already has a terminal result. +33. **Response vs async-send rejection.** Valid response arrives while send unresolved; send later rejects. Which event wins? +34. **Simultaneous terminal events.** Reply / call timeout / abort / destroy / send completion concurrently ready — required winner and reset-or-not. +35. **Reset and shared handshake wording.** Does the timeout-triggered reset itself begin a handshake? Texts disagree. +36. **Prototype-pollution keys across languages.** Language-neutral rules imply preservation outside JS; checklist says sanitization strips them. Observable divergence in handler inputs. +37. **Ed25519 verifier strictness.** Non-canonical scalars, small-order pubs, non-canonical encodings — mandatory rules unstated; libraries differ. +38. **ECDSA high-S.** Accept mathematically-valid high-S P-256 signatures or enforce low-S? Only the vector is identified as low-S. +39. ~~**Reply transcript "includes the proof".**~~ **FIXED 2026-07-14** — transcript = magic‖epoch‖clientPub‖clientNonce‖serverPub; spec text corrected in checklist + security.md. + +## Ambiguities + +1. CSPRNG requirement + failure error for keypairs/nonces. +2. Malformed-reply attribution during an attempt (which hellos fail it vs are ignored). +3. `onError` coverage matrix on the server. +4. Monotonic vs wall clock; deadline-boundary inclusive/exclusive. +5. `secret()` type-failure code taxonomy. +6. `t`-check ordering vs sanitization in decrypt pseudocode. +7. Unknown/consumed response ids — always silent? local diagnostic? +8. Error `d` absent/malformed — expose absent, nil, sanitized value, or discard? +9. Typed-error precedence when a schema/middleware throws a typed RPC error (its code vs the stage code). +10. `maxPending` accounting (waiting-on-handshake? queued? async-in-flight? sent?) + slot reserve/release points. +11. Numeric option validation: integrality of fractional values. +12. Sanitization depth accounting (root 0 or 1; keys counted?). +13. Cycles in outbound data — classification. +14. JWT `ts` integrality (1700000000000.5 rejected?). +15. JWT `now` sampling point. +16. Client error details for proof mismatch / malformed reply / sanitizer failure / invalid auth payload / hs timeout. +17. "Should emit the same codes" — mandatory or not; non-pipeline protocol failures. +18. Test vectors — conformance scope (primitives only vs protocol certification; no full hello/reply/response/malformed/timer/concurrency vectors). + +## Nits + +- Security "Why authentication is required": hello transcript cannot contain the not-yet-created server key. +- Server state-machine diagram vs prose on "attempt error" dropping the candidate. +- JWT `ts` labeled `uint` while wire representation is float64 (verify!). +- Compatibility "peers that do not understand auth ignore it" reads like unauthenticated-legacy support. +- XSalsa20-Poly1305 labeled AEAD though secretbox has no AD input; resolved later. + +## Urge log (questions the agent wanted src/ for) + +msgpack strictness (trailing/dup-keys/UTF-8); hello shape guards; epoch counter sharing between reset and wire; deadline creation points; hello send queueing + async handshake-send rejection; verifier output extraction; candidate promotion + replay insertion locking; promotion clear-vs-record order; response required fields before pending consumption; middleware runner edge cases; queued frames on foreign reset; race winners; maxPending accounting; sanitizer pollution-keys in non-JS + depth start; Ed25519 strict verification; ECDSA high-S; exact protocol error messages/classes. diff --git a/review/0.8/review-porter-audit-sol-round2-2026-07-15.md b/review/0.8/review-porter-audit-sol-round2-2026-07-15.md new file mode 100644 index 0000000..5b713cf --- /dev/null +++ b/review/0.8/review-porter-audit-sol-round2-2026-07-15.md @@ -0,0 +1,259 @@ +# Porter audit round 2 — gpt-5.6-sol:high, 2026-07-15 + +**Target:** HEAD `36ebeaa` (branch `0.8.0-fixes`), release candidate 0.8.0. +**Method:** adversarial re-read after the previous 9 findings were closed, plus runtime probes. All 9 prior items confirmed fixed. +**Verdict:** **0.8.0 not release-ready.** 10 new divergences below. Artifact verification added per item by opus (main agent) 2026-07-15 — every finding re-checked against `src/`, not inherited. + +## Resolution status (2026-07-15, opus) + +All 10 addressed. Gate after fixes: tsc (main+test) clean, project-lint clean, **291/291** tests (+12 regressions, each verified red-before-green), build (ESM+CJS) + `npm pack` clean. + +| # | Kind | Fix | Regression | +|---|------|-----|------------| +| 1 | doc | security.md: JWT wire-visible → needs confidential transport | — | +| 2 | doc | security.md "Authentication is directional" + getting-started caveat | — | +| 3 | config | `engines.node` → `>=20.19.0`, README | — | +| 4 | code | `sanitize()` rejects bigint outside `[-2^63, 2^64-1]` | sanitize.test.ts | +| 5 | code | sanitizer drops `undefined`-valued object keys | sanitize.test.ts | +| 6 | code | candidate-promotion absolute-deadline check + single onError | review-round2-fixes | +| 7 | code+doc | downstream promise observed on fire-and-forget `next()`; spec note | middleware-attacks | +| 8 | code+doc | `isEmptySecret` any-length; `deriveSessionSecret` rejects zero input; doc | psk-auth.test.ts | +| 9 | code | nonce recorded only after direction (`t`) check | review-round2-fixes | +| 10 | doc | protocol.md 446/595/726 aligned to code (typed context error preserved) | — | + +Still pending (not a code fix): version bump `package.json` / `jsr.json` 0.7.0 → release. + +> Note: sol produced these findings. An Opus-model verification pass was blocked mid-run by Anthropic's content-safety refusal (flagged the security probes as "violative cyber content"); the artifact re-check below was completed on the main agent without refusal. fable-5 is not wired to any agent; the non-Anthropic reviewers are `porter-audit-sol` / `reviewer-luna` (gpt-5.6). + +## Gate at time of review + +- `npm test`: 279/279 passed +- `npm run test:typecheck`: passed +- `npm run lint`: passed +- `npm run build`: passed +- ESM/CJS smoke on Node 22: passed +- `npm pack --dry-run`: passed +- Tracked files unchanged; `?? auth-api-review-question.md` still present +- `package.json` / `jsr.json` still at 0.7.0 — separate release bump pending + +All findings are holes that pass the current test suite. + +--- + +## 1. 🔴→🟠 JWT bearer visible in cleartext handshake frame + +**Priority:** sol 🔴 / opus 🟠 (documentation completeness, not a code bug) +**Code:** `src/auth/client.ts:46-51`, `src/client.ts:602-613` +**Spec:** `spec/security.md:9,314-316` + +`createJWTClientAuth()` puts the JWT into the auth payload, which rides the **unencrypted** TAG_HELLO. Probe decoded the token straight off the frame: + +```text +{ capturedJwt: "secret-bearer-token", outerEncrypted: false } +``` + +The transcript digest binds the payload to this handshake but does not hide the JWT. A passive eavesdropper on the transport harvests the bearer token directly and can mint a fresh payload for its own session. + +**Artifact check (opus):** `security.md:316` is honest about bearer *reuse* ("anyone holding one can authenticate until it expires. Combine with PSK or a real signature mode when this matters") and `security.md:9` lists eavesdrop in the threat model. But no text connects the dots: the JWT travels in cleartext in the hello, so **JWT-only mode over an untrusted transport hands the token to any passive listener**. Real gap, but in docs, not code. + +**Required alignment:** state in the JWT section that JWT-only requires a confidential transport (or PSK / signature mode) because the token is wire-visible in the hello. + +## 2. 🔴→🟠 Asymmetric examples do not authenticate the server + +**Priority:** sol 🔴 / opus 🟠 (examples/docs; code is correct) +**Spec:** `spec/getting-started.md:222-237`, `spec/security.md:333-348` +**Code correct per:** `spec/protocol.md:260` + +The built-in examples give the client `sign` only and the server `verify` only — one-directional. This authenticates the client to the server, but the client never verifies the server's identity. Under EMPTY_SECRET the client's HMAC proof only proves possession of the current ephemeral key. Probe: a client holding a real Ed25519 device key completed a handshake with an `attacker-server` that does not check the client proof. + +**Artifact check (opus):** the protocol explicitly permits one-directional configuration (`protocol.md:260`), so the code conforms. The mutual-auth machinery and the MFA cross-principal warning exist (`security.md:323`). The gap is that the **examples** never state one-directional ≠ mutual: for the client to authenticate the server you need both directions (`sign`+`verify` on each side) or a PSK. + +**Required alignment:** annotate the asymmetric examples with the one-directional caveat. + +## 3. 🔴 CommonJS build breaks on the declared Node range + +**Priority:** 🔴 (verified real) +**Code:** `package.json:40,54` + +`engines: { node: ">=18" }`, but `@noble/* ^2.2.0` requires Node ≥ 20.19.0 and the CJS build calls ESM-only deps through `require()`. + +```text +Node 18.20.8 → ERR_REQUIRE_ESM +Node 20.18.3 → ERR_REQUIRE_ESM +Node 20.19.5 → OK +Node 22 → OK +``` + +ESM entry loads on Node 18 but the dependency engines still conflict with the declared range. README promises Node 18+ AND dual ESM/CJS. + +**Artifact check (opus):** confirmed `package.json:40` (`@noble/* ^2.2.0`) and `:54` (`node >=18`). Real. + +**Required alignment:** raise `engines.node` to the real floor (≥ 20.19) and correct README, or pin deps that support 18. + +## 4. 🔴 BigInt beyond 64 bits silently changes value + +**Priority:** 🔴 (verified real) +**Code:** `src/common.ts` `sanitize()`, `mpEncode(..., { useBigInt64: true })` at `:203` + +`sanitize()` returns any bigint unchanged (`typeof v !== "object" → return v`, no range guard). `mpEncode` then truncates to 64 bits: + +```text +18446744073709551616n → 0n +-9223372036854775809n → 9223372036854775807n +``` + +End-to-end echo probe: `input 18446744073709551616 → output 0, equal: false`. + +**Artifact check (opus):** confirmed — no range check between `sanitize()` and encode. The protocol permits big-integer but does not pin a range. + +**Required alignment:** reject bigint outside `[-2^63, 2^64−1]` in `sanitize()` as `INVALID_DATA`, or document the hard range. + +## 5. 🔴 Nested `undefined` violates the typed Zod contract + +**Priority:** 🔴 (verified real) +**Code:** `src/common.ts` `mpEncode` (no `ignoreUndefined`) + +Top-level `undefined` correctly omits `i`. Inside an object the sanitizer keeps `undefined` and msgpack encodes it as `nil`, so a type-valid call: + +```ts +z.object({ x: z.string().optional() }); +api.method({ x: undefined }); +``` + +arrives on the server as `{ x: null }` → `INPUT_VALIDATION: expected string, received null`. `api.method({})` passes. Symmetrically, output `{ x: undefined }` reaches the client as `{ x: null }` though the inferred type promises `undefined`. + +**Artifact check (opus):** confirmed — `mpEncode` (`common.ts:203`) does not set `ignoreUndefined`; @msgpack default is `false` → `undefined` encodes as `nil`. + +**Required alignment:** either strip `undefined`-valued keys in the sanitizer (match top-level omission semantics), or set `ignoreUndefined: true` and document it. + +## 6. 🟠 Server handshake deadline depends on timer-callback execution ("the guard") + +**Priority:** 🟠 (verified real — the core code bug of this round) +**Code:** `src/server.ts` `promoteCandidate()` `:424`, candidate timer `:709`, TAG_MSG promote `:831` +**Spec:** `spec/protocol.md:262-265` + +The attempt phase has an absolute-deadline guard: + +```ts +// server.ts:513-516 +const attemptDeadline = attemptStart + hsTimeout; +const attemptDead = () => attemptExpired || Date.now() >= attemptDeadline; +``` + +with the comment "The timer alone is not enough… Every guard therefore also checks wall-clock time" — matching the spec's "timer callbacks are only wakeups, every continuation after an async suspension must check the absolute deadline." + +The **candidate-promotion phase has no equivalent.** `grep candidateDead|candidateDeadline src/server.ts` → 0 hits. `remainingBudget` feeds only the `setTimeout` at `:709`; the confirming TAG_MSG calls `promoteCandidate()` (`:831`) with no wall-clock check — `promoteCandidate` only guards `if (candidateKey === null) return`. + +Probe with `handshakeTimeout: 100`: +1. candidate installed; +2. event loop busy 160 ms; +3. confirming frame processed before the overdue timer callback; +4. candidate promoted, request executed — an already-expired candidate confirmed. + +**Second half — double onError.** The attempt timer fires `onError("Handshake timeout")` and sets `attemptExpired = true` but does not advance `attemptEpoch`. A later async auth-callback rejection reaches `onHsError` (`:766`), which only checks `attemptEpoch !== myAttempt` — still equal → fires a second `onError("Handshake failed")`. Late reply-send rejection produces the same doubling (timeout + "Handshake reply send failed"). + +```text +HANDSHAKE: Handshake timeout +HANDSHAKE: Handshake failed +``` + +**Artifact check (opus):** both halves confirmed. The deadline guard is half-built: present for the attempt, absent for promotion. + +**Required alignment:** store `candidateDeadline = attemptStart + hsTimeout`; check `Date.now() >= candidateDeadline` in the TAG_MSG handler before `promoteCandidate()` (drop the candidate if expired). Gate the attempt catch on an already-reported flag so one attempt yields at most one `onError`. + +## 7. 🟠 Unreturned `next()` can create an unhandled rejection + +**Priority:** 🟠 (verified real — regression surface of the 2026-07-15 middleware change) +**Code:** `src/server.ts` `runMiddleware` + +The docs now permit calling `next()` without return/await. If the middleware returns its own value and downstream later rejects, the client already has the middleware result and the downstream promise is unobserved: + +```text +client result: outer-success +unhandled: 1 ["downstream-boom"] +``` + +On Node without an `unhandledRejection` listener this can terminate the process. + +**Artifact check (opus):** confirmed. `result = await mw(...)` awaits the middleware's own return, not the downstream promise returned by `next()`; if unreturned and it rejects, nothing catches it. The `completed` guard does not cover this. This is a direct consequence of the sync/unreturned-`next()` pattern accepted in the same-day middleware fix. + +**Required alignment:** either attach a `.catch` to the downstream promise when the middleware does not observe it, or drop the "unreturned `next()` is supported" claim from the spec/API docs. + +## 8. 🟠 All-zero secret guard covers only one length + +**Priority:** 🟠 (verified real) +**Code:** `src/common.ts` `isEmptySecret()` +**Spec:** `spec/security.md:203-207` + +```ts +export function isEmptySecret(buf: Uint8Array): boolean { + if (buf.length !== KEY_LEN) return false; // ← any non-32 length ⇒ "not empty" + let acc = 0; + for (let i = 0; i < buf.length; i++) acc |= buf[i]!; + return acc === 0; +} +``` + +```text +32 zeros → REJECTED +33 zeros → ACCEPTED +64 zeros → ACCEPTED +65 zeros → ACCEPTED +``` + +Also `deriveSessionSecret("public-session-id", new Uint8Array(32))` turns a zero input into a non-zero but publicly derivable result the handshake accepts — diverging from `security.md:203-207`, which says such a config fails HANDSHAKE. + +**Artifact check (opus):** confirmed — the `length !== KEY_LEN → false` early return lets any non-32 all-zero buffer through. + +**Required alignment:** reject an all-zero secret of any length (guard the accumulator regardless of length), and reconcile `deriveSessionSecret` with the security.md example. + +## 9. 🟡 Reflected server responses consume replay-window slots + +**Priority:** 🟡 (verified real, subtle) +**Code:** `src/server.ts:787-792` (D2 replay comment), nonce record before `t` check + +Both directions use one session key. The server records the nonce after a successful Poly1305 but before the `t` check, so a genuine server response reflected back to the server passes the crypto check and occupies a replay slot. + +Probe with `replayWindow: 2`: + +```text +without reflected response: handler count = 2 +with reflected response: handler count = 3 +``` + +Adding the response nonce evicts the oldest request nonce one request early. No full bypass, but the effective window can shrink ~2×. + +**Artifact check (opus):** confirmed. The comment "unforgeable frames can never pollute the window" (`server.ts:787-792`) does not account for frames the *other direction of the same session* produces. + +**Required alignment:** record the nonce only after the `t`-direction check passes (so opposite-direction frames don't consume slots), or account for both directions in the window sizing/comment. + +## 10. 🟡 Context error contract contradicts itself + +**Priority:** 🟡 (verified real — spec self-contradiction) +**Spec:** `spec/protocol.md:430` vs `:446,595` + conformance checklist + +`:430` says a typed `RPCError` thrown from the context factory keeps its `code`/`message`/`data`; `:446,595` and the checklist require a generic `INTERNAL`. The code preserves the typed error: + +```text +RemoteRPCError code: CTX_DENIED message: "context detail" data: { why: "x" } +``` + +**Artifact check (opus):** the two spec passages disagree; a port cannot pick a behavior unambiguously. Code follows `:430` (preserve typed). + +**Required alignment:** pick one rule. If context-thrown typed errors should surface (matches code), fix `:446,595` + checklist to carve out the context stage; otherwise mask to `INTERNAL` and change the code. + +--- + +## Suggested fix order + +1. **#6** candidate-promotion deadline guard + single-onError (the core code bug of this round) +2. **#4 / #5 / #8** small code guards (bigint range, nested-undefined, all-zero secret any length) +3. **#7** decide: catch unobserved downstream, or retract the unreturned-`next()` claim +4. **#9** nonce-record ordering vs the `t` check +5. **doc pack #1 / #2 / #10** (JWT cleartext caveat, asymmetric one-directional caveat, context-error contract) +6. **#3** bump `engines.node` + README + +## Cross-reference + +- Prior round (all closed): `review-porter-audit-sol-2026-07-14.md` (Batches 1–4 + ambiguity closures). +- Same-day middleware fix that #7 regresses on: commit `36ebeaa`. diff --git a/review/0.8/review-protocol-portability-fable-2026-07-14.md b/review/0.8/review-protocol-portability-fable-2026-07-14.md new file mode 100644 index 0000000..52994b7 --- /dev/null +++ b/review/0.8/review-protocol-portability-fable-2026-07-14.md @@ -0,0 +1,133 @@ +I have everything needed. Here is the audit. + +--- + +# Safe RPC — Spec-vs-Implementation Port Audit + +Scope: `spec/protocol.md` as sole normative input; `src/` consulted only to check what the spec pins. Findings are what a clean-room Rust/Go/Python porter would get wrong or leave insecure/incompatible. All line numbers are from the current tree. + +--- + +## P0 — port would be wire-incompatible or insecure + +### P0-1. msgpack **integer-width profile** is unspecified; the reference decoder maps 64-bit wire ints to BigInt and strict-checks `number`/`=== 1`, so a naïvely-encoded port is silently rejected + +**Section:** Primitives / Frame format / RPC message format / Auth payload profiles (all msgpack). +**What's missing:** The spec says "msgpack, all extension types disabled" and, for the frame vector only, "map-key order follows encoding order." It never pins **which msgpack integer width** encodes the integer-valued fields, nor the decode-side number↔BigInt boundary. This is not cosmetic — it breaks interop: + +Measured against the shipped codec (`mpEncode`/`mpDecode` use `useBigInt64: true`, `src/common.ts:180,184`): + +- A wire **int64/uint64** (`0xd3`/`0xcf`) decodes to **BigInt**; every narrower int decodes to **number**. +- Receivers strict-check: server `msg["t"] !== 1` (`src/server.ts:830`), `typeof clientEpoch !== "number"` (`src/server.ts:544`), client `msg["t"] !== 2`, JWT verifier `typeof ts !== "number"` (`src/auth/server.ts`), version `payload["v"] !== 1` (`src/auth/server.ts:41`). +- Proven: a foreign encoder that writes `t: 1` as uint64 makes the TS server see `1n`, and `1n === 1` is **false** → frame silently dropped. Same for `epoch`/`v` → handshake fails; for `ts` → `UNAUTHORIZED`. +- Reference encoder emits non-negative integers ≤ 2³²−1 as fixint/uint8/16/32, integer-valued numbers **> 2³²−1 as float64 (`0xcb`)**, and int64/uint64 **only for BigInt** values. + +Consequence for JWT `ts` (`Date.now()` ≈ 1.78e12): the reference puts it on the wire as **float64** (verified: `cb42…`). A port that encodes `ts` as msgpack uint64 (the natural choice for a ms timestamp) is decoded to BigInt and rejected with `"Invalid timestamp"`. + +**Proposed wording (Primitives, add subsection "msgpack profile"):** +> Integer-valued fields are encoded in the **smallest** msgpack integer type that holds them; a value in `0..2³²−1` uses `fixint`/`uint8`/`uint16`/`uint32`, never `int64`/`uint64`. Integer values outside signed-32/unsigned-32 range are encoded as IEEE-754 `float64` (`0xcb`), **not** as 64-bit integers. The reference decoder maps only the 64-bit integer types (`0xcf`/`0xd3`) to a big-integer; every narrower integer decodes to a native number, and receivers type-check native-number for `t`, `v`, `epoch`, and JWT `ts` and compare small ints by value. A port **must** reproduce these width choices or its frames will be dropped as type-mismatched. In particular JWT `ts` is transmitted as `float64`. Byte-string fields (annotated `bin`) **must** use the msgpack `bin` family and text fields (annotated `string`) the `str` family; cross-encoding fails the receiver's type guards. + +--- + +### P0-2. The protocol has **no self-framing**; frames are transport *messages*, but the spec advertises "duplex socket" (a byte stream) + +**Section:** Goals and non-goals (#4) / Frame format. +**What's missing:** `frame := tag || payload` carries no length prefix. Decryption consumes `frame[25:]` = "the rest of the buffer"; hello decodes `data.subarray(1)` as exactly one msgpack document. The implementation relies entirely on the channel delivering one whole frame per `receive` callback (`src/channels/ws.ts` — WebSocket is message-oriented; every shipped adapter is WebSocket). Goal #4 lists "duplex socket" as a supported byte-pipe, but a raw TCP socket is a byte stream with no message boundaries — a porter wiring frames onto a stream with no length prefix will corrupt/merge frames. + +**Proposed wording (Frame format, first paragraph):** +> Every frame is exactly one transport message. The protocol is **not** self-delimiting: it defines no length prefix and assumes the transport preserves message boundaries (WebSocket, datagram, `MessagePort`). Over a stream transport (raw TCP/TLS) the adapter **must** add its own framing (e.g. a length prefix) and hand each reassembled frame to the core as a single unit; that framing is out of protocol scope and not covered by the test vectors. + +--- + +### P0-3. Low-order X25519 public-key rejection is security-critical but appears **only in the checklist**, not in the normative Handshake/Crypto body + +**Section:** Handshake step 4 (server ECDH) / Crypto. +**What the code does:** relies on `@noble/curves` `getSharedSecret` throwing on RFC 7748 §6.1 low-order points; `test/security/f002-low-order-x25519-pubkey.test.ts` pins that dependency contract. There is **no explicit rejection in `src/`** — it is entirely implicit in the dependency. +**Risk:** In asymmetric-only mode (salt = `EMPTY_SECRET`), a port built on a library that returns an all-zero shared secret for a low-order pub lets an active MITM force a deterministic `session_key = HKDF(zeros, zeros, …)` and decrypt the session. The requirement exists in the checklist but a porter implementing from the Handshake section will miss it, and the reference itself has no in-tree guard to copy. + +**Proposed wording (Handshake step 4, append):** +> Before or during the ECDH, the implementation **must** reject RFC 7748 §6.1 low-order `pub` values (this is normative, not implementation guidance). Either the X25519 primitive rejects them (as `@noble/curves` does) or the handshake rejects `pub` explicitly before `getSharedSecret`. Accepting them in asymmetric-only mode yields an all-zero shared secret and a fully-predictable session key. + +--- + +## P1 — port would diverge observably + +### P1-1. A well-formed request naming an **unknown procedure returns a `NOT_FOUND` error response**, not a silent drop + +**Section:** RPC message format / Failure modes. +**Code:** `src/server.ts:850` — `if (!(procedure in frozen)) throw new RPCError("NOT_FOUND", …)`, which becomes an encrypted `{t:2, ok:false, e:{c:"NOT_FOUND"}}` reply. The spec's silent-drop rule (`§RPC`, "wrong `t`, missing/empty `id`, missing/empty `p` … must be dropped silently") covers only malformed **envelopes**; it never states that a valid envelope with an unknown `p` gets an error reply. A porter could reasonably silent-drop unknown procedures, diverging from the reference (client sees timeout instead of `RemoteRPCError("NOT_FOUND")`). + +**Proposed wording (RPC message format, after the drop rules):** +> A **well-formed** request (`t:1`, valid `id`, non-empty string `p`) whose `p` does not name a procedure in the router is **not** silently dropped: the server returns a normal failure response with code `NOT_FOUND`. Silent drop applies only to malformed envelopes (wrong `t`, absent/oversized `id`, missing/empty `p`) and to frames that fail AEAD. + +--- + +### P1-2. The reference **error-code vocabulary** that crosses the wire is not enumerated + +**Section:** RPC message format (`e.c`). +**Code:** the server emits `INPUT_VALIDATION`, `OUTPUT_VALIDATION`, `MIDDLEWARE`, `NOT_FOUND`, `INVALID_DATA`, `INTERNAL` (`src/server.ts:149,166,185,850,893`, plus `sanitize` `INVALID_DATA`). The spec lists only `INPUT_VALIDATION`, `NOT_FOUND`, `UNAUTHORIZED` as examples and says codes are "any application-defined string." For behavior-compatible error handling a port's server should emit the same protocol-level codes and a port's client should expect them. + +**Proposed wording (RPC message format, add a note):** +> The reference server emits these protocol-level codes (in addition to application codes): `INPUT_VALIDATION`, `OUTPUT_VALIDATION`, `MIDDLEWARE`, `NOT_FOUND`, `INVALID_DATA`, `INTERNAL`. A behavior-compatible port **should** use the same codes for the same conditions. The error `d` payload is implementation-defined (the reference puts the schema library's flattened issues there) and clients **must not** depend on its shape. + +--- + +### P1-3. Candidate **confirmation-timer duration is the *remaining* handshake budget**, not a fresh `HANDSHAKE_TIMEOUT` + +**Section:** Handshake step 2.11 / Constant reference (`HANDSHAKE_TIMEOUT_MS`). +**Code:** `src/server.ts:666,697` — `remainingBudget = max(1, hsTimeout − elapsed)`; the confirmation timer is armed for that remainder, so hello→first-confirming-frame is bounded by **one** `hsTimeout` total. The spec only says "Arm a confirmation timer for the candidate" and the constant table describes `HANDSHAKE_TIMEOUT_MS` as "timeout for completing the handshake," which a porter would read as a fresh full-length timer → candidate lives observably longer (up to ~2× under slow auth). + +**Proposed wording (Handshake step 2.11):** +> The confirmation timer is armed for the **remaining** budget (`handshakeTimeout` minus the time already spent validating the attempt), so the total hello→first-confirming-frame window for one attempt is bounded by a single `handshakeTimeout`, not two. + +--- + +### P1-4. `bin` vs `str` cross-encoding failure isn't stated as a hard requirement + +**Section:** Frame format / RPC message format. +**Code:** `isPlainBytes` (`src/common.ts`) rejects anything that isn't an exact `Uint8Array`; `pub/nonce/proof/auth/sig/th` sent as msgpack `str` would decode to a JS string and be rejected. The schema annotations (`bin` / `string`) imply this, but there is no explicit "never interchange" statement. Folded into P0-1's proposed wording; keeping it here for the RPC-envelope fields (`id`, `p` must be `str`; there are no `bin` fields in the RPC envelope except inside app `i`/`d`). + +--- + +### P1-5. Client drops a valid-but-mismatched reply epoch silently, but **fails the handshake on a malformed reply epoch** — spec only mentions the equality drop + +**Section:** Handshake step 3.3. +**Code:** `src/client.ts` reply path validates `replyEpoch` is a uint32 and **throws → `failHandshake`** on a non-integer/out-of-range value, *before* the `replyEpoch !== currentEpoch` silent-drop. Spec step 3.3 says only "Silently drop if `reply.epoch !== this_epoch`." A porter implementing pure equality would silent-drop a malformed epoch (keep handshaking until timeout) instead of failing fast (reset + surface). Minor but observable. + +**Proposed wording (step 3.3):** +> A reply whose `epoch` is not a valid uint32 fails the handshake attempt; a valid `epoch` that does not equal `this_epoch` is dropped silently (stale reply, keep waiting). + +--- + +### P1-6. Client-side low-order rejection / secret-length symmetry + +**Section:** Handshake step 3.5–3.6. +The client also runs `getSharedSecret(priv, serverPub)` (throws on low-order server pub → `failHandshake`) and requires `secretBytes.length ≥ KEY_LEN`. The spec covers the server's `secret` check (step 5) but states the client's only as "Validate ≥ KEY_LEN bytes" (step 6) and never says the client rejects a low-order **server** pub. Add the same low-order note (P0-3) to step 3.5, and state that the **full** secret byte-string (not truncated to 32) is the HKDF salt on both sides, so both peers must return byte-identical secrets of identical length. + +--- + +## P2 — editorial / informative + +- **NaCl `secretbox` tag position** (Poly1305 layout inside `ciphertext_with_tag`) is only implied by the frame vector. One sentence in Encryption ("the AEAD output layout is NaCl `secretbox`: 16-byte Poly1305 tag as produced by XSalsa20-Poly1305, matched by the test vector") would help porters not using a secretbox-shaped library. +- **JWT `maxAge` default = 30 000 ms** (`src/auth/server.ts`) is server-side policy not stated in the `jwt` profile; note it for helper-porters. +- **Empty/zero-length inbound frame** is dropped (`raw.length === 0`, `src/server.ts` / `src/client.ts`); not mentioned in Failure modes. +- **Context-factory throw** → `INTERNAL` error response with no leak (`src/server.ts` handler try/catch); worth one line in Authorization data flow. +- **`ts` is compared as integer milliseconds** but transmitted as float64 (P0-1) — a port's verifier must accept a float and treat it as ms; note under profile `jwt`. +- **msgpack map keys are `str`** (all field names). Standard, but the vector note ("map-key order follows encoding order") could add "keys are `str`, values per the schema." +- **ECDSA `sig`** length not fixed at 64 (`src/auth/server.ts` checks non-empty only); WebCrypto verify enforces raw r‖s implicitly. Fine, but a note that a wrong-length `sig` fails at verification (not at length-guard) avoids porter confusion. + +--- + +## Normativity audit — sections mixing wire-normative and TS-local detail + +1. **§Failure handling (no auto-retry).** Mixes a wire/security-normative rule (reset trigger set = exactly `RPCAbortedError("TIMEOUT")`; no resend; observable reset behavior) with **TS-local machinery**: the 250 ms `SEND_RETRY_MS` tick, `sendTimeout` default 3 000, the outbound-queue head-of-line policy, optimistic async-send accounting, `RPCError` vs `RPCAbortedError` class split. The *sent-boundary semantics* and *reset trigger set* are normative for "same security decisions"; the tick interval, queue data structure, and error-class taxonomy are implementation guidance. **Split into "Normative session-reset rules (MUST)" and "Reference outbound-queue behavior (informative)."** + +2. **§State machines → Client.** The `idle/handshaking/ready/closed` states and the epoch-coalescing of concurrent resets are behavior-normative; the shared `handshakePromise`, `raceAbort`, and Proxy API are TS-local. Label the epoch/reset semantics MUST, the promise/proxy mechanics informative. + +3. **§Handshake step 2 (server).** Interleaves wire-normative ordering (auth before ECDH; candidate install under a synchronous epoch-guarded block; reply-send-failure drops candidate) with TS event-loop specifics (microtask-vs-macrotask deadline reasoning, `attemptTimer`/`attemptDeadline` twin, `await`-guard idiom). The **absolute wall-clock deadline check after every suspension** is a normative *requirement* (a flag-only guard is exploitable), but the *reason* (JS microtask ordering) is language-specific — restate the requirement language-neutrally ("after every operation that may suspend, before writing any session state, re-check: still the current attempt, not destroyed, within the wall-clock deadline") and move the JS rationale to a note. + +4. **§Constant reference.** `HANDSHAKE_TIMEOUT_MS`, `RPC_TIMEOUT_MS`, `SEND_TIMEOUT_MS`, `MAX_PENDING` are **defaults for local timers/limits**, not wire constants — mixed in the same table as truly wire-normative values (`TAG_*`, `NONCE_LEN`, `KEY_LEN`, magic strings, `MAX_HELLO_BYTES`). Add a column or split: "wire-normative (both peers must agree)" vs "local policy default (implementation may differ; affects observable behavior only)." + +5. **§Sanitization.** Rules 3–4 (`Object.prototype`/`null` prototype check; `__proto__`/`constructor`/`prototype` stripping; `Object.create(null)` rebuild) are **JS-specific prototype-pollution defenses**. The section already flags "A port to a language without prototype pollution still has to…" — good — but the three language-neutral MUSTs (reject unknown ext types incl. Timestamp; depth cap 32; reject shape mismatch) should be lifted to the top as the normative core, with the prototype rules explicitly labeled "JS-specific realization." + +6. **§Auth payload profiles.** Correctly labels itself "normative when a shipped helper is used." Keep — but note that `ed25519`/`ecdsa` bind to **raw transcript bytes** while `jwt` binds to `SHA-256(transcript)`; that per-profile difference is wire-normative and easy to miss. diff --git a/review/0.8/review-release-verdict-0.8.0.md b/review/0.8/review-release-verdict-0.8.0.md new file mode 100644 index 0000000..e1f0f6c --- /dev/null +++ b/review/0.8/review-release-verdict-0.8.0.md @@ -0,0 +1,237 @@ +# 0.8.0 release verdict — branch `0.8.0-fixes` → `main` + +**Audit date:** 2026-07-16 +**HEAD audited:** `d0edc02` ("fix: implement maxPendingHandshakes …") +**Base:** `origin/main` +**Method:** independent re-verification of every prior finding against the code at HEAD (not against changelogs), empirical repro scripts re-run before/after, full gate, packaging smoke tests. Technical basis: `spec/assessment.md`; prior review rounds (`review-*.md`) are kept as historical records. + +--- + +## 1. Branch scope vs `origin/main` + +14 commits, **30 files changed, +2150 / −1308**. + +| Theme | Commits | Content | +|---|---|---| +| Auth surface rework | `ee2f1de`, `73f9d3a`, `a8027d3` | `createCertificateServerAuth` / `createMultifactorServerAuth` **removed** (delegated security-critical binding to app callbacks under a name implying the library did it); JWT / Ed25519 / ECDSA transcript-bound profile helpers added with tests | +| Spec conformance batches | `b13d697`, `10cbf74`, `c07d36d`, `059320f`, `15e83d5`, `6e45c1d` | sent-boundary semantics, error-code map, middleware/validation ordering, cyclic outbound graph rejection, directionality clarifications | +| Round-2 findings (10) | `36ebeaa`, `48ae07e` | middleware completion guard; handshake deadline, sanitizer, replay, secret guards — itemized in §2 | +| Follow-up hardening | `9428901`, `d0edc02` | AEAD-verify/inner-decode split, nonce-slot rules for malformed/reflected frames, outbound response framing bound, `maxPendingHandshakes` (default 16), NaN/Infinity limit validation, client single-pending-handshake rule | +| Packaging | in `48ae07e` | `engines.node` raised `>=18` → `>=20.19.0`; README support matrix corrected | + +No changes on `origin/main` are reverted or bypassed by the branch (`726e928` merges main in cleanly). + +--- + +## 2. Findings, severity, fixes, regression coverage + +All ten round-2 findings re-verified at HEAD by reading the shipped code and re-running the original repro inputs. "Repro re-run" = the exact input that demonstrated the defect now produces the fixed behavior. + +### #6 — Expired handshake candidate could be promoted (main code bug) + +- **Severity: High.** Security impact: a candidate whose `handshakeTimeout` budget had elapsed could still be promoted to the live session and its request executed, if the event loop was busy past the budget (spec `protocol.md` § deadline origins forbids this: timers are wakeups, every continuation must check the absolute deadline). +- **Fix:** `src/server.ts` — `candidateDeadline = attemptStart + hsTimeout` stored at candidate install (`:754`), checked in the TAG_MSG handler **before** `promoteCandidate()` (`:906`: `if (Date.now() >= candidateDeadline) return;`). Expired candidate is left to the overdue timer: no promotion, no nonce record, no handler run. +- **Sub-finding (Low): double `onError` per attempt.** Timer fired "Handshake timeout" without advancing the epoch; a late async rejection then passed the epoch-only guard and fired "Handshake failed" too. Fixed with an attempt-scoped `reported` flag checked at every report site (`:562`, `:567`, `:765`, `:825`). +- **Regression tests:** `test/security/review-round2-fixes-2026-07-15.test.ts` — "#6a a confirming frame that arrives after the budget (loop starved) does not promote an expired candidate"; "#6b a timeout followed by a late-rejecting auth callback reports exactly once". Both green. + +### #8 — All-zero secret guard only covered length 32 + +- **Severity: Medium-High.** Security impact: a 33/64/65-byte all-zero secret passed the guard and silently degraded the session to no-secret authentication; `deriveSessionSecret(publicId, zeros)` produced a deterministic, publicly reproducible secret the handshake accepted — contradicting the documented "fails loudly" example (`security.md:203-207`). +- **Fix:** `src/common.ts` — `isEmptySecret` now treats an all-zero buffer of **any** length (including empty) as empty; `deriveSessionSecret` rejects all-zero secret material with `TypeError("secret must not be all-zero")`. +- **Repro re-run:** `isEmptySecret(new Uint8Array(64))` was `false` → now `true` (also 33, 0); `deriveSessionSecret('user-123', new Uint8Array(32))` returned a valid-looking key → now throws. +- **Regression tests:** `test/unit/psk-auth.test.ts` — "rejects all-zero secret material (#8)", "returns true for an all-zero buffer of any length", "derived secret is never the all-zero sentinel". Green. + +### #4 — Out-of-range bigints silently altered + +- **Severity: Medium.** Impact: silent data corruption — an echo of `18446744073709551616n` returned `0`; msgpack `useBigInt64` reduces to 64 bits without error. +- **Fix:** `src/common.ts:169-176` — sanitizer rejects `bigint` outside `[-2^63, 2^64−1]` with `RPCError("INVALID_DATA", "BigInt out of encodable range")`; `BIGINT_MIN`/`BIGINT_MAX` exported constants. +- **Repro re-run:** `2^64` → was `0n`, now throws `INVALID_DATA`; `−2^63−1` → was clamped to `2^63−1`, now throws. Boundary values `2^64−1` and `−2^63` pass unchanged. +- **Regression tests:** `test/unit/sanitize.test.ts` § "sanitize / bigint range (#4)" — above-max, below-min, in-range. Green. + +### #5 — Nested `undefined` arrived as `null` + +- **Severity: Medium.** Impact: a type-valid call with an optional field set to `undefined` failed server-side validation ("expected string, received null") while an empty object passed — same on the response path. +- **Fix:** `src/common.ts:210-214` — sanitizer drops `undefined`-valued object keys, matching top-level omission; top-level `undefined` still returned as-is. +- **Repro re-run:** `sanitize({a: undefined, b:"x"})` → `{b:"x"}`; encode/decode round-trip has no `a` key (was `{"a":null}`). +- **Regression tests:** `test/unit/sanitize.test.ts` § "sanitize / nested undefined (#5)". Green. + +### #7 — Fire-and-forget `next()` could crash the process + +- **Severity: Medium.** Impact: docs accept an unreturned `next()`; if the downstream chain later rejected, the promise was unobserved → `unhandledRejection` → process termination on default Node config. Remote-triggerable DoS given a middleware written in the documented style. +- **Fix:** `src/server.ts` (runMiddleware) — `void Promise.resolve(downstream).catch(() => {})` attached to the continuation promise at the `next()` call site; `spec/protocol.md:449` now documents the internal observer and that the unpropagated rejection is intentionally not delivered. +- **Regression test:** `test/security/middleware-attacks.test.ts` — "fire-and-forget next() with a rejecting downstream does not leak an unhandled rejection". Green. + +### #9 — Reflected server responses consumed replay-window slots + +- **Severity: Medium.** Impact: both directions share one key; a genuine server response fed back to the server passed Poly1305 and was recorded in the seen-nonce window **before** the message-type check, shrinking the effective replay window (~half with symmetric traffic). +- **Fix:** `src/server.ts:940-941` — direction guard (`t === 1`) runs before the nonce record for the reflected-response case: `t: 2` frames are dropped **without** consuming a slot. Deliberate nuance: authenticated **malformed** envelopes and non-1/non-2 `t` values DO consume their nonce (`:923`, `:928`, `:941`) so a captured authenticated junk frame cannot force unbounded decode work — rationale in-code and in `spec/assessment.md`. +- **Regression test:** `test/security/review-round2-fixes-2026-07-15.test.ts` — "#9 reflecting a genuine server response does not evict a recorded request nonce". Green. + +### #3 — Declared Node range the build didn't support + +- **Severity: Medium (packaging).** Impact: `engines.node >=18` while `@noble/* 2.2.0` is ESM-only with `engines >= 20.19.0`; the CJS build `require()`s it → `ERR_REQUIRE_ESM` on 18.x and 20.18 (require(esm) landed in 20.19). Install succeeded, first import crashed. +- **Fix:** `package.json` `engines.node: ">=20.19.0"`; `README.md:161` states the floor and the reason (noble ESM-only under CJS). +- **Verification:** `node_modules/@noble/hashes/package.json` confirms `"type":"module"`, `engines >= 20.19.0`. CJS `require('./cjs/index.js')` and ESM import both smoke-tested OK on Node 22.22.1. *Not re-tested on 18.x/20.18 in this audit — the floor now excludes them by declaration, which is the fix.* + +### #1 — JWT wire-visibility undocumented (docs) + +- **Severity: Medium (documentation, security-relevant).** Impact: the JWT rides the unencrypted hello frame; threat model includes a passive transport observer; docs never stated the consequence. +- **Fix:** `spec/security.md:328` — explicit paragraph: "The token is wire-visible … JWT-only mode therefore assumes a **confidential transport** (TLS/DTLS) or a second factor (PSK or a signature mode)", including the contrast with signature modes. +- **Coverage:** documentation fix; no test applicable. Code behavior (token in hello) is by design and unchanged. + +### #2 — One-directional auth examples (docs) + +- **Severity: Medium (documentation, security-relevant).** Impact: shipped examples (client `sign`, server `verify`) authenticate only the client; a reader could assume mutual authentication. +- **Fix:** `spec/getting-started.md:241` — "**Authentication is directional.**" callout: client `sign` + server `verify` proves only the client's identity; mutual auth needs both directions or a PSK; links to the security-doc section. +- **Coverage:** documentation fix. `test/unit/psk-auth.test.ts` § validateAuthConfig pins that one-directional configs remain legal (by design). + +### #10 — Context-error contract self-contradiction (spec) + +- **Severity: Low (spec quality / porter hazard).** Impact: `protocol.md:430` said a typed error from the context factory keeps its code; `:446` and `:595` required masking to `INTERNAL`. A second implementer could not choose unambiguously. +- **Fix:** all three passages aligned to the code's actual behavior — typed RPC error keeps its `c`/`m`/`d`, any other thrown value masks to `INTERNAL` (`protocol.md:447`, `:596`, conformance checklist `:727`). Verified against `src/server.ts:886` (context call inside the try whose catch preserves `RPCError` codes). +- **Coverage:** `test/security/middleware-attacks.test.ts` — "middleware-thrown RPCError is not masked as INTERNAL" pins the response-mapping rule. + +### Follow-up round (post-round-2, same branch) — verified fixed + +Documented in `spec/assessment.md` § "2026-07 follow-up review"; regression tests in `test/security/review-fixes-2026-07.test.ts`, `hung-auth-timeout.test.ts`, `replay-and-response-bounds.test.ts`, `dos-attacks.test.ts`: + +| Finding | Severity | Fix | +|---|---|---| +| AEAD verify conflated with inner decode — authenticated-but-malformed candidate frame failed to promote | Medium | split `createAeadOpener` / `decodePlaintext`; promotion on Poly1305 proof alone | +| Sync auth callback overrunning `handshakeTimeout` still installed a candidate | Medium | absolute wall-clock deadline after every await, both sides (`attemptDead()` / `hsDeadline`) | +| Non-cancellable auth callbacks accumulated one closure per timed-out attempt | Medium (DoS) | `maxPendingHandshakes` (default 16, validated integer > 0); client holds max one unsettled attempt | +| `NaN`/`Infinity` accepted for `maxPending`/`maxMessageBytes`/JWT `maxAge` — silently disabled the limit | Medium | finite-positive-integer validation at construction | +| Failed handshake-reply send left candidate lingering → spurious second timeout error | Low | send guarded; candidate dropped, single `HANDSHAKE` error with transport cause | +| Middleware completing without calling `next()` skipped the handler silently | Medium | `RPCError("MIDDLEWARE", "Middleware completed without calling next()")` | +| Oversized encrypted **response** handed to the channel (peer must drop it → opaque client timeout) | Low-Medium | outbound frame checked against `maxMessageBytes`; reported via `onError`, never sent | + +--- + +## 3. Full spec-conformance pass (protocol.md implementation checklist, 44 items) + +`spec/protocol.md` § Implementation checklist is the normative conformance contract (44 items). Every item was checked against `src/` at HEAD with file:line evidence. Result: **43 PASS, 1 PARTIAL (C1, Low)**. + +| # | Item (abbreviated) | Evidence | Result | +|---|---|---|---| +| 1 | Constants byte-exact | `common.ts:24-55,63-66,430`, `client.ts:61-64`, `server.ts:53-54` — every value matches the spec table; wire-normative set additionally pinned by KAT vectors | PASS | +| 2 | msgpack profile (smallest-width ints, bin/str, str keys) | msgpack-javascript defaults; byte-pinned by `vectors.test.ts` (encrypted frame + profile payload bytes), `msgpack.test.ts` | PASS | +| 3 | Whole-message framing, not self-delimiting | core consumes the full delivered buffer as one frame; stream framing delegated to adapters per spec | PASS | +| 4 | Constant-time comparisons | `constTimeEqual` (`common.ts`) used for proof (`client.ts:842`) and JWT digest (`auth/server.ts:112`); MAC verification inside `@noble/ciphers` | PASS | +| 5 | Low-order X25519 rejection | delegated to `@noble/curves`, pinned by `f002-low-order-x25519-pubkey.test.ts` | PASS | +| 6 | All msgpack ext types rejected | `SAFE_CODEC` throws on Timestamp (type −1); unknown ext → `ExtData` instance → rejected by `sanitize` (non-plain object); `type-confusion.test.ts` | PASS | +| 7 | Sanitization: host objects, proto keys, depth | `sanitize` (`common.ts`): POISON set, `MAX_DEPTH = 32`, non-plain rejection; `sanitize.test.ts`, `prototype-pollution.test.ts` | PASS | +| 8 | Auth payloads pass full sanitize gate → `UNAUTHORIZED` | `auth/server.ts:32-41` (`sanitize(mpDecode(...))`, catch → `UNAUTHORIZED`); server core also sanitizes hello (`server.ts:590`) | PASS | +| 9 | Handler output sanitized → `INVALID_DATA` | `server.ts:996` | PASS | +| 10 | Frame bounds, full length incl. tag, `>` compare | hello `server.ts:535` / `client.ts:719`; msg `server.ts:853` / `client.ts:894`; outbound `server.ts:1025` / `client.ts:1047` | PASS | +| 11 | Transcript byte sequences exact | `buildHelloTranscript` / `buildReplyTranscript` (`common.ts`); hex pinned in `vectors.test.ts` = spec §Test vectors, verified identical | PASS | +| 12 | verify before ECDH; sign late; failed sign → no candidate | order in `server.ts`: verify `:641` → ECDH `:662` → derive `:690` → proof `:691` → sign `:699`; sign failure throws into attempt catch, candidate never installed | PASS | +| 13 | Fresh ephemeral pair per hello attempt | `x25519.utils.randomSecretKey()` inside `handleHello`, attempt-local | PASS | +| 14 | Candidate install, live-first trial decrypt | `server.ts` TAG_MSG handler: live → candidate; `promoteCandidate()` on candidate decrypt | PASS | +| 15 | Response epoch captured after promotion | `server.ts:909` (`const reqEpoch = epoch` after `promoteCandidate()`) | PASS | +| 16 | Client epoch uint32, never wraps, exhaustion terminal; server three counters | wire validation both sides (`server.ts:608-616`, `client.ts:754-756`); three counters present (`attemptEpoch`/`candidateEpoch`/`epoch`); exhaustion guard in `startHandshake` (see C1, fixed) | PASS | +| 17 | Attempt counter bumped for every hello | `server.ts:539` (`attemptEpoch++` at handler top, before validation) | PASS | +| 18 | Guards after every await; `maxPendingHandshakes` cap | epoch+destroyed guards throughout; cap `d0edc02`, `hung-auth-timeout.test.ts` "caps server attempts whose auth callbacks never settle" | PASS | +| 19 | Absolute wall-clock deadline both sides | server `attemptDead()` `:564` + `candidateDeadline` `:906`; client `hsDeadline` checked `:636,:802,:816,:853` | PASS | +| 20 | Reply-send failure drops candidate, one error | `review-fixes-2026-07.test.ts` "#6 a failed handshake-reply send drops the candidate and reports exactly one error" | PASS | +| 21 | Promotion on AEAD only; nonce rules for malformed / reflected | `server.ts:895-941`; `replay-and-response-bounds.test.ts` | PASS | +| 22 | Numeric limits validated at construction | `server.ts:331`, `client.ts:225`, `maxPendingHandshakes` integer>0, JWT `maxAge`; `review-fixes-2026-07.test.ts` "#4 NaN / Infinity limits" | PASS | +| 23 | Profile version `v` stamped and required | `auth/server.ts:48` rejects absent/unknown `v`; three profiles byte-pinned in `vectors.test.ts` | PASS | +| 24 | Separate candidate-timer counter | `candidateEpoch` / `myCandEpoch` keying (`server.ts:709` area) | PASS | +| 25 | Attempt-local state; live session undisturbed by invalid/replayed/forged hello | `session-continuity.test.ts` (4 scenarios) | PASS | +| 26 | All-zero secret rejected at any length | `isEmptySecret` (`common.ts:136`), `server.ts:681`, `client.ts:830`; `psk-auth.test.ts` | PASS | +| 27 | Raw shared secret zeroed in try/finally | `server.ts` attempt `finally` zeroes `rawShared`/`localSessionKey`/`localProof`; same pattern client-side | PASS | +| 28 | Ephemeral keys copied, not aliased, for awaits | `client.ts:728-729` (`privateKey.slice()`, `publicKey.slice()`) | PASS | +| 29 | Server accepts hellos in any state | make-before-break design; `session-continuity` / `handshake-attacks` tests | PASS | +| 30 | No auto-retry; reset only on sent-call `TIMEOUT`; guardrails never reset | `client.ts:237,:1247`; `channel-lifecycle.test.ts` "reset predicate" | PASS | +| 31 | Application secret buffer never zeroed by protocol | `secretBytes` absent from all `zero()` sites (only derived material zeroed) | PASS | +| 32 | `id`/`p` validation, unknown proc → `NOT_FOUND` | `server.ts:950-966` | PASS | +| 33 | Confirmation timer gets remaining budget | `server.ts:735` (`hsTimeout − (Date.now() − attemptStart)`) | PASS | +| 34 | Absent input omits `i` | `client.ts:1045` (`if (input !== undefined) req["i"] = input`) | PASS | +| 35 | `t` check before any other processing | `server.ts:940` (before nonce record for `t: 2`, before id/proc parsing) | PASS | +| 36 | Remote error coercion (`c`→`UNKNOWN`, `m`→"") | `client.ts:945` area; `malformed-response.test.ts` | PASS | +| 37 | `ok` never coerced, strict boolean | `malformed-response.test.ts:84-92` (9 garbage variants dropped) | PASS | +| 38 | Pipeline stage order + error-code map | verified in round-2 #10; `middleware-attacks.test.ts`, `chain.test.ts` | PASS | +| 39 | Shared-handshake rejection, per-call abort, timeout starts post-handshake | `channel-lifecycle.test.ts` (abort classes, shared AbortController, queued-vs-sent) | PASS | +| 40 | Sent boundary = handoff, async error rolls back | `client.ts` sent-class comments + `channel-lifecycle.test.ts` (sent/unsent classes per scenario) | PASS | +| 41 | Seen-nonce set semantics | check-before-decrypt `server.ts:869`, insert-after-verify, FIFO `:431`, cleared on promotion (`seenClear()`); `replay-window.test.ts` | PASS | +| 42 | Keys zeroed on reset/destroy | `client.ts:1286` (`zeroKeys()` + epoch bump); `zero.test.ts` | PASS | +| 43 | Proof verified constant-time | `client.ts:842` | PASS | +| 44 | No feedback to malformed-frame sender | silent `return` on every drop path (bad tag, oversize, AEAD fail, malformed envelope) | PASS | + +**KAT vectors:** spec §Test vectors hex values compared against `test/unit/vectors.test.ts` fixtures — identical (c_pub, s_pub, raw_shared, session_key, empty-secret key, proof, both transcripts, three auth-profile payloads). The suite (green) proves `src/` reproduces them. + +### C1 — Client epoch exhaustion was not a terminal client error (Low) — **FIXED in this audit** + +Checklist item 16 asserts "never wraps (**exhaustion is a terminal client error**)". The client incremented `epoch` per attempt and per reset with no guard at 0xffffffff. A JS number does not wrap (integer-safe to 2^53), so past 2^32−1 the server would reject every hello as `Invalid epoch` (`server.ts:614`) — an endless string of opaque handshake timeouts instead of the explicit terminal error the spec promises. + +- **Security impact:** none (no wrap, no nonce/epoch reuse). Practical reachability: ~4.3 × 10⁹ handshake attempts on one client instance — pathological reconnect loops only. +- **Fix applied:** `src/client.ts` `startHandshake` — `epoch >= 0xffffffff` now rejects with `RPCError("CLIENT", "Handshake epoch exhausted; destroy and recreate client")`, mirroring the request-id counter guard (`:1028`) and placed before ephemeral key generation. `CLIENT` is the guardrail class: per checklist item 30 it never resets the session. The reset-path increment needs no guard of its own — an over-ceiling value is only compared locally for staleness and the next attempt hits the guard before anything reaches the wire. +- **Regression test:** none — deliberately. `epoch` is closure-private and reaches the ceiling only via 2^32 real handshakes; a test would require adding a test-only state-injection hook to the public surface of a security library, which is a worse trade than code-read evidence for an unreachable-in-practice guard. Conformance evidence: this section + the guard's in-code comment. +- **Gate re-run after fix:** 295 tests, lint, typecheck, ESM+CJS build — all green. + +--- + +## 4. Verification performed (this audit) + +1. **Full conformance pass** — all 44 normative checklist items of `spec/protocol.md` checked against `src/` with file:line evidence (§3); KAT vectors cross-checked spec ↔ test ↔ implementation. +2. **Per-finding code read at HEAD** — every fix located and read in `src/` / `spec/` (file:line citations above), not inferred from commit messages. +3. **Empirical repro re-runs** (tsx against `src/`): bigint over/under/boundary (#4), nested-undefined round-trip (#5), `isEmptySecret` at lengths 0/32/33/64 (#8), `deriveSessionSecret` with all-zero input (#8b). All produce the fixed behavior; all reproduced the defect on the pre-fix tree in round 2. +4. **Full gate:** `npm test` — **39 files, 295 tests, all green** (10.2 s). `npm run lint` — clean. `npm run build` — ESM + CJS + `cjs/package.json` marker, clean. `npm run test:typecheck` — clean. +5. **Targeted re-run** of the four fix-pinning suites (`review-round2-fixes-2026-07-15`, `middleware-attacks`, `replay-and-response-bounds`, `hung-auth-timeout`) — 17 tests green. +6. **Packaging smoke:** `require('./cjs/index.js')` and ESM `import './esm/index.js'` both load and export `client`/`server` (Node 22.22.1). `files` whitelist (`esm`, `cjs`, `src`) keeps review/spec working files out of the npm artifact. +7. **Dependency floor check:** `@noble/hashes` 2.2.0 `engines` read from the installed package. +8. **Branch hygiene:** commit list and diffstat vs `origin/main`; merge commit inspected; gitignore audit (see §5 — this found blocker B1). + +--- + +## 5. New findings from this audit (blockers) + +### B1 — Regression tests for the fixes are gitignored ⛔ merge blocker + +`.gitignore:12` (`review-*`) matches **`test/security/review-fixes-2026-07.test.ts`** and **`test/security/review-round2-fixes-2026-07-15.test.ts`**. Both are untracked. These files carry the only regression coverage for #6a, #6b, #9 and five of the seven follow-up fixes (8 tests). Consequences on a fresh clone / CI: the suite runs 287, not 295; the highest-severity fix on the branch (#6) has **zero** committed regression coverage; `spec/assessment.md`'s "covered by the security test suite" claim is false for the repository as published. + +**Fix:** rename the two files (e.g. `handshake-deadline.test.ts`, `hardening-2026-07.test.ts`) or narrow the ignore pattern to `/review-*` (root-anchored), then commit them. Mechanical, no code change. + +*Note: this verdict file itself matches `review-*` and is untracked by the same rule — intentional if it stays historical; rename it if it must ship in the repo.* + +### B2 — Version not bumped ⛔ publication blocker + +`package.json` and `jsr.json` both say **0.7.1**. Publication requires `0.8.0` + `npm run sync:jsr` (release notes / tag per the usual flow). Already flagged in round 2 as a separate step; still pending. + +### Hygiene (non-blocking) + +Untracked worktree strays: `0.8-rev`, `auth-api-review-question.md`. Not part of the merge; delete or move out of the repo. + +--- + +## 6. Residual risks (accepted, documented) + +All by design and documented in `spec/assessment.md` / `spec/security.md`; none regressed on this branch: + +1. **Replay window is narrowed, not closed** — a replay older than the last `replayWindow` (default 4096) accepted nonces still executes; non-idempotent handlers on long sessions need idempotency keys. Counter-based nonces deferred (require directional keys). +2. **One session key for both directions** — reflection protection rests solely on the `t` field check. This branch strengthened it (reflected `t: 2` frames no longer consume replay slots) but a port that omits the check loses the protection entirely. Flagged for a future protocol version. Residual micro-cost: each reflected frame still costs one AEAD decrypt, unbounded but cheap. +3. **Weak-secret offline oracle** — server-first proof lets an active attacker brute-force a low-entropy PSK offline. The secret must be a CSPRNG key, not a passphrase. Property of the scheme, unchanged. +4. **Hello-flood candidate starvation** — a flood of hellos can keep overwriting the candidate slot and delay a legitimate *re*-handshake (live session untouched). `maxPendingHandshakes` bounds callback accumulation, not ECDH cost. Rate-limit hellos in the channel adapter on exposed transports. +5. **JWT is a bearer credential** — theft of the token allows fresh handshakes until expiry; JWT-only additionally requires a confidential transport (now documented, #1). +6. **`@noble/curves` pin is load-bearing** for low-order point rejection — regression test `f002-low-order-x25519-pubkey.test.ts` pins it; re-run the suite on every dependency bump. +7. **No server-side concurrency cap / per-request timeout** — application concern, consistent with comparable RPC libraries; bound concurrency in handlers for untrusted peers. +8. **Node <20.19 excluded by declaration, not CI** — the floor is correct per the dependency's engines; no CI matrix run verifies the failure mode on 18.x/20.18 (acceptable: those are now outside the declared range). + +--- + +## 7. Verdict + +| Gate | Status | +|---|---| +| All 10 round-2 findings fixed and verified at HEAD | ✅ | +| Follow-up hardening fixed and verified | ✅ | +| Tests / lint / build / typecheck | ✅ 295 green, clean | +| Empirical repros reproduce fixed behavior | ✅ | +| Full spec conformance (44-item normative checklist) | ✅ 44 PASS (C1 fixed during audit) | +| KAT vectors: spec ↔ tests ↔ implementation | ✅ byte-identical | +| Regression tests committed to the repo | ⛔ B1 | +| Version / manifest ready for publish | ⛔ B2 | + +**Merge to `main`: NOT READY — one mechanical blocker (B1).** The code and spec are ready as-is; merging without the two test files would land the highest-severity fix with no committed regression coverage and make the assessment's coverage claim false. Rename/commit the two test files and the branch is READY with no further review needed. + +**Publication of 0.8.0: NOT READY — B1 + B2.** After B1: bump `package.json`/`jsr.json` to 0.8.0 (`npm version` runs the jsr sync), tag, publish. No code changes required for either blocker. diff --git a/review/0.8/review-round2-neutral-for-fable-2026-07-15.md b/review/0.8/review-round2-neutral-for-fable-2026-07-15.md new file mode 100644 index 0000000..55399f3 --- /dev/null +++ b/review/0.8/review-round2-neutral-for-fable-2026-07-15.md @@ -0,0 +1,101 @@ +# 0.8.0 correctness & spec-conformance review — round 2 + +Context: I maintain an open-source RPC library and I'm gating its 0.8.0 release. Below are ten correctness / specification-conformance deltas found on commit `36ebeaa`. All are internal engineering findings about my own code and my own protocol spec — I'm deciding which block the release and whether my proposed fixes are right. The full test suite (279 cases), build, lint and typecheck are all green; these are gaps the suite does not currently cover. + +**My question for you:** `<< paste your question here — e.g. which of these block a 0.8.0 release, and are the proposed fixes correct? >>` + +--- + +## 1. Credential field is wire-observable in the opening frame + +The JWT auth profile places the token in the auth payload, which travels in the first, not-yet-encrypted handshake frame (`src/auth/client.ts:46-51`, `src/client.ts:602-613`). My threat model already lists a passive observer of the transport as in-scope (`spec/security.md:9`). The docs correctly note that a bearer token can be reused if obtained (`security.md:314-316`), but they never state the direct consequence: in JWT-only mode over a non-confidential transport, the token is readable in that opening frame. Code is correct by design (the opening frame carries the ephemeral public key and cannot be encrypted yet). This is a documentation-completeness gap: the JWT section should say JWT-only requires a confidential transport, or a PSK / signature mode. + +## 2. Asymmetric examples authenticate only one direction + +The shipped examples give the client `sign` and the server `verify` (`spec/getting-started.md:222-237`, `security.md:333-348`). That establishes the client's identity to the server, but the client never establishes the server's identity — with an empty pre-shared secret the client's proof only demonstrates possession of the current ephemeral key, so the client will complete a handshake with any endpoint that finishes the key exchange. The protocol deliberately permits one-directional configuration (`protocol.md:260`), so the code conforms. The gap is that the examples don't state that mutual authentication needs both sides configured (`sign`+`verify` each) or a PSK. + +## 3. Package declares a Node range the build doesn't support + +`package.json` sets `engines.node: ">=18"`, but `@noble/* ^2.2.0` requires Node ≥ 20.19, and the CommonJS build loads these ESM-only dependencies via `require()`. Result: + +```text +Node 18.20.8 → module-load error (ERR_REQUIRE_ESM) +Node 20.18.3 → module-load error +Node 20.19.5 → OK +Node 22 → OK +``` + +README promises both Node 18+ and a dual ESM/CJS build. Fix: raise the declared floor to the real one (≥ 20.19) and correct the README, or pin dependencies that still support 18. + +## 4. Integers beyond 64 bits are silently altered + +The input sanitizer passes any `bigint` through unchanged, and the encoder (`useBigInt64: true`) then reduces it to 64 bits (`src/common.ts:203`). Round-trip: + +```text +18446744073709551616n → 0n +-9223372036854775809n → 9223372036854775807n +``` + +An end-to-end echo of `18446744073709551616` returns `0` — a silent value change, not an error. The protocol permits big integers but pins no range. Fix: reject out-of-range `bigint` in the sanitizer as invalid input, or document the hard `[-2^63, 2^64−1]` range. + +## 5. Nested `undefined` diverges from the declared type contract + +Top-level `undefined` is handled correctly (the field is omitted). Inside an object the sanitizer keeps `undefined` and the encoder writes it as `null`. So a type-valid call with an optional string field set to `undefined` arrives on the server as `null` and fails validation ("expected string, received null"), while an empty object passes. The same happens on the response path. The encoder does not set the "ignore undefined" option, so the default keeps it. Fix: strip `undefined`-valued keys in the sanitizer to match the top-level omission behavior, or enable "ignore undefined" and document it. + +## 6. Second handshake phase has no absolute-deadline check ("the guard") + +This is the main code finding. The handshake is time-boxed. The first phase (accepting the opening frame) checks an absolute wall-clock deadline on every continuation, not just a timer: + +```ts +// server.ts:513-516 +const attemptDeadline = attemptStart + hsTimeout; +const attemptDead = () => attemptExpired || Date.now() >= attemptDeadline; +``` + +with a comment explaining that a timer alone is insufficient because a JS timer is only a wakeup and can fire late, so each guard also checks the clock — matching the spec (`protocol.md:262-265`). + +The second phase — a pending candidate session waiting for its confirming frame — has no such check. Searching the file for a candidate deadline returns zero hits; the remaining budget feeds only a `setTimeout` (`server.ts:709`), and the confirming frame calls `promoteCandidate()` (`:831`) with no clock check (`promoteCandidate` only guards `candidateKey === null`, `:424`). Consequence when the event loop is busy longer than the budget (measured: 160 ms busy vs a 100 ms budget): the confirming frame is processed before the overdue timer fires, and an already-expired candidate is promoted and its request runs. The spec disallows this. + +Related: on a handshake timeout the server can emit two error callbacks for one attempt — the timer fires "timeout" without advancing the attempt epoch, then a late async-callback rejection reaches the catch (`:766`), which checks only the epoch (still equal) and fires "handshake failed" as well. A late reply-send rejection doubles the same way. + +Fix, symmetric to phase one: store a candidate deadline (`attemptStart + hsTimeout`), check it in the frame handler before promoting (drop the candidate if past), and gate the attempt catch on an "already reported" flag so one attempt yields at most one error callback. + +## 7. Fire-and-forget `next()` can produce an unhandled promise rejection + +The docs now allow middleware to call `next()` without returning or awaiting it. If the middleware returns its own value and the downstream step later rejects, the client already has the middleware's result and the downstream promise is unobserved (`src/server.ts` `runMiddleware`). On a Node process with no `unhandledRejection` handler this can terminate the process. This is a direct consequence of the same-day change that started accepting the fire-and-forget pattern. Fix: either attach a `.catch` to the unobserved downstream promise, or remove the "unreturned `next()` is supported" claim from the docs. + +## 8. All-zero secret guard only covers one length + +The guard that rejects an all-zero secret returns early for any length other than 32 bytes, treating it as non-empty: + +```ts +export function isEmptySecret(buf: Uint8Array): boolean { + if (buf.length !== KEY_LEN) return false; // any non-32 length ⇒ "not empty" + let acc = 0; + for (let i = 0; i < buf.length; i++) acc |= buf[i]!; + return acc === 0; +} +``` + +So 32 zeros are rejected but 33, 64, 65 zeros pass. Separately, deriving a session secret from a public identifier and a zero input yields a deterministic, publicly reproducible value the handshake accepts — while the security example (`security.md:203-207`) says such a configuration should fail. Fix: reject an all-zero secret of any length, and reconcile the derivation helper with the documented example. + +## 9. Same-session opposite-direction frames consume replay-window slots + +Both directions of a session use one key. The server records a frame's nonce right after the authentication tag verifies but before the direction check, so a genuine server response fed back to the server passes the crypto check and takes a slot in the replay window. Measured with a window of 2: replaying one server response advances the handler count by one extra and evicts the oldest request nonce a request early. Not a full defeat of the mechanism, but the effective window can shrink by roughly half. The in-code comment "unforgeable frames can never pollute the window" (`server.ts:787-792`) doesn't account for frames produced by the other direction of the same session. Fix: record the nonce only after the direction check passes, or size the window accounting for both directions. + +## 10. Context-error contract contradicts itself in the spec + +One passage (`protocol.md:430`) says a typed error thrown from the context factory keeps its code, message and data; two others (`:446`, `:595`) plus the conformance checklist require masking it to a generic INTERNAL error. The code keeps the typed error. A second implementer of this protocol can't pick a behavior unambiguously. Fix: choose one rule and align the other passages (and the code, if masking is chosen). + +--- + +## Proposed order + +1. #6 second-phase deadline check + single error callback (main code bug) +2. #4 / #5 / #8 small input/guard fixes +3. #7 decide: observe the downstream promise, or retract the fire-and-forget claim +4. #9 nonce-record ordering +5. docs #1 / #2 / #10 +6. #3 raise Node floor + README + +Version fields in `package.json` / `jsr.json` are still 0.7.0 — separate release bump. diff --git a/review/0.8/review-spec-conformance-0.8.0.md b/review/0.8/review-spec-conformance-0.8.0.md new file mode 100644 index 0000000..d1e5d30 --- /dev/null +++ b/review/0.8/review-spec-conformance-0.8.0.md @@ -0,0 +1,313 @@ +# SafeRPC 0.8.0 — Specification vs Implementation Review + +**Target release:** 0.8.0 +**Review date:** 2026-07-14 +**Scope:** `src/`, `spec/*.md`, root implementation specs, README, package scripts, and tests. +**Method:** direct code/spec comparison, full test and typecheck runs, plus focused runtime probes for auth payload decoding. No subagents were used. + +## Verdict + +The handshake, key derivation, make-before-break replacement, replay window, auth profile fields, and known-answer vectors closely match the normative protocol. Full conformance does not hold, however. Two contract-level mismatches remain: + +1. shipped auth verifiers bypass the sanitization rules required by the protocol; +2. async `Channel.send()` crosses the implementation's sent boundary before the Promise resolves, contrary to the documented retry-safety contract. + +The remaining findings are API, adapter-example, error-shape, and documentation drift. + +--- + +## 1. Auth helper payloads bypass mandatory sanitization + +**Priority:** high +**Code:** `src/auth/server.ts:25-42`, `src/common.ts:145-174` +**Specification:** `spec/protocol.md:475-483`, `spec/api.md:469` + +`decodeAuthPayload()` calls `mpDecode()` but never passes the decoded value through `sanitize()`: + +```ts +function decodeAuthPayload(proof: Uint8Array): Record { + let parsed: unknown; + try { + parsed = mpDecode(proof); + } catch { + throw new RPCError("UNAUTHORIZED", "Malformed auth payload"); + } + // ... + return parsed as Record; +} +``` + +This matters because `mpDecode()` alone does not enforce the complete hardened-data policy. Unknown extension types decode to `ExtData`; `sanitize()` is the layer that rejects them, strips poison keys, rejects host objects, and enforces the recursion-depth limit. + +The API explicitly claims: + +> Auth payloads decode through the hardened msgpack codec: extension types rejected, prototype-pollution keys stripped, recursion depth capped. + +Focused probes confirmed that `createJWTServerAuth()` accepts both: + +- a valid profile containing an unknown msgpack extension in an ignored extra field; +- a valid profile containing an ignored object nested 40 levels deep. + +Both payloads should be rejected under the normative sanitization rules. The same common decoder is used by the JWT, Ed25519, and ECDSA server helpers. + +**Impact:** a strict port following the protocol rejects payloads that the TypeScript reference accepts. The 32 KiB auth-payload cap bounds total allocation, but the documented type-confusion and depth defenses are not actually applied to helper payloads. + +**Required alignment:** either sanitize the full decoded helper payload before field access, or narrow the specification and API claim. The former matches the existing security model. + +--- + +## 2. Async sends cross the sent boundary before Promise resolution + +**Priority:** high +**Code:** `src/client.ts:263-267`, `src/client.ts:436-442`, `src/client.ts:1043-1046` +**Specification:** `spec/protocol.md:444-453`, `spec-channel-lifecycle.md:60-69` + +The normative sent boundary is defined as: + +- synchronous adapter: `channel.send(frame)` returns without throwing; +- asynchronous adapter: the returned Promise resolves. + +Before that boundary, terminal events are specified to produce a plain local `RPCError`; after it, they produce `RPCAbortedError` because the outcome is unknown. + +The implementation deliberately marks an async send as sent immediately after receiving its Promise: + +```ts +const sent = channel.send(encrypted) as unknown; +if (isThenable(sent)) { + entry.sent = true; + sent.catch(/* rollback on rejection */); +} +``` + +The queued flush path does the same before the Promise settles. + +Therefore, while an async send Promise is still pending: + +- abort produces `RPCAbortedError("ABORTED")`, not plain `RPCError("ABORTED")`; +- global timeout produces `RPCAbortedError("TIMEOUT")`, not plain `RPCError("CHANNEL")`; +- that timeout resets the session, although the specification says a pre-boundary failure must not reset it; +- `sendTimeout` does not govern the pending send because the frame has already been removed from the outbound queue. + +The implementation's conservative choice is defensible: a pending Promise may later resolve and deliver the frame, so reporting UNKNOWN is safer than falsely reporting “never sent.” It nevertheless contradicts the public contract, which says the core still owns the only copy until Promise resolution. + +**Required alignment:** choose one model and document it consistently. If optimistic accounting remains, the API and protocol must explicitly define invocation/Promise handoff—not resolution—as the conservative boundary for async adapters. + +--- + +## 3. Documented adapters violate the send-or-throw contract + +**Priority:** medium +**Specification:** `spec/getting-started.md:99-110`, `spec/integrations.md:81-89`, `spec/integrations.md:303-339`, `spec/integrations.md:450-465` + +The adapter contract says an adapter must throw or reject when it cannot hand a frame to a live transport immediately. It also forbids adapter-owned queues because they hide the sent boundary from the client core. + +Several documented adapters do the opposite. + +### Getting Started WebSocket + +```ts +const ready = new Promise((resolve) => + ws.addEventListener("open", () => resolve(), { once: true }), +); + +async send(data) { + await ready; + ws.send(data); +} +``` + +This parks frames inside the adapter until the initial socket opens. It also does not check `readyState` after closure, reintroducing the browser behavior where `WebSocket.send()` on a closed socket may silently drop data. + +### WebRTC DataChannel + +```ts +async send(data) { + if (dc.readyState !== "open") await waitDCOpen(dc); + dc.send(data); +} +``` + +The text explicitly says sends are parked until open, directly contradicting the no-queue rule later in the same document. + +### TCP + +The example calls `socket.write()` immediately after `net.connect()`. Node may queue that write internally before the socket connects, while the SafeRPC core treats the synchronous return as crossing the sent boundary. + +**Impact:** users copying these examples do not get the retry classification, stale-frame revocation, or queue ownership promised by the core design. + +**Required alignment:** examples for mortal transports should check liveness and throw/reject while unavailable, or the contract must explicitly allow adapter buffering and give up the claimed definite sent boundary. The shipped `wsChannel` already implements the current contract correctly and should be the reference used by the quick start. + +--- + +## 4. A route named `then` is typed but cannot be called + +**Priority:** medium +**Code:** `src/client.ts:1082-1086` +**Specification:** `spec/api.md:235-245` + +`Client` promises a callable method for every string route key, and `rpc.router()` does not reserve or reject any name. The client proxy, however, always hides `then` to avoid becoming a thenable: + +```ts +get(_target, prop) { + if (typeof prop !== "string") return undefined; + if (prop === "then") return undefined; + // ... +} +``` + +A router containing `{ then: procedure }` therefore compiles as `api.then(...)`, while `api.then` is `undefined` at runtime. The wire protocol and server accept the procedure name; only the generated client makes it unreachable. + +**Required alignment:** reserve and reject `then` when creating a router, exclude it from `Client`, or expose procedures through a lookup API that does not conflict with Promise assimilation. + +--- + +## 5. The client accepts malformed response discriminators + +**Priority:** medium +**Code:** `src/client.ts:840-868` +**Specification:** `spec/protocol.md:347-381` + +The protocol defines exactly two response forms: + +- success: `ok: true`; +- failure: `ok: false`. + +Unexpected field types are specified as malformed envelopes. The client only checks for `true`; every other value enters the failure branch: + +```ts +if (msg["ok"] === true) { + entry.resolve(msg["d"]); +} else { + // treated as a remote failure +} +``` + +Responses with an absent `ok`, `ok: null`, `ok: 1`, or `ok: "false"` settle the pending call instead of being silently dropped. The pending entry is removed before this distinction is made. + +**Impact:** the reference client and a strict port produce observably different results for malformed authenticated responses: immediate `RemoteRPCError` versus timeout/silent drop. + +**Required alignment:** require `ok === true` or `ok === false`; drop any other value before removing the pending entry. + +--- + +## 6. Handshake reply send errors use `data`, not `cause` + +**Priority:** low +**Code:** `src/server.ts:712-724` +**Specification:** `spec/protocol.md:198`, `spec/assessment.md:84` + +The protocol and assessment say the single handshake failure preserves the transport error as its cause. The implementation passes the object as the third `RPCError` argument: + +```ts +throw new RPCError("HANDSHAKE", "Handshake reply send failed", { + cause: sendErr instanceof Error ? sendErr.message : String(sendErr), +}); +``` + +The third argument is `data`; constructor options are the fourth argument. The resulting error has: + +```ts +err.cause === undefined; +err.data.cause === "...string..."; +``` + +It also discards the original error object. + +**Required alignment:** pass `{ cause: sendErr }` as the fourth argument, or document the current `data.cause` shape instead. + +--- + +## 7. Security auth-order prose describes the pre-make-before-break server + +**Priority:** low +**Specification:** `spec/security.md:159-163` +**Code:** `src/server.ts:742-752`, protocol handshake steps 3-10 + +The security page says: + +1. all auth runs before any session key is materialized; +2. an auth failure resets the server to `waiting`. + +Neither statement is generally true: + +- server-side `verify` runs before ECDH, but server-side `sign` runs after session-key derivation and proof construction; +- a failed attempt does not reset an established live session. Under make-before-break it discards only the attempt and leaves the server ready on the existing session. + +The actual security property still holds: failed verification runs before ECDH, and a failed signing step publishes no candidate. The prose should state those narrower guarantees. + +--- + +## 8. Option validation is only partially documented + +**Priority:** low +**Code:** `src/client.ts:183-222`, `src/server.ts:273-298` +**Specification:** `spec/api.md:138-151`, `spec/api.md:216-233` + +Runtime validation includes: + +- client `timeout`: finite and `> 0`; +- client `sendTimeout`: finite and `>= 0`; +- client/server `handshakeTimeout`: finite and `>= 100 ms`; +- client/server `maxMessageBytes`: positive integer; +- client `maxPending`: positive integer; +- server `replayWindow`: non-negative integer. + +The API documents only part of this contract and does not mention the 100 ms handshake minimum. Calls that look valid from the option tables can therefore throw synchronously at construction. + +The client-options paragraph also discusses auth-helper `maxAge`, which is not a `ClientOptions` field. + +--- + +## 9. Remaining documentation and release drift + +**Priority:** low + +### `sendTimeout` default + +`spec-channel-lifecycle.md:170` says the default is 10 seconds. The implementation, public API, protocol, and assessment use 3 seconds. + +### Security assessment test count + +`spec/assessment.md:10` records 265 tests. The current suite contains 268 passing tests. This may be retained as a dated historical measurement, but it is not the current count. + +### Release command + +README says `npm version patch` runs a `postversion` hook that publishes and pushes. `package.json` has no `postversion` script. It has: + +- `version`: sync and stage `jsr.json`; +- `prepublishOnly`: lint, test, build; +- `postpublish`: push tags. + +Running `npm version patch` alone therefore does not publish the package or push the tag as documented. + +--- + +## Validation results + +- Full test suite: **268/268 passed**. +- Typecheck: **passed**. +- Lint: **failed** due to formatting drift in: + - `src/server.ts` + - `test/unit/auth-server.test.ts` + - `test/unit/vectors.test.ts` +- Focused auth probes: + - unknown extension in an ignored helper-profile field: **accepted**; + - ignored helper-profile field nested 40 levels deep: **accepted**. +- Working tree was not modified during the review itself. The pre-existing untracked `auth-api-review-question.md` remained untouched. + +## Conformant areas checked + +No mismatch was found in: + +- X25519/HKDF/HMAC formulas and byte ordering; +- transcript magic, field order, and epoch encoding; +- low-order X25519 rejection through `@noble/curves`; +- make-before-break candidate installation and promotion; +- promotion on successful AEAD before inner-payload validation; +- replay-window insertion, FIFO eviction, and clearing on promotion; +- no-auto-resend and reset-on-sent-timeout policy for synchronous transports; +- input omission for `undefined`; +- built-in auth profile field names, versions, signature inputs, and published vectors; +- constant-time proof and JWT transcript-digest comparisons; +- handler/internal-error detail suppression; +- ESM/CJS/auth/channels package export paths. diff --git a/review/README.md b/review/README.md new file mode 100644 index 0000000..8632853 --- /dev/null +++ b/review/README.md @@ -0,0 +1,57 @@ +# Reviews + +Independent review artifacts for the 0.7 and 0.8 release lines. These are the +audits, design-review requests, and verdicts that drove the changes recorded in +`../changelog/`. Every finding raised here was re-verified in shipped code and +closed before release — nothing security-sensitive remains open, which is why +they're published rather than kept local. + +Reviewers are distinct independent LLM auditors (referred to by handle in the +filenames): **Fable** and **gpt-5.6-sol** (the "porter" cross-language audit), +plus **neutral** passes done with a fresh, verdict-free context. Read each audit +as an outside opinion, not ground truth — the ground truth is the code + tests it +was checked against. + +Files are grouped by the release they fed into and named `…-YYYY-MM-DD` where a +date applies. Read top to bottom within each table for chronological order. + +## 0.7 line — client & transport rework + +Landed: no-auto-retry, deferred reset, replay window, `abortPending`, +make-before-break session continuity, reconnecting channel adapters, and the +first port-complete spec + KAT vectors. + +| # | File | What it is | +|---|------|-----------| +| 1 | [`0.7/review-spec-conformance.md`](0.7/review-spec-conformance.md) | Baseline spec ↔ implementation conformance at 0.6.1 — the starting state before the 0.7 rework. | +| 2 | [`0.7/review-transport-lifecycle.md`](0.7/review-transport-lifecycle.md) | Design review of transport lifecycle, retry, and error taxonomy. Motivated removing auto-retry and the deferred-reset model. | +| 3 | [`0.7/handshake-continuity-question.md`](0.7/handshake-continuity-question.md) | Review request: how to keep a session alive across a lazy re-handshake. | +| 4 | [`0.7/handshake-continuity-review.md`](0.7/handshake-continuity-review.md) | Outcome of that request: the make-before-break continuity mechanism. | + +## 0.8 line — security hardening, auth profiles, conformance + +Landed: absolute handshake deadlines, `maxPendingHandshakes`, epoch-exhaustion +guard, normative JWT/Ed25519/ECDSA auth profiles, removal of the +certificate/MFA helpers, input-validation hardening, and a full 44-item +conformance pass. + +| # | File | What it is | +|---|------|-----------| +| 1 | [`0.8/auth-api-review-question.md`](0.8/auth-api-review-question.md) | Review request: is the auth-helper surface the right shape? | +| 2 | [`0.8/review-auth-helpers-fable-2026-07-14.md`](0.8/review-auth-helpers-fable-2026-07-14.md) | Fable's verdict: delete `createCertificateServerAuth` + `createMultifactorServerAuth` (unsafe composition), make the three surviving profiles normative with an enforced `v` field. | +| 3 | [`0.8/review-findings-2026-07-14.md`](0.8/review-findings-2026-07-14.md) | Security round 1 — 7 findings (candidate/AEAD split, sync-callback deadline, NaN limits, puppeteer in prod deps, reply-send candidate, middleware-without-`next`). | +| 4 | [`0.8/review-protocol-portability-fable-2026-07-14.md`](0.8/review-protocol-portability-fable-2026-07-14.md) | Fable's protocol-portability audit — can a clean-room port interoperate from the spec alone. | +| 5 | [`0.8/review-porter-audit-sol-2026-07-14.md`](0.8/review-porter-audit-sol-2026-07-14.md) | gpt-5.6-sol porter audit, round 1 — spec gaps a re-implementer would hit. | +| 6 | [`0.8/review-porter-audit-sol-round2-2026-07-15.md`](0.8/review-porter-audit-sol-round2-2026-07-15.md) | Porter audit round 2 — re-check after the round-1 fixes. | +| 7 | [`0.8/review-round2-neutral-for-fable-2026-07-15.md`](0.8/review-round2-neutral-for-fable-2026-07-15.md) | Neutral correctness & conformance pass — the 10 findings that define the 0.8.0 security set. | +| 8 | [`0.8/review-spec-conformance-0.8.0.md`](0.8/review-spec-conformance-0.8.0.md) | Spec ↔ implementation conformance at 0.8.0. | +| 9 | [`0.8/review-release-verdict-0.8.0.md`](0.8/review-release-verdict-0.8.0.md) | **Final release verdict.** Re-verifies every finding in shipped code, runs the full gate, the 44-item conformance checklist, and lists residual risks. Start here for the current state. | + +## How to read + +- Want the current state and what's left before merge/publish → start at the + **0.8 release verdict** (last row). +- Want why a specific 0.8 change exists → find its finding in round-1 + (`review-findings`) or round-2 (`review-round2-neutral`). +- Want the 0.7 behavioural model (retry/reset/continuity) → + `review-transport-lifecycle` + `handshake-continuity-review`. diff --git a/scripts/gen-vectors.mjs b/scripts/gen-vectors.mjs index 1e7d3f3..e065482 100644 --- a/scripts/gen-vectors.mjs +++ b/scripts/gen-vectors.mjs @@ -3,7 +3,8 @@ // 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 { x25519, ed25519 } from "@noble/curves/ed25519.js"; +import { p256 } from "@noble/curves/nist.js"; import { hkdf } from "@noble/hashes/hkdf.js"; import { sha256 } from "@noble/hashes/sha2.js"; import { hmac } from "@noble/hashes/hmac.js"; @@ -69,6 +70,40 @@ const plaintext = encode({ t: 1, id: "1", p: "ping" }); // default codec fine: n const ct = xsalsa20poly1305(session_key_psk, msg_nonce).encrypt(plaintext); const frame = concatBytes(new Uint8Array([0x01]), msg_nonce, ct); +// ── Auth profile payload KATs ── +// All bind to hello_transcript above. Encoded with the library codec options +// ({ useBigInt64: true } — matters for jwt.ts, which must land as float64). +const mp = (data) => encode(data, { useBigInt64: true }); + +// jwt profile: fully deterministic given fixed ts. +const jwt_token = "test.jwt.token"; +const jwt_ts = 1700000000000; // fixed; encodes as msgpack float64 (0xcb) +const jwt_payload = mp({ + v: 1, + jwt: jwt_token, + ts: jwt_ts, + th: sha256(hello_transcript), +}); + +// ed25519 profile: RFC 8032 signatures are deterministic — byte-exact KAT. +const ed_seed = fromPattern(0x61); // 0x61..0x80 +const ed_pub = ed25519.getPublicKey(ed_seed); +const ed_sig = ed25519.sign(hello_transcript, ed_seed); // exact call from src/auth/client.ts +const ed_payload = mp({ v: 1, deviceId: "device-1", sig: ed_sig }); + +// ecdsa profile: WebCrypto signing is randomized, so the KAT signature is the +// RFC 6979 deterministic lowS signature (prehash SHA-256, P-1363 r||s) — it +// verifies under WebCrypto like any other valid signature. A port with a +// randomized signer will NOT reproduce these bytes; its payload must instead +// VERIFY against ecdsa_pub, and this payload must verify in its verifier. +const ecdsa_priv = fromPattern(0xa1); // 0xa1..0xc0, valid P-256 scalar +const ecdsa_pub = p256.getPublicKey(ecdsa_priv, false); // uncompressed, 65 bytes +const ecdsa_sig = p256.sign(hello_transcript, ecdsa_priv, { + lowS: true, + prehash: true, +}); +const ecdsa_payload = mp({ v: 1, identifier: "entity-1", sig: ecdsa_sig }); + const out = { inputs: { c_priv: hex(c_priv), @@ -93,5 +128,28 @@ const out = { plaintext_msgpack: hex(plaintext), frame: hex(frame), }, + auth_profiles: { + transcript: "hello_transcript (above)", + jwt: { + token: jwt_token, + ts: jwt_ts, + th: hex(sha256(hello_transcript)), + payload: hex(jwt_payload), + }, + ed25519: { + seed: hex(ed_seed), + publicKey: hex(ed_pub), + deviceId: "device-1", + sig: hex(ed_sig), + payload: hex(ed_payload), + }, + ecdsa: { + privateScalar: hex(ecdsa_priv), + publicKeyUncompressed: hex(ecdsa_pub), + identifier: "entity-1", + sig: hex(ecdsa_sig), + payload: hex(ecdsa_payload), + }, + }, }; console.log(JSON.stringify(out, null, 2)); diff --git a/spec-channel-lifecycle.md b/spec-channel-lifecycle.md index ddf3936..ee3fb41 100644 --- a/spec-channel-lifecycle.md +++ b/spec-channel-lifecycle.md @@ -1,8 +1,14 @@ # 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 +> **Design document (historical).** This is the implementation plan that +> shipped the 0.7.0 lifecycle work, kept for design rationale. It is NOT the +> normative protocol contract: porters work from [`spec/protocol.md`](spec/protocol.md), +> which wins wherever the two disagree (e.g. the async sent boundary is +> handoff-based there). + +Status: shipped in 0.7.0. Superseded 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. +`src/channels/`, spec docs, tests. Decisions locked with CTO 2026-07-10: @@ -63,10 +69,13 @@ throw. > 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. +throwing (sync adapters) or handed back a pending promise (async adapters — +handoff, not resolution). Between handoff and settlement the frame may +already be on the wire, so the conservative classification is "outcome +unknown"; a rejection settling later proves the frame never left and rolls +the call back to the unsent class. Before the boundary the core holds the +only copy of the frame and can discard it with certainty; after it 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/` @@ -141,10 +150,13 @@ 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 +1. Try `channel.send(encrypted)` immediately. Success (no throw / a pending + promise handed back) → the call is **sent**: it waits for reply-or-timeout + exactly as today. If an async send's promise later rejects, the call rolls + back to unsent: the frame re-enters the outbound queue while the session + is unchanged, or fails with plain `RPCError("CHANNEL")` if a reset staled + it in flight. +2. On sync throw → 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 @@ -167,7 +179,7 @@ Send path in `sendRequest` (and the hello path in `ensureHandshake`): 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 +Defaults sanity: `sendTimeout` (3 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 @@ -262,7 +274,8 @@ 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 + throws for the whole `sendTimeout` 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. @@ -387,8 +400,11 @@ Keep neutral naming/comments, as with `session-continuity.test.ts`. 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. +4. Async-send adapters: the boundary is counted at handoff (§2), so a + rejection always arrives *after* the call was optimistically classed as + sent — it is the rollback proof: the frame provably never left, the call + returns to the unsent class (re-enqueued at the head of the queue while + the session is unchanged, failed plain `CHANNEL` if a reset staled it). + State in the `Channel` jsdoc that adapters must reject rather than + resolve-then-error — a resolved promise is the adapter's word that the + frame reached the transport. diff --git a/spec-make-before-break.md b/spec-make-before-break.md index 7196286..b26b93b 100644 --- a/spec-make-before-break.md +++ b/spec-make-before-break.md @@ -1,6 +1,11 @@ # Spec: make-before-break session replacement (saferpc server) -Status: ready to implement. Target: `src/server.ts` + four spec docs + one test +> **Design document (historical).** The implementation plan that shipped the +> make-before-break server, kept for design rationale. It is NOT the normative +> protocol contract: porters work from [`spec/protocol.md`](spec/protocol.md), +> which wins wherever the two disagree. + +Status: shipped. 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 diff --git a/spec/api.md b/spec/api.md index ac827f2..2488305 100644 --- a/spec/api.md +++ b/spec/api.md @@ -85,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` must return `next()` (called exactly once) — the type contract; its result type is obtainable only from `next()`. `next(extra)` merges `extra` into ctx — and its type flows into every downstream step. At runtime, a middleware that completes while its `next()` promise is still **pending** yields `MIDDLEWARE`. | +| `.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). @@ -141,18 +141,23 @@ 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` | — | -| `replayWindow` | `number` | `4096` | — | -| `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` | — | +| `maxPendingHandshakes` | `number` | `16` | — | +| `replayWindow` | `number` | `4096` | — | +| `onError` | `(err: unknown) => void` | — | — | + +Numeric options are validated at construction with a synchronous `TypeError`: `handshakeTimeout` — finite number ≥ **100 ms**; `maxMessageBytes` and `maxPendingHandshakes` — positive integers; `replayWindow` — integer ≥ 0 (`0` disables the seen-nonce set). `NaN`/`Infinity` never silently disables a limit. `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. +`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). Every authenticated malformed request frame consumes a slot; reflected response frames (`t: 2`) do not. 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. + +`maxPendingHandshakes` bounds handshake attempts whose async work has not settled. A timed-out attempt remains counted until its `auth` callback settles because an arbitrary JavaScript Promise cannot be cancelled; new hellos are silently dropped at the cap. This trades re-handshake availability for bounded memory under a peer that opens never-settling auth calls. Default: `16`. `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. @@ -226,9 +231,13 @@ Returns synchronously. The handshake stays lazy: it starts on the first `api` ca `maxPending` caps concurrent in-flight calls. Past the cap, calls reject with `RPCError("CLIENT", "Too many pending requests")`. +Every numeric option is validated at construction; a value that looks fine in the table above can still throw a synchronous `TypeError`. The full contract: `timeout` — finite number > 0; `sendTimeout` — finite number ≥ 0; `handshakeTimeout` — finite number ≥ **100 ms** (a sub-100 ms budget cannot complete a real handshake and would only produce spurious timeouts); `maxPending` and `maxMessageBytes` — positive integers. `NaN`/`Infinity` — easy to produce with `Number(process.env.X)` on an unset variable — is rejected rather than silently disabling the limit (`length > NaN` is always false). + +If a previous client handshake timed out while an auth callback or transport Promise is still unsettled, the next call fails with `RPCError("HANDSHAKE")` instead of starting another handshake operation. Once the stale operation settles, later calls may handshake again. This keeps client-side non-cancellable auth work bounded to one operation. + `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. +`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. Note the async edge: an adapter's `send` counts as sent the moment it hands back a pending promise (handoff, not resolution — see [Protocol § Failure handling](protocol.md#failure-handling-no-auto-retry)), so from that point the call is governed by `timeout` and rides the aborted class; a later rejection rolls it back only while the call is still pending and the session is unchanged. If timeout, abort, destroy, or a valid response already settled the call, the late rejection is ignored. ### `Client` @@ -242,6 +251,8 @@ type Client = { 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. +One route name is **reserved**: `then`. The generated client is a Proxy that must not look thenable — if `api.then` were a function, `await api` and every other Promise-assimilation point would invoke it, so such a route could never be called. `rpc.router()` rejects the name at creation (compile-time and runtime). This is a JS-client reservation, not a wire rule: the protocol and server accept any procedure name, and ports reserve whatever names collide with their own language's implicit member protocols. + ### `CallOptions` — per-call abort ```typescript @@ -264,7 +275,7 @@ Fetch-style. Aborting rejects **that call** with code `ABORTED`. The class depen - 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. +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 @@ -283,7 +294,7 @@ idle → handshaking → ready ## 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). +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 itself does not begin a handshake; it only returns to `idle`. The next call, or another already-pending call that later reaches its own handshake path, starts or joins one shared lazy re-handshake. 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). 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. @@ -335,21 +346,21 @@ class RPCAbortedError extends RPCError {} ### 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 | +| 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) | +| `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, completed without calling `next()`, or 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. @@ -400,7 +411,7 @@ server(appRouter, channel, { }); ``` -The middleware must **return** `next(...)`. Calling `next()` twice throws `RPCError("MIDDLEWARE", ...)`; so does passing a non-object `extra`. +The public contract is: call `next(...)` **exactly once** and return its result — the `MiddlewareResult` type is obtainable only from `next()`, so this is enforced at compile time. The runtime enforces the checkable subset. Calling `next()` twice throws `RPCError("MIDDLEWARE", ...)`; so does passing a non-object `extra`. Completing without calling `next()` at all also throws `MIDDLEWARE` — otherwise the handler would be silently skipped while the caller still observed a success. Completing while the `next()` promise is still **pending** throws `MIDDLEWARE` — otherwise the caller would receive the middleware's own value while the handler outcome is silently dropped. A late call after completion is ignored. Note the exact runtime invariant: the error fires only when the downstream is still pending at middleware completion. A middleware that bypasses the types, lets `next()` settle, and returns a different value (transform/fallback) is observationally indistinguishable from having consumed the settled result; the runtime forwards its return value, but this is a tolerated behavior, not a supported public API — no guarantees are made for it. The `context` factory runs **per request**, after auth verification, and receives `{ auth }` carrying the data returned by `auth.verify` for that session. @@ -445,6 +456,8 @@ import { | `generateEd25519Keypair()` | `{ privateKey: Uint8Array, publicKey: Uint8Array }` | Pure JS, works everywhere. | | `generateECDSAKeypair()` | `{ privateKey: CryptoKey, publicKey: CryptoKey }` | Non-extractable. | +Each signing helper emits a versioned, normative wire payload (a msgpack map stamped with a profile version `v: 1`) — these are the schemas a cross-language port must reproduce to interoperate, specified in [Protocol § Auth payload profiles](protocol.md#auth-payload-profiles). The matching server helper rejects a payload whose `v` is absent or unknown. + ### Server-side ```typescript @@ -452,21 +465,19 @@ import { createJWTServerAuth, createEd25519ServerAuth, createECDSAServerAuth, - createCertificateServerAuth, - createMultifactorServerAuth, } from "@dotex/saferpc/auth/server"; // 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. | + +Auth payloads run the full hardened-data pipeline before any field access — the hardened msgpack codec followed by the sanitization gate: extension types rejected, prototype-pollution keys stripped/banned, host objects rejected, recursion depth capped — even in fields a profile ignores. Returned `auth` data is sanitized again before reaching `context`. -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`. +`maxAge` (JWT helper, default 30 000 ms) must be a finite number ≥ 0, validated at construction with a `TypeError` — a `NaN` skew budget would silently disable the staleness check. --- diff --git a/spec/assessment.md b/spec/assessment.md index 2c10b7b..65f7d65 100644 --- a/spec/assessment.md +++ b/spec/assessment.md @@ -7,7 +7,7 @@ An internal line-by-line review of the implementation against the [Protocol](pro ## 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). +- Full test suite run (265 tests at the review date — the suite has grown since; including `test/security/`: handshake attacks, replay, tampering, type confusion, prototype pollution, DoS limits, deferred reset, replay window, session continuity, and the 2026-07 follow-up fixes below). - 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. @@ -20,7 +20,7 @@ Checked line-by-line against the spec, no findings: - **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. +- **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 all-zero bytes of any length 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. @@ -39,13 +39,13 @@ Status: **fixed (narrowed) in 0.7.0.** The server keeps a bounded seen-nonce set 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). +Status: **fixed in 0.7.0.** The auto-retry is removed entirely. Exactly one trigger resets the session (without resending): a reply-timeout on a **sent** request (`RPCAbortedError` code `TIMEOUT`) — "it went out and the server never answered" is the one signal the session may be desynced. The request may have executed, so it is never blind-resent; the caller decides whether to retry. A `CHANNEL` send error is a transport event, **not** a session event — the keys stay good and nothing resets. Guardrail (`CLIENT`), `SESSION`, and `RemoteRPCError` never reset. 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). +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. @@ -55,7 +55,7 @@ On any well-formed hello the server returns `proof = HMAC(HKDF(raw, salt=secret) ### 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. +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 3 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 @@ -73,6 +73,20 @@ The transcript digest binds a captured auth payload to its handshake, so it cann 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. +## 2026-07 follow-up review — fixed + +A second internal read (independent context) after the 0.7.0 fixes above found a +further set of defects, all fixed and covered by the security test suite unless noted. + +- **AEAD verification vs. inner-payload decode were conflated.** The server's inbound path decrypted, msgpack-decoded, and sanitized in one step, so a decode error on an otherwise-authenticated candidate frame was handled as a Poly1305 failure — the candidate never promoted and expired, contradicting the spec (a frame that passes AEAD promotes regardless of inner shape). Split into `createAeadOpener` (Poly1305 → plaintext), candidate promotion, and a separate `decodePlaintext` step. Authenticated malformed envelopes now consume their replay nonce synchronously; reflected `t: 2` responses are excluded from the request replay window. +- **A synchronous auth callback could overrun `handshakeTimeout`.** Expiry was tracked only by a boolean the timer sets; a `verify`/`secret`/`sign` that blocked the event loop past the budget resumed as a microtask before the timer macrotask, so its continuation still installed a candidate and sent a reply. Both sides now also check an absolute wall-clock deadline after every await (`attemptDead()` server-side, `hsDeadline` client-side). +- **Non-cancellable auth callbacks could accumulate after timeout.** A timed-out Promise could not be cancelled, so repeated hello/re-handshake attempts retained one closure each indefinitely. Server attempts are now bounded by `maxPendingHandshakes` (default 16) until their callbacks settle; the client retains one unsettled handshake operation and rejects a new attempt rather than accumulating more. +- **Defensive limits accepted `NaN`/`Infinity`.** `maxPending`, `maxMessageBytes` (client + server), and JWT `maxAge` were not validated — `length > NaN` is always false, silently disabling the limit. All now require a finite positive integer (`maxAge`: finite ≥ 0) at construction. +- **A failed handshake-reply send left the candidate lingering.** The candidate was installed before `channel.send(reply)`; a send failure reported the error but left the candidate to expire, producing a second spurious `Handshake timeout`. The send is now guarded — on failure the candidate is dropped (if still current) and a single `HANDSHAKE` error carries the transport cause. +- **Middleware could complete without calling `next()`.** Only a double `next()` was caught; a middleware that returned a value without calling `next()` skipped the handler while the client still saw success. Completion without `next()` now yields `RPCError("MIDDLEWARE", …)`. + +Separately, the `createCertificateServerAuth` and `createMultifactorServerAuth` helpers were **removed** (not fixed). Both delegated the security-critical work to an application callback (certificate chain / principal binding) while shipping a name that implied the library handled it, and neither had a client counterpart. The multifactor composer additionally merged two independently-verified principals without checking they described the same subject. The documented pattern in [Security § Custom schemes](security.md#custom-schemes-certificates-multiple-factors) replaces them: compose your own `verify` and assert the binding explicitly, where the check is visible to its author rather than hidden in a helper. + ## 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 fc87617..1507d11 100644 --- a/spec/getting-started.md +++ b/spec/getting-started.md @@ -52,31 +52,19 @@ crypto.getRandomValues(new Uint8Array(32)); // run once, store the result ```typescript // server.ts -import { server, type Channel } from "@dotex/saferpc"; -import { WebSocketServer, type WebSocket } from "ws"; +import { server } from "@dotex/saferpc"; +import { socketChannel } from "@dotex/saferpc/channels"; +import { WebSocketServer } from "ws"; import { appRouter } from "./router.js"; const secret = new Uint8Array([ /* 32 bytes from your generator */ ]); -function wsChannel(ws: WebSocket): Channel { - return { - send(data) { - ws.send(data); - }, - receive(cb) { - const handler = (data: Buffer) => cb(new Uint8Array(data)); - ws.on("message", handler); - return () => ws.off("message", handler); - }, - }; -} - const wss = new WebSocketServer({ port: 8080 }); wss.on("connection", (ws) => { - const { destroy } = server(appRouter, wsChannel(ws), { + const { destroy } = server(appRouter, socketChannel(ws), { auth: { secret: () => secret }, context: () => ({ user: null }), onError: console.error, @@ -85,40 +73,23 @@ wss.on("connection", (ws) => { }); ``` +`socketChannel` is the shipped single-socket adapter: it delivers inbound +bytes and throws on `send` when the socket is not open. The server does not +reconnect a client's socket, so connection death is session death — +`ws.on("close", destroy)` handles that. + ### Client (browser) ```typescript // app.ts -import { client, type Channel } from "@dotex/saferpc"; +import { client } from "@dotex/saferpc"; +import { wsChannel } from "@dotex/saferpc/channels"; import type { AppRouter } from "./router"; 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; - 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); - }, - }; -} - const { api } = client(wsChannel("ws://localhost:8080"), { auth: { secret: () => secret }, }); @@ -127,6 +98,14 @@ const { message } = await api.greet({ name: "World" }); console.log(message); // "Hello, World!" ``` +`wsChannel` is the shipped reconnecting adapter: it owns the socket +lifecycle (reconnects with backoff, forever, until `close()`) and throws on +`send` while the transport is down — the client core keeps undelivered +frames in its own outbound queue and retries them until `sendTimeout`. That +ordering matters: an adapter must never buffer frames itself — see +[Integrations § adapter contract](integrations.md#adapter-contract-send-or-throw-no-queues-stay-available) +before writing your own. + 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 @@ -257,7 +236,9 @@ const auth = createEd25519ServerAuth({ }); ``` -All built-in helpers (Ed25519, ECDSA, JWT, certificate, multifactor) bind their proof to the canonical handshake transcript. See [Security → Built-in signature helpers](security.md#built-in-signature-helpers). +All built-in helpers (Ed25519, ECDSA, JWT) bind their proof to the canonical handshake transcript. See [Security → Built-in signature helpers](security.md#built-in-signature-helpers). + +> **Authentication is directional.** Client `sign` + server `verify` proves the _client's_ identity to the server — it does **not** prove the server's identity to the client. For mutual authentication both sides need `sign` **and** `verify`, or a shared `secret` (PSK). See [Security → Authentication is directional](security.md#authentication-is-directional). ### Both (defense-in-depth) diff --git a/spec/integrations.md b/spec/integrations.md index 6a0e73c..414981b 100644 --- a/spec/integrations.md +++ b/spec/integrations.md @@ -73,7 +73,7 @@ new connection): then calls in flight across a socket blip just complete. ### TCP socket (Node.js) -Raw TCP does not preserve message boundaries, so the adapter frames every payload with a 4-byte length prefix. +Raw TCP does not preserve message boundaries, so the adapter frames every payload with a 4-byte length prefix. `send` checks `readyState` first: Node happily buffers `socket.write()` on a still-connecting socket, which would cross the [sent boundary](#adapter-contract-send-or-throw-no-queues-stay-available) invisibly — the core would count a frame as sent while it sits in Node's internal buffer. Throwing instead keeps the frame in the core outbound queue, which retries it until the socket is actually open. ```typescript import net from "net"; @@ -83,6 +83,9 @@ function tcpChannel(socket: net.Socket): Channel { return { send(data) { + if (socket.readyState !== "open") { + throw new Error("socket not open: " + socket.readyState); + } const len = new Uint8Array(4); new DataView(len.buffer).setUint32(0, data.length, false); socket.write(Buffer.concat([len, data])); @@ -297,15 +300,17 @@ Direct connection between peers without a central relay. ### WebRTC DataChannel -Peer-to-peer, no relay. `RTCDataChannel` is ordered and reliable by default. The signalling (offer/answer/ICE) is your problem. Safe RPC starts after the data channel fires `"open"`. And there is no central party to hold a PSK, so peers usually authenticate by signing the handshake transcript with device keys. PSK still works if both sides derive the same secret from a shared room code or account, it just rarely matches how WebRTC apps are wired. +Peer-to-peer, no relay. `RTCDataChannel` is ordered and reliable by default. The signalling (offer/answer/ICE) is your problem. And there is no central party to hold a PSK, so peers usually authenticate by signing the handshake transcript with device keys. PSK still works if both sides derive the same secret from a shared room code or account, it just rarely matches how WebRTC apps are wired. ```typescript function webRTCChannel(dc: RTCDataChannel): Channel { dc.binaryType = "arraybuffer"; return { - async send(data) { - if (dc.readyState !== "open") await waitDCOpen(dc); + send(data) { + if (dc.readyState !== "open") { + throw new Error("data channel not open: " + dc.readyState); + } dc.send(data); }, receive(cb) { @@ -317,26 +322,9 @@ function webRTCChannel(dc: RTCDataChannel): Channel { }, }; } - -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 }, - ); - }); -} ``` -Sends are parked until the channel opens, so the data channel can go straight into `client()` / `server()` — no need to wait for signalling to finish first. +`send` throws while the channel is still `connecting` (or after it closes), per the [adapter contract](#adapter-contract-send-or-throw-no-queues-stay-available). The data channel can still go straight into `client()` / `server()` before signalling finishes: the first call's frames land in the core outbound queue and are retried until `sendTimeout` — raise it if your ICE negotiation routinely takes longer. Do not park frames inside the adapter instead; a frame the adapter accepted but has not delivered looks "sent" to the core, which silently breaks retry classification and stale-frame revocation. #### Usage diff --git a/spec/protocol.md b/spec/protocol.md index aa3f8fe..d4ef995 100644 --- a/spec/protocol.md +++ b/spec/protocol.md @@ -4,6 +4,17 @@ Language-agnostic specification of the Safe RPC wire protocol. Read this to port The reference implementation is in TypeScript, but this document is the contract — the code follows it. +**Document map for porters.** This file is the only fully normative document; where any other document disagrees with it, this one wins. + +| Document | Role for a port | +| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `protocol.md` (this file) | Normative wire contract + conformance checklist + test vectors. | +| [`security.md`](security.md) | Threat model and rationale. Contains porter obligations referenced from the checklist (e.g. low-order X25519 rejection). | +| [`api.md`](api.md) | The TypeScript reference surface. Ports mirror the _semantics_ (error classes, timeout behavior, option validation), not the JS names or types. | +| [`getting-started.md`](getting-started.md), [`integrations.md`](integrations.md) | Usage and adapter examples for the reference implementation. The adapter contract in integrations.md restates protocol rules; the examples are JS. | +| [`assessment.md`](assessment.md) | Dated internal review of the reference — a methodology template for reviewing a port, not a contract. | +| `spec-channel-lifecycle.md`, `spec-make-before-break.md` (repo root) | Historical design documents behind two shipped features. Rationale only. | + ## Goals and non-goals Design constraints, in order: @@ -18,17 +29,28 @@ Non-goals: streaming RPCs in-protocol, multiplexing over a single channel, forma ## 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 | Extension types rejected at the codec/sanitization boundary | All wire numbers are network-byte-order (big-endian) unless explicitly noted. +### msgpack profile + +Every msgpack document in this protocol (hello/reply maps, RPC envelopes, auth payload profiles) follows one encoding profile. A port that deviates produces frames the reference implementation silently drops or rejects as type-mismatched — the reference decoder strict-checks native types, it does not coerce. + +- **Map keys** are msgpack `str`. Field values follow the schema annotations: `bin` fields **must** use the msgpack `bin` family and `string` fields the `str` family — the two are never interchangeable (a `pub`/`nonce`/`sig`/`th` sent as `str`, or an `id`/`p` sent as `bin`, fails the receiver's type guard). +- **Integers** are encoded in the smallest msgpack integer type that holds them: a value in `0..2³²−1` uses `fixint`/`uint8`/`uint16`/`uint32` — never `int64`/`uint64` (`0xd3`/`0xcf`). Integer values outside the 32-bit range are encoded as IEEE-754 `float64` (`0xcb`), not as 64-bit integers. In particular, the JWT profile's `ts` (a millisecond timestamp ≈ 1.8×10¹²) travels as `float64`. +- **Negative integers, non-integers, and special values** (application payload data only — no protocol field carries them): a negative value in signed 32-bit range uses `fixint`-neg/`int8`/`int16`/`int32`; any other real number, including negatives beyond int32 and all fractional numbers, uses `float64`. Negative zero encodes as positive zero (`0x00`). `NaN` and ±`Infinity` are encodable as `float64` and survive [sanitization](#sanitization) (they are numbers) — a port choosing to reject them must do so in its own application layer, not at the codec. Only a **big-integer** typed value ever selects the 64-bit int family; a language without a distinct big-integer type therefore never emits `0xd3`/`0xcf` and needs no special decode for them beyond what the strict-check above already covers. +- **Framing and decoder strictness** (a port's decoder must match, or it diverges on hostile input): a frame carries exactly **one** top-level msgpack value — trailing bytes after it are a decode error and the frame is dropped (the reference throws `"Extra N byte(s)"`). On a **duplicate map key** the reference is last-value-wins and does **not** reject; honest peers never emit duplicate keys and every map here has a fixed key set, but a stricter port that rejects duplicates stays interoperable with honest traffic. Strings are UTF-8; behavior on **invalid UTF-8** is decoder-defined (the reference does not throw, it substitutes) — no honest field is non-UTF-8, so a port may reject or substitute, but **must not** depend on the reference's substituted bytes. +- **Unknown map fields are ignored** on every map in the protocol — hello, reply, request, and response alike. Receivers extract known keys by name and never enumerate for exact-shape rejection. This is the forward-compatibility rule: a future field can be added to any map without breaking an older peer, which simply ignores it. +- Rationale a porter must reproduce: the reference codec maps only the 64-bit integer types to a big-integer (`BigInt`); every narrower integer decodes to a native number. Receivers then strict-check native numbers — `t !== 1`, `v !== 1`, `typeof epoch !== "number"`, `typeof ts !== "number"` — so a foreign encoder that writes `t: 1` as `uint64` is decoded to a big-integer and the frame is silently dropped; a `ts` sent as `uint64` is rejected as `"Invalid timestamp"`. + ## Constant reference | Name | Value | Purpose | @@ -53,6 +75,8 @@ All wire numbers are network-byte-order (big-endian) unless explicitly noted. | `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) | +Two scopes are mixed in this table and a port must distinguish them. **Wire-normative** (both peers must agree or they cannot interoperate): `TAG_*`, `NONCE_LEN`, `KEY_LEN`, `PROOF_LEN`, `MAX_HELLO_BYTES`, `MAX_AUTH_BYTES`, `MAX_ID_LEN`, `KDF_INFO`, `PSK_DERIVE_INFO`, `TRANSCRIPT_*_MAGIC`, `EMPTY_SECRET`. **Local-policy defaults** (each side may configure its own value; they change observable timing/limits but not wire compatibility): `MAX_MSG_BYTES`, `HANDSHAKE_TIMEOUT_MS`, `RPC_TIMEOUT_MS`, `SEND_TIMEOUT_MS`, `MAX_PENDING`, `REPLAY_WINDOW`. + ## Frame format Every wire frame is a single byte tag followed by a payload. @@ -61,6 +85,8 @@ Every wire frame is a single byte tag followed by a payload. frame := tag (1 byte) || payload (...) ``` +**Every frame is exactly one transport message.** The protocol is **not** self-delimiting: there is no length prefix, and decryption/decoding consumes the entire delivered buffer as one frame. The core assumes the transport preserves message boundaries (WebSocket, datagram, `MessagePort`, an in-memory pair). Over a stream transport (raw TCP/TLS — the “duplex socket” of goal #4) the channel adapter **must** add its own framing (e.g. a length prefix), reassemble, and hand each complete frame to the core as a single unit; that outer framing is out of protocol scope and not covered by the test vectors. + 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. @@ -93,6 +119,8 @@ The payload is a msgpack-encoded map. The frame is sent in both handshake direct 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. +The `auth` field is present exactly when the sender is configured to authenticate this direction. When the receiver has **no** verifier configured it never reads `auth` — an absent, empty, or oversized `auth` is simply ignored (an unauthenticated peer is legal only if neither side configured a verifier for that direction; construction-time validation enforces the pairing). When the receiver **does** have a verifier configured, `auth` is **required** and its length **must** be `1..MAX_AUTH_BYTES`: absent, non-`bin`, empty (`bin(0)`), or oversized all fail the handshake attempt before any session state is derived. + ### `TAG_MSG = 0x01` The payload is an encrypted RPC message: @@ -131,7 +159,10 @@ 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. 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. +- All ephemeral private keys and protocol nonces **must** come from a cryptographically secure random source; ports must not substitute `Math.random` or a deterministic PRNG. If the source fails, the current handshake attempt fails without installing a session; the client surfaces a handshake failure and the server reports the failure through `onError`. +- 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 and epoch values are never reused within one client instance. + - **The reference counter also advances on session reset**, not only on handshake attempts. It doubles as the client's internal staleness generation: a reset bumps it so any frame encrypted under the just-zeroed key is recognizably epoch-stale if an in-flight send later rolls back. Only handshake attempts put an epoch on the wire, so **wire epoch values may skip** (a reset that is not immediately followed by a new hello burns a value that never appears in any hello). The server **must not** assume contiguous or gap-free client epochs — it validates the range, echoes the value, and binds it into the transcript, nothing more. A port may keep the wire epoch and the staleness generation as two separate counters, but if it reuses one counter (as the reference does) it must advance it on reset too, or its in-flight-frame invalidation breaks. + - **Exhaustion** (2³² attempts in one client lifetime — unreachable in practice: one per second is 136 years) is handled by never wrapping and never reusing. The reference does not emit a distinct client-side terminal error; it keeps incrementing past `2³²`, at which point every hello carries an out-of-range epoch that the server rejects (`"Invalid epoch"` → a permanent `HANDSHAKE` failure on every subsequent call). A port **may** instead raise an explicit terminal error client-side before sending; either way it **must not** wrap or reuse. 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**: @@ -153,15 +184,15 @@ 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. +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. The reference also bounds the number of attempts whose async work has not settled (`maxPendingHandshakes`, default 16). A timed-out attempt remains counted until its auth callback settles because arbitrary JavaScript Promises cannot be cancelled; hellos received at the cap are dropped silently. > **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. 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, or equal to `EMPTY_SECRET`, fail. If not configured, use `EMPTY_SECRET`. +4. Compute ECDH shared secret: `raw = X25519(s_priv, c_pub)`. The implementation **must** reject RFC 7748 §6.1 low-order `c_pub` values (normative, not guidance): either the X25519 primitive rejects them by erroring on an all-zero shared output (as the reference's `@noble/curves` does), or the handshake rejects the listed points explicitly before the ECDH. Accepting them in asymmetric-only mode yields an all-zero `raw` and a fully attacker-predictable session key. +5. Call `secret()` if configured. A return that is not a byte string, is fewer than `KEY_LEN` bytes, or is all-zero (of any length) **fails the handshake with a `HANDSHAKE` error** (the same class as any other attempt failure — a wrong-typed application secret is not a distinct code). If not configured, use `EMPTY_SECRET`. The **entire** returned byte string — not a 32-byte truncation — becomes the HKDF salt, so both peers must return byte-identical secrets of identical length. 6. Derive session key: `session_key = HKDF(SHA-256, IKM=raw, salt=psk, info=KDF_INFO, L=KEY_LEN)`. 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)`. @@ -176,23 +207,25 @@ reply_transcript := s_pub ``` -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. +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 — for the **remaining** handshake budget (`handshakeTimeout` minus the time already spent validating this attempt), not a fresh full-length timer: the total hello→first-confirming-frame window for one attempt is bounded by a single `handshakeTimeout`. 11. Send: ``` 0x00 || msgpack({ pub: s_pub, proof: proof, epoch: epoch, auth: signed? }) ``` +If sending the reply **fails** (the transport threw), the candidate just installed can never be confirmed — the implementation **must** drop it immediately (if it is still the current candidate: guard on the candidate counter), disarming its confirmation timer, rather than leaving it to expire and report a second, spurious timeout on top of the send failure. The live session, if any, is untouched. A single handshake-failure error is surfaced, carrying the transport error as its cause. + 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 1. Verify frame length and tag. 2. Decode msgpack, sanitize, check shape. -3. Silently drop if `reply.epoch !== this_epoch` (stale reply). +3. A reply whose `epoch` is not a valid uint32 **fails the handshake attempt** (fail fast, surface the error); a valid `epoch` that does not equal `this_epoch` is dropped **silently** (stale reply — keep waiting for the current attempt's reply until the handshake timeout). Any frame tagged `TAG_HELLO` that is otherwise malformed for the current attempt (decode failure, wrong shape, bad key/proof/auth) fails that current attempt; there is no second client-side attempt to attribute it to. Unknown fields are ignored. 4. If `verify` is configured: require `auth`, build reply transcript, call `verify(auth, transcript)`. On failure, reset handshake state. -5. Compute ECDH shared secret: `raw = X25519(c_priv, s_pub)`. -6. Call `secret()` if configured; otherwise use `EMPTY_SECRET`. Validate ≥ `KEY_LEN` bytes. +5. Compute ECDH shared secret: `raw = X25519(c_priv, s_pub)`. The same low-order rejection as server step 4 applies to `s_pub` — the client **must** reject RFC 7748 §6.1 low-order server public keys (failing the handshake), for the same reason. +6. Call `secret()` if configured; otherwise use `EMPTY_SECRET`. Validate ≥ `KEY_LEN` bytes and reject an all-zero value of any length. As on the server, the entire byte string is the HKDF salt. 7. Derive `session_key` with the same HKDF call as the server. 8. Recompute expected proof: `expected = HMAC-SHA-256(session_key, s_pub || c_pub || c_nonce)`. 9. Compare `expected` to `proof` in **constant time**. Mismatch ⇒ fail. @@ -214,6 +247,27 @@ The established session, if one exists, **must survive until a frame decrypts un 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. +**Concurrency and generation guards (normative).** Hello processing contains `await` points (async `verify`, `secret`, `sign`); a newer hello, a candidate timeout, a promotion, or destroy may become ready between them. The reference serializes correctness with three monotonic generation counters, and a port **must** reproduce their effect however it schedules work: + +- **attempt generation** — advances on **every** incoming hello. After every `await` in hello processing, the attempt re-checks that it is still the current generation (and that the server is not destroyed); a superseded attempt abandons silently, installing nothing. This is what makes "latest hello wins" safe under interleaving. +- **candidate generation** — advances only when a candidate is **installed**. The candidate confirmation timer is keyed on it, so a later hello that bumps the attempt generation but then fails validation cannot disarm an existing candidate's timer, and a stale timer cannot drop a newer candidate. +- **live/response generation (epoch)** — advances on every **promotion**. A response captures it at request arrival and is dropped at send time if it has advanced (a promotion completed while the handler ran) or the server was destroyed — see the [execution pipeline](#procedure-execution-pipeline-server). + +On a single-threaded runtime these checks suffice because the synchronous install block runs to completion without interleaving. A threaded port **must additionally** serialize the trial-decrypt-and-promote step against candidate replacement: reading the candidate key, decrypting under it, and promoting must not straddle another thread swapping the candidate, or a frame may promote a candidate that was already replaced. + +### Handshake timing, send failures, and auth configuration + +**Auth configuration predicate (construction-time, normative).** A peer is valid iff **at least one** of `secret`, `sign`, `verify` is configured; a peer with none is rejected at construction (an unauthenticated handshake). Every non-empty subset is legal: `secret`-only, `sign`-only, `verify`-only, or any combination. `sign`-only and `verify`-only configure a single direction — whether a given direction is actually authenticated is decided at handshake time, not at construction: the server reads and requires `hello.auth` **iff** it has `verify`; the client embeds `auth` **iff** it has `sign`. Two peers interoperate only if their per-direction expectations line up (a server with `verify` needs a client with `sign`, and vice versa); a mismatch surfaces as a handshake failure, not a construction error. + +**Deadline origins (normative).** Both sides bound the whole handshake by `handshakeTimeout` measured as wall-clock elapsed from a fixed origin. The reference uses the platform wall clock (`Date.now()`); a monotonic clock is preferable for a port if it preserves the same elapsed-time behavior. A deadline is expired when `now >= deadline`; timer callbacks are only wakeups, so every continuation after an async suspension must check the absolute deadline. + +- **Client**: the origin is the start of the handshake **attempt** — immediately after ephemeral keypair/nonce generation, before the (possibly async) `sign` and send. The single budget spans `sign` + hello send + reply processing + reaching `ready`. It does **not** include time spent in the caller's API before the attempt began. +- **Server**: the origin is when it **begins processing the hello** (hello arrival). The one budget spans validation (`verify`/`secret`/`sign`) **and** candidate confirmation: on successful validation the attempt timer is retired and the candidate confirmation timer inherits the **remaining** budget (`handshakeTimeout − elapsed`), so a slow `verify` shortens the confirmation window rather than extending the total. + +**Client hello-send failure (normative).** A transport error while sending the hello is **not** a handshake failure. The hello is queued and retried by the outbound flush tick, still bounded by the attempt's `handshakeTimeout`; if the attempt dies first (superseded or timed out) the queued hello is revoked and never sent late. The send error is intentionally not surfaced — a down transport is the queue's normal input, not a failure signal. + +**Server reply-send failure (normative).** If sending the reply fails, the candidate just installed can never be confirmed (the client never got the proof). The server drops that candidate **immediately** — guarded on the candidate generation so a newer candidate installed in the meantime is not clobbered — and reports a `HANDSHAKE` error through `onError` carrying the transport error as its `cause`, rather than letting the candidate linger to a spurious second confirmation-timeout report. + 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. @@ -234,7 +288,7 @@ The secret is the **salt** parameter, not the IKM. This is deliberate: the salt If both endpoints derive the same `secret` and the X25519 exchange is intact, both arrive at the same `session_key`. An attacker who runs the X25519 exchange but lacks the secret derives a different key and the HMAC proof fails. -When `secret` is not configured (asymmetric-only mode), `secret_or_EMPTY_SECRET` is 32 zero bytes. RFC 5869 allows an all-zero salt; in this mode session authentication relies entirely on the `sign`/`verify` callbacks. The reference implementation refuses an application-supplied `secret()` that returns 32 zeros, so a misconfigured secret never silently degrades into the asymmetric-only mode. +When `secret` is not configured (asymmetric-only mode), `secret_or_EMPTY_SECRET` is 32 zero bytes. RFC 5869 allows an all-zero salt; in this mode session authentication relies entirely on the `sign`/`verify` callbacks. The reference implementation refuses an application-supplied `secret()` that returns all-zero bytes of any length, so a misconfigured secret never silently degrades into the asymmetric-only mode. ### `deriveSessionSecret` (helper) @@ -288,9 +342,11 @@ plaintext = XSalsa20-Poly1305-decrypt(session_key, nonce, ciphertext, AD=∅) message = sanitize(msgpack_decode(plaintext)) ``` +The AEAD output layout is NaCl `secretbox`: XSalsa20-Poly1305 with the 16-byte Poly1305 tag **prepended** to the ciphertext, exactly as produced by a `secretbox`-shaped library and matched by the test vector. A port using a raw XSalsa20 + Poly1305 composition must reproduce that layout or ciphertexts will not be byte-compatible. + 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.) +> **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. The side-effect-free [sanitization](#sanitization) of the decrypted plaintext runs as part of decoding, before the `t` check; its position is not security-relevant because it neither dispatches nor mutates state — it only rejects non-plain structure. What must not precede the `t` check is any handler dispatch or reply. On the server, AEAD verification (and candidate promotion) precede plaintext decode. After authenticated decode, malformed envelopes and request frames record their nonce synchronously before dispatch; reflected response frames (`t: 2`) are dropped without consuming request replay-window capacity. The `t` check gates only dispatch of an already-authenticated frame. (A future protocol revision may introduce directional keys; any such change bumps the version strings.) ### Replay protection (seen-nonce set) @@ -299,9 +355,11 @@ Random nonces make accidental collision negligible but do nothing against delibe 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. +2. **Insert only after successful Poly1305 verification.** A frame that fails to decrypt must **not** add its nonce to the set. After successful verification, malformed envelopes and request frames consume a slot; reflected response frames (`t: 2`) do not, so response traffic cannot shrink the effective request replay window. 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. +4. **Cleared on promotion, not on candidate install.** The set is cleared exactly when a candidate is **promoted** to the live session (its key becomes the live key) — not when a candidate is merely installed. During make-before-break (candidate installed, old live key still serving) the live replay window stays intact, so an old live-session request cannot be replayed in the gap. A new session key makes old nonces unreplayable anyway (AEAD fails under the new key); the set's lifetime is exactly one live key's lifetime. + +**Check-and-insert must be atomic** (rules 1+2 as one indivisible step). The reference records the verified nonce **synchronously, before any `await`** in the request handler, so two byte-identical frames delivered back-to-back cannot both pass the rule-1 lookup — the first records the nonce before control returns to the transport, the second sees it. A port on a threaded runtime **must** take a lock across the membership check and the post-verification insert; a check-then-`await`-then-insert gap lets concurrent duplicates both execute. **Promotion ordering is likewise indivisible with the confirming frame's insert:** the confirming frame promotes (which clears the set) and only **then** records its own nonce into the now-cleared window — so a replay of that first confirming frame is caught by rule 1 against the new live window. 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. @@ -316,7 +374,7 @@ After decryption, an RPC message is a msgpack-encoded map. Two kinds. ``` { t: 1, - id: string, // non-empty, ≤ MAX_ID_LEN, unique within this client session + id: string, // non-empty, ≤ MAX_ID_LEN, never reused by this client instance p: string, // non-empty procedure name i: any, // input (validated against procedure's .input schema) } @@ -324,9 +382,12 @@ After decryption, an RPC message is a msgpack-encoded map. Two kinds. 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. +- 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. The `MAX_ID_LEN` bound is a resource guard, not a wire invariant: the reference counts the id in **UTF-16 code units** (JS `String.length`), so a port counting UTF-8 bytes or Unicode scalars may pick a different rejection threshold for a non-ASCII id. Because ids are opaque, client-chosen, and **should** be ASCII (the reference generates a decimal counter), the three counts coincide in practice — a peer **must not** depend on the exact threshold for non-ASCII ids. - 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. +- When input is supplied, the client applies the [sanitization](#sanitization) gate **before** msgpack encoding. A non-plain host value, extension value, or over-deep input fails locally with `INVALID_DATA` and no frame is handed to the channel; plain values are rebuilt according to the sanitizer's rules (including stripping poison keys). `undefined` is the one omission sentinel and is not passed through encoding. +- An outbound encrypted request whose full frame length exceeds the client's configured `maxMessageBytes` is rejected locally with `RPCError("CLIENT", "Message exceeds maxMessageBytes")`; it is not queued or sent. This is a local guardrail and does not reset a healthy session. +- The server applies the same bound to encrypted response frames. An oversized response is not handed to the channel and is reported locally as `RPCError("INVALID_DATA", "Response exceeds maxMessageBytes")` through `onError`; no oversized frame is emitted. +- 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 during the entire client instance lifetime**, across session resets and re-handshakes. Recreating the client starts a new id namespace. Uniqueness is what makes response matching (and the client's replay immunity) sound. ### Response (server → client) @@ -358,14 +419,49 @@ The error map's fields: | `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. +The `ok` discriminator itself is **never** coerced: exactly boolean `true` selects the success form and exactly boolean `false` the failure form. The outer envelope has these required shapes: + +- `ok: true` requires both `d` and `e` keys, with `e` exactly `nil` (`null` in the reference encoding); `d` may be any sanitized plain-data value, including `nil` for a handler that returns no value. +- `ok: false` requires both `d` and `e` keys, with `d` exactly `nil` and `e` a map. The error map's `c`, `m`, and `d` members are read defensively as described below; malformed or absent members do not make the outer envelope invalid. + +A response whose `ok` is anything else (absent, nil, a number, a string), or whose required outer shape is wrong, is a malformed envelope — the client **must** drop it silently, before consuming the pending-call entry, so the call keeps waiting for a well-formed response under its own timer. Unknown fields remain ignored. A well-formed response whose `id` is unknown or already settled is also dropped silently; it has no pending call to consume and produces no protocol error or diagnostic response. + +The receiving client must treat every error field as hostile and coerce defensively: if `e` is not a map, the envelope is malformed and is dropped; if `c` is not a non-empty string, surface code `"UNKNOWN"`; if `m` is not a string, use `""`; if `e.d` is absent it is exposed as `nil`/`null` (the reference `RPCError` data default). 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. +On the server, a handler, middleware, context factory, or schema operation that throws the implementation's typed RPC error maps to `{ c: code, m: message, d: sanitize(data) }`; the typed error's own code wins over the surrounding pipeline stage. 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. + +The `m` field is deliberately **not an exact protocol constant**: it is human-readable, untrusted text and is not used for dispatch or conformance. A port must produce the normative `c` code and the required envelope shape; it may choose equivalent wording. The reference messages are `"Procedure not found"`, `"Input validation failed"`, `"Output validation failed"`, `"Middleware completed without calling next()"`, `"next() called more than once"`, `"next() extra must be an object"`, and `"Internal error"` for the corresponding common paths. Schema-library issue text is not normative. 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. +Silent drop applies to malformed **envelopes** only. A **well-formed** request (`t: 1`, valid `id`, non-empty string `p`) whose `p` does not name a procedure in the router is _not_ silently dropped: the server returns a normal failure response with code `NOT_FOUND`. At that point the peer has already proven key possession, so the enumeration argument no longer applies. + +**Protocol-level error codes.** In addition to application-defined codes, the reference server emits: `INPUT_VALIDATION`, `OUTPUT_VALIDATION`, `MIDDLEWARE`, `NOT_FOUND`, `INVALID_DATA`, `INTERNAL`. A behavior-compatible port **must** emit the same codes for the same conditions. The error `d` payload is implementation-defined (the reference puts its schema library's flattened issues there); clients **must not** depend on its shape. + +### Procedure execution pipeline (server) + +The stages between a well-formed decrypted request and its response frame, in order. The stage **order** and the **error code each stage produces** are normative — they are wire-observable; the mechanism (how a port models procedures, schemas, middleware) is not. The reference client also applies the same plain-data sanitization gate to caller input before encoding; a port **must** reject a non-plain host value locally with `INVALID_DATA` and must not send a frame. + +1. **Router lookup.** Unknown `p` ⇒ failure response `NOT_FOUND` (the peer has proven key possession; see above). +2. **Auth snapshot.** The session's verified auth data is captured **at request arrival**, before any `await` — a re-handshake completing mid-request must not swap the principal under a running handler. (The response of a superseded session is dropped by the epoch guard in step 6 regardless.) +3. **Context build.** If the snapshot exists, the application `context` factory runs with `{ auth: snapshot }`; if no verifier/principal exists, it runs with an empty `{}`. If the factory throws a typed RPC error, it maps to that error's own `c`/`m`/`d` like any other pipeline stage (per the response-mapping rule above); any other thrown value is answered `INTERNAL` with no detail leakage (see [Authorization data flow](#authorization-data-flow)). +4. **Chain execution.** A procedure is a chain of steps declared in application order — input schema, middleware, output schema interleave **in declaration order**, they are not grouped into fixed phases. Semantics per step kind: + - **Input schema**: validate the raw `i` value; failure ⇒ `INPUT_VALIDATION`. On success the **parsed** value replaces the raw input for the rest of the chain (schema transforms/defaults apply — the handler sees post-parse data). + - **Middleware**: receives the current `ctx`, the current `input`, and `next(extra?)`. `next` may be called exactly once; a second call, a non-null non-object `extra`, or completion without any call ⇒ `MIDDLEWARE`. A valid `extra` is shallow-merged into a fresh null-prototype context (`ctx` fields first, then `extra` fields), and the continuation returns a promise for the downstream result. The middleware's own returned value — not automatically the downstream result — is the value passed to the preceding step; therefore an output schema declared before this middleware validates that middleware return value. A middleware that completes while its `next()` promise is still **pending** ⇒ `MIDDLEWARE`: the caller would otherwise receive the middleware's own value while the handler outcome is silently dropped. This is the exact runtime invariant — “no reply while the downstream is pending”; if the downstream has already settled by middleware completion, the runtime cannot distinguish a fire-and-forget from a middleware that consumed the settled result, and forwards the middleware's return value (tolerated, not guaranteed — the reference TypeScript API additionally requires returning `next()`'s result at compile time). A synchronous throw from the downstream (e.g. a sync input-schema failure) is normalized into a rejected `next()` promise, so a middleware that catches it and answers is treated as having a settled downstream. The reference attaches an internal observer to the downstream promise, so the detached downstream of a fire-and-forget middleware can never surface as an unhandled rejection (which could terminate the process). + - **Middleware timing**: the call must happen before the middleware's returned promise settles. If the middleware settles without calling `next`, the request gets `MIDDLEWARE`; a later call is a contract violation and is ignored by the reference (it must not launch the downstream continuation after the response path has completed). If the middleware calls `next` and downstream later fails, the downstream failure is observable only where the continuation promise is awaited/returned; an independent middleware failure wins for that middleware invocation. + - **Output schema**: validate the value returned by the remainder of the chain (normally the handler, but a middleware may replace it with its own return value); failure ⇒ `OUTPUT_VALIDATION`. The **parsed** output is what gets serialized into `d`. + + Steps are nested as continuations in declaration order: the first declared step is the outermost call, each step's `next` is all later steps, and the handler is the innermost operation. Thus interleaving is significant; schemas are not regrouped into a fixed input/middleware/output phase order. + +5. **Output sanitization.** The final result passes the [sanitization](#sanitization) gate before encoding; a host object that leaked through ⇒ failure response `INVALID_DATA` (typed, instead of an opaque `INTERNAL`). Error `d` payloads are sanitized the same way — and if _that_ fails, no response is sent at all. +6. **Epoch guard + send.** If a promotion/re-handshake or destroy happened while the handler ran, the response belongs to a superseded session — dropped, not sent. Otherwise it is encrypted under the current live key and sent; a send failure is reported locally (`onError`), never to the peer. + +A handler (or step) throwing the implementation's typed RPC error maps to `{ c, m, d }` as described above; any other thrown value ⇒ `INTERNAL` with no detail. + ## State machines +The states, transitions, and their triggers in this section are **normative** — a port must produce the same observable behavior. The internal mechanics referenced alongside them (shared handshake promise, proxy objects, timer bookkeeping) are how the reference implementation realizes the transitions and are informative. + ### Server State is described by two key slots — the **live** session (serves traffic) and @@ -419,14 +515,28 @@ The client uses an **epoch counter** to coordinate concurrent failure-and-reset. ## Failure handling (no auto-retry) +Normativity split for porters: the **reset trigger set** (exactly `RPCAbortedError("TIMEOUT")` on a sent call, nothing else), the **no-resend rule**, and the **sent-boundary semantics** (which rejections mean "provably never left" vs "outcome unknown") are normative — they are security decisions, and a port that widens the trigger set re-creates a double-execution hazard. The outbound-queue machinery (the 250 ms retry tick, head-of-line policy, the concrete error-class taxonomy) is reference behavior a port may realize differently as long as the observable semantics hold. + +**Handshake sharing and call budgets.** All calls that arrive while no session exists await one shared handshake. If it fails, **every waiting call rejects with that same handshake error** and the client returns to `idle` — the next call starts a fresh attempt. The error is the failure's own typed error where it has one (protocol-level failures carry code `HANDSHAKE`; an application auth callback's typed error — e.g. `UNAUTHORIZED` — passes through as-is); any untyped failure is wrapped as `HANDSHAKE`. A per-call abort rejects **that call only**; the shared handshake keeps running for the others. The handshake is budgeted by `handshakeTimeout` alone: a call's own `timeout` starts **after** the handshake completes, when the call's frame enters the send path — so a slow handshake consumes no part of the call budget, and `timeout` values can be sized to the procedure, not to worst-case connection setup. + +`maxPending` counts calls only when their encrypted request is admitted to the send path, after the shared handshake completes and before the pending entry is created. Calls waiting for the handshake do not consume slots. A queued frame, an asynchronous send in flight, and a sent call each consume one slot; the slot is released exactly when that call settles (response, abort, timeout, send failure, reset invalidation, or destroy). + 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. +**Sent boundary.** A call's retry safety is determined by whether its frame reached a live transport. + +Language-neutral core (normative): the boundary is crossed the moment the adapter **accepts** the frame — a synchronous `send` returns without error, or an asynchronous `send` is initiated without an immediate error. For an asynchronous adapter this is deliberately **conservative**: acceptance (handoff), not completion, is the boundary. Between initiation and completion the frame may already be on the wire; classifying a terminal event in that window as "never left" would license a blind resend of a request the server might execute. A false "outcome unknown" merely costs the caller caution; a false "never left" re-creates the double-execution hazard — so the unknown class wins. If the asynchronous operation later completes with an **error**, the frame provably never left after all: the call rolls back to the unsent class — the frame re-enters the outbound queue if the session is unchanged, or the call fails with a plain `RPCError("CHANNEL")` if a reset staled it in flight. An adapter must therefore report a delivery failure as the async operation's error result, never as "complete successfully, then signal an error elsewhere" — successful completion is the adapter's word that the frame reached the transport, and it is never rolled back. + +Before the boundary, terminal events (timeout, abort, destroy, `sendTimeout`) reject with a plain `RPCError` — the frame provably never left and the caller may retry freely. After the boundary, terminal events reject with `RPCAbortedError` — outcome unknown. Consequently, while an asynchronous send is in flight: a global timeout rejects with `RPCAbortedError("TIMEOUT")` and triggers the reset above, an abort rejects with `RPCAbortedError("ABORTED")`, and `sendTimeout` no longer governs the frame — it left the outbound queue at handoff. The first event that settles the pending call wins; the event loop/port's serialized observation order is the tie-breaker for events that are externally concurrent. A later reply, send completion, or send rejection is ignored after settlement. The boundary placement is normative (it is a security decision); the rollback machinery is reference behavior. -**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. +JS realization: `channel.send` returning a thenable is the handoff; the thenable resolving/rejecting is the completion. A port maps "initiation" and "completion" onto its own async model (future creation vs. resolution, coroutine launch vs. return, callback registration vs. invocation). + +**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. + +A reset invalidates every still-queued message encrypted under the retired epoch immediately: the frame is removed, the call rejects with plain `RPCError("CHANNEL")`, and the ciphertext is never re-encrypted or carried into the replacement session. A hello queued for the retired handshake is simply revoked. A message whose asynchronous `send` is already in flight is not in this queue; its later rejection follows the asynchronous rollback rule above. If the call has already settled by timeout, abort, destroy, or a valid response when that rejection arrives, the pending entry is gone and the rejection is ignored — it must not requeue the frame or settle the call a second time. **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. @@ -438,35 +548,39 @@ Calls that received a `RemoteRPCError` (the server responded with `ok: false`) a 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. - - +A reset only retires the current key and returns the client to `idle`; it does **not** start a handshake by itself. The call that timed out surfaces its error, while the next call that reaches `ensureHandshake()` starts a new attempt. Calls already waiting for a handshake, or later calls arriving while that attempt is active, share that one promise and its result. Concurrent failures share one re-handshake via the epoch counter, so there are no reset storms. ## Sanitization -Every decoded msgpack value passes through a sanitization step, inbound and outbound (the latter on error payloads). Any of the following rejects the message: +Every decoded msgpack value passes through a sanitization step, inbound and outbound (the latter on error payloads). + +**Language-neutral core (normative for every port):** -1. Recursion depth greater than 32 ⇒ `INVALID_DATA`. +1. Recursion depth greater than 32 ⇒ `INVALID_DATA`; the root value starts at depth 0, and each array element or map value adds one. Map-key strings do not add a traversal level. An outbound cyclic graph, which has no msgpack representation, is also rejected locally with `INVALID_DATA`; repeated references that are not cyclic may be rebuilt independently. 2. Any msgpack extension type, **including the built-in Timestamp (type -1)** ⇒ `INVALID_DATA`. The Timestamp extension is explicitly rejected because msgpack libraries hard-code its decoder. -3. Any object whose prototype is neither `Object.prototype` nor `null`. This rejects `Date`, `Map`, `Set`, `ExtData`, and any host object that arrived through an unexpected codec path. -4. Object keys equal to `"__proto__"`, `"constructor"`, or `"prototype"` are stripped during traversal. +3. Reject any decoded value whose type is not in the plain-data set: nil, boolean, number/big-integer, string, `bin`, array, string-keyed map. In the reference language realization, `function` and `symbol` are also rejected as `INVALID_DATA` rather than being handed to the msgpack encoder. Anything a codec maps to a richer host type (dates, native collections, wrapped ext data) is rejected. -`Uint8Array` (msgpack `bin`) is preserved. `BigInt64` is decoded as JavaScript `BigInt`. Plain objects are rebuilt with `Object.create(null)` so prototype chains cannot be re-poisoned downstream. +**JS-specific realization (how the reference implements rule 3 and defends a JS-only attack class):** -A port to a language without prototype pollution still has to: +- Any object whose prototype is neither `Object.prototype` nor `null` is rejected. This catches `Date`, `Map`, `Set`, `ExtData`, and any host object that arrived through an unexpected codec path. +- Object keys equal to `"__proto__"`, `"constructor"`, or `"prototype"` are stripped during traversal (prototype-pollution defense; meaningless in most other languages). +- Plain objects are rebuilt with `Object.create(null)` so prototype chains cannot be re-poisoned downstream. -- Reject extension types it does not know about. -- Limit recursion depth. -- Reject inputs whose structure does not match the expected shape. +`Uint8Array` (msgpack `bin`) is preserved. 64-bit wire integers are decoded as big-integers (see [msgpack profile](#msgpack-profile)). A port to a language without prototype pollution implements the language-neutral core and whatever "weird host type" rejection its own codec requires. ## Authorization data flow -When `auth.verify` is configured on the server, the value it returns is the verified principal for the lifetime of the session. Safe RPC takes the returned `{ auth: ... }` object, sanitizes it, and: +When `auth.verify` is configured on the server, the value it returns is the verified principal for the lifetime of the session. The verify callback returns a wrapper `{ auth: }`; the server extracts the **`.auth` member**, sanitizes **that** (not the wrapper), and: -- Stores it on the server session. -- Passes it as `{ auth: verified }` to the `context` factory on every request. +- Stores the sanitized principal on the server session. +- Passes it wrapped as `{ auth: }` to the `context` factory on every request. - 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. +**Edge cases a port must reproduce:** + +- If the verify callback returns a value whose `.auth` member is **absent** (e.g. `{}` or a bare non-object), verification still counts as **success** (it did not throw) but **no** principal is stored — the session is authenticated-by-not-throwing yet carries no auth data. A present `.auth` that is a non-object (non-null) is a hard error (`HANDSHAKE`, "result must be an object"); a present object is sanitized and stored. +- When **no** verifier is configured, the server stores no auth data. The `context` factory then receives an **empty object `{}`** (no `auth` key at all — not `{ auth: undefined }`). With no `context` factory configured and auth data present, the request `ctx` is the principal's own fields copied onto a null-prototype object; with no factory and no auth data, `ctx` is an empty null-prototype object. + ``` server.verify(hello.auth, hello_transcript) → { auth: { userId, ... } } @@ -479,26 +593,87 @@ on each request: procedure runs with ctx ``` +If the `context` factory throws a **typed RPC error**, its `c`/`m`/`d` surface to the peer — the application deliberately raised it (e.g. an authorization denial). Any **other** thrown value is answered with a generic `INTERNAL` error response, and the thrown detail **must not** leak to the peer. + Clients can also configure `verify`. On the client side the return value is unused. Success is signaled by not throwing. +## Auth payload profiles + +The `auth` field carried in a hello or reply frame is **opaque bytes to the core protocol** — the handshake only bounds its length (`1..MAX_AUTH_BYTES`) and hands it to the application's `sign`/`verify` pair. A custom `sign`/`verify` may put anything there; it is not bound by this section. + +The shipped auth helpers, however, define a fixed wire schema, and a captured helper payload is de-facto wire format: a port in another language that wants to interoperate with a TypeScript peer using a shipped helper **must** produce and accept the exact msgpack maps below. These schemas are therefore **normative when a shipped helper is used**. + +Every helper payload is a msgpack map carrying a profile version `v`. A verifier **must** reject a payload whose `v` is absent or not a version it implements (rather than best-effort decoding a schema it was not written for), with an `UNAUTHORIZED`-class failure. The current version for every profile is `1`. Any change to a profile's field set, field meaning, or signature input **must** bump that profile's `v`. + +All three signing profiles bind to the same handshake transcript defined in [Handshake](#handshake): the client-side helper signs (or digests) the **hello** transcript; a server-side signing helper would use the **reply** transcript. Byte layout of the transcript is fixed by that section. The binding _input_ differs per profile and is wire-normative: `ed25519` and `ecdsa` sign the **raw transcript bytes**; `jwt` embeds **`SHA-256(transcript)`**. Mixing these up produces payloads the counterparty rejects. + +### Profile `jwt` (bearer token, digest-bound) — `v: 1` + +``` +{ + v: 1, + jwt: string, // non-empty; opaque to the protocol, validated by the app + ts: uint, // client clock, milliseconds since Unix epoch + th: bin (32 bytes) // SHA-256(transcript) +} +``` + +On the wire `ts` is a `float64` (see [msgpack profile](#msgpack-profile)); the reference client emits an integer count of milliseconds, but a verifier must accept any finite numeric value and treat it as ms. The reference `maxAge` default is 30 000 ms (server-side policy, configurable, not a wire constant). + +The verifier **must**, in order: reject unknown `v`; require `jwt` a non-empty string; require `ts` a finite number and `|now - ts| ≤ maxAge` (symmetric skew — a one-sided `>` check accepts future-dated forgeries), sampling `now` once at this timestamp-check step; fractional millisecond values are accepted because the reference checks finiteness rather than integrality; require `th` exactly 32 bytes and equal to `SHA-256(transcript)` compared in **constant time**; only then call the application token validator. A JWT is a bearer credential: the digest binds a captured payload to _this_ handshake, but possession of the token is sufficient to mint a fresh payload — the profile does not and cannot change that. See [Security § JWT](security.md#jwt-bearer-token-transcript-bound). + +### Profile `ed25519` (device signature) — `v: 1` + +``` +{ + v: 1, + deviceId: string, // non-empty; server looks up the matching public key + sig: bin (64 bytes) // Ed25519 signature over the transcript bytes +} +``` + +The verifier **must**: reject unknown `v`; require `deviceId` a non-empty string; require `sig` exactly 64 bytes; resolve the device's 32-byte Ed25519 public key (rejecting an out-of-band revoked/unknown device before any crypto if a policy hook is configured); verify `sig` over the **raw transcript bytes** (not a pre-hash). The returned principal is `{ deviceId, verified: true }`. + +Verification strictness is a wire-normative parameter for cross-implementation vector agreement. The reference verifies with **ZIP-215** rules (the `@noble/curves` default: cofactored equation, non-canonical `y` and small-order/mixed-order points accepted). A port whose Ed25519 library defaults to strict RFC 8032 / NIST FIPS 186-5 verification (e.g. `ed25519-dalek`) will **disagree with the reference on edge-case signatures** — small-order or non-canonically-encoded points that ZIP-215 accepts and RFC 8032 rejects. Honest signers producing canonical signatures verify identically under both, so a port **may** choose either rule, but **must** document its choice and understand adversarial test vectors will not agree across the two. Malleability is not replay-exploitable here regardless: the transcript binds the epoch and both nonces, so a mutated signature buys no session advantage. + +### Profile `ecdsa` (P-256 signature) — `v: 1` + +``` +{ + v: 1, + identifier: string, // non-empty; server looks up the matching public key + sig: bin // ECDSA P-256 signature (IEEE-P1363 r||s), SHA-256 of transcript +} +``` + +The verifier **must**: reject unknown `v`; require `identifier` a non-empty string; require `sig` non-empty; resolve the P-256 public key; verify the signature over the transcript with SHA-256 as the hash. Signature encoding is WebCrypto's raw `r||s` (IEEE P-1363), **not** DER — a port using a DER-only ECDSA library must transcode. Unlike `ed25519`, `sig` has no length guard: a wrong-length value fails at signature verification, not at a shape check. The returned principal is `{ identifier, verified: true }`. + +The reference verifies via WebCrypto (`crypto.subtle.verify`), which accepts any mathematically valid `(r, s)` — it does **not** enforce low-S. A port **must** likewise accept high-S signatures (do not add a low-S/canonical-S gate) or it will reject signatures the reference and every WebCrypto peer produce. As with `ed25519`, ECDSA malleability grants no replay advantage because the transcript binds the epoch and both nonces. + +> Certificate and multi-factor composition are **not** shipped helpers and define no profile: build them from a custom `sign`/`verify`. A multi-factor verifier that composes two of the above must additionally assert both factors resolve to the **same** principal before combining them — the composition itself carries no such guarantee. See [Security § Custom schemes](security.md#custom-schemes-certificates-multiple-factors). + ## Failure modes -| 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** | +| Failure | Server response | Client response | +| ------------------------------------------------ | --------------------------------------------- | --------------------------------------------------------- | +| Empty (zero-length) frame | Drop silently | Drop silently | +| 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`) | +| Auth payload `v` absent/unknown (shipped helper) | Discard attempt (`UNAUTHORIZED`), `onError` | Fail handshake | +| Reply send throws (server) | Drop candidate, one `HANDSHAKE` error (cause) | — | +| Sync auth callback overruns `handshakeTimeout` | Install nothing; attempt dies at the deadline | 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. @@ -512,33 +687,46 @@ Silent drops are deliberate. Any feedback at the wire level gives an attacker pr A new-language port that ticks every item is conformant: -- [ ] Constants match the table above exactly. +- [ ] Constants match the table above exactly (wire-normative set byte-exact; local-policy defaults may differ by configuration). +- [ ] The msgpack profile is reproduced: smallest-width integers (never `int64`/`uint64` for values ≤ 2³²−1; `float64` beyond), `bin`/`str` families never interchanged, `str` map keys. See [msgpack profile](#msgpack-profile). +- [ ] Frames are delivered as whole transport messages; over stream transports the adapter adds its own framing (the protocol is not self-delimiting). - [ ] X25519, XSalsa20-Poly1305, HKDF-SHA-256, HMAC-SHA-256 implementations are constant-time where the spec requires (proof comparison, MAC verification). - [ ] The X25519 implementation rejects RFC 7748 §6.1 low-order public keys (or the application layer rejects them before `getSharedSecret`). Accepting them in asymmetric-only mode lets an active MITM force a deterministic all-zero ECDH output and decrypt the session. See [Security § Ephemeral key validity](security.md#ephemeral-key-validity). -- [ ] msgpack codec rejects all extension types; built-in Timestamp explicitly. +- [ ] The msgpack boundary rejects all extension types: built-in Timestamp is rejected by the codec and other extensions by the full sanitization gate. - [ ] Sanitization rejects host objects (or the language equivalent of "weird types"), strips prototype-pollution keys, limits depth. +- [ ] Auth payloads pass the **full** sanitization gate (not just the hardened codec) before any field access — including fields a profile ignores. A payload smuggling an unknown ext type or over-deep nesting in an extra field is rejected as `UNAUTHORIZED`. - [ ] Handler output is also sanitized (or otherwise restricted to plain-data trees) before encoding, so a stray host object surfaces as `INVALID_DATA` and not an opaque `INTERNAL`. - [ ] 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. +- [ ] Client-auth verification (server-side `verify`) runs **before** ECDH and key derivation, so a failed verification never materializes session-key state. Server-side `sign` runs late in the attempt (step 9 of the normative order, after key derivation and proof computation); the reply transcript it signs binds both ephemeral pubs, the client nonce, and the epoch — not the proof — and a failed `sign` publishes no candidate and no reply. A failed attempt never disturbs an established live session (see make-before-break). - [ ] 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. +- [ ] 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. Unsettled server attempts are capped by `maxPendingHandshakes`; a timed-out non-cancellable callback retains one bounded slot until it settles, and does not permit an unbounded hello-flood accumulation. +- [ ] The handshake budget is enforced by an **absolute wall-clock deadline**, checked after every suspension point, not only by a timer/flag. A synchronous auth callback (`sign`/`verify`/`secret`) that blocks the event loop past the budget resumes before a timer macrotask can fire; a flag-only guard is still unset at that point and would install a candidate + send a reply after expiry. The deadline check (both sides) rejects that. +- [ ] A reply-send failure on the server drops the just-installed candidate (guarded on the candidate counter) and reports a single handshake error; it does not leave the candidate to expire into a second timeout. +- [ ] Inbound `TAG_MSG` promotion is gated on **AEAD verification only**. Decoding/sanitizing the inner payload happens _after_ promotion; malformed inner payloads under a proven key still promote and consume their nonce, while reflected `t: 2` responses are dropped without consuming request replay-window capacity. Conflating decode failure with Poly1305 failure would strand the candidate. +- [ ] Numeric limits (`maxPending`, `maxPendingHandshakes`, `maxMessageBytes`, JWT `maxAge`) are validated at construction: a non-finite or non-positive value is rejected with an error, never accepted (a `NaN` bound silently disables the check, since `x > NaN` is always false). +- [ ] Shipped auth helpers stamp a profile version `v` and verifiers reject an absent/unknown `v` (see [Auth payload profiles](#auth-payload-profiles)); the three profile schemas are reproduced byte-for-byte. - [ ] 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. +- [ ] An all-zero secret of any length is rejected at runtime when `auth.secret` is configured; `EMPTY_SECRET` is the 32-byte no-secret sentinel used only when it is not 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`). -- [ ] 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. +- [ ] 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. If a timed-out handshake still has a non-cancellable auth or transport Promise, the client retains one occupied handshake slot and rejects a new attempt until that operation settles. - [ ] 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. +- [ ] Request `id` is validated: non-empty string, ≤ `MAX_ID_LEN`; `p` non-empty string; violations are silent drops. A well-formed request naming an unknown procedure gets a `NOT_FOUND` error response, not a silent drop. +- [ ] The candidate confirmation timer is armed with the **remaining** handshake budget, so one attempt's hello→confirmation window never exceeds a single `handshakeTimeout`. - [ ] 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. +- [ ] The response `ok` discriminator is **never** coerced: exactly boolean `true`/`false` selects a form; any other value is a malformed envelope, dropped silently before the pending-call entry is consumed. +- [ ] The server pipeline follows the normative stage order and code map (lookup → auth snapshot → context → chain in declaration order → output sanitize → epoch guard): `NOT_FOUND` / `INTERNAL` (untyped context throw — a typed RPC error from context keeps its own code) / `INPUT_VALIDATION` / `MIDDLEWARE` (exactly-once `next`, including completed-without-`next`) / `OUTPUT_VALIDATION` / `INVALID_DATA` (output sanitize). Parsed schema values replace raw ones for the rest of the chain. +- [ ] Calls awaiting a shared handshake all reject with the handshake's error when it fails; a per-call abort rejects only that call and never cancels the shared handshake. A call's `timeout` starts after the handshake completes. +- [ ] The sent boundary for asynchronous adapters is **handoff, not completion**: a frame counts as sent the moment the async send is initiated; an async error result rolls the call back to the unsent class. Terminal events on an in-flight async send report the unknown-outcome class. - [ ] 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. @@ -622,3 +810,52 @@ frame = 0x01 || msg_nonce || XSalsa20-Poly1305(session_key, msg_nonce, plain 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. + +### Auth profile payloads + +Known-answer payloads for the three shipped profiles (see [Auth payload profiles](#auth-payload-profiles)), all bound to `hello_transcript` above. Key order in each map follows the field order shown in the profile schemas (`v` first). Pinned by `test/unit/vectors.test.ts`. + +**Profile `jwt`** — fully deterministic. Note `ts` on the wire is a `float64` (`0xcb`), per the [msgpack profile](#msgpack-profile). + +``` +jwt = "test.jwt.token" +ts = 1700000000000 +th = SHA-256(hello_transcript) + = c76c6aaff8ac6c00e1168ffdafc87255a79eef052a4a7b39c542506a81010c9e +payload = msgpack({v:1, jwt, ts, th}) + = 84a17601a36a7774ae746573742e6a77742e746f6b656ea27473cb4278bcfe56800000 + a27468c420c76c6aaff8ac6c00e1168ffdafc87255a79eef052a4a7b39c542506a81010c9e +``` + +**Profile `ed25519`** — fully deterministic (RFC 8032 signatures are deterministic); a conformant port reproduces the payload byte-for-byte. + +``` +seed = 6162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f80 +pub = 882d0ea3b2864e7a587f3e698cea4459998312e655e05fa5e8b5119d8baac8cd +deviceId = "device-1" +sig = Ed25519(seed, hello_transcript) + = c056e0893556d73576ab05fa9ef2314d16686f326905c3e1e1f0b2b10eb003f5 + 1a6a41aa2d1e14f737fdfeede47d7ecec84380d7e70733cd3579653db72c7105 +payload = msgpack({v:1, deviceId, sig}) + = 83a17601a86465766963654964a86465766963652d31a3736967c440 + c056e0893556d73576ab05fa9ef2314d16686f326905c3e1e1f0b2b10eb003f5 + 1a6a41aa2d1e14f737fdfeede47d7ecec84380d7e70733cd3579653db72c7105 +``` + +**Profile `ecdsa`** — ECDSA is randomized in most signers (including WebCrypto), so this vector's signature is the **RFC 6979 deterministic** lowS signature (SHA-256 prehash, P-1363 `r||s`). A port with a randomized signer will not reproduce these bytes — and does not have to. The two-sided conformance check: (a) this payload **must verify** in the port's verifier against the public key below; (b) a payload produced by the port's signer **must verify** against the same public key, and its envelope (everything except the `sig` value) must be byte-identical to this vector's structure. + +``` +priv = a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0 +pub = 04ad137f7ef829eef8a8571bf4d307664ea8e024e05bda4e26da8f7ae8844560 + 58a88e48c9a1d9386471f13f2559758edc4bc1e11394eb415d63e2d33e4d38519d +identifier = "entity-1" +sig = ECDSA-P256-RFC6979-lowS(priv, SHA-256(hello_transcript)) + = 383ae1bc960796f9ae710ffa7dc73cc8bdb7522567e0f5b2180f4a74cac0f68a + 00bea85c160d745e881050a72bdb9fbb4a03a2aba4c65dcf29c29dc319796b01 +payload = msgpack({v:1, identifier, sig}) + = 83a17601aa6964656e746966696572a8656e746974792d31a3736967c440 + 383ae1bc960796f9ae710ffa7dc73cc8bdb7522567e0f5b2180f4a74cac0f68a + 00bea85c160d745e881050a72bdb9fbb4a03a2aba4c65dcf29c29dc319796b01 +``` + +(Hex line breaks are for readability; each value is one contiguous byte string.) diff --git a/spec/security.md b/spec/security.md index e222dd4..11db4a6 100644 --- a/spec/security.md +++ b/spec/security.md @@ -26,7 +26,7 @@ The `auth` callbacks close that gap by binding the ephemeral keys to something t - **`secret`** mixes a pre-shared 32-byte value into HKDF as the salt (see [Protocol § Key derivation](protocol.md#key-derivation)). A peer without the secret derives a different `session_key`, and the HMAC proof in the reply fails to verify. - **`sign` / `verify`** signs the [hello/reply transcript](#transcript-format) with a long-term key the peer can verify. The transcript covers the epoch and both ephemeral public keys, so a signature captured from one handshake will not validate in another. -Without one of those configured, `client()` / `server()` throws a `TypeError` at construction. There is no anonymous fallback, and a `secret()` callback that returns 32 zero bytes is also rejected at runtime — a typo cannot silently downgrade an intended PSK deployment into asymmetric-only mode. +Without one of those configured, `client()` / `server()` throws a `TypeError` at construction. There is no anonymous fallback, and a `secret()` callback that returns all-zero bytes of any length is also rejected at runtime — a typo cannot silently downgrade an intended PSK deployment into asymmetric-only mode. ### Transport encryption is not a substitute @@ -158,9 +158,11 @@ For the full wire layout of the frames that carry these signatures, see [Protoco ## Auth processing order -Auth runs **before** any session key is materialized, so a failed verification never leaks ECDH artifacts. Step-by-step in [Protocol § Handshake](protocol.md#handshake). +Client-auth **verification** (the server's `verify` callback) runs **before** ECDH and key derivation, so a failed verification never materializes session-key state and never leaks ECDH artifacts. The server's **`sign`** step runs _after_ key derivation in the normative step order (step 9; the reply transcript binds both ephemeral pubs, the client nonce, and the epoch), but a failed `sign` publishes nothing: no candidate is installed and no reply is sent. Step-by-step in [Protocol § Handshake](protocol.md#handshake). -A throw at any auth step rejects the handshake. The client resets to `idle`, the server resets to `waiting`. Failed verification never silently downgrades into an unauthenticated session. +A throw at any auth step rejects the handshake **attempt**. On the client a failed attempt returns to `idle`. On the server, under make-before-break, only the attempt is discarded — an established live session is never disturbed by a failed attempt and keeps serving. Failed verification never silently downgrades into an unauthenticated session. + +Auth callbacks are application-owned Promises and cannot be forcibly cancelled by the library. The server therefore bounds unsettled attempts with `maxPendingHandshakes` (default 16); timed-out attempts retain a slot until their callback settles, and hellos at the cap are silently dropped. The client retains one timed-out unsettled handshake operation and rejects a new attempt until it settles, preventing repeated retries from accumulating closures. ## Ephemeral key validity @@ -201,9 +203,10 @@ auth: { secret: () => new TextEncoder().encode("secret123") } auth: { secret: () => deriveSessionSecret("user-123", secret) } // ❌ All-zero or weak derivation material: no security at all. -// Safe RPC refuses an all-zero secret at runtime: `HANDSHAKE` is thrown with -// "Application returned an all-zero secret" so this mistake fails loudly -// instead of silently degrading into the asymmetric-only mode. +// `deriveSessionSecret` rejects all-zero input material outright, and the +// handshake refuses an all-zero secret of ANY length at runtime ("Application +// returned an all-zero secret") — either way this mistake fails loudly instead +// of silently degrading into the asymmetric-only mode. auth: { secret: () => deriveSessionSecret(sessionId, new Uint8Array(32)) } // ❌ Secret material in client-side bundle @@ -232,15 +235,22 @@ Against a random 32-byte key this is 2²⁵⁶ work — infeasible. Against a pa // ✅ Real random key from a CSPRNG import { randomBytes } from "@noble/ciphers/utils.js"; const key = randomBytes(32); -auth: { secret: () => key } +auth: { + secret: () => key; +} // ❌ Password / passphrase used directly — offline-bruteforceable via the proof -auth: { secret: () => new TextEncoder().encode("correct horse battery staple") } +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 }) } +auth: { + secret: async () => + await scrypt(password, salt, { N: 2 ** 17, r: 8, p: 1, dkLen: 32 }); +} ``` ## Built-in signature helpers @@ -255,8 +265,6 @@ import { createECDSAServerAuth, createJWTClientAuth, createJWTServerAuth, - createCertificateServerAuth, - createMultifactorServerAuth, generateEd25519Keypair, generateECDSAKeypair, } from "@dotex/saferpc"; @@ -317,36 +325,20 @@ The JWT helper does **not** sign the transcript. JWTs are bearer tokens. Instead The transcript digest prevents replay of a captured auth payload into a different handshake — the digest was computed over the old transcript and will not match the new one. It does **not** prevent an attacker who has obtained the JWT itself from mounting a fresh handshake with their own ephemeral key and recomputing the digest. JWTs are bearer credentials: anyone holding one can authenticate until it expires. Combine with PSK or a real signature mode when this matters. -### Certificate-based - -```typescript -const serverAuth = createCertificateServerAuth({ - verifyCertificate: async (certBytes) => { - return { subject, publicKey }; // your chain verification - }, -}); -``` - -The client embeds `{ cert, sig }` where `sig` is an ECDSA P-256 signature over the transcript using the cert's key. +**The token is wire-visible.** The hello frame is not yet encrypted — it carries the ephemeral public key that establishes the session — so the JWT rides it in cleartext. A passive observer of the transport (already in scope in the threat model above) reads the token directly off the opening frame; obtaining it does not require any out-of-band access. JWT-only mode therefore assumes a **confidential transport** (TLS / DTLS) or a second factor (PSK or a signature mode). Over an untrusted transport the token is disclosed on every handshake. Signature modes (`ed25519` / `ecdsa`) do not have this property: their payload is a signature over the transcript, not a reusable secret. -### Multifactor +### Custom schemes (certificates, multiple factors) -Compose two verifiers. Both must pass. - -```typescript -const serverAuth = createMultifactorServerAuth({ - primary: createEd25519ServerAuth({ getPublicKey: ... }), - secondary: createJWTServerAuth({ verifyToken: ... }), -}); -``` +There are no built-in helpers for certificate-based or multi-factor auth: in both cases the security-critical logic (chain verification; asserting that all factors resolve to the _same_ principal) depends on application knowledge the library does not have, so a generic helper would only wrap the trivial part while its name suggested it handles the hard part. Implement `sign`/`verify` directly instead: -The client embeds `{ primary, secondary }`: two pre-encoded sub-payloads. +- **Certificates** — client `sign` returns an encoded `{ cert, sig }` where `sig` is a signature over the transcript with the cert's key; server `verify` runs your chain verification, then checks the signature (the checking step is exactly what `createECDSAServerAuth` does — reuse it for the signature half if the key is P-256). +- **Multiple factors** — client `sign` encodes both sub-proofs; server `verify` decodes them, runs each sub-verifier against the same transcript, and **must reject unless both factors resolve to the same principal** (e.g. the JWT's `sub` owns the signing `deviceId` per your own store). Only then combine them into one explicit principal. Never merge two independently verified principals blindly: a stolen bearer token plus the attacker's _own_ validly registered second factor would otherwise pass as "multi-factor" for the victim's identity. ## Replay within a session 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. -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. +As of 0.7.0 the server keeps a **bounded seen-nonce set** per session (`replayWindow`, default 4096): after Poly1305 verification it records malformed envelopes and request frames, then silently drops later duplicates. Reflected response frames (`t: 2`) are dropped without consuming request replay-window capacity. This closes the replay window for the last `replayWindow` request-side 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. 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. @@ -360,7 +352,7 @@ auth: { } ``` -**Mobile app ↔ backend:** device certificates or platform attestation. +**Mobile app ↔ backend:** device keys (`createEd25519ClientAuth` / `createECDSAClientAuth`) or platform attestation. ```typescript auth: { @@ -386,6 +378,12 @@ auth: { } ``` +### Authentication is directional + +`sign` / `verify` authenticate one direction at a time, and the examples above (client `sign`, server `verify`) are **one-directional**: they prove the _client's_ identity to the server. They do **not** prove the _server's_ identity to the client — a client configured with only `sign` learns nothing about the peer beyond "it completed the key exchange". Under an empty secret the reply proof only demonstrates that the peer derived the same session key, which any endpoint reachable on the wire can do; a client with a real device key will therefore complete a handshake with any server that accepts it. + +For **mutual** authentication configure both directions — the server also `sign`s and the client also `verify`s (as in the high-security block above) — or bind both peers to a shared `secret` (PSK), which authenticates symmetrically. Choose one-directional only when the unauthenticated side is genuinely untrusted (a public server serving anonymous clients, for instance). + ## Constants and limits | Constant | Value | Notes | diff --git a/src/auth/index.ts b/src/auth/index.ts index 761bda6..3f15dde 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -28,11 +28,7 @@ export { createJWTServerAuth, createEd25519ServerAuth, createECDSAServerAuth, - createCertificateServerAuth, - createMultifactorServerAuth, type JWTServerConfig, type Ed25519ServerConfig, type ECDSAServerConfig, - type CertificateServerConfig, - type MultifactorServerConfig, } from "./server.ts"; diff --git a/src/auth/server.ts b/src/auth/server.ts index 043110d..c325a82 100644 --- a/src/auth/server.ts +++ b/src/auth/server.ts @@ -1,11 +1,12 @@ /** * Server-side authentication helpers. * - * All decoders use the hardened msgpack codec (`mpDecode`) so that auth - * payloads cannot smuggle ext types, prototype-polluting keys, or oversized - * arrays. Every helper performs strict type validation on the decoded - * fields and binds verification to the canonical handshake transcript that - * Safe RPC passes in. + * All decoders run the full hardened-data pipeline — `mpDecode` (hardened + * codec) followed by `sanitize()` — so auth payloads cannot smuggle ext + * types, prototype-polluting keys, host objects, or over-deep nesting, + * even in fields a profile ignores. Every helper then performs strict type + * validation on the decoded fields and binds verification to the canonical + * handshake transcript that Safe RPC passes in. */ import { ed25519 } from "@noble/curves/ed25519.js"; @@ -16,6 +17,7 @@ import { isPlainBytes, mpDecode, RPCError, + sanitize, type AuthOptions, type Ctx, } from "../common.ts"; @@ -25,14 +27,27 @@ import { function decodeAuthPayload(proof: Uint8Array): Record { let parsed: unknown; try { - parsed = mpDecode(proof); + // Full hardened-data policy, not just the codec: `mpDecode` alone lets + // unregistered ext types surface as `ExtData` and enforces no depth + // limit — `sanitize()` is the layer that rejects non-plain objects, + // strips poison keys, and caps recursion. Auth payloads are + // attacker-controlled bytes and MUST pass the same gate as RPC frames + // (Protocol § Sanitization), even in fields a profile ignores. + parsed = sanitize(mpDecode(proof)); } catch { throw new RPCError("UNAUTHORIZED", "Malformed auth payload"); } if (typeof parsed !== "object" || parsed === null) { throw new RPCError("UNAUTHORIZED", "Malformed auth payload"); } - return parsed as Record; + const payload = parsed as Record; + // Auth-payload profile version (see Protocol § Auth payload profiles). Every + // client helper stamps `v: 1`; a verifier MUST reject an unknown/absent + // version rather than best-effort decode a schema it was not written for. + if (payload["v"] !== 1) { + throw new RPCError("UNAUTHORIZED", "Unsupported auth payload version"); + } + return payload; } // ─── JWT bearer (transcript-bound) ────────────────────────────────── @@ -61,6 +76,13 @@ export function createJWTServerAuth( config: JWTServerConfig, ): Pick { const maxAge = config.maxAge ?? 30_000; + // Reject NaN/Infinity outright: `Math.abs(diff) > NaN` is false for every + // diff, which would silently disable the staleness check entirely. + if (typeof maxAge !== "number" || !Number.isFinite(maxAge) || maxAge < 0) { + throw new TypeError( + "createJWTServerAuth() maxAge must be a finite number ≥ 0 ms", + ); + } return { verify: async (proof, transcript) => { const payload = decodeAuthPayload(proof); @@ -190,114 +212,3 @@ export function createECDSAServerAuth( }, }; } - -// ─── Certificate-based authentication ────────────────────────────── - -export interface CertificateServerConfig { - /** Verify cert chain, return the bound public key + subject metadata. */ - verifyCertificate: ( - certBytes: Uint8Array, - ) => Promise<{ subject: Record; publicKey: CryptoKey }>; - /** Optional subject allow-list / policy check. */ - validateSubject?: ( - subject: Record, - ) => boolean | Promise; -} - -export function createCertificateServerAuth( - config: CertificateServerConfig, -): Pick { - return { - verify: async (proof, transcript) => { - const payload = decodeAuthPayload(proof); - const cert = payload["cert"]; - const sig = payload["sig"]; - - if (!isPlainBytes(cert) || cert.length === 0) { - throw new RPCError("UNAUTHORIZED", "Missing certificate"); - } - if (!isPlainBytes(sig) || sig.length === 0) { - throw new RPCError("UNAUTHORIZED", "Missing certificate signature"); - } - if (typeof crypto === "undefined" || !crypto.subtle) { - throw new RPCError("INTERNAL", "WebCrypto not available"); - } - - const { subject, publicKey } = await config.verifyCertificate(cert); - if (config.validateSubject && !(await config.validateSubject(subject))) { - throw new RPCError("UNAUTHORIZED", "Invalid certificate subject"); - } - - const ok = await crypto.subtle.verify( - { name: "ECDSA", hash: "SHA-256" }, - publicKey, - sig as BufferSource, - transcript as BufferSource, - ); - if (!ok) { - throw new RPCError("UNAUTHORIZED", "Invalid certificate signature"); - } - - return { auth: { subject, verified: true } }; - }, - }; -} - -// ─── Multifactor (compose two verifiers) ─────────────────────────── - -export interface MultifactorServerConfig { - primary: Pick; - secondary: Pick; - /** Combine the two verified principals. Default: shallow merge with `multifactor: true`. */ - combineAuth?: (primary: Ctx | undefined, secondary: Ctx | undefined) => Ctx; -} - -export function createMultifactorServerAuth( - config: MultifactorServerConfig, -): Pick { - if (typeof config.primary?.verify !== "function") { - throw new TypeError("primary.verify must be a function"); - } - if (typeof config.secondary?.verify !== "function") { - throw new TypeError("secondary.verify must be a function"); - } - return { - verify: async (proof, transcript) => { - const payload = decodeAuthPayload(proof); - const primary = payload["primary"]; - const secondary = payload["secondary"]; - - if (!isPlainBytes(primary) || primary.length === 0) { - throw new RPCError("UNAUTHORIZED", "Missing primary factor"); - } - if (!isPlainBytes(secondary) || secondary.length === 0) { - throw new RPCError("UNAUTHORIZED", "Missing secondary factor"); - } - - const primaryRes = await config.primary.verify!(primary, transcript); - const secondaryRes = await config.secondary.verify!( - secondary, - transcript, - ); - - const primaryAuth = - primaryRes && typeof primaryRes === "object" - ? ((primaryRes as { auth?: Ctx }).auth ?? undefined) - : undefined; - const secondaryAuth = - secondaryRes && typeof secondaryRes === "object" - ? ((secondaryRes as { auth?: Ctx }).auth ?? undefined) - : undefined; - - const combined = config.combineAuth - ? config.combineAuth(primaryAuth, secondaryAuth) - : { - ...(primaryAuth ?? {}), - ...(secondaryAuth ?? {}), - multifactor: true, - }; - - return { auth: combined }; - }, - }; -} diff --git a/src/client.ts b/src/client.ts index be5474d..c89a2fb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -13,8 +13,8 @@ * 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 + * until `sendTimeout`; the sent boundary (handoff to `channel.send`, not + * promise resolution) 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. @@ -157,6 +157,10 @@ export interface ClientOptions { * 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 * a previous handshake failure / reset. Default: 5000ms. + * + * If an auth callback or transport Promise is still unsettled after the + * timeout, a new handshake is rejected until that operation settles; this + * prevents non-cancellable work from accumulating across retries. */ handshakeTimeout?: number; maxMessageBytes?: number; @@ -190,6 +194,11 @@ export function client( } const maxPending = opts.maxPending !== undefined ? opts.maxPending : MAX_PENDING; + // Reject NaN/Infinity/non-integers outright: `pending.size >= NaN` is + // false for every size, which would silently disable the cap. + if (!Number.isInteger(maxPending) || maxPending <= 0) { + throw new TypeError("client() maxPending must be an integer > 0"); + } const sendTimeout = opts.sendTimeout !== undefined ? opts.sendTimeout : DEFAULT_SEND_TIMEOUT; if ( @@ -212,6 +221,10 @@ export function client( } const maxBytes = opts.maxMessageBytes !== undefined ? opts.maxMessageBytes : MAX_MSG_BYTES; + // Same NaN guard as maxPending: `data.length > NaN` is always false. + if (!Number.isInteger(maxBytes) || maxBytes <= 0) { + throw new TypeError("client() maxMessageBytes must be an integer > 0"); + } // ── State machine: idle → handshaking → ready, or closed ── // idle: no session. Next RPC call triggers handshake. @@ -245,6 +258,27 @@ export function client( let handshakeResolve: (() => void) | null = null; let handshakeReject: ((err: unknown) => void) | null = null; let hsTimer: ReturnType | null = null; + // A timed-out auth callback cannot be cancelled. Keep one handshake + // operation occupied until its async work settles, rather than starting a + // new callback on every retry and accumulating closures indefinitely. + let handshakeBusy = false; + let handshakeBuildInFlight = false; + let handshakeReplyInFlight = false; + let handshakeAwaitingReply = false; + function maybeReleaseHandshake(): void { + if ( + !handshakeBuildInFlight && + !handshakeReplyInFlight && + !handshakeAwaitingReply + ) { + handshakeBusy = false; + } + } + // Absolute wall-clock deadline of the current handshake attempt. Twin of + // hsTimer: the timer alone cannot bound a SYNCHRONOUS auth callback that + // blocks past the budget — its continuation resumes as a microtask BEFORE + // the timer macrotask fires. Guards after every await also check this. + let hsDeadline = 0; // Pending RPC responses. `sent` is the wire boundary: true once // `channel.send` returned without throwing (or its promise was handed @@ -513,12 +547,14 @@ export function client( function failHandshake(err: unknown): void { clearHsTimer(); + handshakeAwaitingReply = false; const rej = handshakeReject; handshakePromise = null; handshakeResolve = null; handshakeReject = null; zeroKeys(); state = "idle"; + maybeReleaseHandshake(); if (rej !== null) { rej( err instanceof RPCError @@ -529,9 +565,49 @@ export function client( } function startHandshake(): Promise { - privateKey = x25519.utils.randomSecretKey(); - publicKey = x25519.getPublicKey(privateKey); - clientNonce = randomBytes(KEY_LEN); + if (handshakeBusy) { + return Promise.reject( + new RPCError( + "HANDSHAKE", + "Previous handshake is still settling; retry later", + ), + ); + } + // The epoch is a uint32 on the wire (the server rejects anything larger), + // and it never wraps — wrapping would let an old session's frames alias a + // new epoch. `epoch++` below would push it past the ceiling, so exhaustion + // is a terminal client error (mirrors the request-id counter guard): the + // alternative is every subsequent hello being silently dropped server-side + // as `Invalid epoch`, which surfaces only as opaque handshake timeouts. + if (epoch >= 0xffffffff) { + return Promise.reject( + new RPCError( + "CLIENT", + "Handshake epoch exhausted; destroy and recreate client", + ), + ); + } + // Generate before occupying the slot so an unexpected crypto/RNG throw + // cannot leave the client permanently marked as busy. + const nextPrivateKey = x25519.utils.randomSecretKey(); + let nextPublicKey: Uint8Array | null = null; + let nextClientNonce: Uint8Array | null = null; + try { + nextPublicKey = x25519.getPublicKey(nextPrivateKey); + nextClientNonce = randomBytes(KEY_LEN); + } catch (err: unknown) { + zero(nextPrivateKey); + if (nextPublicKey !== null) zero(nextPublicKey); + if (nextClientNonce !== null) zero(nextClientNonce); + throw err; + } + handshakeBusy = true; + handshakeBuildInFlight = true; + handshakeReplyInFlight = false; + handshakeAwaitingReply = false; + privateKey = nextPrivateKey; + publicKey = nextPublicKey; + clientNonce = nextClientNonce; epoch++; state = "handshaking"; @@ -554,6 +630,7 @@ export function client( // `await` can attach its own handler. promise.catch(() => {}); + hsDeadline = Date.now() + hsTimeout; hsTimer = setTimeout(function onHsTimeout() { if (state !== "handshaking" || epoch !== currentEpoch) return; failHandshake(new RPCError("HANDSHAKE", "Handshake timeout")); @@ -567,7 +644,13 @@ export function client( if (auth.sign !== undefined) { const transcript = buildHelloTranscript(currentEpoch, myPub, myNonce); const signed = await auth.sign(transcript); - if (state !== "handshaking" || epoch !== currentEpoch) return; + if ( + state !== "handshaking" || + epoch !== currentEpoch || + Date.now() >= hsDeadline + ) { + return; + } if ( !(signed instanceof Uint8Array) || signed.length === 0 || @@ -588,6 +671,10 @@ export function client( const helloPayload = mpEncode(helloMsg); const hello = concatBytes(new Uint8Array([TAG_HELLO]), helloPayload); zero(helloPayload); + // From this handoff onward the operation is waiting for the matching + // reply. Set this before send because synchronous transports can + // deliver and process the reply re-entrantly. + handshakeAwaitingReply = true; try { await channel.send(hello); } catch { @@ -601,10 +688,21 @@ export function client( outbound.push({ kind: "hello", frame: hello, epoch: currentEpoch }); startFlushTimer(); } - })().catch(function onProduceError(err: unknown) { - if (state !== "handshaking" || epoch !== currentEpoch) return; - failHandshake(err); - }); + })() + .catch(function onProduceError(err: unknown) { + if (state !== "handshaking" || epoch !== currentEpoch) return; + failHandshake(err); + }) + .then( + function onHelloBuildSettled() { + handshakeBuildInFlight = false; + maybeReleaseHandshake(); + }, + function onHelloBuildReportError() { + handshakeBuildInFlight = false; + maybeReleaseHandshake(); + }, + ); return promise; } @@ -644,6 +742,8 @@ export function client( const priv = privateKey.slice(); const pub = publicKey.slice(); const nonce = clientNonce.slice(); + handshakeAwaitingReply = false; + handshakeReplyInFlight = true; // auth.verify can be async (e.g. WebCrypto.verify) so // run the entire reply path in a coroutine. The epoch guard @@ -707,8 +807,16 @@ export function client( ); await auth.verify(replyAuth, transcript); // Epoch guard: handshake might have been reset / destroyed - // while verify was awaiting (e.g. user destroy()). - if (state !== "handshaking" || epoch !== currentEpoch) return; + // while verify was awaiting (e.g. user destroy()). Deadline + // guard: a sync verify that blocked past the budget must not + // publish a session the timer already condemned. + if ( + state !== "handshaking" || + epoch !== currentEpoch || + Date.now() >= hsDeadline + ) { + return; + } } rawShared = x25519.getSharedSecret(priv, serverPub); @@ -716,7 +824,13 @@ export function client( const secretBytes = auth.secret !== undefined ? await auth.secret() : EMPTY_SECRET; - if (state !== "handshaking" || epoch !== currentEpoch) return; + if ( + state !== "handshaking" || + epoch !== currentEpoch || + Date.now() >= hsDeadline + ) { + return; + } if ( !(secretBytes instanceof Uint8Array) || @@ -747,7 +861,13 @@ export function client( // Final guard before publishing module-level state. The block // below is synchronous; no further awaits can race against us. - if (state !== "handshaking" || epoch !== currentEpoch) return; + if ( + state !== "handshaking" || + epoch !== currentEpoch || + Date.now() >= hsDeadline + ) { + return; + } sessionKey = localSessionKey; localSessionKey = null; // ownership transferred — finally won't zero @@ -767,6 +887,8 @@ export function client( zero(priv); zero(pub); zero(nonce); + handshakeReplyInFlight = false; + maybeReleaseHandshake(); } })().catch(function onReplyError(err: unknown) { // Only fail the handshake if we're STILL actively handshaking this @@ -798,7 +920,30 @@ export function client( if (msg["t"] !== 2) return; const rawId = msg["id"]; - if (typeof rawId !== "string") return; + if (typeof rawId !== "string" || rawId.length === 0) return; + + // Strict discriminator and envelope shape: the protocol defines + // exactly two response forms. Validate all required outer fields + // BEFORE touching the pending entry, so malformed frames cannot + // consume a call that is still waiting for a valid response. + const has = Object.prototype.hasOwnProperty; + const ok = msg["ok"]; + if (ok !== true && ok !== false) return; + if (!has.call(msg, "d") || !has.call(msg, "e")) return; + if (ok === true) { + if (msg["e"] !== null) return; + } else { + const error = msg["e"]; + if ( + msg["d"] !== null || + typeof error !== "object" || + error === null || + Array.isArray(error) || + error instanceof Uint8Array + ) { + return; + } + } const entry = pending.get(rawId); if (entry === undefined) return; @@ -806,7 +951,7 @@ export function client( pending.delete(rawId); clearTimeout(entry.timer); - if (msg["ok"] === true) { + if (ok === true) { entry.resolve(msg["d"]); } else { const e = msg["e"]; @@ -907,9 +1052,21 @@ export function client( // msgpack has no `undefined` primitive and would round-trip it as // `null`, which a `.optional()` (as opposed to `.nullish()`) Zod schema // rejects. A dropped key decodes back to `undefined` on the server. + // `input` is already sanitized by the proxy (before the handshake, so a + // non-plain value never even emits TAG_HELLO) — do not re-sanitize the + // rebuilt tree here. const req: Record = { t: 1, id, p: prop }; if (input !== undefined) req["i"] = input; const encrypted = enc(req); + if (encrypted.length > maxBytes) { + // The frame is still local: do not hand an oversized ciphertext to the + // adapter, where it would become a sent request that the peer must + // silently drop and the caller would only discover by timeout. + zero(encrypted); + return Promise.reject( + new RPCError("CLIENT", "Message exceeds maxMessageBytes"), + ); + } return new Promise(function rpcExec(res, rej) { // Listener hygiene: every settle path (resolve, reject, timeout, @@ -1057,6 +1214,12 @@ export function client( throw abortError(prop, signal.reason); } + // Validate caller data before starting a lazy handshake. A bad first + // input must not even emit TAG_HELLO; sanitize() also canonicalizes + // plain objects before the encrypted request is built. + const sanitizedInput = + input === undefined ? undefined : sanitize(input); + if (signal !== undefined) { // Abort rejects THIS call only; the handshake itself is shared // state and keeps running for other callers / the next call. @@ -1073,7 +1236,7 @@ export function client( const sentEpoch = epoch; try { - return await sendRequest(prop, input, signal); + return await sendRequest(prop, sanitizedInput, signal); } catch (err: unknown) { // No auto-retry. An RPCAbortedError leaves the outcome UNKNOWN // (the request may have executed — auto-resending it is a silent diff --git a/src/common.ts b/src/common.ts index edf72bb..8cee6f7 100644 --- a/src/common.ts +++ b/src/common.ts @@ -23,6 +23,11 @@ export { concatBytes } from "@noble/ciphers/utils.js"; export const NONCE_LEN = 24; export const KEY_LEN = 32; +// msgpack `useBigInt64` encodes a bigint as int64 (down to -2^63) or uint64 +// (up to 2^64-1). Anything outside that range silently wraps, so the sanitizer +// rejects it up front instead of shipping a changed value. +export const BIGINT_MIN: bigint = -(2n ** 63n); +export const BIGINT_MAX: bigint = 2n ** 64n - 1n; export const TAG_HELLO = 0x00; export const TAG_MSG = 0x01; export const MAX_MSG_BYTES = 1_048_576; @@ -62,15 +67,15 @@ const TRANSCRIPT_REPLY_MAGIC = new TextEncoder().encode( ); /** - * Hardened ExtensionCodec — rejects ALL msgpack extension types including - * the built-in Timestamp (type -1). This prevents type-confusion attacks - * where a malicious payload uses ext types to inject Date / Map / Set / - * other non-plain host objects that would surprise handlers. + * Hardened msgpack extension handling. The built-in Timestamp extension + * (type -1) is explicitly rejected by the codec; any other unregistered + * extension decodes as ExtData and is rejected by the full sanitize() gate. + * This prevents type-confusion attacks where a malicious payload uses ext + * types to inject Date / Map / Set / other non-plain host objects that would + * surprise handlers. * * Implementation: msgpack-javascript hard-codes the Timestamp decoder, so * we explicitly register a throwing decoder for type -1 to override it. - * Unregistered ext types bypass our codec and surface as ExtData; sanitize() - * rejects those via its non-plain-object check. */ const SAFE_CODEC = new ExtensionCodec(); SAFE_CODEC.register({ @@ -127,14 +132,17 @@ export function toPlainBytes(v: Uint8Array): Uint8Array { } /** - * Constant-time check that a buffer is the protocol's "no secret" sentinel: - * 32 zero bytes. Returns false for any other length. Safe RPC's internal flow - * uses `EMPTY_SECRET` as the HKDF salt when `auth.secret` is absent — but if a - * user-provided `secret()` returns 32 zeros (e.g. `new Uint8Array(32)`), the - * resulting session has no secret authentication. Refuse it at runtime. + * Constant-time check for an all-zero secret. `EMPTY_SECRET` is the protocol's + * 32-byte "no secret" sentinel, but a configured secret of any all-zero length + * would likewise provide no secret authentication. Refuse it at runtime. */ export function isEmptySecret(buf: Uint8Array): boolean { - if (buf.length !== KEY_LEN) return false; + // An all-zero secret of ANY length is treated as empty. A caller that + // returns zeros (forgot to set the secret, or derived it from all-zero + // material) must fail loudly rather than silently degrade to asymmetric-only + // mode. The earlier guard only inspected exactly KEY_LEN bytes, so 33 / 64 / + // 65 zero bytes slipped through. + if (buf.length === 0) return true; let acc = 0; for (let i = 0; i < buf.length; i++) acc |= buf[i]!; return acc === 0; @@ -143,35 +151,73 @@ export function isEmptySecret(buf: Uint8Array): boolean { const POISON = new Set(["__proto__", "constructor", "prototype"]); export function sanitize(v: unknown, depth: number = 0): unknown { + return sanitizeValue(v, depth, new WeakSet()); +} + +function sanitizeValue( + v: unknown, + depth: number, + active: WeakSet, +): unknown { if (depth > MAX_DEPTH) { throw new RPCError("INVALID_DATA", "Max nesting depth exceeded"); } if (v === null || v === undefined) return v; + if (typeof v === "function" || typeof v === "symbol") { + throw new RPCError("INVALID_DATA", "Unsupported value type"); + } + if (typeof v === "bigint") { + if (v < BIGINT_MIN || v > BIGINT_MAX) { + throw new RPCError( + "INVALID_DATA", + "BigInt out of encodable range (-2^63 .. 2^64-1)", + ); + } + return v; + } if (typeof v !== "object") return v; if (v instanceof Uint8Array) return v; - if (Array.isArray(v)) { - const out: unknown[] = []; - for (let i = 0; i < v.length; i++) { - out[i] = sanitize(v[i], depth + 1); + if (active.has(v)) { + throw new RPCError("INVALID_DATA", "Cyclic data rejected"); + } + active.add(v); + try { + if (Array.isArray(v)) { + const out: unknown[] = []; + for (let i = 0; i < v.length; i++) { + out[i] = sanitizeValue(v[i], depth + 1, active); + } + return out; + } + // Reject non-plain objects (Date, Map, Set, ExtData, etc.). Any object + // whose prototype is neither Object.prototype nor null is suspicious — + // it likely came in via a msgpack ext type or a JS host object that + // could surprise downstream handlers. + const proto = Object.getPrototypeOf(v); + if (proto !== Object.prototype && proto !== null) { + throw new RPCError("INVALID_DATA", "Non-plain object rejected"); + } + const out: Record = Object.create(null); + const keys = Object.keys(v as Record); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]!; + if (POISON.has(k)) continue; + const sv = sanitizeValue( + (v as Record)[k], + depth + 1, + active, + ); + // Drop undefined-valued keys so a nested `undefined` matches the + // top-level omission semantics. Otherwise msgpack encodes it as nil, + // which decodes to null on the peer and fails a `.optional()` + // (non-nullable) schema field the inferred type says should be absent. + if (sv === undefined) continue; + out[k] = sv; } return out; + } finally { + active.delete(v); } - // Reject non-plain objects (Date, Map, Set, ExtData, etc.). Any object - // whose prototype is neither Object.prototype nor null is suspicious — - // it likely came in via a msgpack ext type or a JS host object that - // could surprise downstream handlers. - const proto = Object.getPrototypeOf(v); - if (proto !== Object.prototype && proto !== null) { - throw new RPCError("INVALID_DATA", "Non-plain object rejected"); - } - const out: Record = Object.create(null); - const keys = Object.keys(v as Record); - for (let i = 0; i < keys.length; i++) { - const k = keys[i]!; - if (POISON.has(k)) continue; - out[k] = sanitize((v as Record)[k], depth + 1); - } - return out; } // ─── Safe msgpack wrappers ────────────────────────────── @@ -223,6 +269,14 @@ export function deriveSessionSecret( if (!(secret instanceof Uint8Array) || secret.length < KEY_LEN) { throw new TypeError(`secret must be at least ${KEY_LEN} bytes`); } + // All-zero input material derives to a non-zero but publicly reproducible + // key that the empty-secret runtime guard cannot catch (it inspects the + // derived output, which HKDF makes non-zero). Reject it here so + // `deriveSessionSecret(id, new Uint8Array(32))` fails loudly, as the + // security guide documents. + if (isEmptySecret(secret)) { + throw new TypeError("secret must not be all-zero"); + } const sessionBytes = new TextEncoder().encode(sessionId); const info = new TextEncoder().encode("saferpc-session-v1"); return hkdf(sha256, secret, sessionBytes, info, KEY_LEN); @@ -246,23 +300,47 @@ export function createEncryptor( }; } -export function createDecryptor( +/** + * AEAD-only opener: verifies Poly1305 and returns the raw plaintext. + * Throws ONLY on authentication failure — the plaintext is returned as-is, + * without msgpack decoding. This split matters on the server: successful + * AEAD verification under a candidate key is the proof that promotes the + * candidate (make-before-break), regardless of whether the inner payload + * is a well-formed RPC message. Decode failures are a separate, later step + * (`decodePlaintext`) and must not be conflated with crypto failures. + */ +export function createAeadOpener( sessionKey: Uint8Array, -): (payload: Uint8Array) => unknown { - return function decrypt(payload: Uint8Array): unknown { +): (payload: Uint8Array) => Uint8Array { + return function open(payload: Uint8Array): Uint8Array { const nonce = payload.slice(1, 1 + NONCE_LEN); const ct = payload.slice(1 + NONCE_LEN); const cipher = xsalsa20poly1305(sessionKey, nonce); const encoded = cipher.decrypt(ct); - const data = mpDecode(encoded); - // NOTE: msgpack-javascript v3 returns Uint8Array (bin) fields as - // zero-copy views into `encoded`. Zeroing `encoded` or `payload` here - // would clobber any binary field on the returned object. The plaintext - // remains in the caller's hands; callers that need stricter memory - // hygiene should sanitize and zero their own buffers after use. zero(nonce); zero(ct); - return sanitize(data); + return encoded; + }; +} + +/** + * Decode + sanitize an already-authenticated plaintext. + * NOTE: msgpack-javascript v3 returns Uint8Array (bin) fields as + * zero-copy views into the plaintext buffer. Zeroing it here would + * clobber any binary field on the returned object. The plaintext + * remains in the caller's hands; callers that need stricter memory + * hygiene should sanitize and zero their own buffers after use. + */ +export function decodePlaintext(encoded: Uint8Array): unknown { + return sanitize(mpDecode(encoded)); +} + +export function createDecryptor( + sessionKey: Uint8Array, +): (payload: Uint8Array) => unknown { + const open = createAeadOpener(sessionKey); + return function decrypt(payload: Uint8Array): unknown { + return decodePlaintext(open(payload)); }; } @@ -491,8 +569,10 @@ export type RouterContext = UnionToIntersection< * 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. + * `send` must reject, never resolve-then-error: the core counts the frame + * as sent at handoff (a pending promise may already be on the wire), and a + * rejection is the only proof that rolls it back to "never left" — a + * resolve-then-error would freeze a lost frame in the sent class forever. * Shipped reference implementations: `@dotex/saferpc/channels` * (`wsChannel`, `socketChannel`). */ @@ -760,12 +840,19 @@ export interface SafeRPC extends ProcedureBuilder< unknown > { /** - * Assemble a router. An identity function at runtime; its job is to - * preserve each procedure's precise input/output types so - * `Client` infers a typed call for every route. - * Equivalent to `{ ... } satisfies Router` — use whichever reads better. + * Assemble a router. An identity function at runtime (plus one reserved + * name); its job is to preserve each procedure's precise input/output + * types so `Client` infers a typed call for every + * route. Equivalent to `{ ... } satisfies Router` — use whichever reads + * better. + * + * The route name `then` is **reserved** (JS-specific): the generated + * client is a Proxy that must not look thenable — if `client.then` were + * a function, `await client` (and every Promise-assimilation point) + * would call it, so a `then` route could never be invoked. Rejected here + * at creation instead of failing silently at call time. */ - router(routes: R): R; + router(routes: R & { then?: never }): R; /** * Author a reusable middleware bound to `TCtx`. Plugs into `.use(...)`; @@ -793,11 +880,20 @@ export interface SafeRPC extends ProcedureBuilder< * ``` */ export function saferpc(): SafeRPC { - const router = function router(routes: R): R { + const router = function router( + routes: R & { then?: never }, + ): R { if (typeof routes !== "object" || routes === null) { throw new TypeError("router() requires an object of procedures"); } - return routes; + if ("then" in routes) { + throw new TypeError( + 'router() rejects a route named "then": the client proxy must not ' + + "look thenable, so that route could never be called " + + "(reserved, JS-specific)", + ); + } + return routes as R; }; const middleware = function middleware( mw: Middleware, diff --git a/src/index.ts b/src/index.ts index b3ee565..389fde1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,8 +88,6 @@ export { createJWTServerAuth, createEd25519ServerAuth, createECDSAServerAuth, - createCertificateServerAuth, - createMultifactorServerAuth, // Types type JWTClientConfig, type Ed25519ClientConfig, @@ -97,6 +95,4 @@ export { type JWTServerConfig, type Ed25519ServerConfig, type ECDSAServerConfig, - type CertificateServerConfig, - type MultifactorServerConfig, } from "./auth/index.ts"; diff --git a/src/server.ts b/src/server.ts index 8492911..3d9bf63 100644 --- a/src/server.ts +++ b/src/server.ts @@ -34,7 +34,8 @@ import { deriveSessionKey, computeProof, createEncryptor, - createDecryptor, + createAeadOpener, + decodePlaintext, validateAuthConfig, EMPTY_SECRET, buildHelloTranscript, @@ -51,6 +52,7 @@ import { const MAX_ID_LEN = 64; const DEFAULT_REPLAY_WINDOW = 4096; +const DEFAULT_MAX_PENDING_HANDSHAKES = 16; // ─── Server types ───────────────────────────────────────── @@ -87,7 +89,20 @@ export interface ServerOptionsBase { * Default: 5000ms. */ handshakeTimeout?: number; + /** + * Maximum full TAG_MSG frame size accepted inbound and emitted outbound. + * Oversized inbound frames are silently dropped; oversized responses are + * reported through `onError` and never handed to the channel. Default: + * 1 MiB. + */ maxMessageBytes?: number; + /** + * Maximum number of handshake attempts whose async work has not settled. + * A timed-out attempt remains counted until its auth callback settles, + * because JavaScript cannot cancel an arbitrary Promise. New hellos are + * silently dropped at the cap. Default: 16. + */ + maxPendingHandshakes?: number; /** * Size of the per-session replay window: how many recently-seen AEAD * nonces the server remembers so it can drop duplicate (replayed) @@ -173,31 +188,116 @@ function execute( } case "m": { const mw = step.fn; - tip = function runMiddleware() { + tip = async function runMiddleware() { let called = false; - return mw({ - ctx, - input, - next(extra?: Ctx) { - if (called) { - throw new RPCError( - "MIDDLEWARE", - "next() called more than once", - ); - } - called = true; - if (extra !== undefined) { - if (typeof extra !== "object" || extra === null) { + let completed = false; + let downstreamSettled = false; + let result: unknown; + try { + // try/finally (not `.finally()` on the returned value) so that + // `completed` is set on EVERY exit: async settle, sync throw, + // and a sync non-promise return. Attaching `.finally()` to the + // return value would miss a synchronous throw — a late next() + // scheduled before the throw could then run the handler after + // the error response — and would TypeError on a sync middleware + // that returns a plain value after calling next(). + result = await mw({ + ctx, + input, + next(extra?: Ctx) { + // A continuation that arrives after the middleware has + // completed (settled, returned, or thrown) cannot be part of + // this request anymore. Ignore it rather than launching the + // handler after an error response. + if (completed) return Promise.resolve(undefined); + if (called) { throw new RPCError( "MIDDLEWARE", - "next() extra must be an object", + "next() called more than once", ); } - ctx = Object.assign(Object.create(null), ctx, extra); - } - return next(); - }, - }); + called = true; + if (extra !== undefined) { + if (typeof extra !== "object" || extra === null) { + throw new RPCError( + "MIDDLEWARE", + "next() extra must be an object", + ); + } + ctx = Object.assign(Object.create(null), ctx, extra); + } + // A synchronous throw from the downstream tip (sync input + // schema, sync handler) must become a rejected promise BEFORE + // the settlement observer is attached — otherwise no promise + // exists, the flag stays false, and a middleware that caught + // the throw and answered with a fallback would be rejected + // as MIDDLEWARE despite the downstream having fully finished. + let downstream: Promise; + try { + downstream = Promise.resolve(next()); + } catch (error) { + downstream = Promise.reject(error); + } + // Observe downstream settlement (fulfil OR reject) for two + // reasons. (1) The flag feeds the completion check below: + // a middleware that settles while its next() is still + // PENDING did a fire-and-forget — the client would receive + // the middleware's own value while the handler outcome is + // silently dropped — so the request is rejected (inspired by + // tRPC's return-next guard: "did you forget to `return + // next()`?"; tRPC enforces via a runtime envelope/marker, + // not settlement tracking). (2) The reaction observes a + // rejection, so a dropped downstream promise can never + // surface as an unhandledRejection and terminate the process. + // Attached before `downstream` is handed to the middleware, + // so the flag is set before any await on it resumes (promise + // reactions run FIFO) — a middleware that awaits/returns + // next() always passes the check deterministically. The + // enforced invariant is exactly "no reply while downstream + // is still pending": a fire-and-forget whose downstream + // happened to settle before the middleware completed is + // observationally identical to a catch-fallback and is + // tolerated — whether user code looked at the settled value + // is not detectable without a tRPC-style envelope API. The + // full "must return next()" contract is enforced at the + // type level (phantom MiddlewareResult, common.ts). + downstream.then( + function markDownstreamSettled() { + downstreamSettled = true; + }, + function markDownstreamSettled() { + downstreamSettled = true; + }, + ); + return downstream; + }, + }); + } finally { + completed = true; + } + // Contract: middleware must call next() exactly once. A middleware + // that returns without calling next() would silently skip the + // handler while the client still receives a success reply — reject + // it instead of forwarding its return value. + if (!called) { + throw new RPCError( + "MIDDLEWARE", + "Middleware completed without calling next()", + ); + } + // Contract: the downstream chain must have settled before the + // middleware itself completed — i.e. next() was awaited or + // returned. A fire-and-forget next() leaves the handler running + // detached while the client already received the middleware's own + // value; that pattern is not supported (the detached rejection is + // still observed above, so it cannot crash the process). + if (!downstreamSettled) { + throw new RPCError( + "MIDDLEWARE", + "Middleware completed before next() settled — return or await next()", + ); + } + return result; }; break; } @@ -271,6 +371,18 @@ export function server( } const maxBytes = opts.maxMessageBytes !== undefined ? opts.maxMessageBytes : MAX_MSG_BYTES; + // Reject NaN/Infinity/non-integers outright: `data.length > NaN` is false + // for every length, which would silently disable the size limit. + if (!Number.isInteger(maxBytes) || maxBytes <= 0) { + throw new TypeError("server() maxMessageBytes must be an integer > 0"); + } + const maxPendingHandshakes = + opts.maxPendingHandshakes !== undefined + ? opts.maxPendingHandshakes + : DEFAULT_MAX_PENDING_HANDSHAKES; + if (!Number.isInteger(maxPendingHandshakes) || maxPendingHandshakes <= 0) { + throw new TypeError("server() maxPendingHandshakes must be an integer > 0"); + } const replayWindow = opts.replayWindow !== undefined ? opts.replayWindow : DEFAULT_REPLAY_WINDOW; if ( @@ -300,15 +412,22 @@ export function server( let epoch = 0; let attemptEpoch = 0; let candidateEpoch = 0; + // A timed-out auth callback cannot be cancelled. Keep its attempt counted + // until the callback settles, so a hello flood can retain only a bounded + // number of attempt closures. New hellos are dropped at the configured cap. + let pendingHandshakes = 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. + // Decrypt slots hold AEAD-only openers (Poly1305 → plaintext); msgpack + // decoding happens separately in the TAG_MSG handler, AFTER promotion and + // nonce recording, so a junk inner payload cannot mask a proven key. let liveKey: Uint8Array | null = null; let liveEncrypt: ((data: unknown) => Uint8Array) | null = null; - let liveDecrypt: ((payload: Uint8Array) => unknown) | null = null; + let liveDecrypt: ((payload: Uint8Array) => Uint8Array) | 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; @@ -319,13 +438,18 @@ export function server( // 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 candidateDecrypt: ((payload: Uint8Array) => Uint8Array) | null = null; let candidateAuthData: Ctx | null = null; let destroyed = false; // Confirmation timer for the pending candidate. On expiry the candidate is // dropped; the live session (if any) is untouched. let candidateTimer: ReturnType | null = null; + // Absolute wall-clock deadline for the pending candidate (hello receipt + + // hsTimeout). The candidate timer is only a wakeup and may fire late if the + // loop was busy; the promotion path checks this deadline directly so an + // overdue confirming frame cannot promote an expired candidate. + let candidateDeadline = 0; // ── D2: bounded seen-nonce set (in-session replay defense) ──────── // Ring buffer of the last `replayWindow` accepted nonce keys + a Set for @@ -454,6 +578,8 @@ export function server( // its await guards without touching the session or the candidate. if (tag === TAG_HELLO) { if (data.length > MAX_HELLO_BYTES) return; + if (pendingHandshakes >= maxPendingHandshakes) return; + pendingHandshakes++; attemptEpoch++; const myAttempt = attemptEpoch; @@ -467,10 +593,24 @@ export function server( // confirmation timer below, so hello → first-decrypted-frame is // bounded by ONE hsTimeout total. const attemptStart = Date.now(); + // Absolute deadline twin of the timer below. The timer alone is not + // enough: a SYNCHRONOUS auth callback that blocks past the budget + // resumes as a microtask BEFORE the timer macrotask fires, so the + // `attemptExpired` flag is still false when the continuation runs. + // Every guard therefore also checks wall-clock time. + const attemptDeadline = attemptStart + hsTimeout; let attemptExpired = false; + // One attempt reports at most one onError. The attempt timer, the + // candidate timer, and the failure catch all gate on this flag so a + // timeout followed by a late async rejection (or reply-send failure) + // cannot emit two errors for the same handshake. + let reported = false; + const attemptDead = (): boolean => + attemptExpired || Date.now() >= attemptDeadline; const attemptTimer = setTimeout(function onAttemptTimeout() { attemptExpired = true; - if (attemptEpoch !== myAttempt || destroyed) return; + if (attemptEpoch !== myAttempt || destroyed || reported) return; + reported = true; if (onError !== null) { onError(new RPCError("HANDSHAKE", "Handshake timeout")); } @@ -544,7 +684,7 @@ export function server( nonce, ); const verifyResult = await auth.verify(helloAuth, transcript); - if (attemptEpoch !== myAttempt || destroyed || attemptExpired) { + if (attemptEpoch !== myAttempt || destroyed || attemptDead()) { return; } if (verifyResult && typeof verifyResult === "object") { @@ -570,7 +710,7 @@ export function server( const secretBytes = auth.secret !== undefined ? await auth.secret() : EMPTY_SECRET; - if (attemptEpoch !== myAttempt || destroyed || attemptExpired) { + if (attemptEpoch !== myAttempt || destroyed || attemptDead()) { return; } @@ -609,7 +749,7 @@ export function server( myPub, ); const signed = await auth.sign(replyTranscript); - if (attemptEpoch !== myAttempt || destroyed || attemptExpired) { + if (attemptEpoch !== myAttempt || destroyed || attemptDead()) { return; } if ( @@ -628,7 +768,7 @@ export function server( // 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) { + if (attemptEpoch !== myAttempt || destroyed || attemptDead()) { return; } @@ -651,8 +791,12 @@ export function server( candidateEpoch++; candidateKey = localSessionKey; localSessionKey = null; // ownership transferred — skip finally zero - candidateDecrypt = createDecryptor(candidateKey); + candidateDecrypt = createAeadOpener(candidateKey); candidateAuthData = localAuthData; + // Absolute twin of the confirmation timer below: total budget is + // hello receipt + hsTimeout, so the candidate expires at the same + // wall-clock instant the timer is scheduled for. + candidateDeadline = attemptStart + hsTimeout; // Arm the confirmation timer for this candidate. On expiry the // candidate is dropped and the live session is untouched. Keyed on @@ -663,6 +807,8 @@ export function server( candidateTimer = setTimeout(function onCandidateTimeout() { if (candidateEpoch !== myCandEpoch || destroyed) return; dropCandidate(); + if (reported) return; + reported = true; if (onError !== null) { onError(new RPCError("HANDSHAKE", "Handshake timeout")); } @@ -681,7 +827,26 @@ export function server( zero(localProof); localProof = null; - await channel.send(reply); + try { + await channel.send(reply); + } catch (sendErr: unknown) { + // The reply never reached the wire — this candidate can never be + // confirmed. Drop it NOW (if it is still ours) instead of letting + // it linger until the confirmation timer fires a second, spurious + // "Handshake timeout" on top of the send failure below. + if (candidateEpoch === myCandEpoch && !destroyed) { + dropCandidate(); + } + // 4th argument: `cause` is constructor options, not `data` — + // the original error object rides err.cause per the protocol + // ("carrying the transport error as its cause"). + throw new RPCError( + "HANDSHAKE", + "Handshake reply send failed", + undefined, + { cause: sendErr }, + ); + } if (candidateEpoch !== myCandEpoch || destroyed) return; // Timer continues running — waiting for first valid TAG_MSG that @@ -698,18 +863,31 @@ export function server( zero(myPriv); zero(myPub); } - })().catch(function onHsError(err: unknown) { - // 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 - ? err - : new RPCError("HANDSHAKE", "Handshake failed"), - ); - } - }); + })() + .catch(function onHsError(err: unknown) { + // 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 || reported) return; + reported = true; + if (onError !== null) { + onError( + err instanceof RPCError + ? err + : new RPCError("HANDSHAKE", "Handshake failed"), + ); + } + }) + .then( + function onHsSettled() { + pendingHandshakes--; + }, + function onHsReportError() { + // An application onError callback is allowed to throw. The + // attempt slot must still be released without creating an + // unhandled rejection. + pendingHandshakes--; + }, + ); return; } @@ -735,12 +913,14 @@ export function server( // 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; + // retire the live session (make-before-break). AEAD verification + // ONLY — the inner payload is decoded later, so a junk payload under + // a proven key still promotes and still lands in the replay window. + let plain: Uint8Array | null = null; let decryptedUnder: "live" | "candidate" | null = null; if (liveDecrypt !== null) { try { - raw = liveDecrypt(data); + plain = liveDecrypt(data); decryptedUnder = "live"; } catch { /* fall through to candidate */ @@ -748,32 +928,70 @@ export function server( } if (decryptedUnder === null && candidateDecrypt !== null) { try { - raw = candidateDecrypt(data); + plain = candidateDecrypt(data); decryptedUnder = "candidate"; } catch { /* neither key */ } } - if (decryptedUnder === null) { + if (decryptedUnder === null || plain === 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(); + if (decryptedUnder === "candidate") { + // Absolute-deadline check: the candidate timer is only a wakeup and + // may fire late if the loop was busy past the budget. If this + // confirming frame arrives after the candidate expired, do NOT + // promote — leave the (now overdue) candidate timer to drop it and + // report the timeout. The nonce is not recorded and the handler is + // not run, so an expired candidate cannot execute a request. + if (Date.now() >= candidateDeadline) return; + 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); + // Decode the authenticated plaintext. A malformed inner payload is + // dropped silently — the session promotion above stands: the key was + // proven by Poly1305, not by the payload's shape (spec § Step 4). + // It still consumes a replay-window slot: otherwise an attacker who + // has obtained one authenticated malformed frame can make the server + // repeat decode/sanitize work indefinitely without the normal replay + // bound. This happens before any await, so duplicate delivery cannot + // race the membership check at the channel boundary. + let raw: unknown; + try { + raw = decodePlaintext(plain); + } catch { + if (nKey !== null) seenAdd(nKey); + return; + } - if (typeof raw !== "object" || raw === null) return; + if (typeof raw !== "object" || raw === null) { + if (nKey !== null) seenAdd(nKey); + return; + } const msg = raw as Record; - if (msg["t"] !== 1) return; + // Direction guard: t === 1 marks a REQUEST. Both directions share one + // session key, so a genuine server RESPONSE reflected back to the + // server also passes Poly1305 — dropping it BEFORE recording its + // nonce keeps opposite-direction frames from consuming replay-window + // slots (which would shrink the effective window against real + // request replays). Other authenticated malformed envelopes consume + // their nonce even though they are not dispatched. + if (msg["t"] !== 1) { + if (msg["t"] !== 2 && nKey !== null) seenAdd(nKey); + return; + } + + // Request-direction frame, Poly1305-verified — record its nonce in + // the (now-current) live window so a later duplicate is rejected. + // Still synchronous (no await since decrypt), so back-to-back + // duplicates cannot both slip through. + if (nKey !== null) seenAdd(nKey); const rawId = msg["id"]; if ( typeof rawId !== "string" || @@ -848,7 +1066,21 @@ export function server( const enc = liveEncrypt; if (enc === null) return; - await channel.send(enc(res)); + const encrypted = enc(res); + if (encrypted.length > maxBytes) { + // The response is local until handed to the adapter. Never emit an + // oversized frame that the peer is required to drop; otherwise a + // large handler result turns into an opaque client timeout and also + // defeats the server-side framing bound on outbound traffic. + zero(encrypted); + if (onError !== null) { + onError( + new RPCError("INVALID_DATA", "Response exceeds maxMessageBytes"), + ); + } + return; + } + await channel.send(encrypted); })().catch(function onSendError(err: unknown) { if (onError !== null) onError(err); }); diff --git a/test/helpers/protocol.ts b/test/helpers/protocol.ts index 6f57ef9..f4faa46 100644 --- a/test/helpers/protocol.ts +++ b/test/helpers/protocol.ts @@ -4,10 +4,12 @@ */ import { randomBytes } from "@noble/ciphers/utils.js"; +import { xsalsa20poly1305 } from "@noble/ciphers/salsa.js"; import { TAG_HELLO, TAG_MSG, KEY_LEN, + NONCE_LEN, x25519, concatBytes, mpEncode, @@ -21,6 +23,21 @@ import { export { TAG_HELLO, TAG_MSG, KEY_LEN }; +/** + * Build a TAG_MSG frame carrying ARBITRARY plaintext bytes under `sessionKey` + * — bypassing the msgpack encoder. Lets a test forge a frame that passes + * Poly1305 but carries a payload the RPC decoder will reject (e.g. `0xc1`, + * msgpack "never used"). Mirrors `createEncryptor`'s framing exactly. + */ +export function encryptRaw( + sessionKey: Uint8Array, + plaintext: Uint8Array, +): Uint8Array { + const nonce = randomBytes(NONCE_LEN); + const ct = xsalsa20poly1305(sessionKey, nonce).encrypt(plaintext); + return concatBytes(new Uint8Array([TAG_MSG]), nonce, ct); +} + /** Build a forged client hello with the given fields (defaults filled in). */ export function forgeHello( opts: { diff --git a/test/security/auth-handshake.test.ts b/test/security/auth-handshake.test.ts index dcd7d69..ffa0e38 100644 --- a/test/security/auth-handshake.test.ts +++ b/test/security/auth-handshake.test.ts @@ -25,8 +25,6 @@ import { createEd25519ServerAuth, createECDSAClientAuth, createECDSAServerAuth, - createCertificateServerAuth, - createMultifactorServerAuth, generateEd25519Keypair, generateECDSAKeypair, deriveSessionSecret, @@ -274,104 +272,6 @@ describe("auth integration / ECDSA", () => { }); }); -// ─── Certificate ───────────────────────────────────────────── - -describe("auth integration / Certificate", () => { - it("completes a handshake when cert + transcript signature verify", async () => { - const { privateKey, publicKey } = await generateECDSAKeypair(); - const { a, b } = createChannelPair(); - const psk = randomBytes(32); - - const srv = server(router, a, { - auth: { - secret: () => psk, - ...createCertificateServerAuth({ - verifyCertificate: async (cert) => { - if (cert[0] !== 0xaa) throw new Error("bad cert"); - return { subject: { cn: "alice" }, publicKey }; - }, - }), - }, - onError: (e) => { - throw e; - }, - }); - - // No client helper for certs — application supplies its own `sign`. - const cert = new Uint8Array([0xaa, 0xbb, 0xcc]); - const { api, destroy } = client(b, { - auth: { - secret: () => psk, - sign: async (transcript) => { - const sig = new Uint8Array( - await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - privateKey, - transcript as BufferSource, - ), - ); - return mpEncode({ cert, sig }); - }, - }, - }); - - const me = (await api["whoami"]!(undefined)) as { - subject?: { cn?: string }; - verified?: boolean; - }; - expect(me.subject?.cn).toBe("alice"); - expect(me.verified).toBe(true); - - destroy(); - srv.destroy(); - }); - - it("rejects when validateSubject denies the subject", async () => { - const { privateKey, publicKey } = await generateECDSAKeypair(); - const { a, b } = createChannelPair(); - const psk = randomBytes(32); - const errors: RPCError[] = []; - - const srv = server(router, a, { - auth: { - secret: () => psk, - ...createCertificateServerAuth({ - verifyCertificate: async () => ({ - subject: { cn: "mallory" }, - publicKey, - }), - validateSubject: (s) => s["cn"] === "alice", - }), - }, - onError: (e) => errors.push(e as RPCError), - }); - - const { api, destroy } = client(b, { - auth: { - secret: () => psk, - sign: async (transcript) => { - const sig = new Uint8Array( - await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - privateKey, - transcript as BufferSource, - ), - ); - return mpEncode({ cert: new Uint8Array([0xaa]), sig }); - }, - }, - timeout: 800, - handshakeTimeout: 400, - }); - - await expect(api["ping"]!(undefined)).rejects.toBeInstanceOf(RPCError); - expect(errors.some((e) => e.code === "UNAUTHORIZED")).toBe(true); - - destroy(); - srv.destroy(); - }); -}); - // ─── Secret-only / session-derived secret ────────────────────────── describe("auth integration / secret", () => { @@ -456,109 +356,3 @@ describe("auth integration / secret", () => { srv.destroy(); }); }); - -// ─── Multifactor (JWT × Ed25519) ───────────────────────────── - -describe("auth integration / Multifactor", () => { - it("requires both factors to succeed; merges both principals into ctx", async () => { - const { privateKey, publicKey } = await generateEd25519Keypair(); - const { a, b } = createChannelPair(); - const psk = randomBytes(32); - - const jwtServer = createJWTServerAuth({ - verifyToken: async (jwt) => (jwt === "tok" ? { sub: "u" } : null), - }); - const edServer = createEd25519ServerAuth({ - getPublicKey: async () => publicKey, - }); - - const srv = server(router, a, { - auth: { - secret: () => psk, - ...createMultifactorServerAuth({ - primary: jwtServer, - secondary: edServer, - }), - }, - onError: (e) => { - throw e; - }, - }); - - const jwtClient = createJWTClientAuth({ getToken: () => "tok" }); - const edClient = createEd25519ClientAuth({ privateKey, deviceId: "dev" }); - - const { api, destroy } = client(b, { - auth: { - secret: () => psk, - sign: async (transcript) => { - const [primary, secondary] = await Promise.all([ - jwtClient.sign!(transcript), - edClient.sign!(transcript), - ]); - return mpEncode({ primary, secondary }); - }, - }, - }); - - const me = (await api["whoami"]!(undefined)) as Ctx; - expect(me["sub"]).toBe("u"); - expect(me["deviceId"]).toBe("dev"); - expect(me["verified"]).toBe(true); - expect(me["multifactor"]).toBe(true); - - destroy(); - srv.destroy(); - }); - - it("rejects when the secondary factor fails (primary succeeds)", async () => { - const honest = await generateEd25519Keypair(); - const attacker = await generateEd25519Keypair(); - const { a, b } = createChannelPair(); - const psk = randomBytes(32); - const errors: RPCError[] = []; - - const srv = server(router, a, { - auth: { - secret: () => psk, - ...createMultifactorServerAuth({ - primary: createJWTServerAuth({ - verifyToken: async () => ({ sub: "u" }), - }), - secondary: createEd25519ServerAuth({ - getPublicKey: async () => honest.publicKey, - }), - }), - }, - onError: (e) => errors.push(e as RPCError), - }); - - const jwtClient = createJWTClientAuth({ getToken: () => "tok" }); - // Attacker forges with the wrong device key — secondary verify fails. - const edClient = createEd25519ClientAuth({ - privateKey: attacker.privateKey, - deviceId: "spoofed", - }); - - const { api, destroy } = client(b, { - auth: { - secret: () => psk, - sign: async (transcript) => { - const [primary, secondary] = await Promise.all([ - jwtClient.sign!(transcript), - edClient.sign!(transcript), - ]); - return mpEncode({ primary, secondary }); - }, - }, - timeout: 800, - handshakeTimeout: 400, - }); - - await expect(api["ping"]!(undefined)).rejects.toBeInstanceOf(RPCError); - expect(errors.some((e) => e.code === "UNAUTHORIZED")).toBe(true); - - destroy(); - srv.destroy(); - }); -}); diff --git a/test/security/dos-attacks.test.ts b/test/security/dos-attacks.test.ts index 324de7f..57c64e8 100644 --- a/test/security/dos-attacks.test.ts +++ b/test/security/dos-attacks.test.ts @@ -21,6 +21,80 @@ import { } from "../helpers/channels.ts"; describe("security / DoS — framing limits", () => { + it("client rejects oversized outbound TAG_MSG locally", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + let invocations = 0; + const router: Router = { + ping: chain().handler(async () => { + invocations++; + return "pong"; + }), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + maxMessageBytes: 128, + timeout: 500, + }); + try { + await expect(api.ping({ data: "x".repeat(500) })).rejects.toMatchObject({ + code: "CLIENT", + message: "Message exceeds maxMessageBytes", + }); + expect(invocations).toBe(0); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("client sanitizes input before encoding and before handshake", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + let invocations = 0; + let framesSent = 0; + const observed = { + send(data: Uint8Array) { + framesSent++; + return b.send(data); + }, + receive: b.receive.bind(b), + }; + const router: Router = { + ping: chain().handler(async () => { + invocations++; + return "pong"; + }), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(observed, { + auth: { secret: () => psk }, + timeout: 500, + }); + try { + await expect( + api.ping(new Date() as unknown as Record), + ).rejects.toMatchObject({ code: "INVALID_DATA" }); + await expect(api.ping((() => {}) as unknown)).rejects.toMatchObject({ + code: "INVALID_DATA", + }); + await expect(api.ping(Symbol("bad") as unknown)).rejects.toMatchObject({ + code: "INVALID_DATA", + }); + const cyclic: Record = {}; + cyclic.self = cyclic; + await expect(api.ping(cyclic)).rejects.toMatchObject({ + code: "INVALID_DATA", + }); + expect(invocations).toBe(0); + expect(framesSent).toBe(0); + } finally { + destroy(); + srv.destroy(); + } + }); + it("server drops TAG_MSG larger than maxMessageBytes", async () => { const psk = randomBytes(32); const { a, b, mitm } = createMitmChannelPair(); diff --git a/test/security/hung-auth-timeout.test.ts b/test/security/hung-auth-timeout.test.ts index 4941f58..741e1d3 100644 --- a/test/security/hung-auth-timeout.test.ts +++ b/test/security/hung-auth-timeout.test.ts @@ -17,6 +17,7 @@ import { type Router, } from "../../src/index.ts"; import { createChannelPair } from "../helpers/channels.ts"; +import { forgeHello } from "../helpers/protocol.ts"; type LooseApi = Record< string, @@ -140,4 +141,65 @@ describe("handshakeTimeout bounds the async auth phase", () => { srv.destroy(); } }); + + it("caps server attempts whose auth callbacks never settle", async () => { + const { a, b } = createChannelPair(); + let secretCalls = 0; + const srv = server(router, a, { + auth: { + secret: () => { + secretCalls++; + return new Promise(() => {}); + }, + }, + handshakeTimeout: 100, + maxPendingHandshakes: 2, + }); + + try { + await b.send(forgeHello()); + await b.send(forgeHello()); + await b.send(forgeHello()); + await sleep(150); + expect(secretCalls).toBe(2); + + // Timed-out attempts remain counted until their callbacks settle, so + // repeated hellos cannot accumulate an unbounded number of closures. + await b.send(forgeHello()); + expect(secretCalls).toBe(2); + } finally { + srv.destroy(); + } + }); + + it("does not start a second client auth callback while the first is hung", async () => { + const { a } = createChannelPair(); + let signCalls = 0; + const { api, destroy } = client(a, { + auth: { + sign: () => { + signCalls++; + return new Promise(() => {}); + }, + }, + handshakeTimeout: 100, + timeout: 300, + }) as unknown as { api: LooseApi; destroy: () => void }; + + try { + const first = api["ping"]!(); + const firstResult = await first.catch((error: unknown) => error); + expect(firstResult).toBeInstanceOf(RPCError); + expect((firstResult as RPCError).code).toBe("HANDSHAKE"); + + const secondResult = await api["ping"]!().catch( + (error: unknown) => error, + ); + expect(secondResult).toBeInstanceOf(RPCError); + expect((secondResult as RPCError).code).toBe("HANDSHAKE"); + expect(signCalls).toBe(1); + } finally { + destroy(); + } + }); }); diff --git a/test/security/malformed-response.test.ts b/test/security/malformed-response.test.ts new file mode 100644 index 0000000..8ee4e81 --- /dev/null +++ b/test/security/malformed-response.test.ts @@ -0,0 +1,130 @@ +/** + * Malformed response discriminator (Protocol § Request/response framing). + * + * The protocol defines exactly two response forms: `ok: true` and + * `ok: false`. Any other `ok` value is a malformed envelope and MUST be + * dropped silently — before the pending entry is consumed, so the call + * keeps waiting for a well-formed response under its own timer. A client + * that settled on garbage would diverge observably from a strict port + * (immediate RemoteRPCError vs timeout/drop). + * + * The test runs a real client against a hand-rolled server that answers + * one RPC with a burst of malformed envelopes followed by a valid one: + * the call must resolve on the valid response, proving the malformed + * ones did not consume the pending entry. + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { + client, + x25519, + mpDecode, + deriveSessionKey, + computeProof, + createEncryptor, + createDecryptor, + concatBytes, + mpEncode, + TAG_HELLO, + TAG_MSG, + RemoteRPCError, + type Channel, +} from "../../src/index.ts"; +import { createChannelPair } from "../helpers/channels.ts"; + +interface FakeServer { + /** Called with each decrypted request; returns envelopes to send back. */ + respond: (req: { id: string; p: string }) => Record[]; +} + +/** Minimal protocol-correct server: one session, scripted responses. */ +function fakeServer( + channel: Channel, + secret: Uint8Array, + script: FakeServer, +): void { + let encrypt: ((data: unknown) => Uint8Array) | null = null; + let decrypt: ((payload: Uint8Array) => unknown) | null = null; + + channel.receive((data) => { + if (data[0] === TAG_HELLO) { + const hello = mpDecode(data.subarray(1)) as { + pub: Uint8Array; + nonce: Uint8Array; + epoch: number; + }; + const priv = x25519.utils.randomSecretKey(); + const pub = x25519.getPublicKey(priv); + const rawShared = x25519.getSharedSecret(priv, hello.pub); + const sessionKey = deriveSessionKey(rawShared, secret); + const proof = computeProof(sessionKey, pub, hello.pub, hello.nonce); + encrypt = createEncryptor(sessionKey); + decrypt = createDecryptor(sessionKey); + const reply = mpEncode({ pub, proof, epoch: hello.epoch }); + void channel.send(concatBytes(new Uint8Array([TAG_HELLO]), reply)); + return; + } + if (data[0] === TAG_MSG && decrypt !== null && encrypt !== null) { + const req = decrypt(data) as { id: string; p: string }; + for (const envelope of script.respond(req)) { + void channel.send(encrypt(envelope)); + } + } + }); +} + +describe("security / malformed response discriminator", () => { + it("drops responses whose ok is not strictly boolean; the pending call survives", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + + fakeServer(a, psk, { + respond: (req) => [ + // Malformed: each of these must be silently dropped. + { t: 2, id: req.id, d: "garbage-absent-ok", e: null }, + { t: 2, id: req.id, ok: null, d: "garbage-null", e: null }, + { t: 2, id: req.id, ok: 1, d: "garbage-one", e: null }, + { t: 2, id: req.id, ok: "false", d: null, e: { c: "X", m: "no" } }, + // Malformed outer shapes: required d/e fields and their pairing. + { t: 2, id: req.id, ok: true, e: null }, + { t: 2, id: req.id, ok: true, d: "wrong", e: {} }, + { t: 2, id: req.id, ok: false, d: "wrong", e: { c: "X" } }, + { t: 2, id: req.id, ok: false, d: null, e: [] }, + // Valid: must still find the pending entry and resolve it. + { t: 2, id: req.id, ok: true, d: "real-answer", e: null }, + ], + }); + + const { api } = client(b, { + auth: { secret: () => psk }, + timeout: 2_000, + }); + const result = await ( + api as { ping: (i: unknown) => Promise } + ).ping({}); + expect(result).toBe("real-answer"); + }); + + it("ok: false still settles as RemoteRPCError (failure form untouched)", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + + fakeServer(a, psk, { + respond: (req) => [ + { t: 2, id: req.id, ok: false, d: null, e: { c: "BOOM", m: "nope" } }, + ], + }); + + const { api } = client(b, { + auth: { secret: () => psk }, + timeout: 2_000, + }); + await expect( + (api as { ping: (i: unknown) => Promise }).ping({}), + ).rejects.toMatchObject({ + constructor: RemoteRPCError, + code: "BOOM", + message: "nope", + }); + }); +}); diff --git a/test/security/middleware-attacks.test.ts b/test/security/middleware-attacks.test.ts index 965cccd..60a1a13 100644 --- a/test/security/middleware-attacks.test.ts +++ b/test/security/middleware-attacks.test.ts @@ -109,6 +109,271 @@ describe("security / middleware pipeline", () => { } }); + it("late next() cannot run the handler after middleware completion", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + let invocations = 0; + const router: Router = { + lateNext: chain() + .use(({ next }) => { + setTimeout(() => { + void next(); + }, 10); + return Promise.resolve("ignored" as never); + }) + .handler(async () => { + invocations++; + return "must-not-run"; + }), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + await expect(api.lateNext({})).rejects.toMatchObject({ + code: "MIDDLEWARE", + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(invocations).toBe(0); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("late next() cannot run the handler after a synchronous throw", async () => { + // The sync-throw variant of the zombie-handler hole: `completed` must be + // set even when the middleware never produces a promise at all. + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + let invocations = 0; + const router: Router = { + syncThrow: chain() + .use((({ next }: { next: () => Promise }) => { + setTimeout(() => { + void next().catch(() => {}); + }, 10); + throw new Error("sync boom"); + }) as never) + .handler(async () => { + invocations++; + return "must-not-run"; + }), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + await expect(api.syncThrow({})).rejects.toMatchObject({ + code: "INTERNAL", + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(invocations).toBe(0); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("sync middleware returning a non-promise after fire-and-forget next() is rejected", async () => { + // Spec: the next() promise must have settled before the middleware + // completes. A middleware that returns its own value while the downstream + // is still pending would hand the client a success and silently drop the + // handler outcome — rejected as MIDDLEWARE (cf. tRPC's return-next + // guard: “did you forget to `return next()`?”). The handler delays so + // the downstream is genuinely pending at middleware completion. + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const router: Router = { + syncReturn: chain() + .use((({ next }: { next: () => Promise }) => { + void next(); + return "sync-value"; + }) as never) + .handler(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return "handler-value"; + }), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + await expect(api.syncReturn({})).rejects.toMatchObject({ + code: "MIDDLEWARE", + }); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("fire-and-forget next() is rejected and does not leak an unhandledRejection (#7)", async () => { + // The regression scenario: middleware replies while the downstream is + // still pending, and the downstream REJECTS LATER. The request must fail + // closed with MIDDLEWARE, and the detached rejection must still be + // observed by the runtime — otherwise it surfaces as an + // unhandledRejection that can terminate the process. + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + const router: Router = { + fireForget: chain() + .use((({ next }: { next: () => Promise }) => { + void next(); // fire-and-forget: not awaited, not returned + return "outer-success"; + }) as never) + .handler(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + throw new Error("downstream-boom"); + }), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + await expect(api.fireForget({})).rejects.toMatchObject({ + code: "MIDDLEWARE", + }); + // Let the delayed downstream rejection fire and get a chance to be + // reported as unhandled. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect( + unhandled.some((r) => + String((r as Error | undefined)?.message ?? r).includes( + "downstream-boom", + ), + ), + ).toBe(false); + } finally { + process.off("unhandledRejection", onUnhandled); + destroy(); + srv.destroy(); + } + }); + + it("awaiting next() and returning a transformed value is accepted", async () => { + // `return next()` is the public contract; `await next()` + own return is + // runtime-tolerated (type-illegal). In both cases the downstream settled + // before the middleware completed, so the check must not fire. + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const router: Router = { + passThrough: chain() + .use(async ({ next }) => next()) + .handler(async () => "plain"), + transform: chain() + // Type-illegal on purpose (the phantom MiddlewareResult requires + // returning next()'s result) — this asserts the RUNTIME behavior: + // downstream settled before completion, own value becomes the reply. + .use((async ({ next }: { next: () => Promise }) => { + const inner = await next(); + return `wrapped:${String(inner)}`; + }) as never) + .handler(async () => "plain"), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + await expect(api.passThrough({})).resolves.toBe("plain"); + await expect(api.transform({})).resolves.toBe("wrapped:plain"); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("catch-fallback survives a synchronous downstream throw", async () => { + // A sync input schema failure or a sync-throwing handler throws before + // any downstream promise exists. next() must normalize that into a + // rejected promise so the settlement flag is still set — otherwise the + // middleware catches the error, answers with a fallback, and is then + // wrongly rejected as MIDDLEWARE. + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const recoverMw = (async ({ next }: { next: () => Promise }) => { + try { + return await next(); + } catch { + return "fallback"; + } + }) as never; + const router: Router = { + syncSchema: chain() + .use(recoverMw) + .input(z.object({ id: z.string() })) + .handler(async ({ input }) => (input as { id: string }).id), + syncHandler: chain() + .use(recoverMw) + .handler((() => { + throw new Error("sync-handler-boom"); + }) as never), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + // Input validation throws synchronously inside next(). + await expect(api.syncSchema({ id: 42 })).resolves.toBe("fallback"); + // A sync (non-async) handler throwing does the same. + await expect(api.syncHandler({})).resolves.toBe("fallback"); + } finally { + destroy(); + srv.destroy(); + } + }); + + it("middleware catching a downstream rejection and returning a fallback is accepted", async () => { + // Error-handling middleware: awaits next(), downstream rejects, the + // middleware swallows it and answers with its own value. The downstream + // HAS settled (rejected), so this remains a supported pattern. + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const router: Router = { + recover: chain() + // Type-illegal on purpose (see transform above) — asserts runtime + // tolerance of the catch-fallback form. + .use((async ({ next }: { next: () => Promise }) => { + try { + return await next(); + } catch { + return "fallback"; + } + }) as never) + .handler(async () => { + throw new Error("handler-boom"); + }), + }; + const srv = server(router, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + await expect(api.recover({})).resolves.toBe("fallback"); + } finally { + destroy(); + srv.destroy(); + } + }); + it("middleware-thrown RPCError is not masked as INTERNAL", async () => { const psk = randomBytes(32); const { a, b } = createChannelPair(); diff --git a/test/security/replay-and-response-bounds.test.ts b/test/security/replay-and-response-bounds.test.ts new file mode 100644 index 0000000..34ed2f6 --- /dev/null +++ b/test/security/replay-and-response-bounds.test.ts @@ -0,0 +1,91 @@ +/** Regression coverage for authenticated malformed-frame replay bounds and response framing limits. */ +import { describe, expect, it, vi } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { + chain, + client, + server, + type Channel, + type Router, +} from "../../src/index.ts"; +import * as common from "../../src/common.ts"; +import { createChannelPair } from "../helpers/channels.ts"; +import { encryptRaw, manualHandshake } from "../helpers/protocol.ts"; + +function countSends(ch: Channel): { ch: Channel; sends: () => number } { + let count = 0; + return { + ch: { + send(data) { + count++; + return ch.send(data); + }, + receive: (cb) => ch.receive(cb), + }, + sends: () => count, + }; +} + +describe("security / authenticated malformed frames", () => { + it("records the nonce before a duplicate can repeat decode work", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const srv = server({ ping: chain().handler(async () => "pong") }, a, { + auth: { secret: () => psk }, + replayWindow: 16, + }); + const decodeSpy = vi.spyOn(common, "decodePlaintext"); + try { + const sess = await manualHandshake(b, psk); + const junk = encryptRaw(sess.sessionKey, new Uint8Array([0xc1])); + + b.send(junk); + b.send(junk); + await Promise.resolve(); + + // The second copy is rejected by the pre-decrypt nonce check. Before + // the fix both copies reached decodePlaintext. + expect(decodeSpy).toHaveBeenCalledTimes(1); + } finally { + decodeSpy.mockRestore(); + srv.destroy(); + } + }); +}); + +describe("security / response framing limits", () => { + it("does not hand an oversized encrypted response to the channel", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const wrapped = countSends(a); + const errors: unknown[] = []; + const router: Router = { + big: chain().handler(async () => "x".repeat(2000)), + }; + const srv = server(router, wrapped.ch, { + auth: { secret: () => psk }, + maxMessageBytes: 128, + onError: (error) => errors.push(error), + }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 300, + maxMessageBytes: 4096, + }); + try { + await expect(api.big({})).rejects.toMatchObject({ code: "TIMEOUT" }); + expect(wrapped.sends()).toBe(1); // handshake reply only + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "INVALID_DATA", + message: "Response exceeds maxMessageBytes", + }), + ]), + ); + } finally { + destroy(); + srv.destroy(); + } + }); +}); diff --git a/test/security/review-fixes-2026-07.test.ts b/test/security/review-fixes-2026-07.test.ts new file mode 100644 index 0000000..f15eafc --- /dev/null +++ b/test/security/review-fixes-2026-07.test.ts @@ -0,0 +1,262 @@ +/** + * Regression tests for the 2026-07 review fixes (findings #2, #3, #4, #6, #7). + * + * Each test is written to FAIL against the pre-fix code and pass after: + * #2 — a frame that passes Poly1305 but carries a junk inner payload still + * promotes the candidate (spec: promotion is proven by AEAD, not by + * the payload's shape). Pre-fix the decode error was conflated with a + * decrypt failure, so the candidate never promoted and expired. + * #3 — a SYNCHRONOUS auth callback that overruns handshakeTimeout installs + * nothing. Pre-fix the boolean expiry flag was still false when the + * microtask continuation resumed, so a candidate + reply went out. + * #4 — NaN/Infinity limits are rejected at construction. + * #6 — a failed handshake-reply send drops the candidate and reports ONE + * error, not a send-failure followed by a spurious timeout. + * #7 — middleware that returns without calling next() is rejected. + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { + chain, + client, + server, + createJWTServerAuth, + RPCError, + RemoteRPCError, + TAG_MSG, + type Channel, + type Router, +} from "../../src/index.ts"; +import { createChannelPair } from "../helpers/channels.ts"; +import { + manualHandshake, + forgeHello, + encryptRaw, +} from "../helpers/protocol.ts"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const router: Router = { ping: chain().handler(async () => "pong") }; + +/** Wrap a channel so we can count outbound frames (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, + }; +} + +const dummyChannel: Channel = { + send() {}, + receive() { + return () => {}; + }, +}; + +describe("review-fixes 2026-07", () => { + // ── #2 ──────────────────────────────────────────────────────────── + it("#2 a Poly1305-valid frame with a junk inner payload still promotes the candidate", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const errors: unknown[] = []; + const srv = server(router, a, { + auth: { secret: () => psk }, + handshakeTimeout: 100, + onError: (e) => errors.push(e), + }); + try { + // Drive the client half manually: sends hello, server installs a + // candidate armed with a ~<100 ms confirmation timer. + const sess = await manualHandshake(b, psk); + expect(sess.proofOk).toBe(true); + + // Immediately send an authenticated-but-junk frame (0xc1 = msgpack + // "never used"). It must promote the candidate before the timer fires. + b.send(encryptRaw(sess.sessionKey, new Uint8Array([0xc1]))); + + // Capture RPC responses on the now-live session. + const responses: unknown[] = []; + b.receive((d) => { + if (d[0] === TAG_MSG) responses.push(sess.decrypt(d)); + }); + + // Past the handshake budget: if the junk frame promoted correctly, the + // candidate timer was cleared and NO timeout fired. + await sleep(150); + const timeoutErr = errors.find( + (e) => e instanceof RPCError && /timeout/i.test(e.message), + ); + expect(timeoutErr).toBeUndefined(); + + // Positive proof the session is live: a real request gets answered. + b.send(sess.encrypt({ t: 1, id: "1", p: "ping", i: {} })); + await sleep(50); + expect( + responses.some( + (r) => + typeof r === "object" && + r !== null && + (r as Record)["ok"] === true && + (r as Record)["d"] === "pong", + ), + ).toBe(true); + } finally { + srv.destroy(); + } + }); + + // ── #3 ──────────────────────────────────────────────────────────── + it("#3 a synchronous auth.verify overrunning handshakeTimeout installs nothing", async () => { + const { a, b } = createChannelPair(); + const wrapped = countSends(a); + const errors: unknown[] = []; + const srv = server(router, wrapped.ch, { + auth: { + // Blocks the event loop synchronously, well past the 100 ms budget. + // The timer macrotask cannot fire during the block, so only the + // absolute-deadline check can catch this. + verify: () => { + const end = Date.now() + 160; + while (Date.now() < end) { + /* busy-wait */ + } + return {}; + }, + }, + handshakeTimeout: 100, + onError: (e) => errors.push(e), + }); + const { api, destroy } = client(b, { + auth: { sign: async () => new Uint8Array([1]) }, + handshakeTimeout: 500, + timeout: 1000, + }); + try { + const pCall = api.ping({}); + pCall.catch(() => {}); + await sleep(300); + + // The security property: the sync verify resumed AFTER the deadline, so + // the deadline guard bailed BEFORE installing a candidate or sending a + // reply — zero frames out. (Pre-fix the boolean flag was still false at + // that point, so a candidate + reply went out: sends() === 1.) + expect(wrapped.sends()).toBe(0); + + // With no server reply, the client's own handshakeTimeout fails the call. + const err = await pCall.catch((e: unknown) => e); + expect(err).toBeInstanceOf(RPCError); + expect((err as RPCError).code).toBe("HANDSHAKE"); + // The server never reported success and never leaked crypto state. + expect( + errors.every((e) => !(e instanceof RPCError) || e.code === "HANDSHAKE"), + ).toBe(true); + } finally { + destroy(); + srv.destroy(); + } + }); + + // ── #4 ──────────────────────────────────────────────────────────── + it("#4 NaN / Infinity limits are rejected at construction", () => { + expect(() => + client(dummyChannel, { + auth: { secret: () => randomBytes(32) }, + maxPending: NaN, + }), + ).toThrow(/maxPending/); + expect(() => + client(dummyChannel, { + auth: { secret: () => randomBytes(32) }, + maxMessageBytes: NaN, + }), + ).toThrow(/maxMessageBytes/); + expect(() => + server(router, dummyChannel, { + auth: { secret: () => randomBytes(32) }, + maxMessageBytes: Infinity, + }), + ).toThrow(/maxMessageBytes/); + expect(() => + createJWTServerAuth({ verifyToken: async () => ({}), maxAge: NaN }), + ).toThrow(/maxAge/); + expect(() => + createJWTServerAuth({ verifyToken: async () => ({}), maxAge: Infinity }), + ).toThrow(/maxAge/); + }); + + // ── #6 ──────────────────────────────────────────────────────────── + it("#6 a failed handshake-reply send drops the candidate and reports exactly one error", async () => { + const psk = randomBytes(32); + const errors: RPCError[] = []; + let serverCb: ((d: Uint8Array) => void) | null = null; + const throwingChannel: Channel = { + send() { + throw new Error("link down"); + }, + receive(cb) { + serverCb = cb; + return () => { + serverCb = null; + }; + }, + }; + const srv = server(router, throwingChannel, { + auth: { secret: () => psk }, + handshakeTimeout: 100, + onError: (e) => errors.push(e as RPCError), + }); + try { + // A valid PSK hello (verify not configured → no auth needed). The server + // derives a key, installs a candidate, then tries to send the reply, + // which throws. + serverCb!(forgeHello()); + await sleep(200); // well past handshakeTimeout + + // Exactly one error: the send failure. The candidate timer must have + // been cleared by dropCandidate, so NO spurious "Handshake timeout" + // follows. + expect(errors.length).toBe(1); + expect(errors[0]!.code).toBe("HANDSHAKE"); + expect(errors[0]!.message).toMatch(/send failed/i); + // The ORIGINAL transport error object rides err.cause (constructor + // options, 4th RPCError argument) — not err.data, not stringified. + expect(errors[0]!.cause).toBeInstanceOf(Error); + expect((errors[0]!.cause as Error).message).toBe("link down"); + expect(errors[0]!.data).toBeNull(); + } finally { + srv.destroy(); + } + }); + + // ── #7 ──────────────────────────────────────────────────────────── + it("#7 middleware that returns without calling next() is rejected", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const noNextRouter: Router = { + skip: chain() + // Returns a value but never calls next() — the handler must not run + // and the client must not observe a success. + .use(async () => "skipped" as unknown as never) + .handler(async () => "ok"), + }; + const srv = server(noNextRouter, a, { auth: { secret: () => psk } }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + const err = await api.skip({}).catch((e: unknown) => e); + expect(err).toBeInstanceOf(RemoteRPCError); + expect((err as RemoteRPCError).code).toBe("MIDDLEWARE"); + } finally { + destroy(); + srv.destroy(); + } + }); +}); diff --git a/test/security/review-round2-fixes-2026-07-15.test.ts b/test/security/review-round2-fixes-2026-07-15.test.ts new file mode 100644 index 0000000..a0d1e59 --- /dev/null +++ b/test/security/review-round2-fixes-2026-07-15.test.ts @@ -0,0 +1,184 @@ +/** + * Regressions for porter-audit round 2 (2026-07-15). + * + * #6a — candidate promotion checks the absolute deadline, not just the + * timer. A confirming frame arriving after the budget elapsed while + * the loop was busy must NOT promote an expired candidate. + * #6b — one handshake attempt reports at most one onError: a timeout + * followed by a late-rejecting async auth callback must not double up. + * #9 — a genuine server response reflected back to the server (same session + * key, passes Poly1305) is dropped by the direction guard BEFORE its + * nonce is recorded, so it cannot consume a replay-window slot. + */ +import { describe, it, expect } from "vitest"; +import { randomBytes } from "@noble/ciphers/utils.js"; +import { + chain, + client, + server, + RPCError, + type CallOptions, + type Router, +} from "../../src/index.ts"; +import { + createChannelPair, + createMitmChannelPair, +} from "../helpers/channels.ts"; +import { manualHandshake } from "../helpers/protocol.ts"; + +type LooseApi = Record< + string, + (input?: unknown, opts?: CallOptions) => Promise +>; + +const sleep = (ms: number): Promise => + new Promise((r) => setTimeout(r, ms)); + +describe("round2 / #6a candidate promotion honours the absolute deadline", () => { + it("a confirming frame that arrives after the budget (loop starved) does not promote an expired candidate", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + let invocations = 0; + const errors: unknown[] = []; + const router: Router = { + starve: chain().handler(async () => { + invocations++; + return "ran"; + }), + }; + const srv = server(router, a, { + auth: { secret: () => psk }, + handshakeTimeout: 100, + onError: (e) => { + errors.push(e); + }, + }); + try { + // Drive the client side manually so we control WHEN the confirming + // frame is delivered. After this resolves the server has installed a + // candidate with deadline = hello-receipt + 100 ms. + const start = Date.now(); + const session = await manualHandshake(b, psk); + expect(session.proofOk).toBe(true); + + // Starve the event loop synchronously past the candidate deadline. The + // candidate timer (a macrotask due at ~100 ms) cannot fire while we + // block, so only the wall-clock check in the promotion path can catch + // the expiry. + const until = start + 160; + while (Date.now() < until) { + /* busy-wait — deliberately block the loop */ + } + + // Deliver the confirming request frame synchronously. The server's + // trial-decrypt + deadline check run before any await, so promotion is + // decided in this call. + b.send(session.encrypt({ t: 1, id: "req1", p: "starve" })); + await sleep(40); + + // Expired candidate must not be promoted → handler never runs. + expect(invocations).toBe(0); + // The overdue candidate timer still reports the timeout. + expect( + errors.some( + (e) => + e instanceof RPCError && + e.code === "HANDSHAKE" && + /timeout/i.test(e.message), + ), + ).toBe(true); + } finally { + srv.destroy(); + } + }); +}); + +describe("round2 / #6b one onError per handshake attempt", () => { + it("a timeout followed by a late-rejecting auth callback reports exactly once", async () => { + const psk = randomBytes(32); + const { a, b } = createChannelPair(); + const errors: unknown[] = []; + const router: Router = { + ping: chain().handler(async () => "pong"), + }; + const srv = server(router, b, { + auth: { + // Rejects at 300 ms — well past the 100 ms budget. Before the fix + // this produced a second "Handshake failed" on top of the timeout. + secret: async () => { + await sleep(300); + throw new Error("late auth failure"); + }, + }, + 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(() => {}); + + await sleep(400); // past both the 100 ms timeout and the 300 ms rejection + expect(errors.length).toBe(1); + expect(errors[0]).toBeInstanceOf(RPCError); + expect((errors[0] as RPCError).message).toMatch(/timeout/i); + + await pCall.catch(() => {}); + } finally { + destroy(); + srv.destroy(); + } + }); +}); + +describe("round2 / #9 reflected server response cannot consume a replay slot", () => { + it("reflecting a genuine server response does not evict a recorded request nonce", async () => { + const psk = randomBytes(32); + const { a, b, mitm } = createMitmChannelPair(); + let execCount = 0; + const router: Router = { + bump: chain().handler(async () => ({ n: ++execCount })), + }; + // Window of 1: a single spurious slot occupant would evict the request. + const srv = server(router, a, { + auth: { secret: () => psk }, + replayWindow: 1, + }); + const { api, destroy } = client(b, { + auth: { secret: () => psk }, + timeout: 1000, + }); + try { + await api.bump({}); // exec 1; request nonce recorded (window full) + + const reqFrame = mitm.state.captures + .filter((c) => c.dir === "BtoA" && c.data[0] === 0x01) + .pop()!.data; + const respFrame = mitm.state.captures + .filter((c) => c.dir === "AtoB" && c.data[0] === 0x01) + .pop()!.data; + + // Reflect the genuine server response back to the server. It passes + // Poly1305 (shared key) but is a response, not a request (t !== 1). + mitm.injectToA(respFrame); + await sleep(40); + expect(execCount).toBe(1); // a response never runs a handler + + // Replay the original request. If the reflected response had taken the + // single slot, the request nonce would be evicted and this would + // re-execute. With the fix the slot is intact → dropped. + mitm.injectToA(reqFrame); + await sleep(40); + expect(execCount).toBe(1); + } finally { + destroy(); + srv.destroy(); + } + }); +}); diff --git a/test/unit/auth-server.test.ts b/test/unit/auth-server.test.ts index 630e937..246e5d0 100644 --- a/test/unit/auth-server.test.ts +++ b/test/unit/auth-server.test.ts @@ -9,13 +9,12 @@ import { describe, it, expect } from "vitest"; import { ed25519 } from "@noble/curves/ed25519.js"; import { sha256 } from "@noble/hashes/sha2.js"; +import { encode as rawEncode, ExtData } from "@msgpack/msgpack"; import { createJWTServerAuth, createEd25519ServerAuth, createECDSAServerAuth, - createCertificateServerAuth, - createMultifactorServerAuth, createJWTClientAuth, createEd25519ClientAuth, createECDSAClientAuth, @@ -75,6 +74,24 @@ describe("createJWTServerAuth", () => { }); }); + it("rejects an auth payload with an absent or unknown profile version", async () => { + const helper = createJWTServerAuth({ verifyToken: async () => ({}) }); + // No `v` at all. + await expect( + helper.verify!( + mpEncode({ jwt: "x", ts: Date.now(), th: sha256(transcript) }), + transcript, + ), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + // A future/unknown version the verifier was not written for. + await expect( + helper.verify!( + mpEncode({ v: 2, jwt: "x", ts: Date.now(), th: sha256(transcript) }), + transcript, + ), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + it("rejects a missing JWT field", async () => { const helper = createJWTServerAuth({ verifyToken: async () => ({}) }); const proof = mpEncode({ @@ -205,6 +222,66 @@ describe("createJWTServerAuth", () => { }); }); +// ─── Auth payload sanitization (shared decodeAuthPayload gate) ─── +// +// The decode gate is shared by all three server helpers; the JWT helper +// stands in for all of them. Protocol § Sanitization applies to auth +// payloads in full — including fields a profile ignores — so a payload +// smuggling an unknown msgpack ext type or over-deep nesting in an extra +// field must be rejected even when every validated field is well-formed. + +describe("auth payload sanitization", () => { + const validFields = () => ({ + v: 1, + jwt: "jwt-1", + ts: Date.now(), + th: sha256(transcript), + }); + const helper = () => + createJWTServerAuth({ verifyToken: async () => ({ sub: "u" }) }); + + it("rejects an unknown msgpack ext type in an ignored extra field", async () => { + const proof = rawEncode( + { ...validFields(), extra: new ExtData(42, new Uint8Array([1, 2, 3])) }, + { useBigInt64: true }, + ); + await expect(helper().verify!(proof, transcript)).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + it("rejects nesting beyond MAX_DEPTH in an ignored extra field", async () => { + const deep: Record = {}; + let cur = deep; + for (let i = 0; i < 40; i++) { + const next: Record = {}; + cur["x"] = next; + cur = next; + } + const proof = rawEncode( + { ...validFields(), extra: deep }, + { useBigInt64: true }, + ); + await expect(helper().verify!(proof, transcript)).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + it("maps a __proto__ map key to UNAUTHORIZED, not an unhandled throw", async () => { + // The codec itself bans `__proto__` keys (first line of defense; + // `sanitize` strips constructor/prototype as depth-in-depth). The + // decode gate must surface that as a clean UNAUTHORIZED. + const proof = rawEncode( + { ...validFields(), ["__proto__"]: { polluted: true } }, + { useBigInt64: true }, + ); + await expect(helper().verify!(proof, transcript)).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + expect(({} as Record)["polluted"]).toBeUndefined(); + }); +}); + // ─── createEd25519ServerAuth ───────────────────────────────── describe("createEd25519ServerAuth", () => { @@ -403,341 +480,3 @@ describe("createECDSAServerAuth", () => { ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); }); }); - -// ─── createCertificateServerAuth ───────────────────────────── - -describe("createCertificateServerAuth", () => { - /** - * Build a happy-path config: verifyCertificate trusts any cert whose - * first byte is 0xAA and returns the given signing key as the bound - * subject key. Tests can override subject / pub on demand. - */ - function mkConfig(opts: { - publicKey: CryptoKey; - subject?: Record; - validateSubject?: (subject: Record) => boolean; - }) { - return { - verifyCertificate: async (certBytes: Uint8Array) => { - if (certBytes[0] !== 0xaa) throw new Error("bad cert chain"); - return { - subject: opts.subject ?? { cn: "alice@example.com" }, - publicKey: opts.publicKey, - }; - }, - validateSubject: opts.validateSubject, - }; - } - - async function signOver( - transcriptBytes: Uint8Array, - key: CryptoKey, - ): Promise { - const sig = await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - key, - transcriptBytes as BufferSource, - ); - return new Uint8Array(sig); - } - - it("accepts a valid cert + transcript signature", async () => { - const { privateKey, publicKey } = await generateECDSAKeypair(); - const server = createCertificateServerAuth(mkConfig({ publicKey })); - const proof = mpEncode({ - cert: new Uint8Array([0xaa, 0xbb, 0xcc]), - sig: await signOver(transcript, privateKey), - }); - const res = (await server.verify!(proof, transcript)) as { - auth: { subject: Record; verified: boolean }; - }; - expect(res.auth.verified).toBe(true); - expect(res.auth.subject.cn).toBe("alice@example.com"); - }); - - it("rejects a missing cert", async () => { - const { publicKey } = await generateECDSAKeypair(); - const server = createCertificateServerAuth(mkConfig({ publicKey })); - const proof = mpEncode({ sig: new Uint8Array(64) }); - await expect(server.verify!(proof, transcript)).rejects.toMatchObject({ - code: "UNAUTHORIZED", - }); - }); - - it("rejects an empty cert", async () => { - const { publicKey } = await generateECDSAKeypair(); - const server = createCertificateServerAuth(mkConfig({ publicKey })); - const proof = mpEncode({ - cert: new Uint8Array(0), - sig: new Uint8Array(64), - }); - await expect(server.verify!(proof, transcript)).rejects.toMatchObject({ - code: "UNAUTHORIZED", - }); - }); - - it("rejects a missing signature", async () => { - const { publicKey } = await generateECDSAKeypair(); - const server = createCertificateServerAuth(mkConfig({ publicKey })); - const proof = mpEncode({ cert: new Uint8Array([0xaa]) }); - await expect(server.verify!(proof, transcript)).rejects.toMatchObject({ - code: "UNAUTHORIZED", - }); - }); - - it("propagates verifyCertificate failures", async () => { - const { privateKey, publicKey } = await generateECDSAKeypair(); - const server = createCertificateServerAuth(mkConfig({ publicKey })); - const proof = mpEncode({ - cert: new Uint8Array([0x00]), // first byte ≠ 0xAA → verifyCertificate throws - sig: await signOver(transcript, privateKey), - }); - await expect(server.verify!(proof, transcript)).rejects.toThrow( - /bad cert chain/, - ); - }); - - it("rejects when validateSubject returns false", async () => { - const { privateKey, publicKey } = await generateECDSAKeypair(); - const server = createCertificateServerAuth( - mkConfig({ - publicKey, - subject: { cn: "denied@example.com" }, - validateSubject: (s) => s["cn"] === "alice@example.com", - }), - ); - const proof = mpEncode({ - cert: new Uint8Array([0xaa]), - sig: await signOver(transcript, privateKey), - }); - await expect(server.verify!(proof, transcript)).rejects.toMatchObject({ - code: "UNAUTHORIZED", - }); - }); - - it("rejects a signature that does not match the certificate's public key", async () => { - const wrongKey = await generateECDSAKeypair(); - const certKey = await generateECDSAKeypair(); - // Server says "this cert binds wrongKey.publicKey" but client signs with certKey.privateKey. - const server = createCertificateServerAuth( - mkConfig({ publicKey: wrongKey.publicKey }), - ); - const proof = mpEncode({ - cert: new Uint8Array([0xaa]), - sig: await signOver(transcript, certKey.privateKey), - }); - await expect(server.verify!(proof, transcript)).rejects.toMatchObject({ - code: "UNAUTHORIZED", - }); - }); - - it("rejects a signature over a different transcript (replay across handshakes)", async () => { - const { privateKey, publicKey } = await generateECDSAKeypair(); - const server = createCertificateServerAuth(mkConfig({ publicKey })); - const proof = mpEncode({ - cert: new Uint8Array([0xaa]), - sig: await signOver(transcript, privateKey), - }); - const other = new TextEncoder().encode("another-handshake-transcript"); - await expect(server.verify!(proof, other)).rejects.toMatchObject({ - code: "UNAUTHORIZED", - }); - }); -}); - -// ─── createMultifactorServerAuth ───────────────────────────── - -describe("createMultifactorServerAuth", () => { - it("merges both factors' auth into one (default combine)", async () => { - const { privateKey, publicKey } = await generateEd25519Keypair(); - const primary = createJWTServerAuth({ - verifyToken: async (jwt) => - jwt === "tok" ? { sub: "u", role: "user" } : null, - }); - const secondary = createEd25519ServerAuth({ - getPublicKey: async () => publicKey, - }); - - const mfa = createMultifactorServerAuth({ primary, secondary }); - - const jwtPayload = await createJWTClientAuth({ getToken: () => "tok" }) - .sign!(transcript); - const sigPayload = await createEd25519ClientAuth({ - privateKey, - deviceId: "dev", - }).sign!(transcript); - - const proof = mpEncode({ primary: jwtPayload, secondary: sigPayload }); - const res = (await mfa.verify!(proof, transcript)) as { - auth: Record; - }; - expect(res.auth["sub"]).toBe("u"); - expect(res.auth["role"]).toBe("user"); - expect(res.auth["deviceId"]).toBe("dev"); - expect(res.auth["verified"]).toBe(true); - expect(res.auth["multifactor"]).toBe(true); - }); - - it("uses combineAuth when provided", async () => { - const { privateKey, publicKey } = await generateEd25519Keypair(); - const primary = createJWTServerAuth({ - verifyToken: async () => ({ sub: "u" }), - }); - const secondary = createEd25519ServerAuth({ - getPublicKey: async () => publicKey, - }); - const mfa = createMultifactorServerAuth({ - primary, - secondary, - combineAuth: (p, s) => ({ - principal: (p as { sub: string }).sub, - device: (s as { deviceId: string }).deviceId, - mfa: "2fa-strong", - }), - }); - - const jwtPayload = await createJWTClientAuth({ getToken: () => "x" }).sign!( - transcript, - ); - const sigPayload = await createEd25519ClientAuth({ - privateKey, - deviceId: "dev-mfa", - }).sign!(transcript); - const proof = mpEncode({ primary: jwtPayload, secondary: sigPayload }); - - const res = (await mfa.verify!(proof, transcript)) as { - auth: Record; - }; - expect(res.auth["principal"]).toBe("u"); - expect(res.auth["device"]).toBe("dev-mfa"); - expect(res.auth["mfa"]).toBe("2fa-strong"); - // combineAuth replaces the default merge — `multifactor: true` must NOT be auto-added - expect(res.auth["multifactor"]).toBeUndefined(); - }); - - it("constructor rejects non-function primary/secondary verify", () => { - expect(() => - createMultifactorServerAuth({ - primary: {}, - secondary: { verify: () => undefined }, - }), - ).toThrow(TypeError); - expect(() => - createMultifactorServerAuth({ - primary: { verify: () => undefined }, - // @ts-expect-error — deliberately bad shape - secondary: { verify: "nope" }, - }), - ).toThrow(TypeError); - }); - - it("rejects when primary factor bytes are missing", async () => { - const primary = { verify: async () => ({ auth: {} }) }; - const secondary = { verify: async () => ({ auth: {} }) }; - const mfa = createMultifactorServerAuth({ primary, secondary }); - const proof = mpEncode({ secondary: new Uint8Array([1, 2, 3]) }); - await expect(mfa.verify!(proof, transcript)).rejects.toMatchObject({ - code: "UNAUTHORIZED", - }); - }); - - it("rejects when secondary factor bytes are missing", async () => { - const primary = { verify: async () => ({ auth: {} }) }; - const secondary = { verify: async () => ({ auth: {} }) }; - const mfa = createMultifactorServerAuth({ primary, secondary }); - const proof = mpEncode({ primary: new Uint8Array([1, 2, 3]) }); - await expect(mfa.verify!(proof, transcript)).rejects.toMatchObject({ - code: "UNAUTHORIZED", - }); - }); - - it("rejects when either factor is empty bytes", async () => { - const primary = { verify: async () => ({ auth: {} }) }; - const secondary = { verify: async () => ({ auth: {} }) }; - const mfa = createMultifactorServerAuth({ primary, secondary }); - - const proofA = mpEncode({ - primary: new Uint8Array(0), - secondary: new Uint8Array([1]), - }); - await expect(mfa.verify!(proofA, transcript)).rejects.toMatchObject({ - code: "UNAUTHORIZED", - }); - - const proofB = mpEncode({ - primary: new Uint8Array([1]), - secondary: new Uint8Array(0), - }); - await expect(mfa.verify!(proofB, transcript)).rejects.toMatchObject({ - code: "UNAUTHORIZED", - }); - }); - - it("rejects if the primary verifier throws (and does not call secondary)", async () => { - let secondaryCalls = 0; - const primary = { - verify: async () => { - throw new RPCError("UNAUTHORIZED", "bad primary"); - }, - }; - const secondary = { - verify: async () => { - secondaryCalls++; - return { auth: {} }; - }, - }; - const mfa = createMultifactorServerAuth({ primary, secondary }); - const proof = mpEncode({ - primary: new Uint8Array([1]), - secondary: new Uint8Array([2]), - }); - await expect(mfa.verify!(proof, transcript)).rejects.toMatchObject({ - code: "UNAUTHORIZED", - }); - expect(secondaryCalls).toBe(0); - }); - - it("rejects if the secondary verifier throws (after primary OK)", async () => { - const primary = { verify: async () => ({ auth: { sub: "u" } }) }; - const secondary = { - verify: async () => { - throw new RPCError("UNAUTHORIZED", "bad secondary"); - }, - }; - const mfa = createMultifactorServerAuth({ primary, secondary }); - const proof = mpEncode({ - primary: new Uint8Array([1]), - secondary: new Uint8Array([2]), - }); - await expect(mfa.verify!(proof, transcript)).rejects.toMatchObject({ - code: "UNAUTHORIZED", - }); - }); - - it("forwards the *same* transcript bytes to both verifiers", async () => { - let pTr: Uint8Array | null = null; - let sTr: Uint8Array | null = null; - const primary = { - verify: async (_proof: Uint8Array, t: Uint8Array) => { - pTr = t; - return { auth: {} }; - }, - }; - const secondary = { - verify: async (_proof: Uint8Array, t: Uint8Array) => { - sTr = t; - return { auth: {} }; - }, - }; - const mfa = createMultifactorServerAuth({ primary, secondary }); - const proof = mpEncode({ - primary: new Uint8Array([1]), - secondary: new Uint8Array([2]), - }); - await mfa.verify!(proof, transcript); - expect(pTr).not.toBeNull(); - expect(sTr).not.toBeNull(); - expect(Array.from(pTr!)).toEqual(Array.from(transcript)); - expect(Array.from(sTr!)).toEqual(Array.from(transcript)); - }); -}); diff --git a/test/unit/psk-auth.test.ts b/test/unit/psk-auth.test.ts index 386293d..23b5f21 100644 --- a/test/unit/psk-auth.test.ts +++ b/test/unit/psk-auth.test.ts @@ -53,6 +53,18 @@ describe("deriveSessionSecret", () => { expect(() => deriveSessionSecret("", randomBytes(32))).toThrow(TypeError); }); + it("rejects all-zero secret material (#8)", () => { + // HKDF of zeros is non-zero, so the runtime empty-secret guard cannot + // catch it downstream — reject the zero input here, as the security + // guide documents. + expect(() => deriveSessionSecret("s", new Uint8Array(32))).toThrow( + TypeError, + ); + expect(() => deriveSessionSecret("s", new Uint8Array(64))).toThrow( + TypeError, + ); + }); + it("rejects a non-string sessionId", () => { expect(() => // @ts-expect-error — deliberately bad shape @@ -103,10 +115,20 @@ describe("isEmptySecret", () => { expect(isEmptySecret(buf)).toBe(false); }); - it("returns false for wrong-length buffers regardless of contents", () => { - expect(isEmptySecret(new Uint8Array(0))).toBe(false); - expect(isEmptySecret(new Uint8Array(16))).toBe(false); - expect(isEmptySecret(new Uint8Array(64))).toBe(false); + it("returns true for an all-zero buffer of any length", () => { + // An all-zero secret is empty regardless of size — a caller returning + // zeros must fail loudly, not slip through on a non-32 length. + expect(isEmptySecret(new Uint8Array(0))).toBe(true); + expect(isEmptySecret(new Uint8Array(16))).toBe(true); + expect(isEmptySecret(new Uint8Array(33))).toBe(true); + expect(isEmptySecret(new Uint8Array(64))).toBe(true); + expect(isEmptySecret(new Uint8Array(65))).toBe(true); + }); + + it("returns false for a non-zero buffer of a non-32 length", () => { + const buf = new Uint8Array(64); + buf[40] = 1; + expect(isEmptySecret(buf)).toBe(false); }); }); diff --git a/test/unit/sanitize.test.ts b/test/unit/sanitize.test.ts index 66e85cb..31cb6e8 100644 --- a/test/unit/sanitize.test.ts +++ b/test/unit/sanitize.test.ts @@ -25,6 +25,50 @@ describe("sanitize / primitives", () => { }); }); +describe("sanitize / bigint range (#4)", () => { + it("passes bigints within the msgpack 64-bit range", () => { + expect(sanitize(0n)).toBe(0n); + expect(sanitize(-(2n ** 63n))).toBe(-(2n ** 63n)); // int64 min + expect(sanitize(2n ** 64n - 1n)).toBe(2n ** 64n - 1n); // uint64 max + }); + + it("rejects a bigint above uint64 max with INVALID_DATA", () => { + // Without the guard, mpEncode(useBigInt64) silently wraps this to 0n. + try { + sanitize(2n ** 64n); + throw new Error("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(RPCError); + expect((err as RPCError).code).toBe("INVALID_DATA"); + } + }); + + it("rejects a bigint below int64 min with INVALID_DATA", () => { + expect(() => sanitize(-(2n ** 63n) - 1n)).toThrow(RPCError); + }); +}); + +describe("sanitize / nested undefined (#5)", () => { + it("drops undefined-valued object keys (matches top-level omission)", () => { + // A nested undefined would otherwise encode as nil and decode to null on + // the peer, breaking a `.optional()` (non-nullable) schema field. + const out = sanitize({ a: 1, b: undefined, c: "z" }) as Record< + string, + unknown + >; + expect(Object.prototype.hasOwnProperty.call(out, "b")).toBe(false); + expect(out).toEqual({ a: 1, c: "z" }); + }); + + it("an object with only undefined values sanitizes to an empty object", () => { + expect(sanitize({ x: undefined })).toEqual({}); + }); + + it("still returns top-level undefined as undefined", () => { + expect(sanitize(undefined)).toBeUndefined(); + }); +}); + describe("sanitize / arrays", () => { it("recurses into nested arrays", () => { expect(sanitize([1, [2, [3]]])).toEqual([1, [2, [3]]]); diff --git a/test/unit/typing.test.ts b/test/unit/typing.test.ts index 7b8e4c5..283799d 100644 --- a/test/unit/typing.test.ts +++ b/test/unit/typing.test.ts @@ -64,6 +64,15 @@ describe("saferpc() — runtime shape", () => { expect(() => rpc.router("nope" as unknown as Router)).toThrow(TypeError); }); + it('rpc.router() rejects the reserved route name "then"', () => { + // The client proxy hides `then` so it never looks thenable to `await`; + // a route by that name would be typed but uncallable. Reserved at + // creation instead. + expect(() => rpc.router({ then: greet } as unknown as Router)).toThrow( + /reserved/, + ); + }); + it("rpc.middleware() returns the function and rejects non-functions", () => { const fn = async ({ next }: { next: () => Promise }) => next(); expect(rpc.middleware(fn as never)).toBe(fn); diff --git a/test/unit/vectors.test.ts b/test/unit/vectors.test.ts index ae8beeb..30adc68 100644 --- a/test/unit/vectors.test.ts +++ b/test/unit/vectors.test.ts @@ -5,6 +5,8 @@ * DO NOT regenerate casually: a change here means a wire-protocol change. */ import { describe, it, expect } from "vitest"; +import { sha256 } from "@noble/hashes/sha2.js"; + import { deriveSessionKey, computeProof, @@ -14,6 +16,11 @@ import { createDecryptor, x25519, EMPTY_SECRET, + mpEncode, + createEd25519ClientAuth, + createJWTServerAuth, + createEd25519ServerAuth, + createECDSAServerAuth, } from "../../src/index.ts"; const h = (b: Uint8Array) => Buffer.from(b).toString("hex"); @@ -81,3 +88,92 @@ describe("KAT vectors match src implementation", () => { expect(msg["p"]).toBe("ping"); }); }); + +describe("KAT vectors — auth profile payloads", () => { + // All profiles bind to the hello transcript from the main vector set. + const c_priv = pat(0x01); + const c_nonce = pat(0x81); + const c_pub = x25519.getPublicKey(c_priv); + const transcript = buildHelloTranscript(1, c_pub, c_nonce); + + it("jwt profile: payload bytes + verifies through createJWTServerAuth", async () => { + const payload = mpEncode({ + v: 1, + jwt: "test.jwt.token", + ts: 1700000000000, // travels as msgpack float64 — see spec §msgpack profile + th: sha256(transcript), + }); + expect(h(payload)).toBe( + "84a17601a36a7774ae746573742e6a77742e746f6b656ea27473cb4278bcfe56800000a27468c420c76c6aaff8ac6c00e1168ffdafc87255a79eef052a4a7b39c542506a81010c9e", + ); + const server = createJWTServerAuth({ + verifyToken: async (t) => + t === "test.jwt.token" ? { sub: "vector" } : null, + maxAge: 1e13, // vector ts is fixed in the past; finite skew large enough forever + }); + const res = (await server.verify!(payload, transcript)) as { + auth: { sub: string }; + }; + expect(res.auth.sub).toBe("vector"); + }); + + it("ed25519 profile: helper reproduces payload bytes (RFC 8032 deterministic) + verifies", async () => { + const seed = pat(0x61); + const payload = await createEd25519ClientAuth({ + privateKey: seed, + deviceId: "device-1", + }).sign!(transcript); + expect(h(payload)).toBe( + "83a17601a86465766963654964a86465766963652d31a3736967c440c056e0893556d73576ab05fa9ef2314d16686f326905c3e1e1f0b2b10eb003f51a6a41aa2d1e14f737fdfeede47d7ecec84380d7e70733cd3579653db72c7105", + ); + const server = createEd25519ServerAuth({ + getPublicKey: async () => + fromHex( + "882d0ea3b2864e7a587f3e698cea4459998312e655e05fa5e8b5119d8baac8cd", + ), + }); + const res = (await server.verify!(payload, transcript)) as { + auth: { deviceId: string; verified: boolean }; + }; + expect(res.auth.deviceId).toBe("device-1"); + expect(res.auth.verified).toBe(true); + }); + + it("ecdsa profile: pinned RFC 6979 payload verifies through createECDSAServerAuth", async () => { + // WebCrypto signing is randomized; the KAT signature is the deterministic + // RFC 6979 lowS one. Byte-reproduction requires a deterministic signer; + // the normative check is that this payload VERIFIES against the pinned key. + const payload = fromHex( + "83a17601aa6964656e746966696572a8656e746974792d31a3736967c440383ae1bc960796f9ae710ffa7dc73cc8bdb7522567e0f5b2180f4a74cac0f68a00bea85c160d745e881050a72bdb9fbb4a03a2aba4c65dcf29c29dc319796b01", + ); + // Envelope construction is still byte-exact given the sig: + expect( + h( + mpEncode({ + v: 1, + identifier: "entity-1", + sig: fromHex( + "383ae1bc960796f9ae710ffa7dc73cc8bdb7522567e0f5b2180f4a74cac0f68a00bea85c160d745e881050a72bdb9fbb4a03a2aba4c65dcf29c29dc319796b01", + ), + }), + ), + ).toBe(h(payload)); + const publicKey = await crypto.subtle.importKey( + "raw", + fromHex( + "04ad137f7ef829eef8a8571bf4d307664ea8e024e05bda4e26da8f7ae884456058a88e48c9a1d9386471f13f2559758edc4bc1e11394eb415d63e2d33e4d38519d", + ), + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + const server = createECDSAServerAuth({ + getPublicKey: async () => publicKey, + }); + const res = (await server.verify!(payload, transcript)) as { + auth: { identifier: string; verified: boolean }; + }; + expect(res.auth.identifier).toBe("entity-1"); + expect(res.auth.verified).toBe(true); + }); +});