Files
paseo/packages/relay/src/live-relay.e2e.test.ts
T

207 lines
6.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { WebSocket } from "ws";
import {
generateKeyPair,
exportPublicKey,
importPublicKey,
deriveSharedKey,
encrypt,
decrypt,
} from "./crypto.js";
const RELAY_BASE_URL = "wss://relay.paseo.sh";
async function withRetry<T>(
fn: () => Promise<T>,
options: { retries: number; delayMs: number }
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= options.retries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt < options.retries) {
await new Promise((r) => setTimeout(r, options.delayMs));
}
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
describe("Live relay (relay.paseo.sh) E2E", () => {
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 serverId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const clientId = `clt_live_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const serverControlUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server`;
const serverDataUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
serverId
)}&role=server&clientId=${encodeURIComponent(clientId)}`;
const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
serverId
)}&role=client&clientId=${encodeURIComponent(clientId)}`;
// === Key setup ===
const daemonKeyPair = await generateKeyPair();
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
const clientKeyPair = await generateKeyPair();
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
const clientSharedKey = await deriveSharedKey(
clientKeyPair.secretKey,
daemonPubKeyOnClient
);
// === Connect ===
const daemonControlWs = new WebSocket(serverControlUrl);
const clientWs = new WebSocket(clientUrl);
let daemonWs: WebSocket | null = null;
const waitOpen = (ws: WebSocket, label: string) =>
new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out opening ${label} websocket`)),
10_000
);
ws.once("open", () => {
clearTimeout(timeout);
resolve();
});
ws.once("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});
try {
await Promise.all([
waitOpen(daemonControlWs, "server-control"),
waitOpen(clientWs, "client"),
]);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for client_connected")),
10_000
);
daemonControlWs.on("message", (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg && msg.type === "client_connected" && msg.clientId === clientId) {
clearTimeout(timeout);
resolve();
}
} catch {
// ignore
}
});
});
daemonWs = new WebSocket(serverDataUrl);
await waitOpen(daemonWs, "server-data");
// === Handshake ===
// Client sends hello with its public key (not encrypted).
clientWs.send(JSON.stringify({ type: "hello", key: clientPubKeyB64 }));
const daemonReceivedHello = await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for hello")),
10_000
);
daemonWs!.once("message", (data) => {
clearTimeout(timeout);
resolve(data.toString());
});
});
const hello = JSON.parse(daemonReceivedHello) as {
type: string;
key?: string;
};
expect(hello.type).toBe("hello");
expect(typeof hello.key).toBe("string");
const clientPubKeyOnDaemon = await importPublicKey(hello.key!);
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.secretKey,
clientPubKeyOnDaemon
);
// === Encrypted exchange ===
const plaintextFromClient = "hello-from-client";
const ciphertextFromClient = await encrypt(
clientSharedKey,
plaintextFromClient
);
clientWs.send(Buffer.from(ciphertextFromClient));
const daemonReceivedCiphertext = await new Promise<Buffer>(
(resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for encrypted message")),
10_000
);
daemonWs!.once("message", (data) => {
clearTimeout(timeout);
resolve(data as Buffer);
});
}
);
const decryptedOnDaemon = await decrypt(
daemonSharedKey,
daemonReceivedCiphertext.buffer.slice(
daemonReceivedCiphertext.byteOffset,
daemonReceivedCiphertext.byteOffset +
daemonReceivedCiphertext.byteLength
)
);
expect(decryptedOnDaemon).toBe(plaintextFromClient);
const plaintextFromDaemon = "hello-from-daemon";
const ciphertextFromDaemon = await encrypt(
daemonSharedKey,
plaintextFromDaemon
);
daemonWs!.send(Buffer.from(ciphertextFromDaemon));
const clientReceivedCiphertext = await new Promise<Buffer>(
(resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for encrypted response")),
10_000
);
clientWs.once("message", (data) => {
clearTimeout(timeout);
resolve(data as Buffer);
});
}
);
const decryptedOnClient = await decrypt(
clientSharedKey,
clientReceivedCiphertext.buffer.slice(
clientReceivedCiphertext.byteOffset,
clientReceivedCiphertext.byteOffset +
clientReceivedCiphertext.byteLength
)
);
expect(decryptedOnClient).toBe(plaintextFromDaemon);
} finally {
daemonControlWs.close();
daemonWs?.close();
clientWs.close();
}
},
{ retries: 2, delayMs: 250 }
);
});
});