Switch to double quotes and reformat codebase with Biome

This commit is contained in:
Mohamed Boudra
2026-03-21 20:22:15 +07:00
parent ab40786882
commit f6579dbeb4
805 changed files with 38132 additions and 40175 deletions
+13 -9
View File
@@ -43,7 +43,7 @@ function createMockState() {
}
async function withMockWebSocketPair(
run: (sockets: { clientWs: MockSocket; serverWs: MockSocket }) => Promise<void> | void
run: (sockets: { clientWs: MockSocket; serverWs: MockSocket }) => Promise<void> | void,
): Promise<void> {
const serverWs = createMockSocket();
const clientWs = createMockSocket();
@@ -173,7 +173,7 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
headers: {
Upgrade: "websocket",
},
}
},
);
await relay.fetch(req).catch(() => undefined);
@@ -211,25 +211,27 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
disconnectedClient as unknown as WebSocket,
1001,
"Client disconnected",
true
true,
);
expect(serverData.close).not.toHaveBeenCalled();
expect(control.send).not.toHaveBeenCalledWith(
JSON.stringify({ type: "disconnected", connectionId: clientId })
JSON.stringify({ type: "disconnected", connectionId: clientId }),
);
});
});
describe("relay worker endpoint routing", () => {
it("routes missing v to legacy v1 isolated DO ids", async () => {
const fetch = vi.fn(async (request: Request) => new Response(`ok:${new URL(request.url).searchParams.get("v")}`));
const fetch = vi.fn(
async (request: Request) => new Response(`ok:${new URL(request.url).searchParams.get("v")}`),
);
const get = vi.fn(() => ({ fetch }));
const idFromName = vi.fn(() => ({ toString: () => "id" }));
const response = await relayWorker.fetch(
new Request("https://relay.test/ws?serverId=srv_test&role=server"),
{ RELAY: { idFromName, get } } as any
{ RELAY: { idFromName, get } } as any,
);
expect(idFromName).toHaveBeenCalledWith("relay-v1:srv_test");
@@ -238,13 +240,15 @@ describe("relay worker endpoint routing", () => {
});
it("routes v=2 to v2 isolated DO ids", async () => {
const fetch = vi.fn(async (request: Request) => new Response(`ok:${new URL(request.url).searchParams.get("v")}`));
const fetch = vi.fn(
async (request: Request) => new Response(`ok:${new URL(request.url).searchParams.get("v")}`),
);
const get = vi.fn(() => ({ fetch }));
const idFromName = vi.fn(() => ({ toString: () => "id" }));
const response = await relayWorker.fetch(
new Request("https://relay.test/ws?serverId=srv_test&role=server&v=2"),
{ RELAY: { idFromName, get } } as any
{ RELAY: { idFromName, get } } as any,
);
expect(idFromName).toHaveBeenCalledWith("relay-v2:srv_test");
@@ -259,7 +263,7 @@ describe("relay worker endpoint routing", () => {
const response = await relayWorker.fetch(
new Request("https://relay.test/ws?serverId=srv_test&role=server&v=nope"),
{ RELAY: { idFromName, get } } as any
{ RELAY: { idFromName, get } } as any,
);
expect(response.status).toBe(400);
+28 -20
View File
@@ -91,7 +91,9 @@ export class RelayDurableObject {
}
private createWebSocketPair(): [WebSocket, WebSocket] {
const pair = new (globalThis as unknown as { WebSocketPair: new () => WebSocketPair }).WebSocketPair();
const pair = new (
globalThis as unknown as { WebSocketPair: new () => WebSocketPair }
).WebSocketPair();
return [pair[0], pair[1]];
}
@@ -188,8 +190,14 @@ export class RelayDurableObject {
const out = new Set<string>();
for (const ws of this.state.getWebSockets("client")) {
try {
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
if (attachment?.role === "client" && typeof attachment.connectionId === "string" && attachment.connectionId) {
const attachment = (
ws as WebSocketWithAttachment
).deserializeAttachment() as RelaySessionAttachment | null;
if (
attachment?.role === "client" &&
typeof attachment.connectionId === "string" &&
attachment.connectionId
) {
out.add(attachment.connectionId);
}
} catch {
@@ -244,7 +252,7 @@ export class RelayDurableObject {
request: Request,
role: ConnectionRole,
serverId: string,
connectionId: string
connectionId: string,
): Response {
const upgradeError = this.requireWebSocketUpgrade(request);
if (upgradeError) return upgradeError;
@@ -295,7 +303,7 @@ export class RelayDurableObject {
(server as WebSocketWithAttachment).serializeAttachment(attachment);
console.log(
`[Relay DO] v2:${role}${isServerControl ? "(control)" : ""}${isServerData ? `(data:${resolvedConnectionId})` : role === "client" ? `(${resolvedConnectionId})` : ""} connected to session ${serverId}`
`[Relay DO] v2:${role}${isServerControl ? "(control)" : ""}${isServerData ? `(data:${resolvedConnectionId})` : role === "client" ? `(${resolvedConnectionId})` : ""} connected to session ${serverId}`,
);
if (role === "client") {
@@ -306,7 +314,9 @@ export class RelayDurableObject {
if (isServerControl) {
// Send current connection list so the daemon can attach existing connections.
try {
server.send(JSON.stringify({ type: "sync", connectionIds: this.listConnectedConnectionIds() }));
server.send(
JSON.stringify({ type: "sync", connectionIds: this.listConnectedConnectionIds() }),
);
} catch {
// ignore
}
@@ -350,7 +360,9 @@ export class RelayDurableObject {
* Called when a WebSocket message is received (wakes from hibernation).
*/
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
const attachment = (
ws as WebSocketWithAttachment
).deserializeAttachment() as RelaySessionAttachment | null;
if (!attachment) {
console.error("[Relay DO] Message from WebSocket without attachment");
return;
@@ -421,18 +433,15 @@ export class RelayDurableObject {
/**
* Called when a WebSocket closes (wakes from hibernation).
*/
webSocketClose(
ws: WebSocket,
code: number,
reason: string,
_wasClean: boolean
): void {
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
webSocketClose(ws: WebSocket, code: number, reason: string, _wasClean: boolean): void {
const attachment = (
ws as WebSocketWithAttachment
).deserializeAttachment() as RelaySessionAttachment | null;
if (!attachment) return;
const version = attachment.version ?? LEGACY_RELAY_VERSION;
console.log(
`[Relay DO] v${version}:${attachment.role}${attachment.connectionId ? `(${attachment.connectionId})` : ""} disconnected from session ${attachment.serverId} (${code}: ${reason})`
`[Relay DO] v${version}:${attachment.role}${attachment.connectionId ? `(${attachment.connectionId})` : ""} disconnected from session ${attachment.serverId} (${code}: ${reason})`,
);
if (version === LEGACY_RELAY_VERSION) {
@@ -476,11 +485,10 @@ export class RelayDurableObject {
* Called on WebSocket error.
*/
webSocketError(ws: WebSocket, error: unknown): void {
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
console.error(
`[Relay DO] WebSocket error for ${attachment?.role ?? "unknown"}:`,
error
);
const attachment = (
ws as WebSocketWithAttachment
).deserializeAttachment() as RelaySessionAttachment | null;
console.error(`[Relay DO] WebSocket error for ${attachment?.role ?? "unknown"}:`, error);
}
}
+11 -36
View File
@@ -51,11 +51,11 @@ describe("crypto", () => {
// Derive shared keys
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.secretKey,
daemonSeesClientPubKey
daemonSeesClientPubKey,
);
const clientSharedKey = await deriveSharedKey(
clientKeyPair.secretKey,
clientSeesDaemonPubKey
clientSeesDaemonPubKey,
);
// Both should derive the same key - test by encrypting with one, decrypting with other
@@ -71,10 +71,7 @@ describe("crypto", () => {
it("roundtrips a string message", async () => {
const daemonKeyPair = await generateKeyPair();
const clientKeyPair = await generateKeyPair();
const sharedKey = await deriveSharedKey(
daemonKeyPair.secretKey,
clientKeyPair.publicKey
);
const sharedKey = await deriveSharedKey(daemonKeyPair.secretKey, clientKeyPair.publicKey);
const plaintext = "Test message with unicode: 你好世界 🎉";
const ciphertext = await encrypt(sharedKey, plaintext);
@@ -89,10 +86,7 @@ describe("crypto", () => {
it("roundtrips binary data", async () => {
const daemonKeyPair = await generateKeyPair();
const clientKeyPair = await generateKeyPair();
const sharedKey = await deriveSharedKey(
daemonKeyPair.secretKey,
clientKeyPair.publicKey
);
const sharedKey = await deriveSharedKey(daemonKeyPair.secretKey, clientKeyPair.publicKey);
const binary = new Uint8Array([0, 1, 2, 255, 254, 253]);
const ciphertext = await encrypt(sharedKey, binary.buffer);
@@ -106,14 +100,8 @@ describe("crypto", () => {
const keypair2 = await generateKeyPair();
const keypair3 = await generateKeyPair();
const correctKey = await deriveSharedKey(
keypair1.secretKey,
keypair2.publicKey
);
const wrongKey = await deriveSharedKey(
keypair1.secretKey,
keypair3.publicKey
);
const correctKey = await deriveSharedKey(keypair1.secretKey, keypair2.publicKey);
const wrongKey = await deriveSharedKey(keypair1.secretKey, keypair3.publicKey);
const ciphertext = await encrypt(correctKey, "secret");
@@ -123,10 +111,7 @@ describe("crypto", () => {
it("produces different ciphertext for same plaintext (random IV)", async () => {
const keypair1 = await generateKeyPair();
const keypair2 = await generateKeyPair();
const sharedKey = await deriveSharedKey(
keypair1.secretKey,
keypair2.publicKey
);
const sharedKey = await deriveSharedKey(keypair1.secretKey, keypair2.publicKey);
const plaintext = "Same message";
const ciphertext1 = await encrypt(sharedKey, plaintext);
@@ -159,10 +144,7 @@ describe("crypto", () => {
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
// Client derives shared key
const clientSharedKey = await deriveSharedKey(
clientKeyPair.secretKey,
daemonPubKeyOnClient
);
const clientSharedKey = await deriveSharedKey(clientKeyPair.secretKey, daemonPubKeyOnClient);
// Client sends hello: { type: "hello", key: clientPubKeyB64 }
@@ -171,10 +153,7 @@ describe("crypto", () => {
const clientPubKeyOnDaemon = await importPublicKey(clientPubKeyB64);
// Daemon derives shared key
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.secretKey,
clientPubKeyOnDaemon
);
const daemonSharedKey = await deriveSharedKey(daemonKeyPair.secretKey, clientPubKeyOnDaemon);
// === VERIFY BOTH HAVE SAME KEY ===
const testFromDaemon = "Message from daemon";
@@ -182,15 +161,11 @@ describe("crypto", () => {
// Daemon encrypts, client decrypts
const encryptedFromDaemon = await encrypt(daemonSharedKey, testFromDaemon);
expect(await decrypt(clientSharedKey, encryptedFromDaemon)).toBe(
testFromDaemon
);
expect(await decrypt(clientSharedKey, encryptedFromDaemon)).toBe(testFromDaemon);
// Client encrypts, daemon decrypts
const encryptedFromClient = await encrypt(clientSharedKey, testFromClient);
expect(await decrypt(daemonSharedKey, encryptedFromClient)).toBe(
testFromClient
);
expect(await decrypt(daemonSharedKey, encryptedFromClient)).toBe(testFromClient);
});
});
});
+1 -4
View File
@@ -103,10 +103,7 @@ export function importSecretKey(base64: string): Uint8Array {
return bytes;
}
export function deriveSharedKey(
ourSecretKey: Uint8Array,
peerPublicKey: Uint8Array
): SharedKey {
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})`);
}
+54 -63
View File
@@ -57,17 +57,23 @@ function spawnRelayDevServer(port: number): ChildProcess {
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
}
},
);
}
function assertRelayStillRunning(relayProcess: ChildProcess): void {
if (relayProcess.exitCode !== null) {
throw new Error(`relay process exited before startup completed (code: ${relayProcess.exitCode})`);
throw new Error(
`relay process exited before startup completed (code: ${relayProcess.exitCode})`,
);
}
}
async function waitForServer(port: number, relayProcess: ChildProcess, timeout = 15000): Promise<void> {
async function waitForServer(
port: number,
relayProcess: ChildProcess,
timeout = 15000,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
assertRelayStillRunning(relayProcess);
@@ -90,7 +96,7 @@ async function waitForServer(port: number, relayProcess: ChildProcess, timeout =
async function waitForRelayWebSocketReady(
port: number,
relayProcess: ChildProcess,
timeout = 60000
timeout = 60000,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
@@ -156,14 +162,20 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
relayProcess = spawnRelayDevServer(relayPort);
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data.toString().split("\n").filter((l) => l.trim());
const lines = data
.toString()
.split("\n")
.filter((l) => l.trim());
for (const line of lines) {
// eslint-disable-next-line no-console
console.log(`[relay] ${line}`);
}
});
relayProcess.stderr?.on("data", (data: Buffer) => {
const lines = data.toString().split("\n").filter((l) => l.trim());
const lines = data
.toString()
.split("\n")
.filter((l) => l.trim());
for (const line of lines) {
// eslint-disable-next-line no-console
console.error(`[relay] ${line}`);
@@ -187,7 +199,9 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
}
}, SHUTDOWN_TIMEOUT_MS);
it("full flow: daemon and client exchange encrypted messages through relay", { timeout: 90_000 }, async () => {
it("full flow: daemon and client exchange encrypted messages through relay", {
timeout: 90_000,
}, async () => {
const serverId = "test-session-" + Date.now();
const connectionId = "clt_test_" + Date.now() + "_" + Math.random().toString(36).slice(2);
@@ -200,7 +214,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
// Daemon connects to relay as "server" control role
const daemonControlWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&v=2`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&v=2`,
);
await new Promise<void>((resolve, reject) => {
@@ -216,16 +230,10 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
// Client imports daemon's public key and derives shared secret
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
const clientSharedKey = await deriveSharedKey(
clientKeyPair.secretKey,
daemonPubKeyOnClient
);
const clientSharedKey = await deriveSharedKey(clientKeyPair.secretKey, daemonPubKeyOnClient);
const waitForClientSeen = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("timed out waiting for connected")),
5000
);
const timeout = setTimeout(() => reject(new Error("timed out waiting for connected")), 5000);
const onMessage = (raw: unknown) => {
try {
const text =
@@ -241,7 +249,11 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
resolve();
return;
}
if (msg?.type === "sync" && Array.isArray(msg.connectionIds) && msg.connectionIds.includes(connectionId)) {
if (
msg?.type === "sync" &&
Array.isArray(msg.connectionIds) &&
msg.connectionIds.includes(connectionId)
) {
clearTimeout(timeout);
daemonControlWs.off("message", onMessage);
resolve();
@@ -255,7 +267,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
// Client connects to relay as "client" role (must include connectionId)
const clientWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&connectionId=${connectionId}&v=2`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&connectionId=${connectionId}&v=2`,
);
await new Promise<void>((resolve, reject) => {
@@ -266,7 +278,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
await waitForClientSeen;
const daemonWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&connectionId=${connectionId}&v=2`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&connectionId=${connectionId}&v=2`,
);
await new Promise<void>((resolve, reject) => {
daemonWs.on("open", resolve);
@@ -288,10 +300,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
// Daemon imports client's public key and derives shared secret
const clientPubKeyOnDaemon = await importPublicKey(hello.key);
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.secretKey,
clientPubKeyOnDaemon
);
const daemonSharedKey = await deriveSharedKey(daemonKeyPair.secretKey, clientPubKeyOnDaemon);
// === VERIFY BOTH HAVE SAME KEY - Exchange encrypted messages ===
@@ -308,8 +317,8 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
clientSharedKey,
clientReceivedReady.buffer.slice(
clientReceivedReady.byteOffset,
clientReceivedReady.byteOffset + clientReceivedReady.byteLength
)
clientReceivedReady.byteOffset + clientReceivedReady.byteLength,
),
);
expect(JSON.parse(decryptedReady as string)).toEqual({ type: "ready" });
@@ -326,8 +335,8 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
daemonSharedKey,
daemonReceivedMsg.buffer.slice(
daemonReceivedMsg.byteOffset,
daemonReceivedMsg.byteOffset + daemonReceivedMsg.byteLength
)
daemonReceivedMsg.byteOffset + daemonReceivedMsg.byteLength,
),
);
expect(decryptedClientMsg).toBe(clientMessage);
@@ -344,8 +353,8 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
clientSharedKey,
clientReceivedMsg.buffer.slice(
clientReceivedMsg.byteOffset,
clientReceivedMsg.byteOffset + clientReceivedMsg.byteLength
)
clientReceivedMsg.byteOffset + clientReceivedMsg.byteLength,
),
);
expect(decryptedDaemonMsg).toBe(daemonMessage);
@@ -368,25 +377,16 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
const clientPubKey = await importPublicKey(clientPubKeyB64);
const daemonPubKey = await importPublicKey(daemonPubKeyB64);
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.secretKey,
clientPubKey
);
const clientSharedKey = await deriveSharedKey(
clientKeyPair.secretKey,
daemonPubKey
);
const daemonSharedKey = await deriveSharedKey(daemonKeyPair.secretKey, clientPubKey);
const clientSharedKey = await deriveSharedKey(clientKeyPair.secretKey, daemonPubKey);
const daemonControlWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&v=2`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&v=2`,
);
await new Promise<void>((r) => daemonControlWs.on("open", r));
const waitForClientSeen = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("timed out waiting for connected")),
5000
);
const timeout = setTimeout(() => reject(new Error("timed out waiting for connected")), 5000);
const onMessage = (raw: unknown) => {
try {
const text =
@@ -402,7 +402,11 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
resolve();
return;
}
if (msg?.type === "sync" && Array.isArray(msg.connectionIds) && msg.connectionIds.includes(connectionId)) {
if (
msg?.type === "sync" &&
Array.isArray(msg.connectionIds) &&
msg.connectionIds.includes(connectionId)
) {
clearTimeout(timeout);
daemonControlWs.off("message", onMessage);
resolve();
@@ -415,13 +419,13 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
});
const clientWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&connectionId=${connectionId}&v=2`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&connectionId=${connectionId}&v=2`,
);
await new Promise<void>((r) => clientWs.on("open", r));
await waitForClientSeen;
const daemonWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&connectionId=${connectionId}&v=2`
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&connectionId=${connectionId}&v=2`,
);
await new Promise<void>((r) => daemonWs.on("open", r));
@@ -448,10 +452,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
// But daemon can decrypt
const decrypted = await decrypt(
daemonSharedKey,
received.buffer.slice(
received.byteOffset,
received.byteOffset + received.byteLength
)
received.buffer.slice(received.byteOffset, received.byteOffset + received.byteLength),
);
expect(decrypted).toBe(secret);
@@ -468,22 +469,12 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
const clientKeyPair = await generateKeyPair();
const attackerKeyPair = await generateKeyPair();
const clientPubKey = await importPublicKey(
await exportPublicKey(clientKeyPair.publicKey)
);
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.secretKey,
clientPubKey
);
const clientPubKey = await importPublicKey(await exportPublicKey(clientKeyPair.publicKey));
const daemonSharedKey = await deriveSharedKey(daemonKeyPair.secretKey, clientPubKey);
// Attacker tries to derive key with their own keypair
const attackerPubKey = await importPublicKey(
await exportPublicKey(attackerKeyPair.publicKey)
);
const attackerKey = await deriveSharedKey(
attackerKeyPair.secretKey,
attackerPubKey
);
const attackerPubKey = await importPublicKey(await exportPublicKey(attackerKeyPair.publicKey));
const attackerKey = await deriveSharedKey(attackerKeyPair.secretKey, attackerPubKey);
// Encrypt with daemon's key
const secret = "Top secret message";
+23 -44
View File
@@ -1,9 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import {
createClientChannel,
createDaemonChannel,
Transport,
} from "./encrypted-channel.js";
import { createClientChannel, createDaemonChannel, Transport } from "./encrypted-channel.js";
import { generateKeyPair, exportPublicKey } from "./crypto.js";
/**
@@ -28,17 +24,13 @@ function createMockTransportPair(): [Transport, Transport] {
};
// Wire them together
(transportA.send as ReturnType<typeof vi.fn>).mockImplementation(
(data: string | ArrayBuffer) => {
setTimeout(() => transportB.onmessage?.(data), 0);
}
);
(transportA.send as ReturnType<typeof vi.fn>).mockImplementation((data: string | ArrayBuffer) => {
setTimeout(() => transportB.onmessage?.(data), 0);
});
(transportB.send as ReturnType<typeof vi.fn>).mockImplementation(
(data: string | ArrayBuffer) => {
setTimeout(() => transportA.onmessage?.(data), 0);
}
);
(transportB.send as ReturnType<typeof vi.fn>).mockImplementation((data: string | ArrayBuffer) => {
setTimeout(() => transportA.onmessage?.(data), 0);
});
return [transportA, transportB];
}
@@ -60,11 +52,9 @@ describe("EncryptedChannel", () => {
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
// Client connects (scanned QR, got daemon's public key)
const clientChannel = await createClientChannel(
clientTransport,
daemonPubKeyB64,
{ onopen: () => clientOpenedResolve?.() }
);
const clientChannel = await createClientChannel(clientTransport, daemonPubKeyB64, {
onopen: () => clientOpenedResolve?.(),
});
// Daemon receives hello and completes handshake
const daemonChannel = await daemonChannelPromise;
@@ -88,17 +78,14 @@ describe("EncryptedChannel", () => {
clientOpenedResolve = resolve;
});
const daemonChannelPromise = createDaemonChannel(
daemonTransport,
daemonKeyPair,
{ onmessage: (data) => daemonMessages.push(data) }
);
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, {
onmessage: (data) => daemonMessages.push(data),
});
const clientChannel = await createClientChannel(
clientTransport,
daemonPubKeyB64,
{ onmessage: (data) => clientMessages.push(data), onopen: () => clientOpenedResolve?.() }
);
const clientChannel = await createClientChannel(clientTransport, daemonPubKeyB64, {
onmessage: (data) => clientMessages.push(data),
onopen: () => clientOpenedResolve?.(),
});
const daemonChannel = await daemonChannelPromise;
await clientOpened;
@@ -111,10 +98,7 @@ describe("EncryptedChannel", () => {
// Wait for async delivery
await new Promise((r) => setTimeout(r, 50));
expect(daemonMessages).toEqual([
"Hello from client",
"Second message from client",
]);
expect(daemonMessages).toEqual(["Hello from client", "Second message from client"]);
expect(clientMessages).toEqual(["Hello from daemon"]);
});
@@ -130,11 +114,9 @@ describe("EncryptedChannel", () => {
});
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
const clientChannel = await createClientChannel(
clientTransport,
daemonPubKeyB64,
{ onopen: () => clientOpenedResolve?.() }
);
const clientChannel = await createClientChannel(clientTransport, daemonPubKeyB64, {
onopen: () => clientOpenedResolve?.(),
});
await daemonChannelPromise;
await clientOpened;
@@ -147,8 +129,7 @@ describe("EncryptedChannel", () => {
// Check what was actually sent over the transport
expect(clientTransport.send).toHaveBeenCalledTimes(1);
const sentData = (clientTransport.send as ReturnType<typeof vi.fn>).mock
.calls[0][0];
const sentData = (clientTransport.send as ReturnType<typeof vi.fn>).mock.calls[0][0];
// Should be base64 string (encrypted)
expect(typeof sentData).toBe("string");
@@ -189,9 +170,7 @@ describe("EncryptedChannel", () => {
expect(onerror).toHaveBeenCalledTimes(1);
expect(onerror.mock.calls[0][0]).toBeInstanceOf(Error);
expect((onerror.mock.calls[0][0] as Error).message).toContain(
"WebSocket not open"
);
expect((onerror.mock.calls[0][0] as Error).message).toContain("WebSocket not open");
// Close the transport to stop retry timer.
transport.onclose?.(1000, "closed");
+12 -20
View File
@@ -58,18 +58,15 @@ interface E2EEReadyMessage {
function buildInvalidHelloError(rawText: string, parsed?: unknown): Error {
const parsedRecord =
parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: null;
parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
const rawType = parsedRecord?.type;
const receivedType =
typeof rawType === "string" ? rawType : rawType === undefined ? "undefined" : typeof rawType;
const hasKey =
typeof parsedRecord?.key === "string" && parsedRecord.key.trim().length > 0;
const hasKey = typeof parsedRecord?.key === "string" && parsedRecord.key.trim().length > 0;
const compact = rawText.replace(/\s+/g, " ").trim();
const preview = compact.length > 160 ? `${compact.slice(0, 157)}...` : compact;
return new Error(
`Invalid hello message (receivedType=${receivedType}, hasKey=${hasKey}, preview=${JSON.stringify(preview)})`
`Invalid hello message (receivedType=${receivedType}, hasKey=${hasKey}, preview=${JSON.stringify(preview)})`,
);
}
@@ -88,7 +85,7 @@ const MAX_PENDING_SENDS = 200;
export async function createClientChannel(
transport: Transport,
daemonPublicKeyB64: string,
events: EncryptedChannelEvents = {}
events: EncryptedChannelEvents = {},
): Promise<EncryptedChannel> {
const keyPair = generateKeyPair();
const daemonPublicKey = importPublicKey(daemonPublicKeyB64);
@@ -152,7 +149,7 @@ export async function createClientChannel(
export async function createDaemonChannel(
transport: Transport,
daemonKeyPair: KeyPair,
events: EncryptedChannelEvents = {}
events: EncryptedChannelEvents = {},
): Promise<EncryptedChannel> {
return new Promise((resolve, reject) => {
const bufferedMessages: Array<string | ArrayBuffer> = [];
@@ -168,8 +165,7 @@ export async function createDaemonChannel(
transport.onmessage = async (data) => {
try {
const helloText =
typeof data === "string" ? data : new TextDecoder().decode(data);
const helloText = typeof data === "string" ? data : new TextDecoder().decode(data);
let parsed: unknown;
try {
@@ -195,9 +191,7 @@ export async function createDaemonChannel(
const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
const channel = new EncryptedChannel(transport, sharedKey, events, { daemonKeyPair });
transport.send(
JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage)
);
transport.send(JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage));
channel.setState("open");
events.onopen?.();
@@ -240,7 +234,7 @@ export class EncryptedChannel {
transport: Transport,
sharedKey: SharedKey,
events: EncryptedChannelEvents = {},
options: EncryptedChannelOptions = {}
options: EncryptedChannelOptions = {},
) {
this.transport = transport;
this.sharedKey = sharedKey;
@@ -287,9 +281,7 @@ export class EncryptedChannel {
try {
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
if (text.trim().startsWith("{")) {
const parsed = JSON.parse(text) as Partial<
E2EEHelloMessage | E2EEReadyMessage
>;
const parsed = JSON.parse(text) as Partial<E2EEHelloMessage | E2EEReadyMessage>;
if (parsed.type === "e2ee_hello" && typeof parsed.key === "string") {
if (this.options.daemonKeyPair) {
@@ -297,7 +289,7 @@ export class EncryptedChannel {
const clientPublicKey = importPublicKey(parsed.key);
const nextSharedKey = deriveSharedKey(
this.options.daemonKeyPair.secretKey,
clientPublicKey
clientPublicKey,
);
// If it's the same client key (handshake retry), re-send
@@ -305,7 +297,7 @@ export class EncryptedChannel {
// the channel and cause decrypt failures.
if (keysEqual(nextSharedKey, this.sharedKey)) {
this.transport.send(
JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage)
JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage),
);
return null;
}
@@ -318,7 +310,7 @@ export class EncryptedChannel {
this.sharedKey = nextSharedKey;
this.pendingSends = [];
this.transport.send(
JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage)
JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage),
);
this.state = "open";
await this.flushPendingSends();
+35 -47
View File
@@ -13,7 +13,7 @@ const RELAY_BASE_URL = "wss://relay.paseo.sh";
async function withRetry<T>(
fn: () => Promise<T>,
options: { retries: number; delayMs: number }
options: { retries: number; delayMs: number },
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= options.retries; attempt++) {
@@ -39,10 +39,10 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
const connectionId = `clt_live_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const serverControlUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server&v=2`;
const serverDataUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
serverId
serverId,
)}&role=server&connectionId=${encodeURIComponent(connectionId)}&v=2`;
const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
serverId
serverId,
)}&role=client&connectionId=${encodeURIComponent(connectionId)}&v=2`;
// === Key setup ===
@@ -55,7 +55,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
const clientSharedKey = await deriveSharedKey(
clientKeyPair.secretKey,
daemonPubKeyOnClient
daemonPubKeyOnClient,
);
// === Connect ===
@@ -67,7 +67,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out opening ${label} websocket`)),
10_000
10_000,
);
ws.once("open", () => {
clearTimeout(timeout);
@@ -88,7 +88,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for connected")),
10_000
10_000,
);
daemonControlWs.on("message", (raw) => {
try {
@@ -113,7 +113,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
const daemonReceivedHello = await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for hello")),
10_000
10_000,
);
daemonWs!.once("message", (data) => {
clearTimeout(timeout);
@@ -131,67 +131,55 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
const clientPubKeyOnDaemon = await importPublicKey(hello.key!);
const daemonSharedKey = await deriveSharedKey(
daemonKeyPair.secretKey,
clientPubKeyOnDaemon
clientPubKeyOnDaemon,
);
// === Encrypted exchange ===
const plaintextFromClient = "hello-from-client";
const ciphertextFromClient = await encrypt(
clientSharedKey,
plaintextFromClient
);
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 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
)
daemonReceivedCiphertext.byteOffset + daemonReceivedCiphertext.byteLength,
),
);
expect(decryptedOnDaemon).toBe(plaintextFromClient);
const plaintextFromDaemon = "hello-from-daemon";
const ciphertextFromDaemon = await encrypt(
daemonSharedKey,
plaintextFromDaemon
);
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 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
)
clientReceivedCiphertext.byteOffset + clientReceivedCiphertext.byteLength,
),
);
expect(decryptedOnClient).toBe(plaintextFromDaemon);
} finally {
@@ -200,7 +188,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
clientWs.close();
}
},
{ retries: 2, delayMs: 250 }
{ retries: 2, delayMs: 250 },
);
});
});