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 withRPCAbortedErrorand leaves
the outcome UNKNOWN — the caller decides whether replaying is safe. A plain
localRPCErrormeans the request provably never left. - BREAKING — deferred reset. The session resets only on a sent call that
getsRPCAbortedError("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-callAbortSignal.- 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
sendTimeout10s → 3s for faster failure detection of an
unsent frame. - Port-complete spec + first KAT vectors.
spec/protocol.md,
spec/security.mdassessment, 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
candidateDeadlineon install and checks it beforepromoteCandidate();
companion fix drops a double-onErrorreport on one failed attempt (reported
flag). - Synchronous auth callbacks can no longer overrun
handshakeTimeout. Timer +
boolean let a synchronousverify/secret/signwin the race (post-await
continuation is a microtask, runs before the timer). Now an absoluteDate.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. maxPendingHandshakescaps 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
candidateEpochstill 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; past0xffffffffevery hello would be
silently dropped server-side asInvalid epoch, surfacing only as opaque
timeouts.startHandshakenow rejects withRPCError("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.isEmptySecretnow rejects any-length all-zero buffers;
deriveSessionSecretthrowsTypeError("secret must not be all-zero").
Replay / framing
- Reflected
t: 2frames 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_MAXexported. - Cyclic outbound graphs rejected (
WeakSetdetection) instead of recursing to
a stack overflow; non-cyclic repeated references still rebuilt independently. NaN/Infinityno longer disable limits.maxPending,
maxMessageBytes, JWTmaxAgevalidated as finite integers at construction.
maxMessageBytes: NaNused to makelen > maxalways false — trivially reached
viaNumber(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 itsnext()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 toreturn next()?" — which uses a
runtime envelope/marker rather than settlement tracking). A synchronous
downstream throw is normalized into a rejectednext()promise so a
catch-fallback middleware is not misclassified. The detached downstream
promise is still observed internally, so its rejection can never surface as
anunhandledRejectionand terminate the process.
Auth payloads
- Auth payloads pass the full
sanitize()gate and are version-checked; malformed
or unversioned payloads rejected asUNAUTHORIZEDbefore reaching appverify.
Added
- Normative auth profiles: JWT, Ed25519, ECDSA (P-256). Each has a versioned
wire schema (vfield, unknown version rejected server-side) and published KAT
vectors inspec/protocol.md/test/unit/vectors.test.ts— cross-language
ports interoperate byte-for-byte. maxPendingHandshakesserver option.BIGINT_MIN/BIGINT_MAXexports.- 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.jsondeclared a range the build
and@noble/hashes2.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 matchesserver.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 customverify;spec/security.md
documents certificate and two-factor-with-binding recipes.
Fixed
- Nested
undefinedin 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/garbagec/m) dropped or coerced
strictly per spec.
Dependencies
puppeteermoved todevDependencies— was inoptionalDependencies
(installs by default for consumers), draggingws(high/moderate) andjs-yaml
(moderate) into productionnpm audit. Production audit now clean.wsbumped 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.
Published to npm: https://www.npmjs.com/package/@dotex/saferpc/v/0.8.0