Protocol
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 in TypeScript, but this document is the contract β the code follows it.
Goals and non-goals
Design constraints, in order:
- Encrypted by default. No βplaintext mode.β
- Lazy. No work happens until the application makes a call.
client()andserver()return synchronously. - Resilient. Either side can fail and re-handshake without coordination from the application.
- Transport-agnostic. The protocol must work over any byte-pipe: duplex socket, message pair, broadcast bus.
- No long-lived state in the protocol. Secret rotation, key revocation, replay caches: application concerns.
Non-goals: streaming RPCs in-protocol, multiplexing over a single channel, formal session tickets, ordering guarantees stronger than what the transport provides.
Primitives
| Primitive | Algorithm | Notes |
|---|---|---|
| Key exchange | X25519 | 32-byte keys |
| Symmetric encryption | XSalsa20-Poly1305 (AEAD) | 24-byte nonce, 32-byte key, 16-byte tag |
| Hash | SHA-256 | β |
| Key derivation | HKDF-Extract+Expand-SHA-256 | RFC 5869 |
| MAC | HMAC-SHA-256 | RFC 2104 |
| Serialization | msgpack | All extension types disabled (see Sanitization) |
All wire numbers are network-byte-order (big-endian) unless explicitly noted.
Constant reference
| Name | Value | Purpose |
|---|---|---|
NONCE_LEN |
24 | XSalsa20-Poly1305 nonce length (per encrypted message) |
KEY_LEN |
32 | Symmetric session key length, X25519 key length, client hello nonce length |
TAG_HELLO |
0x00 |
First byte of a handshake frame |
TAG_MSG |
0x01 |
First byte of an encrypted RPC frame |
MAX_HELLO_BYTES |
65,536 | Max size of a handshake frame (post-tag) |
MAX_AUTH_BYTES |
32,768 | Max size of the optional auth payload |
MAX_MSG_BYTES |
1,048,576 | Max size of an encrypted RPC frame (configurable) |
HANDSHAKE_TIMEOUT_MS |
5,000 | Default timeout for completing the handshake |
RPC_TIMEOUT_MS |
10,000 | Default per-call timeout (client side) |
MAX_PENDING |
256 | Default maximum in-flight RPCs per client |
KDF_INFO |
UTF-8 bytes of "drpc-v1" |
HKDF info parameter for session key |
PSK_DERIVE_INFO |
UTF-8 bytes of "erpc-session-v1" |
HKDF info for deriveSessionSecret helper |
TRANSCRIPT_HELLO_MAGIC |
UTF-8 bytes of "erpc-hs-hello-v1\0" (17 bytes) |
Domain separation for client transcript |
TRANSCRIPT_REPLY_MAGIC |
UTF-8 bytes of "erpc-hs-reply-v1\0" (17 bytes) |
Domain separation for server transcript |
EMPTY_SECRET |
32 zero bytes | HKDF salt when no secret is configured (asymmetric-only mode) |
Frame format
Every wire frame is a single byte tag followed by a payload.
frame := tag (1 byte) || payload (...)
Two tag values are defined. Implementations must drop frames with any other tag.
TAG_HELLO = 0x00
The payload is a msgpack-encoded map. The frame is sent in both handshake directions; the mapβs shape differs by direction.
Hello (client β server):
{
pub: bin (32 bytes, X25519 public key)
nonce: bin (32 bytes, fresh random)
epoch: uint (0..2^32-1)
auth: bin (optional, β€ MAX_AUTH_BYTES)
}
Reply (server β client):
{
pub: bin (32 bytes, X25519 public key)
proof: bin (32 bytes, HMAC-SHA-256 over the transcript message, see Proof)
epoch: uint (echo of the client's hello.epoch)
auth: bin (optional, β€ MAX_AUTH_BYTES)
}
A frame whose payload is longer than MAX_HELLO_BYTES must be dropped without state change. A frame that fails msgpack decoding, or decodes to anything other than a map with the required fields, must cause the receiving side to reset its handshake state (and call onError if observed by the server).
TAG_MSG = 0x01
The payload is an encrypted RPC message:
0x01 || nonce (24 bytes) || ciphertext_with_tag (β₯ 16 bytes)
The ciphertext is the output of XSalsa20-Poly1305 AEAD with:
- Key: the 32-byte session key (see Key derivation)
- Nonce: the 24 bytes immediately following the tag (fresh random per message)
- Plaintext: msgpack-encoded RPC message (request or response)
- Associated data: none
A frame whose total length exceeds MAX_MSG_BYTES must be dropped. A frame whose ciphertext fails Poly1305 verification must be dropped silently. No error, no state change.
Handshake
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.
sequenceDiagram
Client->>Server: TAG_HELLO + { pub, nonce, epoch, auth? }
Note right of Server: verify auth (if configured)<br/>derive session key<br/>compute proof<br/>state β pending
Server->>Client: TAG_HELLO + { pub, proof, epoch, auth? }
Note left of Client: verify auth (if configured)<br/>derive session key<br/>verify proof<br/>state β ready
Client->>Server: TAG_MSG + encrypted RPC
Note right of Server: decrypt OK β state β ready
Server->>Client: TAG_MSG + encrypted response
Step 1: client builds and sends hello
The client generates:
- A fresh X25519 keypair
(c_priv, c_pub). - A fresh 32-byte random nonce
c_nonce. - The next epoch value (start at 1; increment on every handshake attempt; wrap modulo 2Β³Β²).
If asymmetric sign is configured, the client computes the hello transcript:
hello_transcript :=
TRANSCRIPT_HELLO_MAGIC ||
encode_uint32_be(epoch) ||
c_pub ||
c_nonce
and signs it. The signature payload is opaque to the protocol; its length must be in 1..MAX_AUTH_BYTES.
The client then sends:
0x00 || msgpack({ pub: c_pub, nonce: c_nonce, epoch: epoch, auth: signed? })
Step 2: server processes hello
- Verify frame length and tag.
- Decode msgpack, sanitize, check shape.
- If
verifyis configured: requireauth, build hello transcript, callverify(auth, transcript). On failure, reset handshake state. - Compute ECDH shared secret:
raw = X25519(s_priv, c_pub). - Call
secret()if configured. If fewer thanKEY_LENbytes, fail. If not configured, useEMPTY_SECRET. - Derive session key:
session_key = HKDF(SHA-256, IKM=raw, salt=psk, info=KDF_INFO, L=KEY_LEN). - Zero
rawand PSK bytes. - Compute proof:
proof = HMAC-SHA-256(session_key, s_pub || c_pub || c_nonce). - If
signis configured, build reply transcript and sign it:
reply_transcript :=
TRANSCRIPT_REPLY_MAGIC ||
encode_uint32_be(epoch) ||
c_pub ||
c_nonce ||
s_pub
- Set encryptor/decryptor, transition state to
pending. - Send:
0x00 || msgpack({ pub: s_pub, proof: proof, epoch: epoch, auth: signed? })
The server does not transition to ready yet. It does so on the first TAG_MSG whose Poly1305 tag verifies under the freshly-derived session key, regardless of whether the decrypted payload is a well-formed RPC request. Producing a valid AEAD frame is the implicit proof; the inner shape is checked afterwards and may be silently dropped without rolling state back.
Step 3: client processes reply
- Verify frame length and tag.
- Decode msgpack, sanitize, check shape.
- Silently drop if
reply.epoch !== this_epoch(stale reply). - If
verifyis configured: requireauth, build reply transcript, callverify(auth, transcript). On failure, reset handshake state. - Compute ECDH shared secret:
raw = X25519(c_priv, s_pub). - Call
secret()if configured; otherwise useEMPTY_SECRET. Validate β₯KEY_LENbytes. - Derive
session_keywith the same HKDF call as the server. - Recompute expected proof:
expected = HMAC-SHA-256(session_key, s_pub || c_pub || c_nonce). - Compare
expectedtoproofin constant time. Mismatch β fail. - Set encryptor/decryptor, zero intermediate buffers, transition to
ready.
Step 4: first encrypted message
The client encrypts and sends its first RPC request. On the server, successful AEAD verification of the first TAG_MSG (Poly1305 tag passes under the freshly-derived session key) is the implicit proof that the client knows the secret, and the server transitions from pending to ready. The inner RPC payload is validated separately. A junk payload that nonetheless decrypts cleanly still confirms the session; it is just dropped without producing a response.
Re-handshake
A server in any state that receives a TAG_HELLO resets its handshake state and processes the new hello. The client side does the same on reset. This is how transparent recovery works: a dead session triggers a new hello, the server resets, and the new session is established without application-layer coordination.
Key derivation
session_key = HKDF(
hash = SHA-256,
ikm = X25519(local_priv, remote_pub),
salt = secret_or_EMPTY_SECRET,
info = KDF_INFO, // "drpc-v1"
L = KEY_LEN, // 32
)
The secret is the salt parameter, not the IKM. This is deliberate: the salt parameter is what HKDF uses for domain separation and authentication.
If both endpoints derive the same secret and the X25519 exchange is intact, both arrive at the same session_key. An attacker who runs the X25519 exchange but lacks the secret derives a different key and the HMAC proof fails.
When secret is not configured (asymmetric-only mode), secret_or_EMPTY_SECRET is 32 zero bytes. RFC 5869 allows an all-zero salt; in this mode session authentication relies entirely on the sign/verify callbacks. The reference implementation refuses an application-supplied secret() that returns 32 zeros, so a misconfigured secret never silently degrades into the asymmetric-only mode.
deriveSessionSecret (helper)
Optional convenience for binding the secret to a per-session identifier:
deriveSessionSecret(sessionId, secret) := HKDF(
hash = SHA-256,
ikm = secret, // β₯ 32 bytes
salt = utf8(sessionId), // non-empty
info = PSK_DERIVE_INFO, // "erpc-session-v1"
L = KEY_LEN, // 32
)
The protocol does not require its use.
Proof
proof = HMAC-SHA-256(
key = session_key,
data = s_pub || c_pub || c_nonce,
)
The proof binds the session key to the specific ephemeral keys and nonce of this handshake. It is sent by the server in the reply and verified by the client in constant time.
The proof does not include the epoch directly. Replay across epochs is prevented because fresh ephemeral keys produce a different raw, a different session_key, and therefore a different proof.
Encryption
Per-message encryption:
nonce = random_bytes(24)
plaintext = msgpack_encode(message)
ciphertext = XSalsa20-Poly1305-encrypt(session_key, nonce, plaintext, AD=β
)
frame = 0x01 || nonce || ciphertext
Per-message decryption:
require frame[0] == 0x01
require len(frame) β€ MAX_MSG_BYTES
nonce = frame[1 : 25]
ciphertext = frame[25 : ]
plaintext = XSalsa20-Poly1305-decrypt(session_key, nonce, ciphertext, AD=β
)
on failure: drop silently
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. The trade-off: slightly higher nonce size in exchange for stateless encoding and tolerance for out-of-order or duplicated transport delivery.
RPC message format
After decryption, an RPC message is a msgpack-encoded map. Two kinds.
Request (client β server)
{
t: 1,
id: string, // non-empty, unique within this client session
p: string, // procedure name
i: any, // input (validated against procedure's .input schema)
}
Response (server β client)
// Success
{
t: 2,
id: <echo of request id>,
ok: true,
d: any, // handler output (validated against .output schema)
e: null,
}
// Failure
{
t: 2,
id: <echo of request id>,
ok: false,
d: null,
e: { c: string, m: string, d: any },
}
The error mapβs fields:
| Field | Meaning |
|---|---|
c |
Error code. Strings like "INPUT_VALIDATION", "NOT_FOUND", "UNAUTHORIZED", or any application-defined string. |
m |
Human-readable message. Untrusted from the receiverβs perspective. |
d |
Optional structured data, sanitized before transmission. |
Messages with wrong t, missing/empty id, missing/empty p, or any unexpected type must be dropped silently. The protocol has no provision for βbad messageβ responses. Those would be useful only to an attacker enumerating implementation behavior.
State machines
Server
[waiting]
β receive TAG_HELLO (good)
βΌ
[pending] ββββ 1st valid TAG_MSG ββββββββΊ [ready]
β β²
β β receive TAG_HELLO
β timeout β
β error β
βΌ β
[waiting] ββββββββ (resetHandshake β accept new handshake even from ready)
destroy() β [destroyed], terminal
Client
[idle]
β api call
βΌ
[handshaking]
β reply OK + proof OK
βΌ
[ready]
β call timeout / send error
βΌ
[idle] (auto-reset, retry once on next call)
destroy() β [closed], terminal
The client uses an epoch counter to coordinate concurrent failure-and-retry. When multiple calls fail at once, only the first one increments the epoch and resets; the rest see the new epoch and join the in-progress handshake.
Auto-retry semantics
When a call fails with a local TIMEOUT or send error on a ready session:
- If
epoch === sentEpoch(no other call has already reset), callreset(): zero the session key, drop encryptor/decryptor, state βidle. - Call
ensureHandshake(). Ifstate === handshaking, await the existing promise; otherwise start a new handshake. - Once
ready, resend the original request once. - If that also fails, surface the error. No further retries.
Calls that received a RemoteRPCError (the server responded with ok: false) are not retried. The server is alive and gave a real answer.
Sanitization
Every decoded msgpack value passes through a sanitization step, inbound and outbound (the latter on error payloads). Any of the following rejects the message:
- Recursion depth greater than 32 β
INVALID_DATA. - 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. - Any object whose prototype is neither
Object.prototypenornull. This rejectsDate,Map,Set,ExtData, and any host object that arrived through an unexpected codec path. - Object keys equal to
"__proto__","constructor", or"prototype"are stripped during traversal.
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 still has to:
- Reject extension types it does not know about.
- Limit recursion depth.
- Reject inputs whose structure does not match the expected shape.
Authorization data flow
When auth.verify is configured on the server, the value it returns is the verified principal for the lifetime of the session. eRPC takes the returned { auth: ... } object, sanitizes it, and:
- Stores it on the server session.
- Passes it as
{ auth: verified }to thecontextfactory on every request. - Discards it on any reset (timeout, new hello, destroy).
server.verify(hello.auth, hello_transcript)
β { auth: { userId, ... } }
β
βΌ
on each request:
ctx = context({ auth: verified })
β
βΌ
procedure runs with ctx
Clients can also configure verify. On the client side the return value is unused. Success is signaled by not throwing.
Failure modes
| Failure | Server response | Client response |
|---|---|---|
| Bad frame tag | Drop silently | Drop silently |
| Frame > max size | Drop silently | Drop silently |
| msgpack decode error | Reset, onError |
Fail handshake |
| Sanitization failure | Reset, onError |
Fail handshake |
| Bad secret / missing secret bytes | Fail handshake (HANDSHAKE), reset |
Fail handshake (HANDSHAKE) |
verify throws |
Fail handshake, reset | Fail handshake |
sign returns invalid payload |
Fail handshake | Fail handshake |
| Proof mismatch (client) | β | Fail handshake |
| Poly1305 mismatch (post-handshake) | Drop frame silently | Drop frame silently |
Stale reply (epoch mismatch) |
β | Drop reply silently |
| 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 |
Silent drops are deliberate. Any feedback at the wire level gives an attacker probing material.
Compatibility
- The
authfield on hello and reply is optional. Peers that do not understand it ignore it; peers that need it reject frames that lack it. Secret-only deployments stay wire-compatible with mutual-auth deployments as long as neither side hasverifyconfigured. - The transcript magic strings (
erpc-hs-hello-v1,erpc-hs-reply-v1) andKDF_INFO(drpc-v1) are version markers. Any change to transcripts, key derivation inputs, or framing must bump these strings. Otherwise an attacker could mix and match versions in a downgrade attack. - New fields can be added to the request/response messages (
t: 1andt: 2maps). Implementations must ignore unknown fields. They must not accept messages with wrongtor missing required fields.
Implementation checklist
A new-language port that ticks every item is conformant:
When every item holds and the test vectors below pass, two implementations interoperate.
Test vectors
The reference implementationβs test/security and test/unit directories contain canonical fixtures: known (c_priv, c_pub, c_nonce, s_priv, s_pub, secret) inputs and the resulting session_key and proof. Use those to validate a port at the byte level before running end-to-end interop tests over a real channel.