Relay pairing offer v2 (E2EE) + host connections

This commit is contained in:
Mohamed Boudra
2026-02-04 22:53:00 +07:00
parent 9ce4ad9758
commit 0218729316
59 changed files with 2022 additions and 1547 deletions
+8 -1
View File
@@ -5,7 +5,14 @@ export function arrayBufferToBase64(buffer: ArrayBuffer): string {
}
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
const bytes = toByteArray(base64);
const normalized = (() => {
const trimmed = base64.trim();
const standard = trimmed.replace(/-/g, "+").replace(/_/g, "/");
const padLen = (4 - (standard.length % 4)) % 4;
return standard + "=".repeat(padLen);
})();
const bytes = toByteArray(normalized);
const out = new Uint8Array(bytes.byteLength);
out.set(bytes);
return out.buffer;
+10 -10
View File
@@ -75,14 +75,14 @@ export class RelayDurableObject {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const role = url.searchParams.get("role") as ConnectionRole | null;
const sessionId = url.searchParams.get("session");
const serverId = url.searchParams.get("serverId");
if (!role || (role !== "server" && role !== "client")) {
return new Response("Missing or invalid role parameter", { status: 400 });
}
if (!sessionId) {
return new Response("Missing session parameter", { status: 400 });
if (!serverId) {
return new Response("Missing serverId parameter", { status: 400 });
}
const upgradeHeader = request.headers.get("Upgrade");
@@ -105,13 +105,13 @@ export class RelayDurableObject {
// Store attachment for hibernation recovery
const attachment: RelaySessionAttachment = {
sessionId,
serverId,
role,
createdAt: Date.now(),
};
(server as WebSocketWithAttachment).serializeAttachment(attachment);
console.log(`[Relay DO] ${role} connected to session ${sessionId}`);
console.log(`[Relay DO] ${role} connected to session ${serverId}`);
return new Response(null, {
status: 101,
@@ -155,7 +155,7 @@ export class RelayDurableObject {
if (!attachment) return;
console.log(
`[Relay DO] ${attachment.role} disconnected from session ${attachment.sessionId} (${code}: ${reason})`
`[Relay DO] ${attachment.role} disconnected from session ${attachment.serverId} (${code}: ${reason})`
);
// Note: The other connection remains open.
@@ -190,13 +190,13 @@ export default {
// Relay endpoint
if (url.pathname === "/ws") {
const sessionId = url.searchParams.get("session");
if (!sessionId) {
return new Response("Missing session parameter", { status: 400 });
const serverId = url.searchParams.get("serverId");
if (!serverId) {
return new Response("Missing serverId parameter", { status: 400 });
}
// Route to Durable Object instance for this session
const id = env.RELAY.idFromName(sessionId);
const id = env.RELAY.idFromName(serverId);
const stub = env.RELAY.get(id);
return stub.fetch(request);
}
+12 -12
View File
@@ -10,9 +10,9 @@ import {
describe("crypto", () => {
describe("generateKeyPair", () => {
it("generates a valid ECDH keypair", async () => {
it("generates a valid keypair", async () => {
const keypair = await generateKeyPair();
expect(keypair.privateKey).toBeDefined();
expect(keypair.secretKey).toBeDefined();
expect(keypair.publicKey).toBeDefined();
});
});
@@ -50,11 +50,11 @@ describe("crypto", () => {
// Derive shared keys
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.privateKey,
daemonKeyPair.secretKey,
daemonSeesClientPubKey
);
const clientSharedKey = await deriveSharedKey(
clientKeyPair.privateKey,
clientKeyPair.secretKey,
clientSeesDaemonPubKey
);
@@ -72,7 +72,7 @@ describe("crypto", () => {
const daemonKeyPair = await generateKeyPair();
const clientKeyPair = await generateKeyPair();
const sharedKey = await deriveSharedKey(
daemonKeyPair.privateKey,
daemonKeyPair.secretKey,
clientKeyPair.publicKey
);
@@ -90,7 +90,7 @@ describe("crypto", () => {
const daemonKeyPair = await generateKeyPair();
const clientKeyPair = await generateKeyPair();
const sharedKey = await deriveSharedKey(
daemonKeyPair.privateKey,
daemonKeyPair.secretKey,
clientKeyPair.publicKey
);
@@ -107,24 +107,24 @@ describe("crypto", () => {
const keypair3 = await generateKeyPair();
const correctKey = await deriveSharedKey(
keypair1.privateKey,
keypair1.secretKey,
keypair2.publicKey
);
const wrongKey = await deriveSharedKey(
keypair1.privateKey,
keypair1.secretKey,
keypair3.publicKey
);
const ciphertext = await encrypt(correctKey, "secret");
await expect(decrypt(wrongKey, ciphertext)).rejects.toThrow();
expect(() => decrypt(wrongKey, ciphertext)).toThrow();
});
it("produces different ciphertext for same plaintext (random IV)", async () => {
const keypair1 = await generateKeyPair();
const keypair2 = await generateKeyPair();
const sharedKey = await deriveSharedKey(
keypair1.privateKey,
keypair1.secretKey,
keypair2.publicKey
);
@@ -160,7 +160,7 @@ describe("crypto", () => {
// Client derives shared key
const clientSharedKey = await deriveSharedKey(
clientKeyPair.privateKey,
clientKeyPair.secretKey,
daemonPubKeyOnClient
);
@@ -172,7 +172,7 @@ describe("crypto", () => {
// Daemon derives shared key
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.privateKey,
daemonKeyPair.secretKey,
clientPubKeyOnDaemon
);
+132 -111
View File
@@ -1,129 +1,150 @@
/// <reference lib="dom" />
/**
* E2EE crypto primitives using WebCrypto API.
* E2EE crypto primitives using NaCl (tweetnacl).
*
* - ECDH P-256 for key exchange
* - HKDF for key derivation
* - AES-256-GCM for authenticated encryption
*/
import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
const ECDH_ALGORITHM = { name: "ECDH", namedCurve: "P-256" };
const AES_ALGORITHM = { name: "AES-GCM", length: 256 };
const IV_LENGTH = 12;
/**
* Generate an ECDH P-256 keypair for key exchange.
*/
export async function generateKeyPair(): Promise<CryptoKeyPair> {
return crypto.subtle.generateKey(ECDH_ALGORITHM, true, ["deriveBits"]);
}
/**
* Export a public key to base64 string (for transmission).
*/
export async function exportPublicKey(key: CryptoKey): Promise<string> {
const raw = await crypto.subtle.exportKey("raw", key);
return arrayBufferToBase64(raw);
}
/**
* Import a public key from base64 string.
*/
export async function importPublicKey(base64: string): Promise<CryptoKey> {
const raw = base64ToArrayBuffer(base64);
return crypto.subtle.importKey("raw", raw, ECDH_ALGORITHM, true, []);
}
/**
* Derive a shared AES-256-GCM key from ECDH key exchange.
* - Key exchange: Curve25519 (nacl.box.before)
* - Encryption: XSalsa20-Poly1305 (nacl.box.after / open.after)
*
* Uses HKDF with SHA-256 to derive the final key.
* Bundle format (binary):
* [nonce (24 bytes)] [ciphertext...]
*
* Transport format:
* The encrypted-channel sends the bundle as base64 text over WebSocket.
*/
export async function deriveSharedKey(
privateKey: CryptoKey,
peerPublicKey: CryptoKey
): Promise<CryptoKey> {
// Perform ECDH to get shared secret bits
const sharedBits = await crypto.subtle.deriveBits(
{ name: "ECDH", public: peerPublicKey },
privateKey,
256
);
// Import shared bits as HKDF key material
const hkdfKey = await crypto.subtle.importKey(
"raw",
sharedBits,
"HKDF",
false,
["deriveKey"]
);
import nacl from "tweetnacl";
import { fromByteArray, toByteArray } from "base64-js";
// Derive AES-256-GCM key using HKDF
return crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array(0),
info: new TextEncoder().encode("paseo-e2ee-v1"),
},
hkdfKey,
AES_ALGORITHM,
false,
["encrypt", "decrypt"]
);
export type KeyPair = {
publicKey: Uint8Array; // 32 bytes
secretKey: Uint8Array; // 32 bytes
};
export type SharedKey = Uint8Array; // 32 bytes (box.before)
const NONCE_LENGTH = nacl.box.nonceLength; // 24
let prngReady = false;
function ensurePrng(): void {
if (prngReady) return;
try {
nacl.randomBytes(1);
prngReady = true;
return;
} catch {
// fallthrough
}
const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto;
if (cryptoObj?.getRandomValues) {
nacl.setPRNG((x, n) => {
cryptoObj.getRandomValues(x.subarray(0, n));
});
prngReady = true;
return;
}
throw new Error("No secure PRNG available for tweetnacl (missing crypto.getRandomValues)");
}
function encodeBase64(bytes: Uint8Array): string {
return fromByteArray(bytes);
}
function decodeBase64(base64: string): Uint8Array {
return toByteArray(base64);
}
function toUint8(data: string | ArrayBuffer): Uint8Array {
return typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const out = new Uint8Array(bytes.byteLength);
out.set(bytes);
return out.buffer;
}
export function generateKeyPair(): KeyPair {
ensurePrng();
const { publicKey, secretKey } = nacl.box.keyPair();
return { publicKey, secretKey };
}
export function exportPublicKey(publicKey: Uint8Array): string {
if (!(publicKey instanceof Uint8Array) || publicKey.byteLength !== nacl.box.publicKeyLength) {
throw new Error(`Invalid public key length (expected ${nacl.box.publicKeyLength})`);
}
return encodeBase64(publicKey);
}
export function importPublicKey(base64: string): Uint8Array {
const bytes = decodeBase64(base64);
if (bytes.byteLength !== nacl.box.publicKeyLength) {
throw new Error(`Invalid public key length (expected ${nacl.box.publicKeyLength})`);
}
return bytes;
}
export function exportSecretKey(secretKey: Uint8Array): string {
if (!(secretKey instanceof Uint8Array) || secretKey.byteLength !== nacl.box.secretKeyLength) {
throw new Error(`Invalid secret key length (expected ${nacl.box.secretKeyLength})`);
}
return encodeBase64(secretKey);
}
export function importSecretKey(base64: string): Uint8Array {
const bytes = decodeBase64(base64);
if (bytes.byteLength !== nacl.box.secretKeyLength) {
throw new Error(`Invalid secret key length (expected ${nacl.box.secretKeyLength})`);
}
return bytes;
}
export function deriveSharedKey(
ourSecretKey: Uint8Array,
peerPublicKey: Uint8Array
): SharedKey {
if (ourSecretKey.byteLength !== nacl.box.secretKeyLength) {
throw new Error(`Invalid secret key length (expected ${nacl.box.secretKeyLength})`);
}
if (peerPublicKey.byteLength !== nacl.box.publicKeyLength) {
throw new Error(`Invalid peer public key length (expected ${nacl.box.publicKeyLength})`);
}
return nacl.box.before(peerPublicKey, ourSecretKey);
}
/**
* Encrypt data with AES-256-GCM.
*
* Returns: [IV (12 bytes)][ciphertext + auth tag]
* Encrypts data and returns the binary bundle:
* [nonce (24)] [ciphertext...]
*/
export async function encrypt(
key: CryptoKey,
data: string | ArrayBuffer
): Promise<ArrayBuffer> {
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const plaintext =
typeof data === "string" ? new TextEncoder().encode(data) : data;
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
plaintext
);
// Prepend IV to ciphertext
const result = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
result.set(iv, 0);
result.set(new Uint8Array(ciphertext), IV_LENGTH);
return result.buffer;
export function encrypt(sharedKey: SharedKey, data: string | ArrayBuffer): ArrayBuffer {
ensurePrng();
const nonce = nacl.randomBytes(NONCE_LENGTH);
const plaintext = toUint8(data);
const ciphertext = nacl.box.after(plaintext, nonce, sharedKey);
const out = new Uint8Array(nonce.byteLength + ciphertext.byteLength);
out.set(nonce, 0);
out.set(ciphertext, nonce.byteLength);
return toArrayBuffer(out);
}
/**
* Decrypt data with AES-256-GCM.
*
* Input format: [IV (12 bytes)][ciphertext + auth tag]
* Returns string if original was string, ArrayBuffer if binary.
*/
export async function decrypt(
key: CryptoKey,
data: ArrayBuffer
): Promise<string | ArrayBuffer> {
const dataArray = new Uint8Array(data);
const iv = dataArray.slice(0, IV_LENGTH);
const ciphertext = dataArray.slice(IV_LENGTH);
export function decrypt(sharedKey: SharedKey, data: ArrayBuffer): string | ArrayBuffer {
const bytes = new Uint8Array(data);
if (bytes.byteLength < NONCE_LENGTH) {
throw new Error("Ciphertext bundle too short");
}
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
ciphertext
);
const nonce = bytes.slice(0, NONCE_LENGTH);
const ciphertext = bytes.slice(NONCE_LENGTH);
const opened = nacl.box.open.after(ciphertext, nonce, sharedKey);
if (!opened) {
throw new Error("Decryption failed");
}
// Try to decode as UTF-8 string, fall back to ArrayBuffer
const plaintext = toArrayBuffer(opened);
try {
return new TextDecoder("utf-8", { fatal: true }).decode(plaintext);
} catch {
+11 -11
View File
@@ -36,7 +36,7 @@ describe("E2E Relay with E2EE", () => {
// Daemon connects to relay as "server" role
const daemonWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=server`
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=server`
);
await new Promise<void>((resolve, reject) => {
@@ -53,13 +53,13 @@ describe("E2E Relay with E2EE", () => {
// Client imports daemon's public key and derives shared secret
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
const clientSharedKey = await deriveSharedKey(
clientKeyPair.privateKey,
clientKeyPair.secretKey,
daemonPubKeyOnClient
);
// Client connects to relay as "client" role
const clientWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=client`
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=client`
);
await new Promise<void>((resolve, reject) => {
@@ -83,7 +83,7 @@ describe("E2E Relay with E2EE", () => {
// Daemon imports client's public key and derives shared secret
const clientPubKeyOnDaemon = await importPublicKey(hello.key);
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.privateKey,
daemonKeyPair.secretKey,
clientPubKeyOnDaemon
);
@@ -162,20 +162,20 @@ describe("E2E Relay with E2EE", () => {
const daemonPubKey = await importPublicKey(daemonPubKeyB64);
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.privateKey,
daemonKeyPair.secretKey,
clientPubKey
);
const clientSharedKey = await deriveSharedKey(
clientKeyPair.privateKey,
clientKeyPair.secretKey,
daemonPubKey
);
// Connect both
const daemonWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=server`
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=server`
);
const clientWs = new WebSocket(
`ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=client`
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=client`
);
await Promise.all([
@@ -223,7 +223,7 @@ describe("E2E Relay with E2EE", () => {
await exportPublicKey(clientKeyPair.publicKey)
);
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.privateKey,
daemonKeyPair.secretKey,
clientPubKey
);
@@ -232,7 +232,7 @@ describe("E2E Relay with E2EE", () => {
await exportPublicKey(attackerKeyPair.publicKey)
);
const attackerKey = await deriveSharedKey(
attackerKeyPair.privateKey,
attackerKeyPair.secretKey,
attackerPubKey
);
@@ -241,6 +241,6 @@ describe("E2E Relay with E2EE", () => {
const ciphertext = await encrypt(daemonSharedKey, secret);
// Attacker cannot decrypt
await expect(decrypt(attackerKey, ciphertext)).rejects.toThrow();
expect(() => decrypt(attackerKey, ciphertext)).toThrow();
});
});
+8
View File
@@ -5,3 +5,11 @@ export {
} from "./encrypted-channel.js";
export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
export {
generateKeyPair,
exportPublicKey,
importPublicKey,
exportSecretKey,
importSecretKey,
} from "./crypto.js";
export type { KeyPair, SharedKey } from "./crypto.js";
+23 -3
View File
@@ -51,17 +51,24 @@ describe("EncryptedChannel", () => {
const daemonKeyPair = await generateKeyPair();
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
let clientOpenedResolve: (() => void) | null = null;
const clientOpened = new Promise<void>((resolve) => {
clientOpenedResolve = resolve;
});
// Start daemon waiting for client
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
// Client connects (scanned QR, got daemon's public key)
const clientChannel = await createClientChannel(
clientTransport,
daemonPubKeyB64
daemonPubKeyB64,
{ onopen: () => clientOpenedResolve?.() }
);
// Daemon receives hello and completes handshake
const daemonChannel = await daemonChannelPromise;
await clientOpened;
expect(clientChannel.isOpen()).toBe(true);
expect(daemonChannel.isOpen()).toBe(true);
@@ -76,6 +83,11 @@ describe("EncryptedChannel", () => {
const daemonMessages: (string | ArrayBuffer)[] = [];
const clientMessages: (string | ArrayBuffer)[] = [];
let clientOpenedResolve: (() => void) | null = null;
const clientOpened = new Promise<void>((resolve) => {
clientOpenedResolve = resolve;
});
const daemonChannelPromise = createDaemonChannel(
daemonTransport,
daemonKeyPair,
@@ -85,10 +97,11 @@ describe("EncryptedChannel", () => {
const clientChannel = await createClientChannel(
clientTransport,
daemonPubKeyB64,
{ onmessage: (data) => clientMessages.push(data) }
{ onmessage: (data) => clientMessages.push(data), onopen: () => clientOpenedResolve?.() }
);
const daemonChannel = await daemonChannelPromise;
await clientOpened;
// Send messages both directions
await clientChannel.send("Hello from client");
@@ -111,12 +124,19 @@ describe("EncryptedChannel", () => {
const daemonKeyPair = await generateKeyPair();
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
let clientOpenedResolve: (() => void) | null = null;
const clientOpened = new Promise<void>((resolve) => {
clientOpenedResolve = resolve;
});
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
const clientChannel = await createClientChannel(
clientTransport,
daemonPubKeyB64
daemonPubKeyB64,
{ onopen: () => clientOpenedResolve?.() }
);
await daemonChannelPromise;
await clientOpened;
// Clear mock call history
(clientTransport.send as ReturnType<typeof vi.fn>).mockClear();
+213 -33
View File
@@ -13,6 +13,8 @@ import {
deriveSharedKey,
encrypt,
decrypt,
type KeyPair,
type SharedKey,
} from "./crypto.js";
import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
@@ -33,11 +35,30 @@ export interface EncryptedChannelEvents {
type ChannelState = "connecting" | "handshaking" | "open" | "closed";
type EncryptedChannelOptions = {
/**
* If set, the channel can validate repeated plaintext `{type:"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:"ready"}` yet). In that case,
* the daemon should re-send `{type:"ready"}` without changing keys.
*/
daemonKeyPair?: KeyPair;
};
interface HelloMessage {
type: "hello";
key: string;
}
interface ReadyMessage {
type: "ready";
}
const HANDSHAKE_RETRY_MS = 1000;
const MAX_PENDING_SENDS = 200;
/**
* Creates an encrypted channel as the initiator (client).
*
@@ -52,19 +73,38 @@ export async function createClientChannel(
daemonPublicKeyB64: string,
events: EncryptedChannelEvents = {}
): Promise<EncryptedChannel> {
const keyPair = await generateKeyPair();
const daemonPublicKey = await importPublicKey(daemonPublicKeyB64);
const sharedKey = await deriveSharedKey(keyPair.privateKey, daemonPublicKey);
const keyPair = generateKeyPair();
const daemonPublicKey = importPublicKey(daemonPublicKeyB64);
const sharedKey = deriveSharedKey(keyPair.secretKey, daemonPublicKey);
const channel = new EncryptedChannel(transport, sharedKey, events);
// Send hello with our public key
const ourPublicKeyB64 = await exportPublicKey(keyPair.publicKey);
const ourPublicKeyB64 = exportPublicKey(keyPair.publicKey);
const hello: HelloMessage = { type: "hello", key: ourPublicKeyB64 };
transport.send(JSON.stringify(hello));
const helloText = JSON.stringify(hello);
channel.setState("open");
events.onopen?.();
let retry: ReturnType<typeof setInterval> | null = null;
const clearRetry = () => {
if (retry) {
clearInterval(retry);
retry = null;
}
};
channel.onTransitionToOpen(() => clearRetry());
channel.onClose(() => clearRetry());
transport.send(helloText);
retry = setInterval(() => {
if (channel.isOpen()) {
clearRetry();
return;
}
transport.send(helloText);
}, HANDSHAKE_RETRY_MS);
// Avoid keeping Node processes alive (e.g. tests) if the handshake is stuck.
(retry as unknown as { unref?: () => void }).unref?.();
return channel;
}
@@ -79,15 +119,20 @@ export async function createClientChannel(
*/
export async function createDaemonChannel(
transport: Transport,
daemonKeyPair: CryptoKeyPair,
daemonKeyPair: KeyPair,
events: EncryptedChannelEvents = {}
): Promise<EncryptedChannel> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Handshake timeout"));
}, 10000);
const bufferedMessages: Array<string | ArrayBuffer> = [];
const shouldIgnorePostHelloPlaintext = (data: string | ArrayBuffer): boolean => {
try {
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
const parsed = JSON.parse(text) as Partial<HelloMessage | ReadyMessage>;
return parsed.type === "hello" || parsed.type === "ready";
} catch {
return false;
}
};
transport.onmessage = async (data) => {
try {
@@ -99,8 +144,6 @@ export async function createDaemonChannel(
throw new Error("Invalid hello message");
}
clearTimeout(timeout);
// 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
@@ -109,34 +152,31 @@ export async function createDaemonChannel(
bufferedMessages.push(next);
};
const clientPublicKey = await importPublicKey(msg.key);
const sharedKey = await deriveSharedKey(
daemonKeyPair.privateKey,
clientPublicKey
);
const clientPublicKey = importPublicKey(msg.key);
const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
const channel = new EncryptedChannel(transport, sharedKey, events, { daemonKeyPair });
transport.send(JSON.stringify({ type: "ready" } satisfies ReadyMessage));
const channel = new EncryptedChannel(transport, sharedKey, events);
channel.setState("open");
events.onopen?.();
for (const buffered of bufferedMessages) {
if (shouldIgnorePostHelloPlaintext(buffered)) continue;
transport.onmessage?.(buffered);
}
resolve(channel);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
};
transport.onerror = (error) => {
clearTimeout(timeout);
reject(error);
};
transport.onclose = (code, reason) => {
clearTimeout(timeout);
reject(new Error(`Connection closed during handshake: ${code} ${reason}`));
};
});
@@ -147,23 +187,30 @@ export async function createDaemonChannel(
*/
export class EncryptedChannel {
private transport: Transport;
private sharedKey: CryptoKey;
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: CryptoKey,
events: EncryptedChannelEvents = {}
sharedKey: SharedKey,
events: EncryptedChannelEvents = {},
options: EncryptedChannelOptions = {}
) {
this.transport = transport;
this.sharedKey = sharedKey;
this.events = events;
this.options = options;
transport.onmessage = (data) => this.handleMessage(data);
transport.onclose = (code, reason) => {
this.state = "closed";
this.events.onclose?.(code, reason);
for (const cb of this.onCloseCallbacks) cb();
};
transport.onerror = (error) => {
this.events.onerror?.(error);
@@ -175,21 +222,129 @@ export class EncryptedChannel {
}
private async handleMessage(data: string | ArrayBuffer): Promise<void> {
if (this.state === "handshaking") {
try {
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
const msg = JSON.parse(text) as Partial<ReadyMessage>;
if (msg.type === "ready") {
this.state = "open";
this.events.onopen?.();
for (const cb of this.onOpenCallbacks) cb();
await this.flushPendingSends();
}
} catch {
// ignore non-ready handshake traffic
}
return;
}
if (this.state !== "open") return;
try {
const ciphertext =
typeof data === "string" ? base64ToArrayBuffer(data) : data;
const plaintext = await decrypt(this.sharedKey, ciphertext);
this.events.onmessage?.(plaintext);
const ciphertext = await (async () => {
// Handle (or ignore) any stray plaintext handshake traffic.
try {
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
if (text.trim().startsWith("{")) {
const parsed = JSON.parse(text) as Partial<HelloMessage | ReadyMessage>;
if (parsed.type === "hello" && typeof parsed.key === "string") {
if (this.options.daemonKeyPair) {
try {
const clientPublicKey = importPublicKey(parsed.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: "ready" } satisfies ReadyMessage)
);
return null;
}
// Different key implies a new client connection (common with relays
// where the daemon's socket stays open while the client reconnects).
// Re-key and re-send "ready". Drop any queued sends to avoid leaking
// messages between logical client sessions.
this.state = "handshaking";
this.sharedKey = nextSharedKey;
this.pendingSends = [];
this.transport.send(
JSON.stringify({ type: "ready" } satisfies ReadyMessage)
);
this.state = "open";
await this.flushPendingSends();
return null;
} catch (error) {
throw error;
}
}
return null;
}
if (parsed.type === "ready") {
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 (typeof data === "string") {
return base64ToArrayBuffer(data);
}
// Some WebSocket implementations deliver text frames as ArrayBuffer.
// Our protocol always transmits ciphertext as base64 text.
try {
const decoded = new TextDecoder().decode(data);
return base64ToArrayBuffer(decoded);
} catch {
return data;
}
})();
if (ciphertext) {
const plaintext = await decrypt(this.sharedKey, ciphertext);
this.events.onmessage?.(plaintext);
}
} catch (error) {
this.events.onerror?.(
error instanceof Error ? error : new Error(String(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");
}
@@ -199,6 +354,15 @@ export class EncryptedChannel {
this.transport.send(arrayBufferToBase64(ciphertext));
}
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);
}
}
close(code = 1000, reason = "Normal closure"): void {
this.state = "closed";
this.transport.close(code, reason);
@@ -207,4 +371,20 @@ export class EncryptedChannel {
isOpen(): boolean {
return this.state === "open";
}
onTransitionToOpen(cb: () => void): void {
this.onOpenCallbacks.push(cb);
}
onClose(cb: () => void): void {
this.onCloseCallbacks.push(cb);
}
}
function keysEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) return false;
for (let i = 0; i < a.byteLength; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
+7 -5
View File
@@ -30,12 +30,14 @@ async function withRetry<T>(
}
describe("Live relay (relay.paseo.sh) E2E", () => {
it("bridges encrypted traffic end-to-end", { timeout: 45_000 }, async () => {
const liveIt = process.env.RUN_LIVE_RELAY_E2E === "1" ? it : it.skip;
liveIt("bridges encrypted traffic end-to-end", { timeout: 45_000 }, async () => {
await withRetry(
async () => {
const sessionId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const serverUrl = `${RELAY_BASE_URL}/ws?session=${encodeURIComponent(sessionId)}&role=server`;
const clientUrl = `${RELAY_BASE_URL}/ws?session=${encodeURIComponent(sessionId)}&role=client`;
const serverUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(sessionId)}&role=server`;
const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(sessionId)}&role=client`;
// === Key setup ===
const daemonKeyPair = await generateKeyPair();
@@ -46,7 +48,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
const clientSharedKey = await deriveSharedKey(
clientKeyPair.privateKey,
clientKeyPair.secretKey,
daemonPubKeyOnClient
);
@@ -97,7 +99,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
const clientPubKeyOnDaemon = await importPublicKey(hello.key!);
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.privateKey,
daemonKeyPair.secretKey,
clientPubKeyOnDaemon
);
+114 -22
View File
@@ -1,4 +1,5 @@
import http from "http";
import type { Socket } from "net";
import { WebSocketServer, WebSocket as NodeWebSocket } from "ws";
import { Relay } from "./relay.js";
import type { ConnectionRole, RelayConnection } from "./types.js";
@@ -21,8 +22,8 @@ export interface NodeRelayServerConfig {
* ```
*
* Clients connect via:
* - ws://host:port/ws?session=abc&role=server
* - ws://host:port/ws?session=abc&role=client
* - ws://host:port/ws?serverId=abc&role=server
* - ws://host:port/ws?serverId=abc&role=client
*/
export interface RelayServer {
start(): Promise<void>;
@@ -32,6 +33,8 @@ export interface RelayServer {
export function createRelayServer(config: NodeRelayServerConfig): RelayServer {
const { port, host = "0.0.0.0" } = config;
const logFrames = process.env.PASEO_RELAY_LOG_FRAMES === "1";
const logUpgrades = process.env.PASEO_RELAY_LOG_UPGRADES === "1";
const relay = new Relay({
onSessionCreated: (id) => console.log(`[Relay] Session created: ${id}`),
@@ -50,41 +53,93 @@ export function createRelayServer(config: NodeRelayServerConfig): RelayServer {
res.end("Not found");
});
const wss = new WebSocketServer({
server: httpServer,
path: "/ws",
verifyClient: ({ req }, callback) => {
const url = new URL(req.url ?? "", `http://${req.headers.host}`);
const session = url.searchParams.get("session");
const role = url.searchParams.get("role");
// NOTE: We intentionally use `noServer` + a manual `upgrade` handler instead
// of `new WebSocketServer({ server, path })`.
//
// In some environments (notably when running via `tsx -e`), we observed
// websocket upgrade requests falling through to the HTTP handler (404),
// despite `ws` being configured with `{ server, path: "/ws" }`.
// Handling `upgrade` explicitly is more robust and easier to debug.
const wss = new WebSocketServer({ noServer: true });
if (!session || !role || (role !== "server" && role !== "client")) {
callback(false, 400, "Missing or invalid session/role parameters");
httpServer.on("upgrade", (req, socket: Socket, head) => {
if (logUpgrades) {
try {
console.log(
`[Relay] upgrade url=${JSON.stringify(req.url)} host=${JSON.stringify(req.headers.host)}`
);
} catch {
// ignore
}
}
try {
const url = new URL(req.url ?? "", `http://${req.headers.host}`);
if (url.pathname !== "/ws") {
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
socket.destroy();
return;
}
callback(true);
},
const serverId = url.searchParams.get("serverId");
const role = url.searchParams.get("role");
if (!serverId || !role || (role !== "server" && role !== "client")) {
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
} catch {
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
socket.destroy();
}
});
wss.on("connection", (ws, req) => {
const url = new URL(req.url ?? "", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("session")!;
const serverId = url.searchParams.get("serverId")!;
const role = url.searchParams.get("role") as ConnectionRole;
const connection = wrapWebSocket(ws, role);
relay.addConnection(sessionId, role, connection);
relay.addConnection(serverId, role, connection);
ws.on("message", (data, isBinary) => {
relay.forward(sessionId, role, normalizeWsMessageForRelay(data, isBinary));
// If this socket was replaced, ignore any late frames.
if (relay.getSession(serverId)?.[role] !== connection) return;
if (logFrames) {
const preview = (() => {
try {
if (isBinary) return "<binary>";
return bufferFromWsData(data).toString("utf8").slice(0, 200);
} catch {
return "<unavailable>";
}
})();
const len = (() => {
try {
return bufferFromWsData(data).byteLength;
} catch {
return -1;
}
})();
console.log(
`[Relay] frame ${serverId}/${role} binary=${isBinary} len=${len} preview=${JSON.stringify(preview)}`
);
}
relay.forward(serverId, role, normalizeWsMessageForRelay(data, isBinary));
});
ws.on("close", () => {
relay.removeConnection(sessionId, role);
// Avoid clearing the current connection if this socket was replaced.
if (relay.getSession(serverId)?.[role] !== connection) return;
relay.removeConnection(serverId, role);
});
ws.on("error", (error) => {
console.error(`[Relay] WebSocket error for ${sessionId}/${role}:`, error);
console.error(`[Relay] WebSocket error for ${serverId}/${role}:`, error);
});
});
@@ -101,12 +156,49 @@ export function createRelayServer(config: NodeRelayServerConfig): RelayServer {
stop() {
return new Promise((resolve) => {
// Stop accepting new connections immediately. Waiting for `wss.close`
// can leave the HTTP server listening while the WS server is closing,
// causing upgrade requests to get 503 responses.
try {
httpServer.close(() => undefined);
} catch {
// ignore
}
// Close active sessions + force-close any remaining clients.
for (const session of relay.listSessions()) {
relay.closeSession(session.id, 1001, "Server shutting down");
relay.closeSession(session.serverId, 1001, "Server shutting down");
}
for (const ws of wss.clients) {
try {
// terminate() is the fastest way to ensure `wss.close` completes.
(ws as unknown as { terminate?: () => void }).terminate?.();
} catch {
try {
ws.close(1001, "Server shutting down");
} catch {
// ignore
}
}
}
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
resolve();
};
const timeout = setTimeout(() => finish(), 1500);
try {
wss.close(() => {
clearTimeout(timeout);
finish();
});
} catch {
clearTimeout(timeout);
finish();
}
wss.close(() => {
httpServer.close(() => resolve());
});
});
},
+40 -20
View File
@@ -24,23 +24,26 @@ export class Relay {
* If both server and client are connected, messages are bridged.
*/
addConnection(
sessionId: string,
serverId: string,
role: ConnectionRole,
connection: RelayConnection
): void {
let session = this.sessions.get(sessionId);
let session = this.sessions.get(serverId);
if (!session) {
session = {
id: sessionId,
serverId,
server: null,
client: null,
createdAt: Date.now(),
};
this.sessions.set(sessionId, session);
this.events.onSessionCreated?.(sessionId);
this.sessions.set(serverId, session);
this.events.onSessionCreated?.(serverId);
}
const previousServer = session.server;
const previousClient = session.client;
const existingConnection = session[role];
if (existingConnection) {
existingConnection.close(1008, "Replaced by new connection");
@@ -48,8 +51,25 @@ export class Relay {
session[role] = connection;
// Important: the E2EE handshake is tied to a specific server↔client socket pair.
// If the daemon reconnects (server socket changes) while the client socket stays
// open, the client will continue sending encrypted frames using its existing
// channel state — but the new daemon socket hasn't handshaked yet. This leads
// to immediate decrypt/handshake failures.
//
// To keep the protocol simple and robust, whenever a new *server* connection
// arrives while a client socket is still connected, force the client to reconnect.
if (role === "server" && previousClient) {
// Only close if the server socket actually changed (or was previously missing).
const serverChanged = !previousServer || previousServer !== connection;
if (serverChanged) {
session.client = null;
previousClient.close(1012, "Server reconnected");
}
}
if (session.server && session.client) {
this.events.onSessionBridged?.(sessionId);
this.events.onSessionBridged?.(serverId);
}
}
@@ -57,15 +77,15 @@ export class Relay {
* Remove a connection from a session.
* If both connections are gone, the session is cleaned up.
*/
removeConnection(sessionId: string, role: ConnectionRole): void {
const session = this.sessions.get(sessionId);
removeConnection(serverId: string, role: ConnectionRole): void {
const session = this.sessions.get(serverId);
if (!session) return;
session[role] = null;
if (!session.server && !session.client) {
this.sessions.delete(sessionId);
this.events.onSessionClosed?.(sessionId);
this.sessions.delete(serverId);
this.events.onSessionClosed?.(serverId);
}
}
@@ -73,11 +93,11 @@ export class Relay {
* Forward a message from one side to the other.
*/
forward(
sessionId: string,
serverId: string,
fromRole: ConnectionRole,
data: string | ArrayBuffer
): void {
const session = this.sessions.get(sessionId);
const session = this.sessions.get(serverId);
if (!session) return;
const target = fromRole === "server" ? session.client : session.server;
@@ -86,7 +106,7 @@ export class Relay {
target.send(data);
} catch (error) {
this.events.onError?.(
sessionId,
serverId,
error instanceof Error ? error : new Error(String(error))
);
}
@@ -96,8 +116,8 @@ export class Relay {
/**
* Get session info for debugging/monitoring.
*/
getSession(sessionId: string): RelaySession | undefined {
return this.sessions.get(sessionId);
getSession(serverId: string): RelaySession | undefined {
return this.sessions.get(serverId);
}
/**
@@ -110,15 +130,15 @@ export class Relay {
/**
* Close a session and both connections.
*/
closeSession(sessionId: string, code = 1000, reason = "Session closed"): void {
const session = this.sessions.get(sessionId);
closeSession(serverId: string, code = 1000, reason = "Session closed"): void {
const session = this.sessions.get(serverId);
if (!session) return;
session.server?.close(code, reason);
session.client?.close(code, reason);
this.sessions.delete(sessionId);
this.events.onSessionClosed?.(sessionId);
this.sessions.delete(serverId);
this.events.onSessionClosed?.(serverId);
}
/**
@@ -126,7 +146,7 @@ export class Relay {
*/
restoreSessions(sessions: RelaySession[]): void {
for (const session of sessions) {
this.sessions.set(session.id, session);
this.sessions.set(session.serverId, session);
}
}
}
+6 -6
View File
@@ -11,7 +11,7 @@
export type ConnectionRole = "server" | "client";
export interface RelaySession {
id: string;
serverId: string;
server: RelayConnection | null;
client: RelayConnection | null;
createdAt: number;
@@ -24,14 +24,14 @@ export interface RelayConnection {
}
export interface RelaySessionAttachment {
sessionId: string;
serverId: string;
role: ConnectionRole;
createdAt: number;
}
export interface RelayEvents {
onSessionCreated?(sessionId: string): void;
onSessionBridged?(sessionId: string): void;
onSessionClosed?(sessionId: string): void;
onError?(sessionId: string, error: Error): void;
onSessionCreated?(serverId: string): void;
onSessionBridged?(serverId: string): void;
onSessionClosed?(serverId: string): void;
onError?(serverId: string, error: Error): void;
}