Skip to content

Relay End to End Cryptography

dazeb edited this page Sep 17, 2026 · 2 revisions

Relay End-to-End Cryptography

The relay is built so that the server is a dumb pipe: it authenticates sockets, pairs peers, then forwards opaque / envelopes. Confidentiality, integrity, and sender binding are implemented in a small primitive module that exists twice — once inside the relay package (relay/src/crypto.mjs, the reference used by the relay tests) and once as the app-side seam (src/core/relay-client.ts, the code the Electron/Server Edition client actually dials through). The two are deliberately byte-compatible: identical HKDF info string, identical DER wrappers, identical { n, c } envelope shape.

Module responsibilities

File Responsibility
relay/src/crypto.mjs Pure primitives only: X25519 keypair generation, raw-key import/export, ECDH→HKDF key derivation, AES-256-GCM seal/open. No sockets, no storage, no module-level state.
relay/src/hub.mjs Session registry and routing. Carries the peers' public keys through hello/peers frames, then forwards env payloads verbatim. Does not import crypto.mjs — the hub never possesses key material.
src/core/relay-client.ts App-side mirror of the primitives (generateRelayKeypair, deriveRelayKey) plus the same envelope contract ("X25519 ECDH → HKDF-SHA256 → AES-256-GCM with the sender id as AAD"). Electron-free: node:crypto plus an injectable WebSocket factory so tests and Server Edition can fake the transport.
relay/src/crypto.test.mjs Executable specification for the primitives: round-trips, tamper detection, nonce uniqueness, malformed input rejection.
relay/README.md The stated security contract ("The relay never sees plaintext") and the wire protocol both sides implement.

The split matters: because the hub has no dependency on crypto.mjs, the relay process cannot derive a shared key or decrypt an envelope even by accident — the capability simply is not linked in.

Key agreement

