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.
This commit is contained in:
Mohamed Boudra
2026-07-27 12:12:02 +02:00
committed by GitHub
parent 1a1ff8828f
commit 1d1132de9c
22 changed files with 397 additions and 113 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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", () => {

View File

@@ -10,7 +10,7 @@ export {
describeTransportClose,
describeTransportError,
encodeUtf8String,
extractRelayMessageData,
extractRelayMessage,
normalizeTransportPayload,
safeRandomId,
} from "./daemon-client-transport-utils.js";

View File

@@ -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 = () => {};

View File

@@ -34,7 +34,7 @@ class FakeWebSocket {
}
message(data: string): void {
this.onmessage?.(data);
this.onmessage?.({ data });
}
}

View File

@@ -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);
});
});
});

View File

@@ -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);
}

View File

@@ -348,7 +348,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
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<void> {
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<void> {
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<void> {
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();

View File

@@ -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,

View File

@@ -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<typeof vi.fn>).mockImplementation((data: string | ArrayBuffer) => {
setTimeout(() => transportB.onmessage?.(data), 0);
setTimeout(() => transportB.onmessage?.({ data, isBinary: data instanceof ArrayBuffer }), 0);
});
(transportB.send as ReturnType<typeof vi.fn>).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<void>((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<typeof vi.fn>).mockClear();
await clientChannel.send(binary);
await waitForAsyncDelivery();
const ciphertext = (clientTransport.send as ReturnType<typeof vi.fn>).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<void>((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<typeof vi.fn>).mockClear();
await clientChannel.send("text payload");
await waitForAsyncDelivery();
expect(typeof (clientTransport.send as ReturnType<typeof vi.fn>).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<void>((resolve) => {
resolveOpen = resolve;
});
const clientChannel = await createClientChannel(
clientTransport,
exportPublicKey(daemonKeyPair.publicKey),
{ onopen: () => resolveOpen?.() },
);
const hello = JSON.parse(
(clientTransport.send as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockClear();
await clientChannel.send(new TextEncoder().encode("legacy binary").buffer);
expect(typeof (clientTransport.send as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0]?.[0]),
).toEqual({
type: "e2ee_ready",
});
(daemonTransport.send as ReturnType<typeof vi.fn>).mockClear();
await daemonChannel.send(new Uint8Array([1, 2, 3]).buffer);
expect(typeof (daemonTransport.send as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toBe(
"string",
);
});
});

View File

@@ -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<string, unknown> {
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<typeof setInterval> | null = null;
@@ -189,10 +218,11 @@ export async function createDaemonChannel(
events: EncryptedChannelEvents = {},
): Promise<EncryptedChannel> {
return new Promise((resolve, reject) => {
const bufferedMessages: Array<string | ArrayBuffer> = [];
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<void> => {
const handleHello = async (message: TransportMessage): Promise<void> => {
try {
const helloText = typeof data === "string" ? data : new TextDecoder().decode(data);
if (message.isBinary) {
throw buildInvalidHelloError("<binary frame>");
}
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<void> {
private async handleMessage(message: TransportMessage): Promise<void> {
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<void> {
if (this.state !== "open") return;
const pending = this.pendingSends;
@@ -409,16 +480,23 @@ export class EncryptedChannel {
}
}
private async handleDaemonRehello(clientKeyB64: string): Promise<void> {
private async handleDaemonRehello(message: E2EEHelloMessage): Promise<void> {
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;

View File

@@ -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();

View File

@@ -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());

View File

@@ -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;

View File

@@ -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);

View File

@@ -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<void>;
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);
}