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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user