diff --git a/spec/api.md b/spec/api.md index 2e77cb1..c065170 100644 --- a/spec/api.md +++ b/spec/api.md @@ -1,6 +1,6 @@ # API Reference -Strict reference for every exported symbol. For an end-to-end walkthrough see [Getting Started](getting-started.md). For threat model and crypto details see [Security](security.md). For the wire format see [Protocol](protocol.md). +Reference for every exported symbol. End-to-end walkthrough lives in [Getting Started](getting-started.md), threat model and crypto in [Security](security.md), wire format in [Protocol](protocol.md). ## Import paths @@ -26,7 +26,7 @@ import { ... } from "@dotex/erpc/client"; function chain(): Chain; ``` -Returns a procedure builder. All methods are immutable and chainable. The chain terminates with `.handler()`, which returns a frozen `Procedure`. +Returns a procedure builder. Every method is immutable and chainable. `.handler()` terminates the chain and returns a frozen `Procedure`. ```typescript interface Chain { @@ -65,7 +65,7 @@ interface Procedure { type Router = Record; ``` -Treat `Procedure` as opaque. The fields are exported only so `server()` can introspect them. +Treat `Procedure` as opaque. The fields are exposed only so `server()` can introspect them. --- @@ -79,7 +79,7 @@ function server( ): { destroy: () => void }; ``` -Subscribes to `channel` and serves the given router. Returns synchronously. +Subscribes to `channel` and serves the router. Returns synchronously. ### `ServerOptions` @@ -91,9 +91,9 @@ Subscribes to `channel` and serves the given router. Returns synchronously. | `maxMessageBytes` | `number` | `1_048_576` | — | | `onError` | `(err: unknown) => void` | — | — | -`context` is called per request. The `auth` argument carries whatever `auth.verify` returned for the current session. When `context` is omitted, the request context is the verified auth data (or `{}` if none). +`context` runs per request. The `auth` argument carries whatever `auth.verify` returned for the current session. When `context` is omitted, the request context falls back to the verified auth data (or `{}` if none). -`onError` is called on handshake failures and non-fatal internal errors. The server does **not** destroy on handshake failure — it resets and accepts the next hello. +`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. ### `AuthOptions` @@ -110,7 +110,7 @@ interface AuthOptions { type VerifyResult = { auth?: Ctx } | void; ``` -At least one of `psk` or asymmetric (`sign` / `verify`) must be set. Configuring neither throws a `TypeError` at construction. +Set at least one of `psk` or asymmetric (`sign` / `verify`). Configuring neither throws a `TypeError` at construction. | Field | Called | Notes | |-------|--------|-------| @@ -118,7 +118,7 @@ At least one of `psk` or asymmetric (`sign` / `verify`) must be set. Configuring | `sign` | Per handshake attempt, if set | Signature payload, ≤ 32 KiB. | | `verify` | Per handshake attempt, if set | Throw to reject. Returned `auth` is bound to the resulting session. | -Returned `auth` data is sanitized (poison keys stripped) before being passed to `context`. +Returned `auth` data is sanitized (poison keys stripped) before reaching `context`. ### Server lifecycle @@ -145,7 +145,7 @@ function client( ): { api: Client; destroy: () => void }; ``` -Returns synchronously. The handshake is lazy: it starts on the first `api` call. +Returns synchronously. The handshake stays lazy: it starts on the first `api` call. ### `ClientOptions` @@ -188,11 +188,11 @@ idle → handshaking → ready ## Auto-retry -When an call fails on a `ready` session with a local `TIMEOUT` or send error, the client zeros its session key, returns to `idle`, runs a fresh handshake, and resends the request **exactly once**. `RemoteRPCError` (server returned an error) is **not** retried — the server is alive. Concurrent failures share one re-handshake via an epoch counter; no infinite loops. Full state-machine and wire-level semantics in [Protocol § Auto-retry semantics](protocol.md#auto-retry-semantics). +A call that fails on a `ready` session with a local `TIMEOUT` or send error triggers a single retry: the client zeros its session key, returns to `idle`, runs a fresh handshake, and resends the request **exactly once**. `RemoteRPCError` (server returned an error) is **not** retried — the server is alive and answered. Concurrent failures share one re-handshake via an epoch counter, so there are no retry storms. Full state-machine and wire-level semantics in [Protocol § Auto-retry semantics](protocol.md#auto-retry-semantics). ## Replay within a session -Per-message AEAD nonces are random; an attacker who can inject into a live channel can replay a captured ciphertext and the receiver will execute it again. For non-idempotent procedures, add an application-level idempotency key inside `input`, or maintain a request-ID set on the server keyed by the verified principal. Full discussion in [Security § Replay within a session](security.md#replay-within-a-session). +Per-message AEAD nonces are random, not counter-derived. An attacker who can inject into a live channel can replay a captured ciphertext and the receiver will execute it again. For non-idempotent procedures, add an idempotency key inside `input`, or keep a request-ID set on the server keyed by the verified principal. Full discussion in [Security § Replay within a session](security.md#replay-within-a-session). --- @@ -211,9 +211,9 @@ The only transport contract. `receive` returns an unsubscribe function. The chan - Deliver each call to `cb` once, in any order - Allow `send` and `receive` to run concurrently -It is **allowed** to drop messages, duplicate them, or reorder them — eRPC will time out and retry. Ready-made adapters live in [Integrations](integrations.md). +Dropping, duplicating, or reordering messages is allowed — eRPC will time out and retry. Ready-made adapters live in [Integrations](integrations.md). -> Within a single eRPC session the protocol assumes the `TAG_HELLO` reply arrives before any `TAG_MSG` sent under the resulting session key. Transports that may reorder *across* the hello/reply boundary (multi-path links, fan-out buses) will hang the handshake until the timeout fires. `TAG_MSG`-to-`TAG_MSG` reordering remains safe because every encrypted frame is independently authenticated and the protocol has no ordering requirement on application messages. +> Within a single session the protocol assumes the `TAG_HELLO` reply arrives before any `TAG_MSG` sent under the resulting session key. Transports that can reorder *across* the hello/reply boundary (multi-path links, fan-out buses) will hang the handshake until the timeout fires. `TAG_MSG`-to-`TAG_MSG` reordering stays safe: every encrypted frame is independently authenticated and the protocol imposes no ordering on application messages. --- @@ -230,7 +230,7 @@ class RemoteRPCError extends RPCError {} ``` - `RPCError` is thrown for **local** failures: timeout, session destroyed, handshake failure, validation failure, channel error. -- `RemoteRPCError` is thrown when the remote peer's handler returned an error. The `code`, `message`, and `data` come from the remote side and are **untrusted strings** — do not log them at warn/error level without sanitization. +- `RemoteRPCError` is thrown when the remote peer's handler returned an error. The `code`, `message`, and `data` come from the remote side and are **untrusted strings** — sanitize before logging at warn/error level, or before showing them to a user. ### Standard local error codes @@ -246,7 +246,7 @@ class RemoteRPCError extends RPCError {} | `INTERNAL` | Defensive — should not be reachable | | `MIDDLEWARE` | Middleware misuse (`next()` called twice, bad `extra` arg) | -Handlers may throw `RPCError(...)` with any code — those codes become `RemoteRPCError.code` on the client. +Handlers may throw `RPCError(...)` with any code; those codes surface as `RemoteRPCError.code` on the client. ### Pattern @@ -268,7 +268,7 @@ try { ## Middleware and context -Middleware extends the context. Each middleware is `({ ctx, input, next })`. It must call `next(extra?)` exactly once. +Middleware extends the context. Signature is `({ ctx, input, next })`, and `next(extra?)` must be called exactly once. ```typescript const d = chain(); @@ -294,9 +294,9 @@ server(router, channel, { }); ``` -Calling `next()` twice in the same middleware throws `RPCError("MIDDLEWARE", ...)`. Passing a non-object `extra` does the same. +Calling `next()` twice in the same middleware throws `RPCError("MIDDLEWARE", ...)`. So does passing a non-object `extra`. -The `context` factory runs **per request**, after auth verification, and receives `{ auth }` carrying the data returned by `auth.verify` for the session. +The `context` factory runs **per request**, after auth verification, and receives `{ auth }` carrying the data returned by `auth.verify` for that session. --- @@ -310,13 +310,13 @@ HKDF-SHA-256 over `secret` with `sessionId` as salt and the fixed info string `" Throws `TypeError` if `sessionId` is empty or `secret` is shorter than 32 bytes. -Use to bind each handshake to a session identifier instead of holding a single static PSK. +Use it to bind each handshake to a session identifier instead of relying on a single static PSK. --- ## Built-in auth helpers -Every helper returns a partial `AuthOptions` you can spread into the `auth` block. All bind their proof to the canonical handshake transcript so a captured payload cannot be replayed into a new handshake. +Every helper returns a partial `AuthOptions` you spread into the `auth` block. Each one binds its proof to the canonical handshake transcript, so a captured payload cannot be replayed into a new handshake. ### Client-side @@ -358,7 +358,7 @@ import { | `createCertificateServerAuth({ verifyCertificate, validateSubject? })` | Verifies a presented certificate chain + ECDSA P-256 signature. | | `createMultifactorServerAuth({ primary, secondary, combineAuth? })` | Composes two verifiers; both must pass. | -All decode auth payloads through the hardened msgpack codec (ext types rejected, prototype-pollution keys stripped, depth-limited). Returned `auth` data is sanitized 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`. --- @@ -382,13 +382,13 @@ import { } from "@dotex/erpc"; ``` -Exported for adapter authors. Application code does not normally need these. +Exported for adapter authors. Application code rarely needs them. --- ## Cleanup -Always call `destroy()` when you are done with a session. +Call `destroy()` when you are done with a session. ```typescript const { destroy: destroyServer } = server(router, channel, { auth }); @@ -409,7 +409,7 @@ After `destroy()`: ## Edge runtime compatibility -Both `server()` and `client()` return **synchronously**. No top-level `await`. Pure JavaScript dependencies. Compatible with: +Both `server()` and `client()` return **synchronously**, with no top-level `await`. Dependencies are pure JavaScript. Compatible with: - Node.js 18+ - Modern browsers diff --git a/spec/getting-started.md b/spec/getting-started.md index ef55e19..7eef7b2 100644 --- a/spec/getting-started.md +++ b/spec/getting-started.md @@ -1,6 +1,6 @@ # Getting Started -Five minutes from `npm install` to encrypted, typed procedure calls. The shape is always the same: define a router, configure auth, attach a channel, call functions. +Five minutes from `npm install` to encrypted, typed procedure calls. The shape never changes: define a router, configure auth, attach a channel, call functions. ## Install @@ -8,7 +8,7 @@ Five minutes from `npm install` to encrypted, typed procedure calls. The shape i npm install @dotex/erpc ``` -Peer dependency: a Zod-compatible schema library. eRPC uses `zod` for input/output validation in procedures. +Peer dependency: a Zod-compatible schema library. eRPC validates procedure input and output through `zod`. ## Define procedures @@ -30,7 +30,7 @@ const router = { }; ``` -The router is a plain object — keys are procedure names, values are procedures. Share it between server and client as a **type**. The client infers the entire API surface from `typeof router` without ever importing the runtime code. +The router is a plain object. Keys are procedure names, values are procedures. Share it between server and client as a **type** — the client infers the whole API surface from `typeof router` and never imports the runtime code. ## Configure authentication @@ -38,7 +38,7 @@ eRPC requires at least one authentication method. ### PSK (shared secret) -Both sides hold the same 32-byte key. Simplest and fastest. +Both sides hold the same 32-byte key. Cheapest mode, no signature ops on the hot path. ```typescript const sharedSecret = crypto.getRandomValues(new Uint8Array(32)); @@ -46,9 +46,9 @@ const sharedSecret = crypto.getRandomValues(new Uint8Array(32)); const auth = { psk: () => sharedSecret }; ``` -`psk()` may return the same `Uint8Array` across calls — eRPC reads it and never mutates the buffer. Lifecycle is yours: zero it yourself if and when the secret should disappear from memory. +`psk()` may return the same `Uint8Array` across calls. eRPC reads it and never mutates the buffer. Lifecycle stays with you: zero it yourself if the secret should disappear from memory. -For better security, derive a fresh PSK from a per-session identifier: +A single static PSK works, but a per-session derivation is harder to misuse — leaked traffic only compromises one session, not every past or future one: ```typescript import { deriveSessionPSK } from "@dotex/erpc"; @@ -64,7 +64,7 @@ const auth = { ### Asymmetric (signatures) -For public clients or when device-level identity matters. The signer proves identity over the handshake transcript; the verifier rejects bad signatures. +For public clients, or when device-level identity matters. The signer proves identity over the handshake transcript; the verifier rejects bad signatures. ```typescript const auth = { @@ -93,11 +93,11 @@ const auth = createEd25519ServerAuth({ }); ``` -All built-in helpers (Ed25519, ECDSA, JWT, certificate, multifactor) bind their proof to the canonical handshake transcript automatically. See [Security: Built-in signature helpers](security.md#built-in-signature-helpers). +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). ### Both (defense-in-depth) -Combine them when you need both session binding and individual revocation. +Combine them when you need session binding and per-key revocation at the same time. ```typescript const auth = { @@ -120,7 +120,7 @@ const { destroy: destroyServer } = server(router, serverChannel, { }); ``` -The server listens on a `Channel` — any bidirectional transport that can carry `Uint8Array`. See [Integrations](integrations.md) for ready-made adapters. +The server listens on a `Channel`: any bidirectional transport that can carry `Uint8Array`. Ready-made adapters live in [Integrations](integrations.md). ## Connect the client @@ -132,7 +132,7 @@ const { api, destroy: destroyClient } = client(clientChannel, { }); ``` -Construction is **synchronous**. The handshake is lazy — it runs on the first call, not when the client is created. No top-level `await`. +Construction is **synchronous**. The handshake is lazy: it runs on the first call, not when the client is created. No top-level `await`. ## Make calls @@ -141,7 +141,7 @@ const result = await api.greet({ name: "World" }); console.log(result.message); // "Hello, World!" ``` -Handshake, encryption, msgpack serialization, schema validation — handled. If the session drops, the client retries once with a fresh handshake. See [API: Auto-Retry](api.md). +Handshake, encryption, msgpack serialization, schema validation — all handled internally. If the session drops, the client retries once with a fresh handshake. See [API: Auto-Retry](api.md). ## End-to-end example @@ -225,10 +225,10 @@ server(router, channel, { ## Error handling -Two error types: +Two error types, and the distinction matters because they imply different recovery paths. -- `RPCError` — local failure (timeout, session lost, validation error, handshake failure) -- `RemoteRPCError` — error returned from the remote peer (carries `code`, `message`, `data`) +- `RPCError` — local failure: timeout, session lost, validation error, handshake failure. Worth retrying or surfacing as a transient problem. +- `RemoteRPCError` — the remote peer's handler threw. Carries `code`, `message`, `data`. The other side is alive and made a deliberate decision. ```typescript import { RPCError, RemoteRPCError } from "@dotex/erpc"; @@ -250,11 +250,11 @@ try { ## Choosing an auth mode -**PSK** when you control both endpoints — server-to-server, internal services, parent ↔ iframe of the same origin. Fast (no signature ops). Simple. +PSK fits when you control both endpoints: server-to-server, internal services, an iframe talking to its parent on the same origin. No signature work per handshake. -**Asymmetric** when one side is untrusted or there is no shared secret — public web clients, mobile apps, IoT devices. Per-device revocation. +Asymmetric fits when one side is untrusted or there is no safe place to put a shared secret: public browser clients, mobile apps, IoT devices. Each key revokes independently. -**Both** when you want session binding *and* per-device identity — regulated environments, high-value systems. +Use both when you need session binding *and* per-device identity. Regulated environments, high-value systems. An attacker now has to compromise the derivation secret *and* a device key, and forward secrecy still protects past traffic if either leaks. ## Next steps diff --git a/spec/integrations.md b/spec/integrations.md index cfef6f1..4871aac 100644 --- a/spec/integrations.md +++ b/spec/integrations.md @@ -1,6 +1,6 @@ # Integrations -eRPC needs one thing from the transport: it must move `Uint8Array` in both directions. That is the whole contract. +eRPC asks one thing of the transport: it must move `Uint8Array` in both directions. That is the whole contract. ```typescript interface Channel { @@ -9,7 +9,7 @@ interface Channel { } ``` -Everything below is a one-screen adapter that satisfies that interface. Each one is a few lines of glue around a native transport. None of them need to know what eRPC does. +Everything below is a one-screen adapter that satisfies that interface. Each one is a few lines of glue around a native transport, and none of them need to know what eRPC does. ## Duplex socket transports @@ -17,7 +17,7 @@ Bidirectional byte streams. Each connection maps to one eRPC session. ### WebSocket -The most common case — browser or service talking to a server over WS. +Browser or service talking to a server over WS. The case you probably have. ```typescript function wsChannel(ws: WebSocket): Channel { @@ -148,7 +148,7 @@ function postMessageChannel(target: Window, origin: string): Channel { } ``` -**Always check `origin`.** Skipping the check is how cross-window attacks happen. The wildcard `"*"` is fine for development but should never ship. +**Always check `origin`.** Skipping it is how cross-window attacks happen. The wildcard `"*"` is fine in development and dangerous in production. ```typescript // Parent (server) @@ -225,7 +225,7 @@ function extensionPortChannel(port: chrome.runtime.Port): Channel { } ``` -The `Array.from` round-trip is the price of `chrome.runtime`. For high-throughput extensions, consider `chrome.runtime.connect` between a content script and an offscreen document and switch to MessagePort there. +The `Array.from` round-trip is the price of `chrome.runtime`. High-throughput extensions should pin a `chrome.runtime.connect` between a content script and an offscreen document, then switch to MessagePort there. ```typescript // background.js (service worker) @@ -248,7 +248,7 @@ const { api } = client(extensionPortChannel(port), { }); ``` -`getExtensionPSK()` is whatever your extension uses to derive a key both sides agree on — extension ID + version + a stored secret, for example. +`getExtensionPSK()` is whatever your extension uses to derive a key both sides agree on, for example extension ID plus version plus a stored secret. ### BroadcastChannel @@ -272,7 +272,7 @@ function broadcastChannel(name: string): Channel { } ``` -eRPC is a 1:1 protocol, so to use BroadcastChannel you need to elect a single server tab (leader). Other tabs become clients. The leader holds the session state; clients re-handshake when leadership moves. +eRPC is a 1:1 protocol. To use BroadcastChannel, elect a single server tab (leader) and let other tabs become clients. The leader holds the session state; clients re-handshake when leadership moves. ```typescript const isLeader = await electLeader(); @@ -294,7 +294,7 @@ Direct connection between peers without a central relay. ### WebRTC DataChannel -Peer-to-peer. Often paired with mutual signature auth because there is no shared infrastructure. +Peer-to-peer, no central relay. Usually paired with mutual signature auth because there is no shared infrastructure to put a PSK on. ```typescript function webRTCChannel(dc: RTCDataChannel): Channel { @@ -324,7 +324,7 @@ const { api } = client(webRTCChannel(dataChannel), { ## Split-channel transports -Asymmetric transports work too — you only need a `send` and a `receive`, not a single duplex socket. +Asymmetric transports work too. The contract is `send` and `receive`, not a single duplex socket. ### Server-Sent Events + fetch @@ -360,14 +360,14 @@ function sseChannel(url: string): Channel { } ``` -The server side needs to keep an in-memory map from session to SSE stream so it knows where to send replies. The adapter is more involved than the duplex transports, but the eRPC code on top is identical. +The server side needs an in-memory map from session to SSE stream so it knows where to send replies. The adapter is more involved than the duplex transports, but the eRPC code on top stays identical. ## Custom transports -The rules do not change: +The rules are the same as everywhere else: -1. `send` accepts `Uint8Array` and gets it to the other side. -2. `receive(cb)` calls `cb` with each incoming `Uint8Array`. It returns an unsubscribe function. -3. The transport is allowed to drop, duplicate, or reorder messages. eRPC will time out and retry. It will not behave correctly if your transport silently corrupts bytes — wrap it in something that fails noisily if you cannot trust it. +1. `send` accepts a `Uint8Array` and gets it to the other side. +2. `receive(cb)` calls `cb` with each incoming `Uint8Array` and returns an unsubscribe function. +3. The transport may drop, duplicate, or reorder messages — eRPC will time out and retry. Silent byte corruption breaks the protocol, so wrap any transport you cannot trust in something that fails noisily. -That is the whole API surface. Encryption, framing, retry, key management — all on the eRPC side. Your adapter does not need to care. +That is the entire API surface. Encryption, framing, retry, key management all live inside eRPC. The adapter is not in the security boundary. diff --git a/spec/protocol.md b/spec/protocol.md index 8085b20..43d66ec 100644 --- a/spec/protocol.md +++ b/spec/protocol.md @@ -1,20 +1,20 @@ # Protocol -A complete, language-agnostic specification of the eRPC wire protocol. Read this if you want to port eRPC to another language, audit the security, or build a compatible implementation. Everything below is normative. +Language-agnostic specification of the eRPC wire protocol. Read this to port eRPC, audit it, or build a compatible implementation. Everything below is normative. -The reference implementation is TypeScript. This document is the contract; the code follows it. +The reference implementation is in TypeScript, but this document is the contract — the code follows it. ## Goals and non-goals Design constraints, in order: 1. **Encrypted by default.** No "plaintext mode." -2. **Lazy.** No work happens until the application makes a call. `client()` and `server()` return synchronously. -3. **Resilient.** Either side can fail and re-handshake without coordination from the application. -4. **Transport-agnostic.** The protocol must work over any byte-pipe — duplex socket, message pair, broadcast bus. -5. **No long-lived state in the protocol.** PSK rotation, key revocation, replay caches — application concerns. +2. **Lazy.** No work runs until the application makes a call. `client()` and `server()` return synchronously. +3. **Resilient.** Either side can fail and re-handshake without coordination from the application layer. +4. **Transport-agnostic.** Any byte pipe will do: duplex socket, message pair, broadcast bus. +5. **No long-lived state in the protocol.** PSK rotation, key revocation, replay caches — those belong to the application. -Non-goals: in-protocol streaming RPCs, multiplexing over a single channel, formal session tickets, ordering guarantees beyond what the transport provides. +Non-goals: streaming RPCs in-protocol, multiplexing over a single channel, formal session tickets, ordering guarantees stronger than what the transport provides. ## Primitives @@ -106,7 +106,7 @@ A frame whose total length exceeds `MAX_MSG_BYTES` **must** be dropped. A frame ## Handshake -The handshake is one round-trip initiated by the client. It is **lazy**: the client does not send anything until the application makes its first RPC call. +The handshake is one round-trip initiated by the client, and it is **lazy** — nothing goes on the wire until the application makes its first RPC call. ```mermaid sequenceDiagram @@ -266,7 +266,7 @@ plaintext = XSalsa20-Poly1305-decrypt(session_key, nonce, ciphertext, AD=∅) message = sanitize(msgpack_decode(plaintext)) ``` -A 24-byte random nonce gives 192 bits of entropy; collisions are negligible for any realistic message volume. eRPC does **not** use a counter — this trades slightly higher nonce size for stateless encoding and tolerance for out-of-order or duplicated transport delivery. +A 24-byte random nonce gives 192 bits of entropy; collisions are negligible for any realistic message volume. eRPC does **not** use a counter. The trade is slightly larger nonces in exchange for stateless encoding and tolerance for out-of-order or duplicated transport delivery. ## RPC message format @@ -366,7 +366,7 @@ Calls that received a `RemoteRPCError` (the server responded with `ok: false`) a ## Sanitization -eRPC applies a strict sanitization pass to every decoded msgpack value, both inbound and outbound (on error payloads). Any of the following causes the protocol to reject the message: +Every decoded msgpack value passes through a sanitization step, inbound and outbound (the latter on error payloads). Any of the following rejects the message: 1. Recursion depth greater than 32 ⇒ `INVALID_DATA`. 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. @@ -375,7 +375,7 @@ eRPC applies a strict sanitization pass to every decoded msgpack value, both inb `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. -A port to a language without prototype pollution should still: +A port to a language without prototype pollution still has to: - Reject extension types it does not know about. - Limit recursion depth. @@ -420,7 +420,7 @@ Clients can also configure `verify`; on the client side the return value is unus | Stale request (after server reset) | Drop response (server-side guard) | Eventually times out, retries | | RPC handler throws non-`RPCError` | Send `{ c: "INTERNAL", m: "Internal error" }` | Surface as `RemoteRPCError` | -"Drop silently" is deliberate. Any feedback at the wire level would help an attacker probe the implementation. +Silent drops are deliberate. Any feedback at the wire level gives an attacker probing material. ## Compatibility @@ -430,7 +430,7 @@ Clients can also configure `verify`; on the client side the return value is unus ## Implementation checklist -A new-language port that ticks all of these is conformant: +A new-language port that ticks every item is conformant: - [ ] Constants match the table above exactly. - [ ] X25519, XSalsa20-Poly1305, HKDF-SHA-256, HMAC-SHA-256 implementations are constant-time where the spec requires (proof comparison, MAC verification). @@ -453,7 +453,7 @@ A new-language port that ticks all of these is conformant: - [ ] The proof is verified in constant time. - [ ] No information is sent back to a peer that sends a malformed frame. -If all these hold and the test vectors below pass, two implementations interoperate. +When every item holds and the test vectors below pass, two implementations interoperate. ## Test vectors diff --git a/spec/security.md b/spec/security.md index d6b84a2..acdb66e 100644 --- a/spec/security.md +++ b/spec/security.md @@ -1,6 +1,6 @@ # Security -eRPC treats the transport as hostile. This page covers what that means, what eRPC actually guarantees, how to configure auth so those guarantees hold, and what is *not* covered. Wire-level mechanics (frame layout, handshake steps, state machines, key derivation) live in [Protocol](protocol.md). +eRPC treats the transport as hostile. This page covers what that buys you, how to configure auth so those guarantees hold, and what is *not* covered. Wire-level mechanics (frame layout, handshake steps, state machines, key derivation) live in [Protocol](protocol.md). ## Threat model @@ -13,9 +13,9 @@ The transport channel is **untrusted**. The attacker may: eRPC does **not** protect against: -- **Denial of service.** If the attacker drops every byte, communication is impossible. No fix at this layer. -- **Compromised endpoints.** If the attacker runs code on either side, encryption is irrelevant. -- **Timing side channels in your handlers.** eRPC's own comparisons are constant-time; your handler code is not unless you write it that way. +- **Denial of service.** An attacker who drops every byte makes communication impossible. No protocol-layer fix. +- **Compromised endpoints.** Once attacker code runs on either side, encryption is moot. +- **Timing side channels in your handlers.** eRPC's own comparisons are constant-time. Your handler code is not, unless you write it that way. ## Security properties @@ -49,9 +49,9 @@ auth: { } ``` -Use when both endpoints are controlled by the same entity, secrets can be rotated, and individual revocation is not required. PSK is cheap — no signature operations on the hot path. +Fits when both endpoints belong to the same entity, secrets can be rotated, and you do not need per-identity revocation. No signature work on the hot path. -> The PSK buffer's lifecycle belongs to the caller. eRPC reads it during HKDF and never mutates it. Returning the same `Uint8Array` from `psk()` across handshakes is safe; if you want it zeroed, zero it yourself when the secret is no longer needed. +> The PSK buffer's lifecycle belongs to the caller. eRPC reads it during HKDF and never mutates it. Returning the same `Uint8Array` from `psk()` across handshakes is safe. If you want it zeroed, zero it yourself when the secret is no longer needed. ### Asymmetric only @@ -70,7 +70,7 @@ auth: { } ``` -Use when one side is a public client (browser, mobile app, IoT device), when there are no shared secrets to safely distribute, or when you need per-device identity and revocation. +Fits when one side is a public client (browser, mobile app, IoT device), when there is no safe place to put a shared secret, or when you need per-device identity and revocation. ### Both (defense-in-depth) @@ -82,7 +82,7 @@ auth: { } ``` -Use when you want session binding *and* identity proof. An attacker must now compromise two independent things — the derivation secret and the device key — and still cannot read past sessions because of forward secrecy. +Fits when you need session binding *and* identity proof. The attacker now has to compromise both the derivation secret and the device key, and forward secrecy still protects past traffic if either one leaks. ### Comparison @@ -96,11 +96,11 @@ Use when you want session binding *and* identity proof. An attacker must now com | Cost | Low (HMAC only) | Higher (signature ops) | | Complexity | Simple | More moving parts | -Forward secrecy comes from the ephemeral X25519 exchange in either mode. Even if a long-term secret leaks, past session ciphertexts remain unreadable — the ephemeral private keys were zeroed when the session ended. +Forward secrecy comes from the ephemeral X25519 exchange in either mode. A leaked long-term secret cannot decrypt past traffic, because the ephemeral private keys are zeroed when each session ends. ## Transcript format -Signatures are taken over canonical byte strings built by eRPC. There are two, with domain-separated magic prefixes so a hello signature can never be replayed as a reply (or vice versa). +Signatures are taken over canonical byte strings built by eRPC. Two transcripts exist, each with a domain-separated magic prefix, so a hello signature cannot be replayed as a reply (or vice versa). ``` HELLO transcript: @@ -117,19 +117,19 @@ REPLY transcript: server_pub (32 bytes) ``` -These prefixes plus the epoch plus the per-handshake nonce defeat: +Prefix, epoch, and per-handshake nonce together defeat: -- Replay across direction (hello vs. reply use different prefixes) -- Replay across handshake attempts (epoch differs each time) -- Substitution attacks (an active MITM cannot swap either ephemeral public key without invalidating the signature) +- Replay across direction — hello and reply use different prefixes +- Replay across handshake attempts — epoch differs each time +- Substitution attacks — an active MITM cannot swap either ephemeral public key without invalidating the signature For the full wire layout of the frames that carry these signatures, see [Protocol § Frame format](protocol.md#frame-format). ## Auth processing order -Auth runs **before** any session key is materialized. Failed verification never leaks ECDH artifacts. See [Protocol § Handshake](protocol.md#handshake) for the step-by-step details. +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). -A throw at any auth step rejects the handshake. The client resets to `idle`; the server resets to `waiting`. Failed verifications never silently downgrade. +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. ## Safe vs unsafe PSK patterns @@ -175,11 +175,11 @@ auth: { } ``` -The common pattern in the unsafe list: the attacker can reproduce the derivation either because the input is guessable or because the secret is in the wrong place. +The unsafe list shares one pattern: the attacker can reproduce the derivation, either because the input is guessable or because the secret material lives in the wrong place. ## Built-in signature helpers -eRPC ships ready-made helpers for common cases. Every helper binds its proof to the handshake transcript that eRPC passes in. +eRPC ships ready-made helpers for the common cases. Each one binds its proof to the handshake transcript that eRPC passes in. ```typescript import { @@ -214,7 +214,7 @@ auth: { ...clientAuth } auth: { ...serverAuth } ``` -Uses `@noble/curves` so it works in every JS runtime — no dependency on WebCrypto Ed25519 (which is not uniformly available across browsers). +Built on `@noble/curves` so it runs in every JS runtime. WebCrypto Ed25519 is not uniformly available across browsers, and the helper sidesteps that. ### ECDSA P-256 (WebCrypto) @@ -229,7 +229,7 @@ const serverAuth = createECDSAServerAuth({ }); ``` -Use this when you want the private key to be non-extractable. Pair `generateECDSAKeypair()` with platform key stores. +Use this when the private key must be non-extractable. Pair `generateECDSAKeypair()` with platform key stores. ### JWT (bearer token, transcript-bound) @@ -247,9 +247,9 @@ const serverAuth = createJWTServerAuth({ }); ``` -The JWT helper does **not** sign the transcript — JWTs are bearer tokens. Instead, the client embeds `{ jwt, ts, th = SHA-256(transcript) }` in the auth payload, and the server validates the JWT, the timestamp (symmetric `maxAge` skew, so future-dated forgeries are rejected too), and the transcript digest in constant time. A captured payload can only be replayed within a handshake that produces the same transcript, which means the attacker cannot mount a new handshake with their own ephemeral key. +The JWT helper does **not** sign the transcript: JWTs are bearer tokens. The client embeds `{ jwt, ts, th = SHA-256(transcript) }` in the auth payload, and the server validates the JWT, the timestamp (symmetric `maxAge` skew, so future-dated forgeries are rejected too), and the transcript digest in constant time. A captured payload can only be replayed inside a handshake that produces the same transcript, which means an attacker cannot mount a new handshake with their own ephemeral key. -A leaked JWT still lets the attacker authenticate as long as the token is valid. Combine with PSK or a real signature mode when this matters. +A leaked JWT still authenticates the attacker for as long as the token is valid. Combine with PSK or a real signature mode when that matters. ### Certificate-based @@ -278,11 +278,11 @@ The client embeds `{ primary, secondary }` — two pre-encoded sub-payloads. ## Replay within a session -eRPC uses random 24-byte nonces (not counters) for XSalsa20-Poly1305. The collision probability is negligible — but **a captured ciphertext can be replayed by an attacker who can inject into a live channel**. The replayed message will decrypt and execute again. +eRPC uses random 24-byte nonces for XSalsa20-Poly1305, not counters. Collision probability is negligible, but **a captured ciphertext can still be replayed by an attacker who can inject into a live channel**. The replay decrypts and executes again. -For non-idempotent operations, add an application-level idempotency key inside the procedure input, or maintain a request-ID set on the server keyed by the verified principal. +For non-idempotent operations, add an idempotency key inside the procedure input, or keep a request-ID set on the server keyed by the verified principal. -This is the only known replay window in the protocol. A counter-based scheme would close it but introduces a stronger ordering requirement on the transport, which is not always available (BroadcastChannel, lossy WebRTC, etc.). +This is the only known replay window in the protocol. A counter-based scheme would close it, but it would also require strict transport ordering, and several supported transports (BroadcastChannel, lossy WebRTC, multi-path links) cannot promise that. ## Recommended configurations