fix(relay): validate handshake keys (#3669)

This commit is contained in:
Mohamed Boudra
2026-08-22 02:32:13 +02:00
committed by GitHub
parent 60a5344cc6
commit 8905ad4160
3 changed files with 229 additions and 22 deletions
+48
View File
@@ -12,6 +12,10 @@ function decryptText(sharedKey: Uint8Array, ciphertext: ArrayBuffer): string {
return new TextDecoder().decode(decrypt(sharedKey, ciphertext)); 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("crypto", () => {
describe("generateKeyPair", () => { describe("generateKeyPair", () => {
it("generates a valid keypair", () => { it("generates a valid keypair", () => {
@@ -36,6 +40,22 @@ describe("crypto", () => {
const reExported = exportPublicKey(imported); const reExported = exportPublicKey(imported);
expect(reExported).toBe(exported); 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", () => { describe("deriveSharedKey", () => {
@@ -63,6 +83,34 @@ describe("crypto", () => {
expect(decrypted).toBe(testMessage); 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", () => { describe("encrypt / decrypt", () => {
+26 -1
View File
@@ -23,6 +23,7 @@ export interface KeyPair {
export type SharedKey = Uint8Array; // 32 bytes (box.before) export type SharedKey = Uint8Array; // 32 bytes (box.before)
const NONCE_LENGTH = nacl.box.nonceLength; // 24 const NONCE_LENGTH = nacl.box.nonceLength; // 24
const ZERO_X25519_SHARED_RESULT = new Uint8Array(nacl.box.sharedKeyLength);
let prngReady = false; let prngReady = false;
@@ -68,6 +69,22 @@ function decodeBase64(base64: string): Uint8Array {
return toByteArray(base64); 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 { function toUint8(data: string | ArrayBuffer): Uint8Array {
return typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data); 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 { export function importPublicKey(base64: string): Uint8Array {
const bytes = decodeBase64(base64); const bytes = decodePublicKeyBase64(base64);
if (bytes.byteLength !== nacl.box.publicKeyLength) { if (bytes.byteLength !== nacl.box.publicKeyLength) {
throw new Error(`Invalid public key length (expected ${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) { if (peerPublicKey.byteLength !== nacl.box.publicKeyLength) {
throw new Error(`Invalid peer public key length (expected ${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); return nacl.box.before(peerPublicKey, ourSecretKey);
} }
@@ -82,6 +82,11 @@ function decodeCiphertext(text: string): ArrayBuffer {
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); 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 { function parseEncryptedJson(sharedKey: Uint8Array, text: string): unknown {
const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text))); const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text)));
return JSON.parse(plaintext); 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`); 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<void> {
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", () => { (shouldRunRelayE2e ? describe : describe.skip)("Relay transport (E2EE) - daemon E2E", () => {
let relayPort: number; let relayPort: number;
let relayProcess: ChildProcess | null = null; let relayProcess: ChildProcess | null = null;
let relayStdoutLines: string[] = []; let relayStdoutLines: string[] = [];
const startRelay = async () => { const startRelay = async (options: { useLocalRelay?: boolean } = {}) => {
relayStdoutLines = []; relayStdoutLines = [];
relayPort = await getAvailablePort(); relayPort = await getAvailablePort();
const relayDir = path.resolve(process.cwd(), "../relay"); const relayDir = path.resolve(process.cwd(), "../relay");
relayProcess = spawn( const relayArgs = [
"npx", "wrangler",
[ "dev",
"wrangler", "--local",
"dev", "--ip",
"--local", "127.0.0.1",
"--ip", "--port",
"127.0.0.1", String(relayPort),
"--port", "--live-reload=false",
String(relayPort), "--show-interactive-dev-session=false",
"--live-reload=false", ];
"--show-interactive-dev-session=false", if (options.useLocalRelay) {
], relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:");
{ }
cwd: relayDir, relayProcess = spawn("npx", relayArgs, {
env: { ...process.env }, cwd: relayDir,
stdio: ["ignore", "pipe", "pipe"], env: { ...process.env },
detached: false, stdio: ["ignore", "pipe", "pipe"],
}, detached: false,
); });
relayProcess.stdout?.on("data", (data: Buffer) => { relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data const lines = data
@@ -364,6 +382,122 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
} }
}, 90000); }, 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 () => { test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";