fix(relay): validate handshake keys (#3669)
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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", () => {
|
||||
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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user