Every peer starts with a fresh X25519 keypair from generateKeyPair() (crypto.mjs#L9-L16):

  • Node produces SPKI/PKCS#8 DER; the module strips fixed prefixes (12 bytes for the public key, 16 bytes for the private key) and exports the remaining 32 raw bytes as base64.
  • Import is the inverse: importRawPublic/importRawPrivate splice the raw bytes back onto the hardcoded DER headers (302a300506032b656e032100 and 302e020100300506032b656e04220420) after asserting the decoded length is exactly 32 bytes (crypto.mjs#L18-L35). Anything else throws invalid x25519 public/private key length.
  • deriveSharedKey(myPrivate, peerPublic) runs crypto.diffieHellman, then HKDF-SHA256 with an empty salt and the constant info string termsprawl-relay-v1, expanding to a 32-byte AES key (crypto.mjs#L37-L43).

Tests confirm the essential symmetry: both sides derive the same 32 bytes, and a third party derives something different (crypto.test.mjs#L19-L40).

Public keys are not secrets in this design — they travel through the relay as plaintext inside the hello and peers frames. The private key never leaves the peer, and the derived shared key is computed locally on each side. The raw base64 public key delivered in { t: 'peers', peer: { login, pub } } is therefore the identity material a trust/pairing UI compares out-of-band; this module's contribution is producing and transporting that 32-byte value, not formatting a fingerprint.

Envelope format

seal() produces exactly two fields (crypto.mjs#L45-L54):

  • n — 12 random bytes, base64 (a fresh nonce per message).
  • c — ciphertext || 16-byte GCM auth tag, base64.

The sender's relay session id is bound in as Additional Authenticated Data: cipher.setAAD(Buffer.from(String(fromId), 'utf8')). open() mirrors this, and additionally validates structure before touching the cipher (crypto.mjs#L56-L72):

  1. key must be 32 bytes;
  2. sealed must exist and have string n / c;
  3. nonce must be exactly 12 bytes and c at least 16 bytes (the two subarray splits assume a trailing tag);
  4. decipher.final() throws on any tampering, wrong key, or wrong fromId.

The tests pin each failure mode: flipped ciphertext byte, flipped tag byte, eavesdropper key, mismatched fromId, truncated/empty envelope, plus unicode and empty-string round-trips and 1000 unique nonces (crypto.test.mjs#L43-L113).

Two boundary facts fall out of the implementation and are worth stating explicitly:

  • The key is symmetric. The same 32-byte key is used to seal and open in both directions (the tests seal with crypto.randomBytes(32) directly). Direction binding comes only from the AAD fromId.
  • There is no replay or ordering protection at this layer. The envelope carries no sequence number; each message gets a random nonce, so ciphertexts are not deterministic, but a rebroadcast envelope would still authenticate. Deduplication/ordering, if needed, belongs to the application protocol above the envelope.

Hub boundary and call chain

The hub never calls deriveSharedKey, seal, or open. Its entire cryptographic involvement is transporting public keys and relaying sealed payloads (hub.mjs#L1-L6).

Call chain, from socket to delivery:

  1. hello is mandatory first frame. Any other message type on an unauthenticated socket gets AUTH and a close — auth on every socket (hub.mjs#L78-L86).
  2. helloHost verifies the GitHub login against the store's tokenHash (or the dev bypass, which prefixes logins with dev-), registers hosts.set('host:'+login, { ws, pub, login }), and answers { t: 'peers', peer: null }. A second host with the same login evicts the first with REPLACED (hub.mjs#L116-L142).
  3. helloClient takes an invite plus the client's pub. A resume path matches an existing offline session with the same inviteCode (no re-redeem); otherwise redeemInvite runs and persist() is called before anything else, so a crash cannot resurrect a consumed invite. Then the host session is looked up; if the host is offline the client is rejected with HOST-OFFLINE (hub.mjs#L144-L187).
  4. pairAndAck sends each side a peers frame containing the other's login and public key — this is the only moment the two public keys are exchanged (hub.mjs#L189-L196). Each side then runs deriveSharedKey locally.
  5. routeFrame accepts frame, direct-offer, and host-only invite-create. Anything else is BADFRAME (hub.mjs#L207-L227).
  6. deliver overwrites from with the authenticated session id (out = { ...msg, from: session.id }) and forwards. Host→client goes to clients.get(to); if that socket is down, only frame messages are buffered (cap OFFLINE_QUEUE_CAP = 100, oldest dropped), direct-offer gets NOBODY-HOME. Client→host goes to the session's paired hostSessionKey or fails with NOBODY-HOME (hub.mjs#L229-L262).

This stamping is what makes the AAD binding meaningful: the recipient opens with msg.from (the relay-stamped host:<login> / client:<login>), which must equal whatever the sender passed as fromId when sealing.

Key state held by the hub is only: hosts map ({ ws, pub, login }), clients map ({ ws, pub, login, inviteCode, hostSessionKey, queue }), and byte/frame counters. Close handlers null the client socket but keep the session so the queue and invite resume survive; host sessions are removed only if the closing socket is still the registered one (hub.mjs#L92-L104). No derived key is ever stored server-side.

sequenceDiagram
    autonumber
    participant H as Host peer<br/>(relay-client.ts)
    participant R as Relay hub<br/>(hub.mjs)
    participant C as Client peer<br/>(relay-client.ts)

    H->>R: hello{role:"host", login, token, pub}
    Note over R: verify tokenHash<br/>hosts.set("host:"+login, {ws, pub})
    C->>R: hello{role:"client", invite, pub}
    Note over R: redeemInvite(store) then persist()<br/>clients.set("client:"+login, {ws, pub, queue})
    R-->>H: peers{peer:{login:client, pub}}
    R-->>C: peers{peer:{login:host, pub}}
    Note over H,C: deriveSharedKey(myPriv, peerPub)<br/>never sent anywhere
    H->>R: frame{to:"client:X", env:{n,c}} sealed with fromId="host:X"
    Note over R: route blindly; out.from = session.id<br/>buffer ≤100 if client offline
    R->>C: frame{from:"host:X", to:"client:X", env:{n,c}}
    Note over C: open(key, env, msg.from)<br/>throws on tamper / wrong key / wrong sender
Loading

Diagram notes:

  • Steps 1–2 are the only frames that carry key material, and it is public key material only.
  • Steps 5–7 are content-blind: the hub reads t, to, and from for routing and writes its own from; the env object is never inspected.
  • The 100-message queue exists only for frame (envelope) traffic, so buffered bytes are ciphertext.
  • REPLACED, HOST-OFFLINE, NOBODY-HOME, and typed invite errors (EXPIRED/REVOKED/EXHAUSTED/UNKNOWN) are the visible failure vocabulary on this path.

Boundary conditions summary

Condition Behavior
Key not 32 bytes seal/open throw immediately.
Public/private blob not 32 raw bytes import helpers throw before any ECDH.
Envelope missing/non-string n/c, nonce ≠ 12 bytes, c < 16 bytes open throws malformed envelope.
Tampered ciphertext or tag, wrong key, wrong fromId GCM verification throws; no partial plaintext returned.
Second host logs in with the same login First socket receives REPLACED and is closed.
Client reconnects with the same client id + invite Session resumed, queue flushed, invite not re-redeemed.
Client offline frame envelopes buffered up to 100 (oldest evicted silently); direct-offer rejected with NOBODY-HOME.
devAuth under NODE_ENV=production createHub refuses to construct (DEV-AUTH-FORBIDDEN).
Any non-hello frame before auth AUTH error + close, on every socket.

Note the queue-eviction and NOBODY-HOME cases: E2E encryption guarantees the relay cannot read or forge contents, but it does not guarantee delivery — drops are a protocol-level reality, and the hub's bytesRelayed metric counts opaque bytes.

Extension points

  • HKDF_INFO = 'termsprawl-relay-v1' (crypto.mjs#L6) is the protocol version tag baked into key derivation. Changing it invalidates every existing pairing, so it is the natural place for an incompatible version bump — and both copies (relay/src/crypto.mjs, src/core/relay-client.ts) must change together.
  • Envelope shape. open requires only string n/c and ignores extra fields; deliver spreads the whole frame and never touches env. New envelope metadata (a counter, a version byte, a suite id) would traverse the relay unchanged, making { n, c } forward-extensible.
  • direct-offer lane. A second routed-verbatim message type (e.g. a WebRTC offer endpoint), explicitly never parsed and never buffered. Once a direct path exists, the same seal/open primitives can carry frames outside the hub — but the direct-offer frame itself is signaling, not encrypted, and requires the peer to be online.
  • Injectable transport. relay-client.ts takes a WebSocket factory and depends only on node:crypto, so the crypto path can be exercised without real sockets, and alternate transports can reuse the exact primitives.
  • Identity/fingerprint layer. The primitives deliberately stop at producing and exchanging raw public keys; persisting an identity keypair, deriving a human-comparable fingerprint, and prompting the user are consumers of this module, not part of it.

Sources:

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally