Files
paseo/packages/relay/src/encrypted-channel.ts
T

580 lines
18 KiB
TypeScript

/// <reference lib="dom" />
/**
* Encrypted channel that wraps a WebSocket-like transport.
*
* Handles ECDH handshake and encrypts/decrypts all messages.
* Works identically for daemon and client sides.
*/
import {
generateKeyPair,
exportPublicKey,
importPublicKey,
deriveSharedKey,
encrypt,
decrypt,
type KeyPair,
type SharedKey,
} from "./crypto.js";
import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
export interface Transport {
send(data: string | ArrayBuffer): void | Promise<void>;
close(code?: number, reason?: string): void;
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;
onclose?: (code: number, reason: string) => void;
onerror?: (error: Error) => void;
}
type ChannelState = "connecting" | "handshaking" | "open" | "closed";
interface EncryptedChannelOptions {
/**
* If set, the channel can validate repeated plaintext `{type:"e2ee_hello"}`
* messages even after it is open.
*
* This is useful for robustness when the client retries the handshake
* (e.g., it didn't observe the daemon's `{type:"e2ee_ready"}` yet). In that case,
* 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 &&
isE2EECapabilities(value.capabilities)
);
}
function isE2EEReadyMessage(value: unknown): value is E2EEReadyMessage {
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 {
const parsedRecord = isRecord(parsed) ? parsed : null;
const rawType = parsedRecord?.type;
function describeType(value: unknown): string {
if (typeof value === "string") return value;
if (value === undefined) return "undefined";
return typeof value;
}
const receivedType = describeType(rawType);
const hasKey = typeof parsedRecord?.key === "string" && parsedRecord.key.trim().length > 0;
const compact = rawText.replace(/\s+/g, " ").trim();
const preview = compact.length > 160 ? `${compact.slice(0, 157)}...` : compact;
return new Error(
`Invalid hello message (receivedType=${receivedType}, hasKey=${hasKey}, preview=${JSON.stringify(preview)})`,
);
}
const HANDSHAKE_RETRY_MS = 1000;
const MAX_PENDING_SENDS = 200;
const REHANDSHAKE_REJECTION_CODE = 1008;
const ENCRYPTED_PAYLOAD_OVERHEAD_BYTES = 40;
export function base64EncryptedWireByteLength(plaintextBytes: number): number {
return 4 * Math.ceil((plaintextBytes + ENCRYPTED_PAYLOAD_OVERHEAD_BYTES) / 3);
}
export function maxBase64EncryptedPlaintextByteLength(wireBytes: number): number {
return Math.floor(wireBytes / 4) * 3 - ENCRYPTED_PAYLOAD_OVERHEAD_BYTES;
}
const REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON = "E2EE re-handshake key mismatch";
interface TimeoutWithUnref {
unref(): void;
}
function hasUnref(timeout: unknown): timeout is TimeoutWithUnref {
return (
typeof timeout === "object" &&
timeout !== null &&
"unref" in timeout &&
typeof (timeout as Record<string, unknown>).unref === "function"
);
}
/**
* Creates an encrypted channel as the initiator (client).
*
* The client:
* 1. Receives daemon's public key via QR code
* 2. Generates own keypair
* 3. Sends e2ee_hello with own public key
* 4. Derives shared key and starts encrypted communication
*/
export async function createClientChannel(
transport: Transport,
daemonPublicKeyB64: string,
events: EncryptedChannelEvents = {},
): Promise<EncryptedChannel> {
const keyPair = generateKeyPair();
const daemonPublicKey = importPublicKey(daemonPublicKeyB64);
const sharedKey = deriveSharedKey(keyPair.secretKey, daemonPublicKey);
const channel = new EncryptedChannel(transport, sharedKey, events);
// Send e2ee_hello with our public key
const ourPublicKeyB64 = exportPublicKey(keyPair.publicKey);
const hello: E2EEHelloMessage = {
type: "e2ee_hello",
key: ourPublicKeyB64,
capabilities: { binaryCiphertext: true },
};
const helloText = JSON.stringify(hello);
let retry: ReturnType<typeof setInterval> | null = null;
const emitSendError = (error: unknown) => {
const err = error instanceof Error ? error : new Error(String(error));
events.onerror?.(err);
};
const sendHello = () => {
try {
const result = transport.send(helloText);
if (result) {
void result.catch(emitSendError);
}
return true;
} catch (error) {
// This can happen during daemon restarts while the socket transitions
// through CLOSING/CLOSED states. Report it but do not throw from timers.
emitSendError(error);
return false;
}
};
const clearRetry = () => {
if (retry) {
clearInterval(retry);
retry = null;
}
};
channel.onTransitionToOpen(() => clearRetry());
channel.onClose(() => clearRetry());
sendHello();
retry = setInterval(() => {
if (channel.isOpen()) {
clearRetry();
return;
}
sendHello();
}, HANDSHAKE_RETRY_MS);
// Avoid keeping Node processes alive (e.g. tests) if the handshake is stuck.
if (hasUnref(retry)) {
retry.unref();
}
return channel;
}
/**
* Creates an encrypted channel as the responder (daemon).
*
* The daemon:
* 1. Has pre-generated keypair (public key was in QR)
* 2. Waits for client's e2ee_hello with their public key
* 3. Derives shared key and starts encrypted communication
*/
export async function createDaemonChannel(
transport: Transport,
daemonKeyPair: KeyPair,
events: EncryptedChannelEvents = {},
): Promise<EncryptedChannel> {
return new Promise((resolve, reject) => {
const bufferedMessages: TransportMessage[] = [];
const shouldIgnorePostHelloPlaintext = (message: TransportMessage): boolean => {
try {
if (message.isBinary) return false;
const text = decodeTransportText(message.data);
const parsed: unknown = JSON.parse(text);
return isE2EEHelloMessage(parsed) || isE2EEReadyMessage(parsed);
} catch {
return false;
}
};
const handleHello = async (message: TransportMessage): Promise<void> => {
try {
if (message.isBinary) {
throw buildInvalidHelloError("<binary frame>");
}
const helloText = decodeTransportText(message.data);
let parsed: unknown;
try {
parsed = JSON.parse(helloText);
} catch {
throw buildInvalidHelloError(helloText);
}
if (!isE2EEHelloMessage(parsed)) {
throw buildInvalidHelloError(helloText, parsed);
}
const msg = parsed;
// Buffer any subsequent messages that arrive while we're doing async
// 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: TransportMessage): void => {
bufferedMessages.push(next);
};
Object.assign(transport, { onmessage: bufferNext });
const clientPublicKey = importPublicKey(msg.key);
const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
const binaryCiphertext = supportsBinaryCiphertext(msg);
await transport.send(
JSON.stringify({
type: "e2ee_ready",
...(binaryCiphertext
? { capabilities: { binaryCiphertext: true } satisfies E2EECapabilities }
: {}),
} satisfies E2EEReadyMessage),
);
const channel = new EncryptedChannel(transport, sharedKey, events, {
daemonKeyPair,
binaryCiphertext,
});
channel.setState("open");
events.onopen?.();
for (const buffered of bufferedMessages) {
if (shouldIgnorePostHelloPlaintext(buffered)) continue;
transport.onmessage?.(buffered);
}
resolve(channel);
} catch (error) {
reject(error);
}
};
Object.assign(transport, {
onmessage: handleHello,
onerror: (error: Error) => {
reject(error);
},
onclose: (code: number, reason: string) => {
reject(new Error(`Connection closed during handshake: ${code} ${reason}`));
},
});
});
}
/**
* Encrypted channel that wraps a transport with E2EE.
*/
export class EncryptedChannel {
private transport: Transport;
private sharedKey: SharedKey;
private state: ChannelState = "handshaking";
private events: EncryptedChannelEvents;
private options: EncryptedChannelOptions;
private pendingSends: Array<string | ArrayBuffer> = [];
private onOpenCallbacks: Array<() => void> = [];
private onCloseCallbacks: Array<() => void> = [];
constructor(
transport: Transport,
sharedKey: SharedKey,
events: EncryptedChannelEvents = {},
options: EncryptedChannelOptions = {},
) {
this.transport = transport;
this.sharedKey = sharedKey;
this.events = events;
this.options = options;
Object.assign(transport, {
onmessage: (message: TransportMessage) => this.handleMessage(message),
onclose: (code: number, reason: string) => {
this.state = "closed";
this.events.onclose?.(code, reason);
for (const cb of this.onCloseCallbacks) cb();
},
onerror: (error: Error) => {
this.events.onerror?.(error);
},
});
}
setState(state: ChannelState): void {
this.state = state;
}
private async handleMessage(message: TransportMessage): Promise<void> {
if (this.state === "handshaking") {
try {
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();
try {
await this.flushPendingSends();
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.events.onerror?.(err);
this.state = "closed";
this.transport.close(1011, err.message);
}
}
} catch {
// ignore non-ready handshake traffic
}
return;
}
if (this.state !== "open") return;
try {
const ciphertext = await (async () => {
// Handle (or ignore) any stray plaintext handshake traffic.
try {
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);
}
return null;
}
if (isE2EEReadyMessage(parsed)) {
return null;
}
// Any other JSON-looking payload is plaintext app traffic, which
// means the peer is not encrypting (or we are out of sync).
throw new Error("Received plaintext frame on encrypted channel");
}
} catch (error) {
// If we detected plaintext protocol mismatch, fail hard.
if (error instanceof Error && error.message.includes("plaintext frame")) {
throw error;
}
// Otherwise ignore JSON parse/TextDecoder failures and fall back to
// decoding ciphertext below.
}
if (this.options.binaryCiphertext) {
return message.isBinary
? { data: requireArrayBuffer(message.data), isBinary: true as const }
: {
data: base64ToArrayBuffer(decodeTransportText(message.data)),
isBinary: false as const,
};
}
// 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 {
return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null };
} catch {
return { data: requireArrayBuffer(message.data), isBinary: null };
}
})();
if (ciphertext) {
const plaintextBytes = decrypt(this.sharedKey, ciphertext.data);
const plaintext = decodePlaintext(plaintextBytes, ciphertext.isBinary);
this.events.onmessage?.(plaintext);
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
// Treat decryption/protocol errors as fatal so the peer can reconnect and
// re-handshake. Emitting an error event here can cause higher-level code
// to tear down the session without triggering a clean reconnect.
try {
this.transport.close(1011, err.message);
} catch {
// ignore
}
}
}
async send(data: string | ArrayBuffer): Promise<void> {
if (this.state === "handshaking") {
if (this.pendingSends.length >= MAX_PENDING_SENDS) {
this.pendingSends.shift();
}
this.pendingSends.push(data);
return;
}
if (this.state !== "open") {
throw new Error("Channel not open");
}
const ciphertext = encrypt(this.sharedKey, data);
if (this.options.binaryCiphertext && data instanceof ArrayBuffer) {
await 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.
await this.transport.send(arrayBufferToBase64(ciphertext));
}
outboundWireByteLength(data: string | ArrayBuffer): number {
const plaintextBytes = utf8ByteLength(data);
const encryptedBytes = plaintextBytes + ENCRYPTED_PAYLOAD_OVERHEAD_BYTES;
if (this.options.binaryCiphertext && data instanceof ArrayBuffer) {
return encryptedBytes;
}
return base64EncryptedWireByteLength(plaintextBytes);
}
private async flushPendingSends(): Promise<void> {
if (this.state !== "open") return;
const pending = this.pendingSends;
this.pendingSends = [];
for (const item of pending) {
await this.send(item);
}
}
private async handleDaemonRehello(message: E2EEHelloMessage): Promise<void> {
if (!this.options.daemonKeyPair) return;
const clientPublicKey = importPublicKey(message.key);
const retryKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey);
if (!keysEqual(retryKey, this.sharedKey)) return this.rejectKeyRotation();
await this.sendReadyForRetry();
}
private async sendReadyForRetry(): Promise<void> {
await this.transport.send(
JSON.stringify({
type: "e2ee_ready",
...(this.options.binaryCiphertext
? { capabilities: { binaryCiphertext: true } satisfies E2EECapabilities }
: {}),
} satisfies E2EEReadyMessage),
);
}
private rejectKeyRotation(): void {
this.state = "closed";
this.transport.close(REHANDSHAKE_REJECTION_CODE, REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON);
}
close(code = 1000, reason = "Normal closure"): void {
this.state = "closed";
this.transport.close(code, reason);
}
isOpen(): boolean {
return this.state === "open";
}
onTransitionToOpen(cb: () => void): void {
this.onOpenCallbacks.push(cb);
}
onClose(cb: () => void): void {
this.onCloseCallbacks.push(cb);
}
}
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;
for (let i = 0; i < a.byteLength; i += 1) {
difference |= a[i] ^ b[i];
}
return difference === 0;
}