Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ node_modules/
/*.js.map
/build
*.tgz
review-*
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ npm install @dotex/saferpc
![Safe RPC](banner.png)

- **Full docs and rationale:** <https://dotex.org/epic/saferpc>
- [Quickstart](./spec/getting-started.md) · [API](./spec/api.md) · [Wire Protocol](./spec/protocol.md) · [Security](./spec/security.md) · [Transports](./spec/integrations.md)
- [Quickstart](./spec/getting-started.md) · [API](./spec/api.md) · [Wire Protocol](./spec/protocol.md) · [Security](./spec/security.md) · [Assessment](./spec/assessment.md) · [Transports](./spec/integrations.md)

## Highlights

- **Typed procedures** with Zod input/output validation
- **End-to-end encryption.** X25519 ECDH, XSalsa20-Poly1305 AEAD, HKDF-SHA-256, with forward secrecy by design
- **Lazy handshake** on the first call. Transparent auto-retry when the session drops
- **Lazy handshake** on the first call. Lazy re-handshake on the next call when the session drops — failures surface with a typed code, never silently resent
- **Three auth modes:** pre-shared secret, asymmetric (Ed25519 / ECDSA / JWT / cert / multifactor), or both for defense-in-depth
- **Synchronous** `client()` and `server()`. Runs in Node.js, browsers, Service Workers, React Native, Vercel Edge, Cloudflare Workers, Deno Deploy
- **Tiny surface.** `@noble/*` crypto, `@msgpack/msgpack`, `zod`, and nothing else
Expand Down Expand Up @@ -65,7 +65,7 @@ const { api, destroy: stopClient } = client<AppRouter>(clientChannel, { auth });
const { message } = await api.greet({ name: "World" }); // input & output typed
```

`client()` and `server()` are synchronous. No top-level `await`. The handshake runs lazily on the first procedure call. If the session drops, the next call retries once with a fresh handshake.
`client()` and `server()` are synchronous. No top-level `await`. The handshake runs lazily on the first procedure call. If the session drops, the first sent call to hit its reply-timeout surfaces `RPCAbortedError("TIMEOUT")` and resets the session — the next call re-handshakes lazily. A call that provably never left surfaces a plain `RPCError` and resets nothing: never-left is a transport event, the keys are fine, retrying is safe. The client never silently resends either way (that would double-execute non-idempotent handlers). Transport death alone doesn't touch the session: a reconnecting adapter (see `@dotex/saferpc/channels`) lets in-flight calls complete across a socket blip. Per-call `AbortSignal` (`api.thing(input, { signal })`) cancels waiting without tearing anything down.

Because `rpc` carries `Context`, procedures can be authored in any file with a fully-typed `ctx`, and `server()` requires a `context()` that returns the type your procedures expect.

Expand Down Expand Up @@ -107,15 +107,20 @@ Built-in helpers cover Ed25519, ECDSA P-256, JWT, certificate-based, and multifa
## Errors

```typescript
import { RPCError, RemoteRPCError } from "@dotex/saferpc";
import { RPCError, RPCAbortedError, RemoteRPCError } from "@dotex/saferpc";

try {
await api.greet({ name: "World" });
} catch (err) {
if (err instanceof RemoteRPCError) {
// The remote peer threw. err.code / err.message / err.data come from there.
} else if (err instanceof RPCAbortedError) {
// The request WAS sent but the outcome is unknown (TIMEOUT, ABORTED,
// SESSION) — it may have executed. Reconcile state before retrying.
} else if (err instanceof RPCError) {
// Local failure: TIMEOUT, SESSION, HANDSHAKE, INPUT_VALIDATION, ...
// Local failure, the request never left the process: CHANNEL, ABORTED,
// SESSION, HANDSHAKE, CLIENT, ... — safe to retry as-is.
// (validation errors like INPUT_VALIDATION run server-side and arrive as RemoteRPCError)
} else {
throw err;
}
Expand All @@ -128,7 +133,7 @@ try {
src/
common.ts : shared types, crypto, msgpack, procedure builder
server.ts : resilient handshake server
client.ts : lazy handshake client with auto-retry
client.ts : lazy handshake client (no auto-retry)
auth/
index.ts : combined re-exports (deriveSessionSecret + client + server)
client.ts : Ed25519, ECDSA, JWT client helpers
Expand All @@ -143,7 +148,10 @@ import { server } from "@dotex/saferpc/server";
import { client } from "@dotex/saferpc/client";
import { saferpc, RPCError } from "@dotex/saferpc/common";
// Auth helpers: combined or split per side
import { createEd25519ClientAuth, createEd25519ServerAuth } from "@dotex/saferpc/auth";
import {
createEd25519ClientAuth,
createEd25519ServerAuth,
} from "@dotex/saferpc/auth";
import { createEd25519ClientAuth } from "@dotex/saferpc/auth/client";
import { createEd25519ServerAuth } from "@dotex/saferpc/auth/server";
```
Expand All @@ -154,7 +162,7 @@ Node.js 18+, modern browsers, Service / Web / Shared Workers, React Native, Verc

## Project status

`0.x` with a stable wire protocol (`saferpc-v1` HKDF info, `saferpc-hs-{hello,reply}-v1` transcript prefixes). Test coverage for handshake attacks, replay, tampering, type confusion, prototype pollution, middleware misuse, and DoS limits lives in `test/security/`. A 1.0 release will lock the public API surface.
`0.x` with a stable wire protocol (`saferpc-v1` HKDF info, `saferpc-hs-{hello,reply}-v1` transcript prefixes). Test coverage for handshake attacks, replay, tampering, type confusion, prototype pollution, middleware misuse, and DoS limits lives in `test/security/`. An internal line-by-line security review — including the honest list of residual risks and open issues — is published in [spec/assessment.md](./spec/assessment.md). A 1.0 release will lock the public API surface.

## Releasing

Expand Down
116 changes: 116 additions & 0 deletions handshake-continuity-question.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Design review request: session-continuity property in a lazy handshake

I'm the author of an open-source end-to-end-encrypted RPC library. I'd like a
design critique of one lifecycle property in my handshake state machine. No code
needed — this is a protocol-design question about which state transition should
carry an authentication guarantee.

## System under review

A peer-to-peer RPC library. Two endpoints share a byte channel. Payloads are
encrypted at the application layer (X25519 ECDH → HKDF-SHA-256 → XSalsa20-Poly1305
AEAD), independent of whatever transport sits underneath.

The handshake is **one round-trip and lazy** — nothing goes on the wire until the
first RPC call:

1. The initiator sends a `hello`:
`{ epoch, pub_i, nonce_i, signature? }`
where `epoch` is a per-attempt counter, `pub_i` is a fresh ephemeral X25519
public key, `nonce_i` is random, and `signature` (optional) covers the
**hello transcript**:
`transcript = DOMAIN_TAG || epoch || pub_i || nonce_i`.

2. The responder derives the session key:
`key = HKDF(ikm = ECDH(priv_r, pub_i), salt = PSK, info = ...)`,
computes an HMAC proof over `(pub_r, pub_i, nonce_i)`, and replies with
`{ pub_r, proof, epoch, signature? }`.

3. The initiator recomputes the same key, checks the proof, and is ready.

4. The responder does **not** consider the session live yet. It becomes live only
when the **first encrypted frame decrypts and authenticates** under the new
key. Producing a valid AEAD frame is the implicit proof that the sender
actually holds the key material.

Two authentication modes, independently or together:

- **Shared-secret mode:** a pre-shared key (PSK) is folded into HKDF. Nothing in
the `hello` itself is signed; possession of the PSK is proven only in step 4.
- **Signature mode:** the initiator signs the hello transcript. The verifier is
**stateless** (e.g. Ed25519 / ECDSA over the transcript bytes) — it has no
server-contributed value in the transcript, so it depends only on
initiator-chosen fields (`epoch`, `pub_i`, `nonce_i`).

## The property I want to hold

> An established, live session must only be **superseded** by a counterparty that
> proves possession of the private key material (`priv_i` or the PSK). A
> **duplicate or stale `hello`** — the same bytes the library already processed
> once — must not be able to retire the live session.

Rationale: a duplicate `hello` carries no new proof of key possession. Whoever
re-sends it does not thereby demonstrate they can participate in the session, so
it should not be allowed to end the working one. This is a **liveness /
continuity** property, not confidentiality.

## Current design and where I think it's wrong

Today the responder treats every incoming `hello` as a fresh handshake *attempt*
on attempt-local state, and — importantly — the live session keeps serving while
the attempt runs. The old session is retired and replaced at the **publish step**,
which happens **right after the signature verifies and the key is derived** (i.e.
after step 2), **before** the new session is confirmed by step 4.

My concern: in signature mode the verifier is stateless over initiator-only
fields, so a **byte-for-byte duplicate `hello` verifies again**. That reaches the
publish step and retires the live session — yet the new session can never reach
step 4, because the party re-sending the duplicate does not hold `priv_i` and so
cannot derive the key or produce a valid encrypted frame. Net effect: a repeated
`hello` ends the working session and replaces it with one that is never
confirmed. That's a continuity regression, and I reproduced it: after a duplicate
`hello`, the next legitimate call on the original session fails with a timeout.

The same reasoning applies in shared-secret mode: nothing in the `hello` proves
PSK possession, so a well-formed `hello` reaches publish and supersedes the live
session there too.

## Proposed fix: promote on first authenticated frame ("make-before-break")

Keep **both** session keys alive concurrently:

- The current (old) key stays live and keeps decrypting ongoing traffic.
- A new `hello` derives a **candidate** key but does **not** retire the old one.
- Incoming encrypted frames are tried against the current key first.
- The candidate is promoted — and the old key retired — **only when the first
frame decrypts and authenticates under the candidate key** (step 4 applied to
the candidate).
- Unconfirmed candidates expire on a timer, leaving the live session untouched.

The claim is that step 4 is the real authentication of the counterparty:
producing a valid AEAD frame under the candidate key requires either `priv_i`
(for the ECDH) or the PSK, neither of which a party re-sending a captured/stale
`hello` possesses. So a duplicate `hello` can create a candidate that never
confirms, but can never retire the live session.

## Questions

1. Does "promote only on the first authenticated frame under the candidate key"
fully establish the continuity property in **both** modes (shared-secret and
stateless-signature)? Where is the reasoning load-bearing, and where could it
leak?

2. What does make-before-break **not** cover? (e.g. resource cost of holding
candidate state; concurrent candidates; interaction with the per-session
nonce-dedup window that is cleared on key change.)

3. Is adding a **responder-contributed freshness value** to the hello transcript
(so signatures aren't replayable at all) strictly better than, or complementary
to, make-before-break? What round-trip cost does that impose on a design whose
whole point is a single lazy round-trip, and is there a way to get freshness
without a second round-trip?

4. Is there a cleaner framing of the underlying rule — something like "a session
transition that ends a live session must be gated on proof-of-key-possession,
never on a signature over counterparty-chosen fields alone"? Does that
generalize, or are there transitions where it's too strong?
136 changes: 136 additions & 0 deletions handshake-continuity-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Session continuity in the saferpc handshake — problem, external review, and where we landed

## The setup

saferpc gives two peers an encrypted RPC channel over any byte transport. The
handshake is deliberately minimal: one round-trip, and it doesn't happen until
the application actually makes its first call. The initiator sends a hello (an
ephemeral public key, a nonce, an epoch counter, and — optionally — a signature),
the responder derives a session key from an X25519 exchange folded together with
a pre-shared secret, proves it knows the secret with an HMAC, and replies. From
that point every message is encrypted.

One detail matters for everything below: the responder does not treat the new
session as *live* the moment the maths checks out. It waits for the first
encrypted frame that actually decrypts under the new key. Producing that frame is
the real proof that the other side holds the key — anyone can send bytes that
*look* like a handshake, but only the genuine peer can send bytes that decrypt.

## The problem

A server that already has a working session should be hard to knock off it. The
question we care about is: **can a stale or duplicated hello — literally the same
bytes the server already handled once — end a live session?** It shouldn't,
because re-sending an old hello proves nothing new about who you are or whether
you can actually talk.

The way the code worked, it could. The server ran each incoming hello as a fresh
attempt, kept the old session serving while the attempt ran (good), but then
retired and replaced the old session *the moment the signature verified and the
key was derived* — before waiting for that first real encrypted frame. And the
signature, in signature mode, only covers fields the initiator chose. It carries
nothing from the server, so a byte-for-byte copy of an old hello verifies again,
every time.

Put those together and you get an ugly outcome: replay an old hello, the server
happily retires the perfectly good session and stands up a new one — except the
new one can never come alive, because whoever replayed the hello doesn't hold the
private key and can't send that confirming frame. The working session is gone;
nothing usable replaces it.

We didn't argue about this in the abstract — we reproduced it. Capture one
legitimate hello, feed it back into an established session, and the very next
call on that session times out. The session was killed by a recording of an old
message.

## The proposed fix

The instinct is "make before break." Don't throw away the old key when a new
hello shows up. Keep both: the old session keeps serving, the new hello only
produces a *candidate* key sitting off to the side. Promote the candidate — and
only then retire the old key — when the first frame arrives that decrypts under
the candidate. That's the same "first real frame confirms it" rule the protocol
already uses for a fresh session, just applied to the replacement of an existing
one.

The point is that decrypting a frame under the candidate key requires actually
holding the private key material, which a replayed hello doesn't. So a duplicate
hello can create a candidate that quietly expires, but it can never take down the
live session.

## What the external review added

I put the question to a second model (framed purely as a protocol-design review,
no attack language, or its safety filter chokes on the vocabulary). It agreed
with the direction and, more usefully, pushed on the assumptions underneath it.

Its sharpest point: make-before-break only holds if the *server's* ephemeral key
is fresh on every attempt. If the server reused a static key, a replayed hello
would derive the *same* key as the live session — and then replayed traffic would
decrypt under the "candidate" and promote it. That turns a mere nuisance into a
full replay of recorded session traffic. So "the server's key is fresh per hello"
isn't an implementation detail; it's the load-bearing assumption the whole
property rests on, and it deserves to be written down as a protocol invariant, not
left to chance in the code.

It also flagged three things worth keeping:

- **A brute-force angle on weak secrets.** The server hands out its HMAC proof to
any well-formed hello, and that proof is computed from a key that mixes in the
shared secret. Someone can send their own hello, collect the proof, and grind
guesses at the secret offline. Against a proper random 32-byte key this is
hopeless (you'd be brute-forcing 256 bits). Against a secret someone derived
from a password, it's real. That's a documentation duty: the secret is a key,
not a passphrase.

- **In-flight frames during a legitimate swap.** When a real re-handshake does
promote a new key, messages still in flight under the old key die. WireGuard
keeps the previous key around, decrypt-only, for a short grace period to cover
exactly this. Cheap to add while we're in here.

- **Flooding hellos to starve a real reconnect.** If an attacker spams hellos,
they can keep bumping the "latest attempt" and crowd out a legitimate peer's
reconnect. Denial of service is explicitly outside saferpc's threat model, so we
won't chase it, but make-before-break raises the stakes a little and it's worth
naming honestly in the assessment.

The review also offered a clean way to state the underlying rule, which is worth
quoting because it's the whole thing in one sentence:

> A transition that destroys authenticated state must be authenticated at least
> as strongly as the state it destroys. A signature over the sender's own chosen
> fields proves identity, not participation. Creating state can be gated on
> identity; destroying it must be gated on proof that you actually hold the key.

That's the same failure family as forged TCP resets and Wi-Fi deauth frames — an
unauthenticated message tearing down an authenticated connection — and the fix is
always the same shape: make the teardown prove itself.

## Where we actually landed

The good news is that the scariest item — the static-key replay — doesn't apply
to us. The server already generates a fresh ephemeral key inside each hello
handler; a duplicate hello produces a different key exchange and therefore a
different session key, so replayed traffic can't confirm anything. The review
raised a valid general risk; checking it against our actual code showed it was
already closed. But that's exactly why it now goes into the spec as a stated
invariant: one refactor moving that key back to a shared scope would silently
reopen the hole, and nobody would notice until it was a headline.

So the plan is:

1. Move the server to make-before-break: keep the live key serving, promote a
candidate only when a frame decrypts under it, and keep the old key around
decrypt-only for a short grace window.
2. Write down "the server's ephemeral key is fresh per attempt" as a protocol
invariant, so it can't be refactored away by accident.
3. Document that the shared secret must be a real random key, not a password.
4. Note the hello-flood-versus-reconnect trade-off in the assessment as a known,
accepted residual.
5. Add a continuity regression test: a duplicated hello must leave the live
session untouched, while a genuine reconnect (a real new key) still succeeds.

The short version: the bug was that the server ended a good session on the
strength of a signature that could be replayed. The fix is to make ending a
session require proof that can't be — the same proof we already demand to start
one.
Loading
Loading