Relay pairing offer v2 (E2EE) + host connections
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user