Dotex

About

Security assessment

An internal line-by-line review of the implementation against the Protocol and Security specs, published as-is. We would rather ship an honest list of residual risks than a clean-looking page. Threat model and configuration guidance live in Security; this page is about how well the code holds up against them.

Reviewed version: 0.7.0 · Date: 2026-07 · Reviewer: internal (Dotex), independent of the original author

Method

  • Full read of src/ (client, server, common, auth helpers) against every normative statement in spec/protocol.md and spec/security.md.
  • Full test suite run (266 tests, including test/security/: handshake attacks, replay, tampering, type confusion, prototype pollution, DoS limits, deferred reset, replay window, session continuity).
  • Instrumented probes for behavioral claims that the suite does not pin (execution counts, handshake counts under fault injection).

An internal review is not a third-party audit. It catches spec/code drift and design-level issues; it does not replace external cryptographic review. If you need the latter for a deployment, treat this page as the starting inventory.

What was verified and holds

Checked line-by-line against the spec, no findings:

  • Handshake ordering. verify runs before any session key material is derived on both sides; a failed verification never leaks ECDH artifacts. Every await in the handshake path is followed by an epoch + destroyed guard; module-level state is published only inside a synchronous block under a final guard.
  • Transcripts. Domain-separated magic prefixes, big-endian uint32 epoch, field order — byte-for-byte per spec. An active MITM cannot substitute either ephemeral key without invalidating the corresponding signature.
  • Key derivation and proof. HKDF(ikm=raw ECDH, salt=secret, info="saferpc-v1"); HMAC(session_key, s_pub‖c_pub‖c_nonce); proof compared in constant time. deriveSessionSecret matches its spec formula.
  • Low-order X25519 points. Rejected by @noble/curves, with a regression test (test/security/f002-low-order-x25519-pubkey.test.ts) pinning both halves of the contract: the library throws, and a forged hello carrying a low-order pub aborts the handshake before any session state exists.
  • Memory hygiene. Ephemeral keys and shared secrets zeroed in try/finally; in-flight handshake attempts own copies of key material, so a concurrent reset cannot corrupt a derivation. An application secret() returning 32 zero bytes is refused at runtime.
  • Sanitization. msgpack extension types rejected (including the hard-coded Timestamp), prototype-pollution keys stripped, non-plain objects rejected, recursion capped, inbound bin fields normalized to plain Uint8Array at the channel boundary.
  • Silent-drop policy. Bad tags, oversized frames, Poly1305 failures, and malformed RPC shapes are dropped without feedback, as specified.
  • Constant-time comparisons everywhere the spec requires them, including the JWT helper’s transcript digest check.

Residual risks

Ordered by how much they should influence a deployment decision. “By design” means the trade-off is deliberate and documented; “fixed in 0.7.0” means the 0.7.0 release closed it (see the per-risk status).

1. Replay within a session — narrowed in 0.7.0, residual by design

Per-message nonces are random, not counter-derived. An attacker who can inject into a live channel can replay a captured ciphertext; without a dedup window the receiver executes it again. Full discussion in Security § Replay within a session.

Status: fixed (narrowed) in 0.7.0. The server keeps a bounded seen-nonce set (replayWindow, default 4096) and silently drops any frame whose nonce it has already accepted in the current session — the nonce is recorded only after Poly1305 verifies, and the set is cleared on re-handshake, with no wire change. Residual by design: a replay older than the last replayWindow accepted messages still executes (the window is narrowed to N, not closed), so non-idempotent handlers on very long sessions should still carry idempotency keys. Counter-based nonces (which would close it fully) require directional keys and are deferred to a future protocol version.

2. Auto-retry double-execution — fixed in 0.7.0

Before 0.7.0 the client’s transparent retry fired on any local error, including the CLIENT backpressure error, and — more fundamentally — resent a call after a TIMEOUT whose outcome is unknown. An instrumented probe reproduced a single backpressure error on a healthy session tearing the session down and re-executing every in-flight call, callers observing clean success while handlers ran twice.

