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:
+3
-1
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 = () => {};
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class FakeWebSocket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message(data: string): void {
|
message(data: string): void {
|
||||||
this.onmessage?.(data);
|
this.onmessage?.({ data });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user