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
+3 -1
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` 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. 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. 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. 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.
+1
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 - The relay is zero-knowledge — it routes encrypted bytes and cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) - Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client - 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` - 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. 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.
+1 -1
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) ## 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. - 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. - `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.
@@ -63,7 +63,7 @@ export function createDesktopLocalDaemonTransportFactory(
const openHandlers = new Set<() => void>(); const openHandlers = new Set<() => void>();
const closeHandlers = new Set<(event?: unknown) => void>(); const closeHandlers = new Set<(event?: unknown) => void>();
const errorHandlers = 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 = () => { const emitOpen = () => {
if (didEmitOpen || disposed) { if (didEmitOpen || disposed) {
@@ -84,9 +84,9 @@ export function createDesktopLocalDaemonTransportFactory(
handler(event); handler(event);
} }
}; };
const emitMessage = (data: unknown) => { const emitMessage = (data: unknown, isBinary: boolean) => {
for (const handler of messageHandlers) { for (const handler of messageHandlers) {
handler(data); handler(data, isBinary);
} }
}; };
@@ -101,11 +101,11 @@ export function createDesktopLocalDaemonTransportFactory(
} }
if (payload.kind === "message") { if (payload.kind === "message") {
if (payload.text) { if (payload.text) {
emitMessage({ data: payload.text }); emitMessage(payload.text, false);
return; return;
} }
if (payload.binaryBase64) { if (payload.binaryBase64) {
emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) }); emitMessage(decodeBase64ToBytes(payload.binaryBase64), true);
} }
return; return;
} }
@@ -8,15 +8,12 @@ import type {
DaemonTransportFactory, DaemonTransportFactory,
TransportLogger, TransportLogger,
} from "./daemon-client-transport-types.js"; } from "./daemon-client-transport-types.js";
import { import { extractRelayMessage, normalizeTransportPayload } from "./daemon-client-transport-utils.js";
extractRelayMessageData,
normalizeTransportPayload,
} from "./daemon-client-transport-utils.js";
type OpenHandler = () => void; type OpenHandler = () => void;
type CloseHandler = (event?: unknown) => void; type CloseHandler = (event?: unknown) => void;
type ErrorHandler = (event?: unknown) => void; type ErrorHandler = (event?: unknown) => void;
type MessageHandler = (data: unknown) => void; type MessageHandler = (data: unknown, isBinary: boolean) => void;
export function createRelayE2eeTransportFactory(args: { export function createRelayE2eeTransportFactory(args: {
baseFactory: DaemonTransportFactory; baseFactory: DaemonTransportFactory;
@@ -70,7 +67,7 @@ export function createEncryptedTransport(
if (closed) { if (closed) {
return; return;
} }
emitHandlers(messageHandlers, data); emitHandlers(messageHandlers, data, data instanceof ArrayBuffer);
}; };
const relayTransport: RelayTransport = { const relayTransport: RelayTransport = {
@@ -115,8 +112,8 @@ export function createEncryptedTransport(
base.onOpen(() => { base.onOpen(() => {
void startHandshake(); void startHandshake();
}); });
base.onMessage((event) => { base.onMessage((data, isBinary) => {
relayTransport.onmessage?.(extractRelayMessageData(event)); relayTransport.onmessage?.(extractRelayMessage(data, isBinary));
}); });
base.onClose((event) => { base.onClose((event) => {
const record = event as { code?: number; reason?: string } | undefined; const record = event as { code?: number; reason?: string } | undefined;
@@ -1,7 +1,7 @@
export interface DaemonTransport { export interface DaemonTransport {
send: (data: string | Uint8Array | ArrayBuffer) => void; send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => 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; onOpen: (handler: () => void) => () => void;
onClose: (handler: (event?: unknown) => void) => () => void; onClose: (handler: (event?: unknown) => void) => () => void;
onError: (handler: (event?: unknown) => void) => () => void; onError: (handler: (event?: unknown) => void) => () => void;
@@ -14,17 +14,25 @@ export function normalizeTransportPayload(
return copyArrayBufferViewToBuffer(data); 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 = const raw =
event && typeof event === "object" && "data" in event event && typeof event === "object" && "data" in event
? (event as { data: unknown }).data ? (event as { data: unknown }).data
: event; : event;
if (typeof raw === "string") return raw; const isBinary = nodeIsBinary ?? typeof raw !== "string";
if (raw instanceof ArrayBuffer) return raw; if (!isBinary) {
if (ArrayBuffer.isView(raw)) { return { data: decodeMessageData(raw) ?? String(raw ?? ""), isBinary: false };
return copyArrayBufferViewToBuffer(raw);
} }
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 { export function describeTransportClose(event?: unknown): string {
@@ -7,7 +7,7 @@ import {
describeTransportClose, describeTransportClose,
describeTransportError, describeTransportError,
encodeUtf8String, encodeUtf8String,
extractRelayMessageData, extractRelayMessage,
} from "./daemon-client-transport.js"; } from "./daemon-client-transport.js";
const createClientChannelMock = vi.hoisted(() => vi.fn()); const createClientChannelMock = vi.hoisted(() => vi.fn());
@@ -130,7 +130,7 @@ describe("daemon-client transport helpers", () => {
const message = { data: "payload" }; const message = { data: "payload" };
listeners.get("message")?.(message); listeners.get("message")?.(message);
expect(onMessage).toHaveBeenCalledWith(message); expect(onMessage).toHaveBeenCalledWith("payload", false);
unsubscribe(); unsubscribe();
expect(ws.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function)); expect(ws.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function));
@@ -165,13 +165,16 @@ describe("daemon-client transport helpers", () => {
expect(describeTransportError()).toBe("Transport error"); expect(describeTransportError()).toBe("Transport error");
}); });
test("extractRelayMessageData returns strings and array buffers", () => { test("extractRelayMessage preserves browser and Node WebSocket frame kind", () => {
expect(extractRelayMessageData({ data: "hello" })).toBe("hello"); expect(extractRelayMessage({ data: "hello" })).toEqual({ data: "hello", isBinary: false });
const view = new Uint8Array([1, 2, 3]); const view = new Uint8Array([1, 2, 3]);
const extracted = extractRelayMessageData({ data: view }); const binary = extractRelayMessage(view, true);
expect(extracted).toBeInstanceOf(ArrayBuffer); expect(binary.isBinary).toBe(true);
expect(Array.from(new Uint8Array(extracted as ArrayBuffer))).toEqual([1, 2, 3]); 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", () => { test("decodeMessageData decodes strings, array buffers, and typed arrays", () => {
@@ -10,7 +10,7 @@ export {
describeTransportClose, describeTransportClose,
describeTransportError, describeTransportError,
encodeUtf8String, encodeUtf8String,
extractRelayMessageData, extractRelayMessage,
normalizeTransportPayload, normalizeTransportPayload,
safeRandomId, safeRandomId,
} from "./daemon-client-transport-utils.js"; } from "./daemon-client-transport-utils.js";
@@ -3,6 +3,7 @@ import type {
WebSocketFactory, WebSocketFactory,
WebSocketLike, WebSocketLike,
} from "./daemon-client-transport-types.js"; } from "./daemon-client-transport-types.js";
import { extractRelayMessage } from "./daemon-client-transport-utils.js";
export function defaultWebSocketFactory( export function defaultWebSocketFactory(
url: string, url: string,
@@ -52,11 +53,25 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
onOpen: (handler) => bindWsHandler(ws, "open", handler), onOpen: (handler) => bindWsHandler(ws, "open", handler),
onClose: (handler) => bindWsHandler(ws, "close", handler), onClose: (handler) => bindWsHandler(ws, "close", handler),
onError: (handler) => bindWsHandler(ws, "error", 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 { function bindTemporaryEarlyCloseErrorHandler(ws: WebSocketLike): () => void {
const noop = () => {}; const noop = () => {};
+1 -1
View File
@@ -34,7 +34,7 @@ class FakeWebSocket {
} }
message(data: string): void { message(data: string): void {
this.onmessage?.(data); this.onmessage?.({ data });
} }
} }
+10 -6
View File
@@ -8,6 +8,10 @@ import {
decrypt, decrypt,
} from "./crypto.js"; } from "./crypto.js";
function decryptText(sharedKey: Uint8Array, ciphertext: ArrayBuffer): string {
return new TextDecoder().decode(decrypt(sharedKey, ciphertext));
}
describe("crypto", () => { describe("crypto", () => {
describe("generateKeyPair", () => { describe("generateKeyPair", () => {
it("generates a valid keypair", () => { 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 // Both should derive the same key - test by encrypting with one, decrypting with other
const testMessage = "Hello, encrypted world!"; const testMessage = "Hello, encrypted world!";
const encrypted = encrypt(daemonSharedKey, testMessage); const encrypted = encrypt(daemonSharedKey, testMessage);
const decrypted = decrypt(clientSharedKey, encrypted); const decrypted = decryptText(clientSharedKey, encrypted);
expect(decrypted).toBe(testMessage); expect(decrypted).toBe(testMessage);
}); });
@@ -73,7 +77,7 @@ describe("crypto", () => {
expect(ciphertext).toBeInstanceOf(ArrayBuffer); expect(ciphertext).toBeInstanceOf(ArrayBuffer);
expect(ciphertext.byteLength).toBeGreaterThan(plaintext.length); expect(ciphertext.byteLength).toBeGreaterThan(plaintext.length);
const decrypted = decrypt(sharedKey, ciphertext); const decrypted = decryptText(sharedKey, ciphertext);
expect(decrypted).toBe(plaintext); expect(decrypted).toBe(plaintext);
}); });
@@ -118,8 +122,8 @@ describe("crypto", () => {
expect(arr1).not.toEqual(arr2); expect(arr1).not.toEqual(arr2);
// But both should decrypt to same plaintext // But both should decrypt to same plaintext
expect(decrypt(sharedKey, ciphertext1)).toBe(plaintext); expect(decryptText(sharedKey, ciphertext1)).toBe(plaintext);
expect(decrypt(sharedKey, ciphertext2)).toBe(plaintext); expect(decryptText(sharedKey, ciphertext2)).toBe(plaintext);
}); });
}); });
@@ -156,11 +160,11 @@ describe("crypto", () => {
// Daemon encrypts, client decrypts // Daemon encrypts, client decrypts
const encryptedFromDaemon = encrypt(daemonSharedKey, testFromDaemon); const encryptedFromDaemon = encrypt(daemonSharedKey, testFromDaemon);
expect(decrypt(clientSharedKey, encryptedFromDaemon)).toBe(testFromDaemon); expect(decryptText(clientSharedKey, encryptedFromDaemon)).toBe(testFromDaemon);
// Client encrypts, daemon decrypts // Client encrypts, daemon decrypts
const encryptedFromClient = encrypt(clientSharedKey, testFromClient); const encryptedFromClient = encrypt(clientSharedKey, testFromClient);
expect(decrypt(daemonSharedKey, encryptedFromClient)).toBe(testFromClient); expect(decryptText(daemonSharedKey, encryptedFromClient)).toBe(testFromClient);
}); });
}); });
}); });
+4 -9
View File
@@ -8,8 +8,8 @@
* Bundle format (binary): * Bundle format (binary):
* [nonce (24 bytes)] [ciphertext...] * [nonce (24 bytes)] [ciphertext...]
* *
* Transport format: * The encrypted channel chooses the WebSocket representation. Crypto remains
* The encrypted-channel sends the bundle as base64 text over WebSocket. * byte-oriented so frame kind is never inferred from plaintext contents.
*/ */
import nacl from "tweetnacl"; import nacl from "tweetnacl";
@@ -139,7 +139,7 @@ export function encrypt(sharedKey: SharedKey, data: string | ArrayBuffer): Array
return toArrayBuffer(out); 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); const bytes = new Uint8Array(data);
if (bytes.byteLength < NONCE_LENGTH) { if (bytes.byteLength < NONCE_LENGTH) {
throw new Error("Ciphertext bundle too short"); 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"); throw new Error("Decryption failed");
} }
const plaintext = toArrayBuffer(opened); return toArrayBuffer(opened);
try {
return new TextDecoder("utf-8", { fatal: true }).decode(plaintext);
} catch {
return plaintext;
}
} }
+4 -4
View File
@@ -348,7 +348,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
clientReceivedReady.byteOffset + clientReceivedReady.byteLength, 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 // Client sends encrypted message
const clientMessage = "Hello from client!"; const clientMessage = "Hello from client!";
@@ -366,7 +366,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
daemonReceivedMsg.byteOffset + daemonReceivedMsg.byteLength, daemonReceivedMsg.byteOffset + daemonReceivedMsg.byteLength,
), ),
); );
expect(decryptedClientMsg).toBe(clientMessage); expect(new TextDecoder().decode(decryptedClientMsg)).toBe(clientMessage);
// Daemon sends encrypted response // Daemon sends encrypted response
const daemonMessage = "Hello from daemon!"; const daemonMessage = "Hello from daemon!";
@@ -384,7 +384,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
clientReceivedMsg.byteOffset + clientReceivedMsg.byteLength, clientReceivedMsg.byteOffset + clientReceivedMsg.byteLength,
), ),
); );
expect(decryptedDaemonMsg).toBe(daemonMessage); expect(new TextDecoder().decode(decryptedDaemonMsg)).toBe(daemonMessage);
// Cleanup // Cleanup
daemonWs.close(); daemonWs.close();
@@ -478,7 +478,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
daemonSharedKey, daemonSharedKey,
received.buffer.slice(received.byteOffset, received.byteOffset + received.byteLength), received.buffer.slice(received.byteOffset, received.byteOffset + received.byteLength),
); );
expect(decrypted).toBe(secret); expect(new TextDecoder().decode(decrypted)).toBe(secret);
daemonControlWs.close(); daemonControlWs.close();
daemonWs.close(); daemonWs.close();
+1 -1
View File
@@ -1,5 +1,5 @@
export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js"; 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 { export {
generateKeyPair, generateKeyPair,
+153 -6
View File
@@ -1,6 +1,13 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { createClientChannel, createDaemonChannel, Transport } from "./encrypted-channel.js"; 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. * Creates a pair of connected mock transports.
@@ -25,11 +32,11 @@ function createMockTransportPair(): [Transport, Transport] {
// Wire them together // Wire them together
(transportA.send as ReturnType<typeof vi.fn>).mockImplementation((data: string | ArrayBuffer) => { (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) => { (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]; return [transportA, transportB];
@@ -193,7 +200,7 @@ describe("EncryptedChannel", () => {
// Send invalid hello // Send invalid hello
setTimeout(() => { setTimeout(() => {
daemonTransport.onmessage?.('{"type":"invalid"}'); daemonTransport.onmessage?.({ data: '{"type":"invalid"}', isBinary: false });
}, 0); }, 0);
await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message"); await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message");
@@ -227,7 +234,7 @@ describe("EncryptedChannel", () => {
)?.[0]; )?.[0];
expect(typeof firstHello).toBe("string"); expect(typeof firstHello).toBe("string");
daemonTransport.onmessage?.(firstHello as string); daemonTransport.onmessage?.({ data: firstHello as string, isBinary: false });
await waitForAsyncDelivery(); await waitForAsyncDelivery();
expect(daemonTransport.close).not.toHaveBeenCalled(); expect(daemonTransport.close).not.toHaveBeenCalled();
@@ -264,9 +271,149 @@ describe("EncryptedChannel", () => {
key: exportPublicKey(attackerKeyPair.publicKey), key: exportPublicKey(attackerKeyPair.publicKey),
}); });
daemonTransport.onmessage?.(attackerHello); daemonTransport.onmessage?.({ data: attackerHello, isBinary: false });
await waitForAsyncDelivery(); await waitForAsyncDelivery();
expect(daemonTransport.close).toHaveBeenCalledWith(1008, "E2EE re-handshake key mismatch"); 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",
);
});
}); });
+134 -29
View File
@@ -21,11 +21,16 @@ import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
export interface Transport { export interface Transport {
send(data: string | ArrayBuffer): void; send(data: string | ArrayBuffer): void;
close(code?: number, reason?: string): 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; onclose: ((code: number, reason: string) => void) | null;
onerror: ((error: Error) => void) | null; onerror: ((error: Error) => void) | null;
} }
export interface TransportMessage {
data: string | ArrayBuffer;
isBinary: boolean;
}
export interface EncryptedChannelEvents { export interface EncryptedChannelEvents {
onopen?: () => void; onopen?: () => void;
onmessage?: (data: string | ArrayBuffer) => void; onmessage?: (data: string | ArrayBuffer) => void;
@@ -45,32 +50,52 @@ interface EncryptedChannelOptions {
* the daemon should re-send `{type:"e2ee_ready"}` without changing keys. * the daemon should re-send `{type:"e2ee_ready"}` without changing keys.
*/ */
daemonKeyPair?: KeyPair; daemonKeyPair?: KeyPair;
binaryCiphertext?: boolean;
} }
interface E2EEHelloMessage { interface E2EEHelloMessage {
type: "e2ee_hello"; type: "e2ee_hello";
key: string; key: string;
capabilities?: E2EECapabilities;
} }
interface E2EEReadyMessage { interface E2EEReadyMessage {
type: "e2ee_ready"; type: "e2ee_ready";
capabilities?: E2EECapabilities;
}
interface E2EECapabilities {
binaryCiphertext?: boolean;
} }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); 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 { function isE2EEHelloMessage(value: unknown): value is E2EEHelloMessage {
return ( return (
isRecord(value) && isRecord(value) &&
value.type === "e2ee_hello" && value.type === "e2ee_hello" &&
typeof value.key === "string" && typeof value.key === "string" &&
value.key.trim().length > 0 value.key.trim().length > 0 &&
isE2EECapabilities(value.capabilities)
); );
} }
function isE2EEReadyMessage(value: unknown): value is E2EEReadyMessage { 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 { function buildInvalidHelloError(rawText: string, parsed?: unknown): Error {
@@ -130,7 +155,11 @@ export async function createClientChannel(
// Send e2ee_hello with our public key // Send e2ee_hello with our public key
const ourPublicKeyB64 = exportPublicKey(keyPair.publicKey); 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); const helloText = JSON.stringify(hello);
let retry: ReturnType<typeof setInterval> | null = null; let retry: ReturnType<typeof setInterval> | null = null;
@@ -189,10 +218,11 @@ export async function createDaemonChannel(
events: EncryptedChannelEvents = {}, events: EncryptedChannelEvents = {},
): Promise<EncryptedChannel> { ): Promise<EncryptedChannel> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const bufferedMessages: Array<string | ArrayBuffer> = []; const bufferedMessages: TransportMessage[] = [];
const shouldIgnorePostHelloPlaintext = (data: string | ArrayBuffer): boolean => { const shouldIgnorePostHelloPlaintext = (message: TransportMessage): boolean => {
try { 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); const parsed: unknown = JSON.parse(text);
return isE2EEHelloMessage(parsed) || isE2EEReadyMessage(parsed); return isE2EEHelloMessage(parsed) || isE2EEReadyMessage(parsed);
} catch { } 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 { 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; let parsed: unknown;
try { try {
@@ -221,7 +254,7 @@ export async function createDaemonChannel(
// WebCrypto work to derive the shared key. Without this, it's possible // WebCrypto work to derive the shared key. Without this, it's possible
// for the next message (already encrypted) to be misinterpreted as a // for the next message (already encrypted) to be misinterpreted as a
// second hello, causing the handshake to fail. // second hello, causing the handshake to fail.
const bufferNext = (next: string | ArrayBuffer): void => { const bufferNext = (next: TransportMessage): void => {
bufferedMessages.push(next); bufferedMessages.push(next);
}; };
Object.assign(transport, { onmessage: bufferNext }); Object.assign(transport, { onmessage: bufferNext });
@@ -229,8 +262,19 @@ export async function createDaemonChannel(
const clientPublicKey = importPublicKey(msg.key); const clientPublicKey = importPublicKey(msg.key);
const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey); const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
const channel = new EncryptedChannel(transport, sharedKey, events, { daemonKeyPair }); const binaryCiphertext = supportsBinaryCiphertext(msg);
transport.send(JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage)); 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"); channel.setState("open");
events.onopen?.(); events.onopen?.();
@@ -283,7 +327,7 @@ export class EncryptedChannel {
this.options = options; this.options = options;
Object.assign(transport, { Object.assign(transport, {
onmessage: (data: string | ArrayBuffer) => this.handleMessage(data), onmessage: (message: TransportMessage) => this.handleMessage(message),
onclose: (code: number, reason: string) => { onclose: (code: number, reason: string) => {
this.state = "closed"; this.state = "closed";
this.events.onclose?.(code, reason); this.events.onclose?.(code, reason);
@@ -299,12 +343,14 @@ export class EncryptedChannel {
this.state = state; this.state = state;
} }
private async handleMessage(data: string | ArrayBuffer): Promise<void> { private async handleMessage(message: TransportMessage): Promise<void> {
if (this.state === "handshaking") { if (this.state === "handshaking") {
try { 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); const parsed: unknown = JSON.parse(text);
if (isE2EEReadyMessage(parsed)) { if (isE2EEReadyMessage(parsed)) {
this.options.binaryCiphertext = supportsBinaryCiphertext(parsed);
this.state = "open"; this.state = "open";
this.events.onopen?.(); this.events.onopen?.();
for (const cb of this.onOpenCallbacks) cb(); for (const cb of this.onOpenCallbacks) cb();
@@ -322,13 +368,14 @@ export class EncryptedChannel {
const ciphertext = await (async () => { const ciphertext = await (async () => {
// Handle (or ignore) any stray plaintext handshake traffic. // Handle (or ignore) any stray plaintext handshake traffic.
try { 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("{")) { if (text.trim().startsWith("{")) {
const parsed: unknown = JSON.parse(text); const parsed: unknown = JSON.parse(text);
if (isE2EEHelloMessage(parsed)) { if (isE2EEHelloMessage(parsed)) {
if (this.options.daemonKeyPair) { if (this.options.daemonKeyPair) {
await this.handleDaemonRehello(parsed.key); await this.handleDaemonRehello(parsed);
} }
return null; return null;
} }
@@ -350,22 +397,33 @@ export class EncryptedChannel {
// decoding ciphertext below. // decoding ciphertext below.
} }
if (typeof data === "string") { if (this.options.binaryCiphertext) {
return base64ToArrayBuffer(data); 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. // COMPAT(binaryCiphertext): added in v0.2.3, remove legacy base64-only
// Our protocol always transmits ciphertext as base64 text. // 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 { try {
const decoded = new TextDecoder().decode(data); return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null };
return base64ToArrayBuffer(decoded);
} catch { } catch {
return data; return { data: requireArrayBuffer(message.data), isBinary: null };
} }
})(); })();
if (ciphertext) { 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); this.events.onmessage?.(plaintext);
} }
} catch (error) { } catch (error) {
@@ -396,10 +454,23 @@ export class EncryptedChannel {
} }
const ciphertext = encrypt(this.sharedKey, data); 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)); 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> { private async flushPendingSends(): Promise<void> {
if (this.state !== "open") return; if (this.state !== "open") return;
const pending = this.pendingSends; 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; if (!this.options.daemonKeyPair) return;
const clientPublicKey = importPublicKey(clientKeyB64); const clientPublicKey = importPublicKey(message.key);
const nextSharedKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey); const nextSharedKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey);
// If it's the same client key (handshake retry), re-send // If it's the same client key (handshake retry), re-send
// "ready" but do not re-key. Re-keying here would desync // "ready" but do not re-key. Re-keying here would desync
// the channel and cause decrypt failures. // the channel and cause decrypt failures.
if (keysEqual(nextSharedKey, this.sharedKey)) { 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; 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 { function keysEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) return false; if (a.byteLength !== b.byteLength) return false;
let difference = 0; let difference = 0;
+2 -2
View File
@@ -171,7 +171,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
daemonReceivedCiphertext.byteOffset + daemonReceivedCiphertext.byteLength, daemonReceivedCiphertext.byteOffset + daemonReceivedCiphertext.byteLength,
), ),
); );
expect(decryptedOnDaemon).toBe(plaintextFromClient); expect(new TextDecoder().decode(decryptedOnDaemon)).toBe(plaintextFromClient);
const plaintextFromDaemon = "hello-from-daemon"; const plaintextFromDaemon = "hello-from-daemon";
const ciphertextFromDaemon = encrypt(daemonSharedKey, plaintextFromDaemon); const ciphertextFromDaemon = encrypt(daemonSharedKey, plaintextFromDaemon);
@@ -190,7 +190,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
clientReceivedCiphertext.byteOffset + clientReceivedCiphertext.byteLength, clientReceivedCiphertext.byteOffset + clientReceivedCiphertext.byteLength,
), ),
); );
expect(decryptedOnClient).toBe(plaintextFromDaemon); expect(new TextDecoder().decode(decryptedOnClient)).toBe(plaintextFromDaemon);
} finally { } finally {
daemonControlWs.close(); daemonControlWs.close();
daemonWs?.close(); daemonWs?.close();
@@ -83,10 +83,7 @@ function decodeCiphertext(text: string): ArrayBuffer {
} }
function parseEncryptedJson(sharedKey: Uint8Array, text: string): unknown { function parseEncryptedJson(sharedKey: Uint8Array, text: string): unknown {
const plaintext = decrypt(sharedKey, decodeCiphertext(text)); const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text)));
if (typeof plaintext !== "string") {
throw new Error("Expected encrypted relay frame to contain UTF-8 JSON");
}
return JSON.parse(plaintext); return JSON.parse(plaintext);
} }
@@ -291,8 +288,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
onerror: null, onerror: null,
}; };
ws.on("message", (data) => { ws.on("message", (data, isBinary) => {
transport.onmessage?.(typeof data === "string" ? data : data.toString()); transport.onmessage?.({
data: isBinary
? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
: data.toString(),
isBinary,
});
}); });
ws.on("close", (code, reason) => { ws.on("close", (code, reason) => {
transport.onclose?.(code, reason.toString()); transport.onclose?.(code, reason.toString());
@@ -434,8 +436,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
onerror: null, onerror: null,
}; };
ws.on("message", (data) => { ws.on("message", (data, isBinary) => {
transport.onmessage?.(typeof data === "string" ? data : data.toString()); transport.onmessage?.({
data: isBinary
? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
: data.toString(),
isBinary,
});
}); });
ws.on("close", (code, reason) => { ws.on("close", (code, reason) => {
transport.onclose?.(code, reason.toString()); transport.onclose?.(code, reason.toString());
@@ -484,7 +484,8 @@ function createRelayTransportAdapter(
}; };
socket.on("message", (data, isBinary) => { 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) => { socket.on("close", (code, reason) => {
const closeCode = typeof code === "number" ? code : 1006; const closeCode = typeof code === "number" ? code : 1006;
@@ -26,12 +26,18 @@ class BlockingChannel implements EncryptedRelayChannel {
this.closes.push({ code, reason }); 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 { drain(): void {
this.resolveSend?.(); 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(); const channel = new BlockingChannel();
let terminations = 0; let terminations = 0;
const socket = createEncryptedRelaySocket({ 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(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(channel.sent).toHaveLength(1);
expect(terminations).toBe(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)); socket.send(new Uint8Array(3 * 1024 * 1024));
expect(channel.sent).toHaveLength(1); expect(channel.sent).toHaveLength(1);
transportBufferedAmount = 4 * 1024 * 1024; transportBufferedAmount = 6 * 1024 * 1024;
socket.send(new Uint8Array(1)); socket.send(new Uint8Array(1));
expect(channel.sent).toHaveLength(1); expect(channel.sent).toHaveLength(1);
@@ -1,12 +1,10 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES, outboundFrameByteLength } from "./physical-socket.js"; import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES } from "./physical-socket.js";
// NaCl adds a 24-byte nonce and 16-byte authenticator before base64 encoding.
const ENCRYPTED_FRAME_OVERHEAD_BYTES = 40;
export interface EncryptedRelayChannel { export interface EncryptedRelayChannel {
setState: (state: "open") => void; setState: (state: "open") => void;
send: (data: string | ArrayBuffer) => Promise<void>; send: (data: string | ArrayBuffer) => Promise<void>;
outboundWireByteLength: (data: string | ArrayBuffer) => number;
close: (code?: number, reason?: string) => void; close: (code?: number, reason?: string) => void;
} }
@@ -58,7 +56,7 @@ export function createEncryptedRelaySocket(params: {
send: (data) => { send: (data) => {
if (readyState !== 1) return; if (readyState !== 1) return;
const outbound = normalizeRelaySendPayload(data); const outbound = normalizeRelaySendPayload(data);
const outboundBytes = encryptedRelayFrameByteLength(outbound); const outboundBytes = channel.outboundWireByteLength(outbound);
const queuedBytes = pendingEncryptedBytes + (getTransportBufferedAmount() ?? 0); const queuedBytes = pendingEncryptedBytes + (getTransportBufferedAmount() ?? 0);
if (queuedBytes + outboundBytes > MAX_PHYSICAL_SOCKET_BUFFERED_BYTES) { if (queuedBytes + outboundBytes > MAX_PHYSICAL_SOCKET_BUFFERED_BYTES) {
terminate(); terminate();
@@ -93,8 +91,3 @@ function normalizeRelaySendPayload(data: string | Uint8Array | ArrayBuffer): str
out.set(view); out.set(view);
return out.buffer; return out.buffer;
} }
function encryptedRelayFrameByteLength(data: string | ArrayBuffer): number {
const encryptedBytes = outboundFrameByteLength(data) + ENCRYPTED_FRAME_OVERHEAD_BYTES;
return 4 * Math.ceil(encryptedBytes / 3);
}