Skip to content

v0.8.0

Latest

Choose a tag to compare

@github-actions github-actions released this 16 Jul 17:00

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.

Published to npm: https://www.npmjs.com/package/@dotex/saferpc/v/0.8.0