Status: fixed in 0.7.0. The auto-retry is removed entirely. A TIMEOUT or CHANNEL send error resets the session but is not resent — the error surfaces with its typed code and the caller decides whether to retry (a TIMEOUT is never blind-resent because the request may have executed). Guardrail (CLIENT) and RemoteRPCError never reset the session. See Protocol § Failure handling.

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.

Residual by design (denial of service, out of threat model): each hello still costs the server an ECDH plus an optional verify, and because the latest hello wins the candidate slot, a flood of hellos can keep overwriting the candidate and starve a legitimate peer’s reconnect before its confirming frame lands. This does not touch an already-live session (make-before-break protects it), but it can delay a genuine re-handshake. Rate-limit hellos in your channel adapter on exposed transports (on BroadcastChannel / postMessage, any same-origin code can still force handshake work). A cheap partial mitigation — dropping byte-identical duplicate hellos via a small recent-transcript-hash cache before signature verification — is possible but not shipped. Do not put Safe RPC on a shared-origin bus you do not fully control.

3b. Weak-secret proof oracle — documented, pre-existing

On any well-formed hello the server returns proof = HMAC(HKDF(raw, salt=secret), …) before the client authenticates. An attacker who completes an ECDH with their own ephemeral knows raw and receives the proof, leaving the secret as the only unknown — which they can then brute-force offline. Infeasible against a random 32-byte key (2²⁵⁶); a fast dictionary attack against a password-derived secret. This is a property of the scheme (PSK as HKDF salt, server-first proof), not a code defect, and is unchanged by make-before-break. Mitigation is documentation: the secret must be a CSPRNG key, not a passphrase — see Security § The secret is a key, not a password.

3c. Core outbound queue — head-of-line blocking bounded by sendTimeout

The client core now owns the outbound queue; adapters have no internal queues. A frame that fails to send (the adapter threw) enters the core queue and is retried every 250 ms. The queue is head-of-line: if the channel is down, no later frame can pass a stuck earlier one. The bound is sendTimeout (default 10 s) — every queued frame fails with a definite RPCError("CHANNEL") if a live channel does not appear within that window. The stale-queued-hello residual (a timed-out hello flushing after handshake timeout) is gone: the core revokes queued hellos when the handshake attempt expires.

4. No server-side concurrency cap — documented, application concern

The server has no per-request timeout and no cap on concurrent handler executions (consistent with tRPC/oRPC). An authenticated client can accumulate hanging closures by calling a slow procedure repeatedly. Bound concurrency in your handlers or in front of the server if your peers are not fully trusted.

5. One session key for both directions — design note

Traffic in both directions is encrypted under the same session key; there are no directional keys (k_c2s / k_s2c). A reflected frame therefore decrypts successfully and is rejected one layer up, by the message-type field (t: 1 requests vs t: 2 responses). This holds, and the shape check is covered by tests — but it is a weaker line of defense than directional keys, and a port that forgets the t check loses reflection protection entirely. Flagged for a future protocol version.

6. JWT mode is bearer-token auth — by design, documented

The transcript digest binds a captured auth payload to its handshake, so it cannot be replayed into a new one. It does not protect against theft of the JWT itself: anyone holding the token can open fresh handshakes until it expires. Documented in Security § JWT; combine with a PSK or a signature mode when this matters.

7. The curve dependency pin is load-bearing — documented, pinned by test

Low-order point rejection is delegated to @noble/curves. A future dependency release that relaxed the check would re-open the MITM attack against asymmetric-only deployments described in Security § Ephemeral key validity. The regression test exists precisely so this cannot happen silently — do not remove it, and re-run the suite on every dependency bump.

Reporting a vulnerability

Found something not on this list? Report it privately via GitHub Security Advisories rather than a public issue. We will credit reporters in the release notes unless asked otherwise.