Skip to content

dotexorg/saferpc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

95 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Safe RPC

npm license types

Encrypted, typed RPC over any bidirectional channel. Two peers, one shared secret (or one keypair). Every call is end-to-end encrypted with XSalsa20-Poly1305 AEAD. WebSocket, postMessage, MessagePort, chrome.runtime, BroadcastChannel, WebRTC — if a channel can carry bytes, Safe RPC encrypts and types what flows through it.

Think tRPC, but transport-agnostic and encrypted by default.

npm install @dotex/saferpc

Safe RPC

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. 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), 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
  • Pure ESM + CJS dual build, side-effect-free, tree-shakeable

Quick start

import { saferpc, server, client } from "@dotex/saferpc";
import { z } from "zod";

// Initialise once, binding your handler context type (à la tRPC).
interface Context {
  user: { id: string } | null;
}
const rpc = saferpc<Context>(); // rpc IS the procedure builder

// `rpc` carries `Context`, so you can author procedures in any file and
// `ctx` stays fully typed — no manual casts. `rpc.router` / `rpc.middleware`
// hang off the same instance.
const greet = rpc
  .input(z.object({ name: z.string() }))
  .output(z.object({ message: z.string() }))
  .handler(async ({ ctx, input }) => ({
    message: `Hello, ${ctx.user?.id ?? input.name}!`,
  }));

const appRouter = rpc.router({ greet });
export type AppRouter = typeof appRouter;

const secret = crypto.getRandomValues(new Uint8Array(32));
const auth = { secret: () => secret };

const { destroy: stopServer } = server(appRouter, serverChannel, {
  auth,
  context: () => ({ user: null }),
});
// Pass the router type to `client` for a fully-inferred API.
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 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.

Channel: the only transport contract

interface Channel {
  send(data: Uint8Array): void | Promise<void>;
  receive(cb: (data: Uint8Array) => void): () => void; // returns unsubscribe
}

Anything that satisfies this can host a Safe RPC session. Ready-made adapters for WebSocket, postMessage, MessagePort, Chrome extension ports, BroadcastChannel, WebRTC, TCP, and SSE live in spec/integrations.md.

Authentication

Three modes. The auth block is the same shape in all three.

// Secret only. Simple, fast, controlled environments.
auth: { secret: () => sharedSecret }

// Asymmetric only. Public clients, no shared secrets.
auth: {
  sign: (transcript) => signWithDeviceKey(transcript),
  verify: (proof, transcript) => verifyPeerSignature(proof, transcript),
}

// Both. Session binding plus identity proof.
auth: {
  secret: () => deriveSessionSecret(sessionId, deploymentSecret),
  sign: (transcript) => signWithDeviceKey(transcript),
  verify: (proof, transcript) => verifyPeerSignature(proof, transcript),
}

Built-in helpers cover Ed25519, ECDSA P-256, and JWT auth. All bind their proof to the handshake transcript, so a captured payload cannot be replayed into a new session. Certificate-based and multi-factor schemes are composed from sign/verify directly — recipes and the full threat model live in spec/security.md.

Errors

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, 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;
  }
}

Package layout

src/
  common.ts       : shared types, crypto, msgpack, procedure builder
  server.ts       : resilient handshake server
  client.ts       : lazy handshake client (no auto-retry)
  auth/
    index.ts      : combined re-exports (deriveSessionSecret + client + server)
    client.ts     : Ed25519, ECDSA, JWT client helpers
    server.ts     : Ed25519, ECDSA, JWT server helpers
  index.ts        : public entry point
import { saferpc, server, client, RPCError } from "@dotex/saferpc";
// Subpaths are also available for tree-shaking:
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 } from "@dotex/saferpc/auth/client";
import { createEd25519ServerAuth } from "@dotex/saferpc/auth/server";

Compatibility

Node.js 20.19+ (the @noble/* dependencies require >=20.19.0; older 18.x / 20.18 fail to load the ESM-only crypto packages under CommonJS), modern browsers, Service / Web / Shared Workers, React Native, Vercel Edge, Cloudflare Workers, Deno Deploy. WebCrypto is required only for the ECDSA helpers.

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/. An internal line-by-line security review — including the honest list of residual risks and open issues — is published in spec/assessment.md. A 1.0 release will lock the public API surface.

Releasing

Two commands: bump the version, then publish (which also pushes the tag):

npm version patch    # or: minor / major / 1.2.3-beta.0
npm publish

The version hook syncs and stages jsr.json into the version commit. prepublishOnly runs lint, tests, and build before publishing; postpublish runs git push --follow-tags. The pushed vX.Y.Z tag triggers .github/workflows/release.yml, which verifies the version is live on npm and creates a GitHub Release with auto-generated changelog notes since the previous tag.

If npm publish fails, the tag exists locally but is not pushed. Fix the issue and re-run npm publish && git push --follow-tags. To abort, run git tag -d vX.Y.Z && git reset --hard HEAD~1.

License

MIT © Dotex