-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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.
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/importRawPrivatesplice the raw bytes back onto the hardcoded DER headers (302a300506032b656e032100and302e020100300506032b656e04220420) after asserting the decoded length is exactly 32 bytes (crypto.mjs#L18-L35). Anything else throwsinvalid x25519 public/private key length. -
deriveSharedKey(myPrivate, peerPublic)runscrypto.diffieHellman, then HKDF-SHA256 with an empty salt and the constant info stringtermsprawl-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.
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):
- key must be 32 bytes;
-
sealedmust exist and have stringn/c; - nonce must be exactly 12 bytes and
cat least 16 bytes (the two subarray splits assume a trailing tag); -
decipher.final()throws on any tampering, wrong key, or wrongfromId.
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 AADfromId. - 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.
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:
-
hellois mandatory first frame. Any other message type on an unauthenticated socket getsAUTHand a close — auth on every socket (hub.mjs#L78-L86). -
helloHostverifies the GitHub login against the store'stokenHash(or the dev bypass, which prefixes logins withdev-), registershosts.set('host:'+login, { ws, pub, login }), and answers{ t: 'peers', peer: null }. A second host with the same login evicts the first withREPLACED(hub.mjs#L116-L142). -
helloClienttakes an invite plus the client'spub. A resume path matches an existing offline session with the sameinviteCode(no re-redeem); otherwiseredeemInviteruns andpersist()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 withHOST-OFFLINE(hub.mjs#L144-L187). -
pairAndAcksends each side apeersframe 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 runsderiveSharedKeylocally. -
routeFrameacceptsframe,direct-offer, and host-onlyinvite-create. Anything else isBADFRAME(hub.mjs#L207-L227). -
deliveroverwritesfromwith the authenticated session id (out = { ...msg, from: session.id }) and forwards. Host→client goes toclients.get(to); if that socket is down, onlyframemessages are buffered (capOFFLINE_QUEUE_CAP = 100, oldest dropped),direct-offergetsNOBODY-HOME. Client→host goes to the session's pairedhostSessionKeyor fails withNOBODY-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
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, andfromfor routing and writes its ownfrom; theenvobject 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.
| 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.
-
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.
openrequires only stringn/cand ignores extra fields;deliverspreads the whole frame and never touchesenv. New envelope metadata (a counter, a version byte, a suite id) would traverse the relay unchanged, making{ n, c }forward-extensible. -
direct-offerlane. A second routed-verbatim message type (e.g. a WebRTC offerendpoint), explicitly never parsed and never buffered. Once a direct path exists, the sameseal/openprimitives can carry frames outside the hub — but thedirect-offerframe itself is signaling, not encrypted, and requires the peer to be online. -
Injectable transport.
relay-client.tstakes a WebSocket factory and depends only onnode: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:
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance