From 8905ad4160cb91f2077ea26e0bd4f627029a9b2c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 22 Aug 2026 02:32:13 +0200 Subject: [PATCH] fix(relay): validate handshake keys (#3669) --- packages/relay/src/crypto.test.ts | 48 +++++ packages/relay/src/crypto.ts | 27 ++- .../daemon-e2e/relay-transport.e2e.test.ts | 176 +++++++++++++++--- 3 files changed, 229 insertions(+), 22 deletions(-) diff --git a/packages/relay/src/crypto.test.ts b/packages/relay/src/crypto.test.ts index 8db9a15f7..2978e04a3 100644 --- a/packages/relay/src/crypto.test.ts +++ b/packages/relay/src/crypto.test.ts @@ -12,6 +12,10 @@ function decryptText(sharedKey: Uint8Array, ciphertext: ArrayBuffer): string { return new TextDecoder().decode(decrypt(sharedKey, ciphertext)); } +function bytesFromHex(hex: string): Uint8Array { + return Uint8Array.from(hex.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); +} + describe("crypto", () => { describe("generateKeyPair", () => { it("generates a valid keypair", () => { @@ -36,6 +40,22 @@ describe("crypto", () => { const reExported = exportPublicKey(imported); expect(reExported).toBe(exported); }); + + it.each([ + ["malformed", "!".repeat(43) + "="], + ["noncanonical", `${"A".repeat(42)}B=`], + ])("rejects %s base64", (_description, encoded) => { + expect(importPublicKey.bind(null, encoded)).toThrowError("Invalid public key encoding"); + }); + + it.each([ + ["short", new Uint8Array(31)], + ["oversized", new Uint8Array(33)], + ])("rejects a %s decoded key", (_description, key) => { + expect(importPublicKey.bind(null, Buffer.from(key).toString("base64"))).toThrowError( + "Invalid public key length (expected 32)", + ); + }); }); describe("deriveSharedKey", () => { @@ -63,6 +83,34 @@ describe("crypto", () => { expect(decrypted).toBe(testMessage); }); + + // Unsupported peer public keys. + it.each([ + ["unsupported key 1", "00".repeat(32)], + ["unsupported key 2", `01${"00".repeat(31)}`], + ["unsupported key 3", "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800"], + ["unsupported key 4", "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157"], + ["unsupported key 5", `ec${"ff".repeat(30)}7f`], + ["unsupported key 6", `ed${"ff".repeat(30)}7f`], + ["unsupported key 7", `ee${"ff".repeat(30)}7f`], + ])("rejects the %s peer public key", (_description, publicKeyHex) => { + const { secretKey } = generateKeyPair(); + + expect(deriveSharedKey.bind(null, secretKey, bytesFromHex(publicKeyHex))).toThrowError( + "Invalid peer public key", + ); + }); + + it.each([ + ["short", new Uint8Array(31)], + ["oversized", new Uint8Array(33)], + ])("rejects a %s peer public key", (_description, publicKey) => { + const { secretKey } = generateKeyPair(); + + expect(deriveSharedKey.bind(null, secretKey, publicKey)).toThrowError( + "Invalid peer public key length (expected 32)", + ); + }); }); describe("encrypt / decrypt", () => { diff --git a/packages/relay/src/crypto.ts b/packages/relay/src/crypto.ts index 298f8dea4..40dc41ae2 100644 --- a/packages/relay/src/crypto.ts +++ b/packages/relay/src/crypto.ts @@ -23,6 +23,7 @@ export interface KeyPair { export type SharedKey = Uint8Array; // 32 bytes (box.before) const NONCE_LENGTH = nacl.box.nonceLength; // 24 +const ZERO_X25519_SHARED_RESULT = new Uint8Array(nacl.box.sharedKeyLength); let prngReady = false; @@ -68,6 +69,22 @@ function decodeBase64(base64: string): Uint8Array { return toByteArray(base64); } +function decodePublicKeyBase64(base64: string): Uint8Array { + if ( + typeof base64 !== "string" || + base64.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64) + ) { + throw new Error("Invalid public key encoding"); + } + + const bytes = decodeBase64(base64); + if (encodeBase64(bytes) !== base64) { + throw new Error("Invalid public key encoding"); + } + return bytes; +} + function toUint8(data: string | ArrayBuffer): Uint8Array { return typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data); } @@ -92,7 +109,7 @@ export function exportPublicKey(publicKey: Uint8Array): string { } export function importPublicKey(base64: string): Uint8Array { - const bytes = decodeBase64(base64); + const bytes = decodePublicKeyBase64(base64); if (bytes.byteLength !== nacl.box.publicKeyLength) { throw new Error(`Invalid public key length (expected ${nacl.box.publicKeyLength})`); } @@ -121,6 +138,14 @@ export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Ar if (peerPublicKey.byteLength !== nacl.box.publicKeyLength) { throw new Error(`Invalid peer public key length (expected ${nacl.box.publicKeyLength})`); } + + const rawSharedResult = nacl.scalarMult(ourSecretKey, peerPublicKey); + const isAllZero = nacl.verify(rawSharedResult, ZERO_X25519_SHARED_RESULT); + rawSharedResult.fill(0); + if (isAllZero) { + throw new Error("Invalid peer public key"); + } + return nacl.box.before(peerPublicKey, ourSecretKey); } diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 327098f2d..3edede83d 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -82,6 +82,11 @@ function decodeCiphertext(text: string): ArrayBuffer { return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); } +// Fixed transport key for an unsupported peer public key. +const UNSUPPORTED_PEER_TRANSPORT_KEY = Uint8Array.from( + Buffer.from("351f86faa3b988468a850122b65b0acece9c4826806aeee63de9c0da2bd7f91e", "hex"), +); + function parseEncryptedJson(sharedKey: Uint8Array, text: string): unknown { const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text))); return JSON.parse(plaintext); @@ -161,35 +166,48 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis throw new Error(`Relay WebSocket endpoint not ready on port ${port} within ${timeout}ms`); } +async function waitForCapturedLog( + lines: string[], + predicate: (line: string) => boolean, + timeout = 1000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (lines.some(predicate)) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for captured log. Recent logs:\n${lines.slice(-20).join("")}`); +} + (shouldRunRelayE2e ? describe : describe.skip)("Relay transport (E2EE) - daemon E2E", () => { let relayPort: number; let relayProcess: ChildProcess | null = null; let relayStdoutLines: string[] = []; - const startRelay = async () => { + const startRelay = async (options: { useLocalRelay?: boolean } = {}) => { relayStdoutLines = []; relayPort = await getAvailablePort(); const relayDir = path.resolve(process.cwd(), "../relay"); - relayProcess = spawn( - "npx", - [ - "wrangler", - "dev", - "--local", - "--ip", - "127.0.0.1", - "--port", - String(relayPort), - "--live-reload=false", - "--show-interactive-dev-session=false", - ], - { - cwd: relayDir, - env: { ...process.env }, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }, - ); + const relayArgs = [ + "wrangler", + "dev", + "--local", + "--ip", + "127.0.0.1", + "--port", + String(relayPort), + "--live-reload=false", + "--show-interactive-dev-session=false", + ]; + if (options.useLocalRelay) { + relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:"); + } + relayProcess = spawn("npx", relayArgs, { + cwd: relayDir, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); relayProcess.stdout?.on("data", (data: Buffer) => { const lines = data @@ -364,6 +382,122 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis } }, 90000); + test("daemon closes a relay client that sends an unsupported handshake key", async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const { logger, lines } = createCapturingLogger(); + await startRelay({ useLocalRelay: true }); + + const daemon = await createTestPaseoDaemon({ + listen: "127.0.0.1", + logger, + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + }); + + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); + const { serverId } = decodeOfferFromFragmentUrl(offerUrl); + const ws = new WebSocket( + buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + ); + + let receivedServerInfo = false; + const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected"); + const outcome = await new Promise< + { type: "closed"; code: number; reason: string } | { type: "errored"; error: Error } + >((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close(); + reject(new Error("timed out waiting for daemon to reject unsupported handshake key")); + }, 20_000); + + ws.on("open", async () => { + try { + await waitForCapturedLog(lines, isRelayDataConnectedLog); + ws.send( + JSON.stringify({ + type: "e2ee_hello", + key: exportPublicKey(new Uint8Array(32)), + }), + ); + ws.send( + encodeCiphertext( + encrypt( + UNSUPPORTED_PEER_TRANSPORT_KEY, + JSON.stringify({ + type: "hello", + clientId: "cid_relay_unsupported_key", + clientType: "cli", + protocolVersion: 1, + }), + ), + ), + ); + } catch (error) { + clearTimeout(timeout); + reject(error); + } + }); + + ws.on("message", (data) => { + try { + const parsed = WSOutboundMessageSchema.parse( + parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()), + ); + if ( + parsed.type === "session" && + parsed.message.type === "status" && + parsed.message.payload?.status === "server_info" + ) { + receivedServerInfo = true; + clearTimeout(timeout); + reject( + new Error("daemon sent server_info after accepting an unsupported handshake key"), + ); + } + } catch { + // Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion. + } + }); + ws.on("close", (code, reason) => { + clearTimeout(timeout); + resolve({ type: "closed", code, reason: reason.toString() }); + }); + ws.on("error", (error) => { + clearTimeout(timeout); + resolve({ type: "errored", error }); + }); + }); + + expect(receivedServerInfo).toBe(false); + if (outcome.type === "closed") { + expect(outcome.code).toBeGreaterThan(0); + } else { + expect(outcome.error).toBeInstanceOf(Error); + } + + const isRejectedHandshakeLog = (line: string) => + line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key"); + await waitForCapturedLog(lines, isRejectedHandshakeLog); + expect(lines.some(isRejectedHandshakeLog)).toBe(true); + } finally { + await daemon.close(); + await stopRelay(); + } + }, 90000); + test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";