From 1d1132de9c513c2febc447755ae9720792b44ab1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 12:12:02 +0200 Subject: [PATCH] perf(relay): reduce encrypted binary traffic overhead (#2480) Preserve application frame identity through encryption so negotiated binary traffic avoids base64 expansion while mixed-version peers remain compatible. --- SECURITY.md | 4 +- docs/architecture.md | 1 + docs/terminal-performance.md | 2 +- .../daemon/desktop-daemon-transport.ts | 10 +- .../src/daemon-client-relay-e2ee-transport.ts | 13 +- .../src/daemon-client-transport-types.ts | 2 +- .../src/daemon-client-transport-utils.ts | 20 ++- .../src/daemon-client-transport.test.ts | 17 +- .../client/src/daemon-client-transport.ts | 2 +- .../src/daemon-client-websocket-transport.ts | 17 +- packages/client/src/index.test.ts | 2 +- packages/relay/src/crypto.test.ts | 16 +- packages/relay/src/crypto.ts | 13 +- packages/relay/src/e2e.test.ts | 8 +- packages/relay/src/e2ee.ts | 2 +- packages/relay/src/encrypted-channel.test.ts | 159 ++++++++++++++++- packages/relay/src/encrypted-channel.ts | 163 ++++++++++++++---- packages/relay/src/live-relay.e2e.test.ts | 4 +- .../daemon-e2e/relay-transport.e2e.test.ts | 23 ++- packages/server/src/server/relay-transport.ts | 3 +- .../websocket/encrypted-relay-socket.test.ts | 16 +- .../websocket/encrypted-relay-socket.ts | 13 +- 22 files changed, 397 insertions(+), 113 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index b09425b68..dacb14c41 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,9 @@ The relay is designed to be untrusted. All traffic between your phone and daemon 1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600` 2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key. 3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes. -4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame. +4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. + +The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations. The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake. diff --git a/docs/architecture.md b/docs/architecture.md index 012e47083..5b492eb01 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -141,6 +141,7 @@ Enables remote access when the daemon is behind a firewall. - The relay is zero-knowledge — it routes encrypted bytes and cannot read content - Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) - Pairing via QR code transfers the daemon's public key to the client +- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only - Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS` The production relay server lives in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay). It is a distributed Elixir service. The Cloudflare relay implementation in this monorepo is retained as legacy code and is not deployed. diff --git a/docs/terminal-performance.md b/docs/terminal-performance.md index 15108e951..1a5f4f9bd 100644 --- a/docs/terminal-performance.md +++ b/docs/terminal-performance.md @@ -41,5 +41,5 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev ## Known remaining contention (follow-up candidates) - A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread. -- Relay-attached clients pay pure-JS tweetnacl encryption + base64 per frame on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). +- Relay-attached clients pay pure-JS tweetnacl encryption on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). Negotiated binary application frames stay binary ciphertext and avoid base64 encode/decode; text and mixed-version traffic remain base64 WebSocket text frames. - `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections. diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts index 1d7894821..32f5ed6cf 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts @@ -63,7 +63,7 @@ export function createDesktopLocalDaemonTransportFactory( const openHandlers = new Set<() => void>(); const closeHandlers = new Set<(event?: unknown) => void>(); const errorHandlers = new Set<(event?: unknown) => void>(); - const messageHandlers = new Set<(data: unknown) => void>(); + const messageHandlers = new Set<(data: unknown, isBinary: boolean) => void>(); const emitOpen = () => { if (didEmitOpen || disposed) { @@ -84,9 +84,9 @@ export function createDesktopLocalDaemonTransportFactory( handler(event); } }; - const emitMessage = (data: unknown) => { + const emitMessage = (data: unknown, isBinary: boolean) => { for (const handler of messageHandlers) { - handler(data); + handler(data, isBinary); } }; @@ -101,11 +101,11 @@ export function createDesktopLocalDaemonTransportFactory( } if (payload.kind === "message") { if (payload.text) { - emitMessage({ data: payload.text }); + emitMessage(payload.text, false); return; } if (payload.binaryBase64) { - emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) }); + emitMessage(decodeBase64ToBytes(payload.binaryBase64), true); } return; } diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index 265a99767..88f359f3c 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -8,15 +8,12 @@ import type { DaemonTransportFactory, TransportLogger, } from "./daemon-client-transport-types.js"; -import { - extractRelayMessageData, - normalizeTransportPayload, -} from "./daemon-client-transport-utils.js"; +import { extractRelayMessage, normalizeTransportPayload } from "./daemon-client-transport-utils.js"; type OpenHandler = () => void; type CloseHandler = (event?: unknown) => void; type ErrorHandler = (event?: unknown) => void; -type MessageHandler = (data: unknown) => void; +type MessageHandler = (data: unknown, isBinary: boolean) => void; export function createRelayE2eeTransportFactory(args: { baseFactory: DaemonTransportFactory; @@ -70,7 +67,7 @@ export function createEncryptedTransport( if (closed) { return; } - emitHandlers(messageHandlers, data); + emitHandlers(messageHandlers, data, data instanceof ArrayBuffer); }; const relayTransport: RelayTransport = { @@ -115,8 +112,8 @@ export function createEncryptedTransport( base.onOpen(() => { void startHandshake(); }); - base.onMessage((event) => { - relayTransport.onmessage?.(extractRelayMessageData(event)); + base.onMessage((data, isBinary) => { + relayTransport.onmessage?.(extractRelayMessage(data, isBinary)); }); base.onClose((event) => { const record = event as { code?: number; reason?: string } | undefined; diff --git a/packages/client/src/daemon-client-transport-types.ts b/packages/client/src/daemon-client-transport-types.ts index 6531ee205..ddcb57246 100644 --- a/packages/client/src/daemon-client-transport-types.ts +++ b/packages/client/src/daemon-client-transport-types.ts @@ -1,7 +1,7 @@ export interface DaemonTransport { send: (data: string | Uint8Array | ArrayBuffer) => void; close: (code?: number, reason?: string) => void; - onMessage: (handler: (data: unknown) => void) => () => void; + onMessage: (handler: (data: unknown, isBinary: boolean) => void) => () => void; onOpen: (handler: () => void) => () => void; onClose: (handler: (event?: unknown) => void) => () => void; onError: (handler: (event?: unknown) => void) => () => void; diff --git a/packages/client/src/daemon-client-transport-utils.ts b/packages/client/src/daemon-client-transport-utils.ts index 400ef2d7a..3b39cf511 100644 --- a/packages/client/src/daemon-client-transport-utils.ts +++ b/packages/client/src/daemon-client-transport-utils.ts @@ -14,17 +14,25 @@ export function normalizeTransportPayload( return copyArrayBufferViewToBuffer(data); } -export function extractRelayMessageData(event: unknown): string | ArrayBuffer { +export interface RelayTransportMessage { + data: string | ArrayBuffer; + isBinary: boolean; +} + +export function extractRelayMessage(event: unknown, nodeIsBinary?: boolean): RelayTransportMessage { const raw = event && typeof event === "object" && "data" in event ? (event as { data: unknown }).data : event; - if (typeof raw === "string") return raw; - if (raw instanceof ArrayBuffer) return raw; - if (ArrayBuffer.isView(raw)) { - return copyArrayBufferViewToBuffer(raw); + const isBinary = nodeIsBinary ?? typeof raw !== "string"; + if (!isBinary) { + return { data: decodeMessageData(raw) ?? String(raw ?? ""), isBinary: false }; } - return String(raw ?? ""); + if (raw instanceof ArrayBuffer) return { data: raw, isBinary: true }; + if (ArrayBuffer.isView(raw)) { + return { data: copyArrayBufferViewToBuffer(raw), isBinary: true }; + } + return { data: String(raw ?? ""), isBinary: true }; } export function describeTransportClose(event?: unknown): string { diff --git a/packages/client/src/daemon-client-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index f70d09eec..378275c67 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -7,7 +7,7 @@ import { describeTransportClose, describeTransportError, encodeUtf8String, - extractRelayMessageData, + extractRelayMessage, } from "./daemon-client-transport.js"; const createClientChannelMock = vi.hoisted(() => vi.fn()); @@ -130,7 +130,7 @@ describe("daemon-client transport helpers", () => { const message = { data: "payload" }; listeners.get("message")?.(message); - expect(onMessage).toHaveBeenCalledWith(message); + expect(onMessage).toHaveBeenCalledWith("payload", false); unsubscribe(); expect(ws.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function)); @@ -165,13 +165,16 @@ describe("daemon-client transport helpers", () => { expect(describeTransportError()).toBe("Transport error"); }); - test("extractRelayMessageData returns strings and array buffers", () => { - expect(extractRelayMessageData({ data: "hello" })).toBe("hello"); + test("extractRelayMessage preserves browser and Node WebSocket frame kind", () => { + expect(extractRelayMessage({ data: "hello" })).toEqual({ data: "hello", isBinary: false }); const view = new Uint8Array([1, 2, 3]); - const extracted = extractRelayMessageData({ data: view }); - expect(extracted).toBeInstanceOf(ArrayBuffer); - expect(Array.from(new Uint8Array(extracted as ArrayBuffer))).toEqual([1, 2, 3]); + const binary = extractRelayMessage(view, true); + expect(binary.isBinary).toBe(true); + expect(Array.from(new Uint8Array(binary.data as ArrayBuffer))).toEqual([1, 2, 3]); + + const text = extractRelayMessage(view, false); + expect(text).toEqual({ data: "\u0001\u0002\u0003", isBinary: false }); }); test("decodeMessageData decodes strings, array buffers, and typed arrays", () => { diff --git a/packages/client/src/daemon-client-transport.ts b/packages/client/src/daemon-client-transport.ts index d07d2774c..05bd08bbc 100644 --- a/packages/client/src/daemon-client-transport.ts +++ b/packages/client/src/daemon-client-transport.ts @@ -10,7 +10,7 @@ export { describeTransportClose, describeTransportError, encodeUtf8String, - extractRelayMessageData, + extractRelayMessage, normalizeTransportPayload, safeRandomId, } from "./daemon-client-transport-utils.js"; diff --git a/packages/client/src/daemon-client-websocket-transport.ts b/packages/client/src/daemon-client-websocket-transport.ts index a4c785da4..27307ef3d 100644 --- a/packages/client/src/daemon-client-websocket-transport.ts +++ b/packages/client/src/daemon-client-websocket-transport.ts @@ -3,6 +3,7 @@ import type { WebSocketFactory, WebSocketLike, } from "./daemon-client-transport-types.js"; +import { extractRelayMessage } from "./daemon-client-transport-utils.js"; export function defaultWebSocketFactory( url: string, @@ -52,11 +53,25 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem onOpen: (handler) => bindWsHandler(ws, "open", handler), onClose: (handler) => bindWsHandler(ws, "close", handler), onError: (handler) => bindWsHandler(ws, "error", handler), - onMessage: (handler) => bindWsHandler(ws, "message", handler), + onMessage: (handler) => bindWsMessageHandler(ws, handler), }; }; } +function bindWsMessageHandler( + ws: WebSocketLike, + handler: (data: unknown, isBinary: boolean) => void, +): () => void { + const listener = (...args: unknown[]) => { + const message = extractRelayMessage( + args[0], + typeof args[1] === "boolean" ? args[1] : undefined, + ); + handler(message.data, message.isBinary); + }; + return bindWsHandler(ws, "message", listener); +} + function bindTemporaryEarlyCloseErrorHandler(ws: WebSocketLike): () => void { const noop = () => {}; diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 5def7dc47..dedf7ce2e 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -34,7 +34,7 @@ class FakeWebSocket { } message(data: string): void { - this.onmessage?.(data); + this.onmessage?.({ data }); } } diff --git a/packages/relay/src/crypto.test.ts b/packages/relay/src/crypto.test.ts index a35533e26..8db9a15f7 100644 --- a/packages/relay/src/crypto.test.ts +++ b/packages/relay/src/crypto.test.ts @@ -8,6 +8,10 @@ import { decrypt, } from "./crypto.js"; +function decryptText(sharedKey: Uint8Array, ciphertext: ArrayBuffer): string { + return new TextDecoder().decode(decrypt(sharedKey, ciphertext)); +} + describe("crypto", () => { describe("generateKeyPair", () => { it("generates a valid keypair", () => { @@ -55,7 +59,7 @@ describe("crypto", () => { // Both should derive the same key - test by encrypting with one, decrypting with other const testMessage = "Hello, encrypted world!"; const encrypted = encrypt(daemonSharedKey, testMessage); - const decrypted = decrypt(clientSharedKey, encrypted); + const decrypted = decryptText(clientSharedKey, encrypted); expect(decrypted).toBe(testMessage); }); @@ -73,7 +77,7 @@ describe("crypto", () => { expect(ciphertext).toBeInstanceOf(ArrayBuffer); expect(ciphertext.byteLength).toBeGreaterThan(plaintext.length); - const decrypted = decrypt(sharedKey, ciphertext); + const decrypted = decryptText(sharedKey, ciphertext); expect(decrypted).toBe(plaintext); }); @@ -118,8 +122,8 @@ describe("crypto", () => { expect(arr1).not.toEqual(arr2); // But both should decrypt to same plaintext - expect(decrypt(sharedKey, ciphertext1)).toBe(plaintext); - expect(decrypt(sharedKey, ciphertext2)).toBe(plaintext); + expect(decryptText(sharedKey, ciphertext1)).toBe(plaintext); + expect(decryptText(sharedKey, ciphertext2)).toBe(plaintext); }); }); @@ -156,11 +160,11 @@ describe("crypto", () => { // Daemon encrypts, client decrypts const encryptedFromDaemon = encrypt(daemonSharedKey, testFromDaemon); - expect(decrypt(clientSharedKey, encryptedFromDaemon)).toBe(testFromDaemon); + expect(decryptText(clientSharedKey, encryptedFromDaemon)).toBe(testFromDaemon); // Client encrypts, daemon decrypts const encryptedFromClient = encrypt(clientSharedKey, testFromClient); - expect(decrypt(daemonSharedKey, encryptedFromClient)).toBe(testFromClient); + expect(decryptText(daemonSharedKey, encryptedFromClient)).toBe(testFromClient); }); }); }); diff --git a/packages/relay/src/crypto.ts b/packages/relay/src/crypto.ts index 3a605b9bb..298f8dea4 100644 --- a/packages/relay/src/crypto.ts +++ b/packages/relay/src/crypto.ts @@ -8,8 +8,8 @@ * Bundle format (binary): * [nonce (24 bytes)] [ciphertext...] * - * Transport format: - * The encrypted-channel sends the bundle as base64 text over WebSocket. + * The encrypted channel chooses the WebSocket representation. Crypto remains + * byte-oriented so frame kind is never inferred from plaintext contents. */ import nacl from "tweetnacl"; @@ -139,7 +139,7 @@ export function encrypt(sharedKey: SharedKey, data: string | ArrayBuffer): Array return toArrayBuffer(out); } -export function decrypt(sharedKey: SharedKey, data: ArrayBuffer): string | ArrayBuffer { +export function decrypt(sharedKey: SharedKey, data: ArrayBuffer): ArrayBuffer { const bytes = new Uint8Array(data); if (bytes.byteLength < NONCE_LENGTH) { throw new Error("Ciphertext bundle too short"); @@ -152,10 +152,5 @@ export function decrypt(sharedKey: SharedKey, data: ArrayBuffer): string | Array throw new Error("Decryption failed"); } - const plaintext = toArrayBuffer(opened); - try { - return new TextDecoder("utf-8", { fatal: true }).decode(plaintext); - } catch { - return plaintext; - } + return toArrayBuffer(opened); } diff --git a/packages/relay/src/e2e.test.ts b/packages/relay/src/e2e.test.ts index 663abf2e4..cbc19f57b 100644 --- a/packages/relay/src/e2e.test.ts +++ b/packages/relay/src/e2e.test.ts @@ -348,7 +348,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise { clientReceivedReady.byteOffset + clientReceivedReady.byteLength, ), ); - expect(JSON.parse(decryptedReady as string)).toEqual({ type: "ready" }); + expect(JSON.parse(new TextDecoder().decode(decryptedReady))).toEqual({ type: "ready" }); // Client sends encrypted message const clientMessage = "Hello from client!"; @@ -366,7 +366,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise { daemonReceivedMsg.byteOffset + daemonReceivedMsg.byteLength, ), ); - expect(decryptedClientMsg).toBe(clientMessage); + expect(new TextDecoder().decode(decryptedClientMsg)).toBe(clientMessage); // Daemon sends encrypted response const daemonMessage = "Hello from daemon!"; @@ -384,7 +384,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise { clientReceivedMsg.byteOffset + clientReceivedMsg.byteLength, ), ); - expect(decryptedDaemonMsg).toBe(daemonMessage); + expect(new TextDecoder().decode(decryptedDaemonMsg)).toBe(daemonMessage); // Cleanup daemonWs.close(); @@ -478,7 +478,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise { daemonSharedKey, received.buffer.slice(received.byteOffset, received.byteOffset + received.byteLength), ); - expect(decrypted).toBe(secret); + expect(new TextDecoder().decode(decrypted)).toBe(secret); daemonControlWs.close(); daemonWs.close(); diff --git a/packages/relay/src/e2ee.ts b/packages/relay/src/e2ee.ts index f538ba60d..c3a3b852f 100644 --- a/packages/relay/src/e2ee.ts +++ b/packages/relay/src/e2ee.ts @@ -1,5 +1,5 @@ export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js"; -export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js"; +export type { Transport, TransportMessage, EncryptedChannelEvents } from "./encrypted-channel.js"; export { generateKeyPair, diff --git a/packages/relay/src/encrypted-channel.test.ts b/packages/relay/src/encrypted-channel.test.ts index ea8970115..90ef5e0e3 100644 --- a/packages/relay/src/encrypted-channel.test.ts +++ b/packages/relay/src/encrypted-channel.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi } from "vitest"; import { createClientChannel, createDaemonChannel, Transport } from "./encrypted-channel.js"; -import { generateKeyPair, exportPublicKey } from "./crypto.js"; +import { + deriveSharedKey, + encrypt, + exportPublicKey, + generateKeyPair, + importPublicKey, +} from "./crypto.js"; +import { arrayBufferToBase64 } from "./base64.js"; /** * Creates a pair of connected mock transports. @@ -25,11 +32,11 @@ function createMockTransportPair(): [Transport, Transport] { // Wire them together (transportA.send as ReturnType).mockImplementation((data: string | ArrayBuffer) => { - setTimeout(() => transportB.onmessage?.(data), 0); + setTimeout(() => transportB.onmessage?.({ data, isBinary: data instanceof ArrayBuffer }), 0); }); (transportB.send as ReturnType).mockImplementation((data: string | ArrayBuffer) => { - setTimeout(() => transportA.onmessage?.(data), 0); + setTimeout(() => transportA.onmessage?.({ data, isBinary: data instanceof ArrayBuffer }), 0); }); return [transportA, transportB]; @@ -193,7 +200,7 @@ describe("EncryptedChannel", () => { // Send invalid hello setTimeout(() => { - daemonTransport.onmessage?.('{"type":"invalid"}'); + daemonTransport.onmessage?.({ data: '{"type":"invalid"}', isBinary: false }); }, 0); await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message"); @@ -227,7 +234,7 @@ describe("EncryptedChannel", () => { )?.[0]; expect(typeof firstHello).toBe("string"); - daemonTransport.onmessage?.(firstHello as string); + daemonTransport.onmessage?.({ data: firstHello as string, isBinary: false }); await waitForAsyncDelivery(); expect(daemonTransport.close).not.toHaveBeenCalled(); @@ -264,9 +271,149 @@ describe("EncryptedChannel", () => { key: exportPublicKey(attackerKeyPair.publicKey), }); - daemonTransport.onmessage?.(attackerHello); + daemonTransport.onmessage?.({ data: attackerHello, isBinary: false }); await waitForAsyncDelivery(); expect(daemonTransport.close).toHaveBeenCalledWith(1008, "E2EE re-handshake key mismatch"); }); + + it("preserves ASCII-only binary frames in negotiated mode", async () => { + const [daemonTransport, clientTransport] = createMockTransportPair(); + const daemonKeyPair = generateKeyPair(); + const daemonMessages: (string | ArrayBuffer)[] = []; + let resolveOpen: (() => void) | null = null; + const opened = new Promise((resolve) => { + resolveOpen = resolve; + }); + + const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, { + onmessage: (data) => daemonMessages.push(data), + }); + const clientChannel = await createClientChannel( + clientTransport, + exportPublicKey(daemonKeyPair.publicKey), + { onopen: () => resolveOpen?.() }, + ); + await daemonChannelPromise; + await opened; + + const binary = new TextEncoder().encode("ASCII terminal output").buffer; + (clientTransport.send as ReturnType).mockClear(); + await clientChannel.send(binary); + await waitForAsyncDelivery(); + + const ciphertext = (clientTransport.send as ReturnType).mock.calls[0]?.[0]; + expect(ciphertext).toBeInstanceOf(ArrayBuffer); + expect((ciphertext as ArrayBuffer).byteLength).toBe(binary.byteLength + 40); + expect(daemonMessages[0]).toBeInstanceOf(ArrayBuffer); + expect(new Uint8Array(daemonMessages[0] as ArrayBuffer)).toEqual(new Uint8Array(binary)); + }); + + it("keeps negotiated text as base64 text frames", async () => { + const [daemonTransport, clientTransport] = createMockTransportPair(); + const daemonKeyPair = generateKeyPair(); + const daemonMessages: (string | ArrayBuffer)[] = []; + let resolveOpen: (() => void) | null = null; + const opened = new Promise((resolve) => { + resolveOpen = resolve; + }); + const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, { + onmessage: (data) => daemonMessages.push(data), + }); + const clientChannel = await createClientChannel( + clientTransport, + exportPublicKey(daemonKeyPair.publicKey), + { onopen: () => resolveOpen?.() }, + ); + await daemonChannelPromise; + await opened; + + (clientTransport.send as ReturnType).mockClear(); + await clientChannel.send("text payload"); + await waitForAsyncDelivery(); + + expect(typeof (clientTransport.send as ReturnType).mock.calls[0]?.[0]).toBe( + "string", + ); + expect(daemonMessages).toEqual(["text payload"]); + }); + + it("new client stays base64-only when an old daemon does not accept the capability", async () => { + const daemonKeyPair = generateKeyPair(); + const clientTransport: Transport = { + send: vi.fn(), + close: vi.fn(), + onmessage: null, + onclose: null, + onerror: null, + }; + let resolveOpen: (() => void) | null = null; + const opened = new Promise((resolve) => { + resolveOpen = resolve; + }); + const clientChannel = await createClientChannel( + clientTransport, + exportPublicKey(daemonKeyPair.publicKey), + { onopen: () => resolveOpen?.() }, + ); + + const hello = JSON.parse( + (clientTransport.send as ReturnType).mock.calls[0]?.[0] as string, + ) as { key: string }; + expect(hello).toMatchObject({ capabilities: { binaryCiphertext: true } }); + clientTransport.onmessage?.({ data: JSON.stringify({ type: "e2ee_ready" }), isBinary: false }); + await opened; + + (clientTransport.send as ReturnType).mockClear(); + await clientChannel.send(new TextEncoder().encode("legacy binary").buffer); + expect(typeof (clientTransport.send as ReturnType).mock.calls[0]?.[0]).toBe( + "string", + ); + }); + + it("new daemon stays base64-only for an old client and drains pipelined traffic", async () => { + const daemonKeyPair = generateKeyPair(); + const clientKeyPair = generateKeyPair(); + const daemonMessages: (string | ArrayBuffer)[] = []; + const daemonTransport: Transport = { + send: vi.fn(), + close: vi.fn(), + onmessage: null, + onclose: null, + onerror: null, + }; + const channelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, { + onmessage: (data) => daemonMessages.push(data), + }); + const sharedKey = deriveSharedKey( + clientKeyPair.secretKey, + importPublicKey(exportPublicKey(daemonKeyPair.publicKey)), + ); + daemonTransport.onmessage?.({ + data: JSON.stringify({ + type: "e2ee_hello", + key: exportPublicKey(clientKeyPair.publicKey), + }), + isBinary: false, + }); + daemonTransport.onmessage?.({ + data: arrayBufferToBase64(encrypt(sharedKey, "pipelined legacy text")), + isBinary: false, + }); + + const daemonChannel = await channelPromise; + await waitForAsyncDelivery(); + expect(daemonMessages).toEqual(["pipelined legacy text"]); + expect( + JSON.parse((daemonTransport.send as ReturnType).mock.calls[0]?.[0]), + ).toEqual({ + type: "e2ee_ready", + }); + + (daemonTransport.send as ReturnType).mockClear(); + await daemonChannel.send(new Uint8Array([1, 2, 3]).buffer); + expect(typeof (daemonTransport.send as ReturnType).mock.calls[0]?.[0]).toBe( + "string", + ); + }); }); diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts index c9baa07e2..f25dc3d05 100644 --- a/packages/relay/src/encrypted-channel.ts +++ b/packages/relay/src/encrypted-channel.ts @@ -21,11 +21,16 @@ import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js"; export interface Transport { send(data: string | ArrayBuffer): void; close(code?: number, reason?: string): void; - onmessage: ((data: string | ArrayBuffer) => void) | null; + onmessage: ((message: TransportMessage) => void) | null; onclose: ((code: number, reason: string) => void) | null; onerror: ((error: Error) => void) | null; } +export interface TransportMessage { + data: string | ArrayBuffer; + isBinary: boolean; +} + export interface EncryptedChannelEvents { onopen?: () => void; onmessage?: (data: string | ArrayBuffer) => void; @@ -45,32 +50,52 @@ interface EncryptedChannelOptions { * the daemon should re-send `{type:"e2ee_ready"}` without changing keys. */ daemonKeyPair?: KeyPair; + binaryCiphertext?: boolean; } interface E2EEHelloMessage { type: "e2ee_hello"; key: string; + capabilities?: E2EECapabilities; } interface E2EEReadyMessage { type: "e2ee_ready"; + capabilities?: E2EECapabilities; +} + +interface E2EECapabilities { + binaryCiphertext?: boolean; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isE2EECapabilities(value: unknown): value is E2EECapabilities { + return ( + value === undefined || + (isRecord(value) && + (value.binaryCiphertext === undefined || typeof value.binaryCiphertext === "boolean")) + ); +} + function isE2EEHelloMessage(value: unknown): value is E2EEHelloMessage { return ( isRecord(value) && value.type === "e2ee_hello" && typeof value.key === "string" && - value.key.trim().length > 0 + value.key.trim().length > 0 && + isE2EECapabilities(value.capabilities) ); } function isE2EEReadyMessage(value: unknown): value is E2EEReadyMessage { - return isRecord(value) && value.type === "e2ee_ready"; + return isRecord(value) && value.type === "e2ee_ready" && isE2EECapabilities(value.capabilities); +} + +function supportsBinaryCiphertext(message: E2EEHelloMessage | E2EEReadyMessage): boolean { + return message.capabilities?.binaryCiphertext === true; } function buildInvalidHelloError(rawText: string, parsed?: unknown): Error { @@ -130,7 +155,11 @@ export async function createClientChannel( // Send e2ee_hello with our public key const ourPublicKeyB64 = exportPublicKey(keyPair.publicKey); - const hello: E2EEHelloMessage = { type: "e2ee_hello", key: ourPublicKeyB64 }; + const hello: E2EEHelloMessage = { + type: "e2ee_hello", + key: ourPublicKeyB64, + capabilities: { binaryCiphertext: true }, + }; const helloText = JSON.stringify(hello); let retry: ReturnType | null = null; @@ -189,10 +218,11 @@ export async function createDaemonChannel( events: EncryptedChannelEvents = {}, ): Promise { return new Promise((resolve, reject) => { - const bufferedMessages: Array = []; - const shouldIgnorePostHelloPlaintext = (data: string | ArrayBuffer): boolean => { + const bufferedMessages: TransportMessage[] = []; + const shouldIgnorePostHelloPlaintext = (message: TransportMessage): boolean => { try { - const text = typeof data === "string" ? data : new TextDecoder().decode(data); + if (message.isBinary) return false; + const text = decodeTransportText(message.data); const parsed: unknown = JSON.parse(text); return isE2EEHelloMessage(parsed) || isE2EEReadyMessage(parsed); } catch { @@ -200,9 +230,12 @@ export async function createDaemonChannel( } }; - const handleHello = async (data: string | ArrayBuffer): Promise => { + const handleHello = async (message: TransportMessage): Promise => { try { - const helloText = typeof data === "string" ? data : new TextDecoder().decode(data); + if (message.isBinary) { + throw buildInvalidHelloError(""); + } + const helloText = decodeTransportText(message.data); let parsed: unknown; try { @@ -221,7 +254,7 @@ export async function createDaemonChannel( // WebCrypto work to derive the shared key. Without this, it's possible // for the next message (already encrypted) to be misinterpreted as a // second hello, causing the handshake to fail. - const bufferNext = (next: string | ArrayBuffer): void => { + const bufferNext = (next: TransportMessage): void => { bufferedMessages.push(next); }; Object.assign(transport, { onmessage: bufferNext }); @@ -229,8 +262,19 @@ export async function createDaemonChannel( const clientPublicKey = importPublicKey(msg.key); const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey); - const channel = new EncryptedChannel(transport, sharedKey, events, { daemonKeyPair }); - transport.send(JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage)); + const binaryCiphertext = supportsBinaryCiphertext(msg); + const channel = new EncryptedChannel(transport, sharedKey, events, { + daemonKeyPair, + binaryCiphertext, + }); + transport.send( + JSON.stringify({ + type: "e2ee_ready", + ...(binaryCiphertext + ? { capabilities: { binaryCiphertext: true } satisfies E2EECapabilities } + : {}), + } satisfies E2EEReadyMessage), + ); channel.setState("open"); events.onopen?.(); @@ -283,7 +327,7 @@ export class EncryptedChannel { this.options = options; Object.assign(transport, { - onmessage: (data: string | ArrayBuffer) => this.handleMessage(data), + onmessage: (message: TransportMessage) => this.handleMessage(message), onclose: (code: number, reason: string) => { this.state = "closed"; this.events.onclose?.(code, reason); @@ -299,12 +343,14 @@ export class EncryptedChannel { this.state = state; } - private async handleMessage(data: string | ArrayBuffer): Promise { + private async handleMessage(message: TransportMessage): Promise { if (this.state === "handshaking") { try { - const text = typeof data === "string" ? data : new TextDecoder().decode(data); + if (message.isBinary) return; + const text = decodeTransportText(message.data); const parsed: unknown = JSON.parse(text); if (isE2EEReadyMessage(parsed)) { + this.options.binaryCiphertext = supportsBinaryCiphertext(parsed); this.state = "open"; this.events.onopen?.(); for (const cb of this.onOpenCallbacks) cb(); @@ -322,13 +368,14 @@ export class EncryptedChannel { const ciphertext = await (async () => { // Handle (or ignore) any stray plaintext handshake traffic. try { - const text = typeof data === "string" ? data : new TextDecoder().decode(data); + if (message.isBinary) throw new Error("not plaintext handshake traffic"); + const text = decodeTransportText(message.data); if (text.trim().startsWith("{")) { const parsed: unknown = JSON.parse(text); if (isE2EEHelloMessage(parsed)) { if (this.options.daemonKeyPair) { - await this.handleDaemonRehello(parsed.key); + await this.handleDaemonRehello(parsed); } return null; } @@ -350,22 +397,33 @@ export class EncryptedChannel { // decoding ciphertext below. } - if (typeof data === "string") { - return base64ToArrayBuffer(data); + if (this.options.binaryCiphertext) { + return message.isBinary + ? { data: requireArrayBuffer(message.data), isBinary: true as const } + : { + data: base64ToArrayBuffer(decodeTransportText(message.data)), + isBinary: false as const, + }; } - // Some WebSocket implementations deliver text frames as ArrayBuffer. - // Our protocol always transmits ciphertext as base64 text. + // COMPAT(binaryCiphertext): added in v0.2.3, remove legacy base64-only + // receive mode after 2027-01-27. + if (!message.isBinary) { + return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null }; + } + + // Older transport adapters could lose the opcode. Retain the former + // base64-first behavior only in the legacy path. try { - const decoded = new TextDecoder().decode(data); - return base64ToArrayBuffer(decoded); + return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null }; } catch { - return data; + return { data: requireArrayBuffer(message.data), isBinary: null }; } })(); if (ciphertext) { - const plaintext = decrypt(this.sharedKey, ciphertext); + const plaintextBytes = decrypt(this.sharedKey, ciphertext.data); + const plaintext = decodePlaintext(plaintextBytes, ciphertext.isBinary); this.events.onmessage?.(plaintext); } } catch (error) { @@ -396,10 +454,23 @@ export class EncryptedChannel { } const ciphertext = encrypt(this.sharedKey, data); - // Send as base64 for WebSocket text compatibility + if (this.options.binaryCiphertext && data instanceof ArrayBuffer) { + this.transport.send(ciphertext); + return; + } + // COMPAT(binaryCiphertext): added in v0.2.3, remove base64 binary sends + // after 2027-01-27 once the supported peer floor includes negotiation. this.transport.send(arrayBufferToBase64(ciphertext)); } + outboundWireByteLength(data: string | ArrayBuffer): number { + const encryptedBytes = utf8ByteLength(data) + 40; + if (this.options.binaryCiphertext && data instanceof ArrayBuffer) { + return encryptedBytes; + } + return 4 * Math.ceil(encryptedBytes / 3); + } + private async flushPendingSends(): Promise { if (this.state !== "open") return; const pending = this.pendingSends; @@ -409,16 +480,23 @@ export class EncryptedChannel { } } - private async handleDaemonRehello(clientKeyB64: string): Promise { + private async handleDaemonRehello(message: E2EEHelloMessage): Promise { if (!this.options.daemonKeyPair) return; - const clientPublicKey = importPublicKey(clientKeyB64); + const clientPublicKey = importPublicKey(message.key); const nextSharedKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey); // If it's the same client key (handshake retry), re-send // "ready" but do not re-key. Re-keying here would desync // the channel and cause decrypt failures. if (keysEqual(nextSharedKey, this.sharedKey)) { - this.transport.send(JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage)); + this.transport.send( + JSON.stringify({ + type: "e2ee_ready", + ...(this.options.binaryCiphertext + ? { capabilities: { binaryCiphertext: true } satisfies E2EECapabilities } + : {}), + } satisfies E2EEReadyMessage), + ); return; } @@ -450,6 +528,33 @@ export class EncryptedChannel { } } +function decodeTransportText(data: string | ArrayBuffer): string { + return typeof data === "string" ? data : new TextDecoder().decode(data); +} + +function requireArrayBuffer(data: string | ArrayBuffer): ArrayBuffer { + if (data instanceof ArrayBuffer) return data; + throw new Error("Binary WebSocket frame did not contain bytes"); +} + +function decodeLegacyPlaintext(data: ArrayBuffer): string | ArrayBuffer { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(data); + } catch { + return data; + } +} + +function decodePlaintext(data: ArrayBuffer, isBinary: boolean | null): string | ArrayBuffer { + if (isBinary === true) return data; + if (isBinary === false) return new TextDecoder("utf-8", { fatal: true }).decode(data); + return decodeLegacyPlaintext(data); +} + +function utf8ByteLength(data: string | ArrayBuffer): number { + return typeof data === "string" ? new TextEncoder().encode(data).byteLength : data.byteLength; +} + function keysEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.byteLength !== b.byteLength) return false; let difference = 0; diff --git a/packages/relay/src/live-relay.e2e.test.ts b/packages/relay/src/live-relay.e2e.test.ts index 411e72091..c88a2199f 100644 --- a/packages/relay/src/live-relay.e2e.test.ts +++ b/packages/relay/src/live-relay.e2e.test.ts @@ -171,7 +171,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => { daemonReceivedCiphertext.byteOffset + daemonReceivedCiphertext.byteLength, ), ); - expect(decryptedOnDaemon).toBe(plaintextFromClient); + expect(new TextDecoder().decode(decryptedOnDaemon)).toBe(plaintextFromClient); const plaintextFromDaemon = "hello-from-daemon"; const ciphertextFromDaemon = encrypt(daemonSharedKey, plaintextFromDaemon); @@ -190,7 +190,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => { clientReceivedCiphertext.byteOffset + clientReceivedCiphertext.byteLength, ), ); - expect(decryptedOnClient).toBe(plaintextFromDaemon); + expect(new TextDecoder().decode(decryptedOnClient)).toBe(plaintextFromDaemon); } finally { daemonControlWs.close(); daemonWs?.close(); diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 7b5a1ab54..327098f2d 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -83,10 +83,7 @@ function decodeCiphertext(text: string): ArrayBuffer { } function parseEncryptedJson(sharedKey: Uint8Array, text: string): unknown { - const plaintext = decrypt(sharedKey, decodeCiphertext(text)); - if (typeof plaintext !== "string") { - throw new Error("Expected encrypted relay frame to contain UTF-8 JSON"); - } + const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text))); return JSON.parse(plaintext); } @@ -291,8 +288,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis onerror: null, }; - ws.on("message", (data) => { - transport.onmessage?.(typeof data === "string" ? data : data.toString()); + ws.on("message", (data, isBinary) => { + transport.onmessage?.({ + data: isBinary + ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) + : data.toString(), + isBinary, + }); }); ws.on("close", (code, reason) => { transport.onclose?.(code, reason.toString()); @@ -434,8 +436,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis onerror: null, }; - ws.on("message", (data) => { - transport.onmessage?.(typeof data === "string" ? data : data.toString()); + ws.on("message", (data, isBinary) => { + transport.onmessage?.({ + data: isBinary + ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) + : data.toString(), + isBinary, + }); }); ws.on("close", (code, reason) => { transport.onclose?.(code, reason.toString()); diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 9eb8cc859..b39fef1a2 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -484,7 +484,8 @@ function createRelayTransportAdapter( }; socket.on("message", (data, isBinary) => { - relayTransport.onmessage?.(normalizeMessageData(data, isBinary === true)); + const binary = isBinary === true; + relayTransport.onmessage?.({ data: normalizeMessageData(data, binary), isBinary: binary }); }); socket.on("close", (code, reason) => { const closeCode = typeof code === "number" ? code : 1006; diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.test.ts b/packages/server/src/server/websocket/encrypted-relay-socket.test.ts index 3add059fc..53b230c56 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.test.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.test.ts @@ -26,12 +26,18 @@ class BlockingChannel implements EncryptedRelayChannel { this.closes.push({ code, reason }); } + outboundWireByteLength(data: string | ArrayBuffer): number { + const plaintextBytes = + typeof data === "string" ? new TextEncoder().encode(data).byteLength : data.byteLength; + return plaintextBytes + 40; + } + drain(): void { this.resolveSend?.(); } } -test("the encrypted send queue terminates its physical transport at the hard bound", async () => { +test("negotiated binary ciphertext accepts the exact hard bound and rejects one byte over", async () => { const channel = new BlockingChannel(); let terminations = 0; const socket = createEncryptedRelaySocket({ @@ -43,11 +49,11 @@ test("the encrypted send queue terminates its physical transport at the hard bou }, }); - socket.send(new Uint8Array(5 * 1024 * 1024)); + socket.send(new Uint8Array(MAX_PHYSICAL_SOCKET_BUFFERED_BYTES - 40)); expect(channel.sent).toHaveLength(1); - expect(socket.bufferedAmount).toBeGreaterThan(5 * 1024 * 1024); + expect(socket.bufferedAmount).toBe(MAX_PHYSICAL_SOCKET_BUFFERED_BYTES); - socket.send(new Uint8Array(2 * 1024 * 1024)); + socket.send(new Uint8Array(1)); expect(channel.sent).toHaveLength(1); expect(terminations).toBe(1); @@ -93,7 +99,7 @@ test("pending encryption and underlying relay backpressure share one hard bound" socket.send(new Uint8Array(3 * 1024 * 1024)); expect(channel.sent).toHaveLength(1); - transportBufferedAmount = 4 * 1024 * 1024; + transportBufferedAmount = 6 * 1024 * 1024; socket.send(new Uint8Array(1)); expect(channel.sent).toHaveLength(1); diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.ts b/packages/server/src/server/websocket/encrypted-relay-socket.ts index 4b00f8200..c225ed3ec 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.ts @@ -1,12 +1,10 @@ import { EventEmitter } from "node:events"; -import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES, outboundFrameByteLength } from "./physical-socket.js"; - -// NaCl adds a 24-byte nonce and 16-byte authenticator before base64 encoding. -const ENCRYPTED_FRAME_OVERHEAD_BYTES = 40; +import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES } from "./physical-socket.js"; export interface EncryptedRelayChannel { setState: (state: "open") => void; send: (data: string | ArrayBuffer) => Promise; + outboundWireByteLength: (data: string | ArrayBuffer) => number; close: (code?: number, reason?: string) => void; } @@ -58,7 +56,7 @@ export function createEncryptedRelaySocket(params: { send: (data) => { if (readyState !== 1) return; const outbound = normalizeRelaySendPayload(data); - const outboundBytes = encryptedRelayFrameByteLength(outbound); + const outboundBytes = channel.outboundWireByteLength(outbound); const queuedBytes = pendingEncryptedBytes + (getTransportBufferedAmount() ?? 0); if (queuedBytes + outboundBytes > MAX_PHYSICAL_SOCKET_BUFFERED_BYTES) { terminate(); @@ -93,8 +91,3 @@ function normalizeRelaySendPayload(data: string | Uint8Array | ArrayBuffer): str out.set(view); return out.buffer; } - -function encryptedRelayFrameByteLength(data: string | ArrayBuffer): number { - const encryptedBytes = outboundFrameByteLength(data) + ENCRYPTED_FRAME_OVERHEAD_BYTES; - return 4 * Math.ceil(encryptedBytes / 3); -}