Files
paseo/packages/relay/src/secure-channel.test.ts
T
Mohamed Boudra 793b16c0df fix(relay): protect sessions against replayed commands (#7610)
Protect relay sessions against replayed commands with authenticated Noise NK negotiation, ordered records, and identity-bound security pins. Preserve legacy admission during migration and add daemon enforcement policy.

All 21 CI checks passed. General engineering review is complete; specialist cryptographic review of the handwritten Noise implementation and static-key reuse remains required before release or deployment.

Closes #359
2026-10-22 19:50:06 +07:00

628 lines
24 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
openClientSecureChannel,
openDaemonSecureChannel,
SecureChannelRejectedError,
type SecureChannel,
} from "./secure-channel.js";
import {
createClientChannel,
createDaemonChannel,
type Transport,
type TransportMessage,
} from "./encrypted-channel.js";
import { exportPublicKey, generateKeyPair } from "./crypto.js";
import { MAX_CHUNK_DATA_BYTES } from "./protected-records.js";
interface Frame {
data: string | ArrayBuffer;
isBinary: boolean;
}
type Interceptor = (frame: Frame) => Frame | null;
/**
* Two transports wired back to back through an optional interceptor per
* direction, standing in for a relay that may record, drop, reorder, or
* rewrite frames.
*/
/** Delivers a frame to a transport a few milliseconds later, like a relay retry. */
function deliverLater(transport: Transport, frame: TransportMessage, ms = 5): void {
setTimeout(() => transport.onmessage?.(frame), ms);
}
function createLink() {
const toDaemon: Frame[] = [];
const toClient: Frame[] = [];
let interceptToDaemon: Interceptor = (frame) => frame;
let interceptToClient: Interceptor = (frame) => frame;
const daemon: Transport = {
send: vi.fn((data: string | ArrayBuffer) => {
const recorded = toFrame(data);
toClient.push(recorded);
const frame = interceptToClient(recorded);
if (frame) setTimeout(() => client.onmessage?.(frame), 0);
}),
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
const client: Transport = {
send: vi.fn((data: string | ArrayBuffer) => {
const recorded = toFrame(data);
toDaemon.push(recorded);
const frame = interceptToDaemon(recorded);
if (frame) setTimeout(() => daemon.onmessage?.(frame), 0);
}),
// The client may run in a browser: WebSocket.close throws on any code other
// than 1000 or 3000-4999 and leaves the socket open, so mirror that here.
close: vi.fn((code?: number, reason?: string) => {
if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) {
throw new DOMException(
`The code must be either 1000, or between 3000 and 4999. ${code} is neither.`,
"InvalidAccessError",
);
}
setTimeout(() => client.onclose?.(code ?? 1005, reason ?? ""), 0);
}),
onmessage: null,
onclose: null,
onerror: null,
};
return {
daemon,
client,
recordedToDaemon: toDaemon,
recordedToClient: toClient,
setToDaemon(fn: Interceptor) {
interceptToDaemon = fn;
},
setToClient(fn: Interceptor) {
interceptToClient = fn;
},
};
}
function toFrame(data: string | ArrayBuffer): Frame {
return { data, isBinary: data instanceof ArrayBuffer };
}
async function settle(ms = 30): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
interface Pair {
link: ReturnType<typeof createLink>;
daemonKeyPair: ReturnType<typeof generateKeyPair>;
client: SecureChannel;
daemon: SecureChannel;
daemonReceived: Array<string | ArrayBuffer>;
clientReceived: Array<string | ArrayBuffer>;
}
async function openProtectedPair(
options: {
link?: ReturnType<typeof createLink>;
daemonKeyPair?: ReturnType<typeof generateKeyPair>;
clientMinimum?: "legacy" | "protected";
daemonMinimum?: "legacy" | "protected";
} = {},
): Promise<Pair> {
const link = options.link ?? createLink();
const daemonKeyPair = options.daemonKeyPair ?? generateKeyPair();
const daemonReceived: Array<string | ArrayBuffer> = [];
const clientReceived: Array<string | ArrayBuffer> = [];
const opened = deferred();
const daemonPromise = openDaemonSecureChannel(link.daemon, {
daemonKeyPair,
minimumMode: options.daemonMinimum,
events: { onmessage: (data) => daemonReceived.push(data) },
});
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
minimumMode: options.clientMinimum,
events: { onopen: () => opened.resolve(), onmessage: (data) => clientReceived.push(data) },
});
const daemon = await daemonPromise;
await opened.promise;
return { link, daemonKeyPair, client, daemon, daemonReceived, clientReceived };
}
function bytes(length: number): ArrayBuffer {
return new Uint8Array(length).map((_, i) => (i * 13 + 1) & 0xff).buffer;
}
describe("secure channel: protected mode", () => {
it("negotiates protected mode between two updated peers and carries both frame kinds", async () => {
const pair = await openProtectedPair();
expect(pair.client.securityMode()).toBe("protected");
expect(pair.daemon.securityMode()).toBe("protected");
await pair.client.send("hello daemon");
await pair.client.send(bytes(5));
await pair.daemon.send("hello client");
await pair.daemon.send(bytes(7));
await settle();
expect(pair.daemonReceived[0]).toBe("hello daemon");
expect(new Uint8Array(pair.daemonReceived[1] as ArrayBuffer)).toEqual(new Uint8Array(bytes(5)));
expect(pair.clientReceived[0]).toBe("hello client");
expect(new Uint8Array(pair.clientReceived[1] as ArrayBuffer)).toEqual(new Uint8Array(bytes(7)));
});
it("keeps text as base64 text frames and binary as binary frames when negotiated", async () => {
const pair = await openProtectedPair();
pair.link.recordedToDaemon.length = 0;
await pair.client.send("text");
await pair.client.send(bytes(3));
await settle();
expect(pair.link.recordedToDaemon.map((frame) => frame.isBinary)).toEqual([false, true]);
expect(pair.link.recordedToDaemon[0].data).not.toContain("text");
expect(pair.client.outboundWireByteLength(bytes(3))).toBe(3 + 2 + 6 + 16);
expect(pair.client.outboundWireByteLength("text")).toBe(4 * Math.ceil((4 + 24) / 3));
});
it("carries frames larger than one Noise message", async () => {
const pair = await openProtectedPair();
const big = bytes(MAX_CHUNK_DATA_BYTES * 2 + 100);
await pair.client.send(big);
await settle(100);
expect(new Uint8Array(pair.daemonReceived[0] as ArrayBuffer)).toEqual(new Uint8Array(big));
});
it("flushes sends queued before the handshake completes, in order", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
const daemonReceived: Array<string | ArrayBuffer> = [];
const daemonPromise = openDaemonSecureChannel(link.daemon, {
daemonKeyPair,
events: { onmessage: (data) => daemonReceived.push(data) },
});
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
});
await client.send("one");
await client.send("two");
expect(client.isOpen()).toBe(false);
await daemonPromise;
await settle();
expect(daemonReceived).toEqual(["one", "two"]);
});
it("rejects an exact duplicate record and delivers nothing twice", async () => {
const pair = await openProtectedPair();
let captured: Frame | null = null;
pair.link.setToDaemon((frame) => {
captured ??= frame;
return frame;
});
await pair.client.send("once");
await settle();
expect(pair.daemonReceived).toEqual(["once"]);
pair.link.daemon.onmessage?.(captured!);
await settle();
expect(pair.daemonReceived).toEqual(["once"]);
expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String));
});
it("rejects a reflected record", async () => {
const pair = await openProtectedPair();
let captured: Frame | null = null;
pair.link.setToDaemon((frame) => {
captured ??= frame;
return frame;
});
await pair.client.send("mine");
await settle();
pair.link.client.onmessage?.(captured!);
await settle();
expect(pair.clientReceived).toEqual([]);
expect(pair.link.client.close).toHaveBeenCalledWith(4003, expect.any(String));
});
it("closes the browser socket and reports the close when a record fails at the client", async () => {
const closes: Array<[number, string]> = [];
const link = createLink();
const daemonKeyPair = generateKeyPair();
const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair });
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
events: { onclose: (code, reason) => closes.push([code, reason]) },
});
await daemonPromise;
await settle();
link.client.onmessage?.({
data: Buffer.from("not a record").toString("base64"),
isBinary: false,
});
await settle();
expect(client.isOpen()).toBe(false);
expect(closes).toEqual([[4003, expect.any(String)]]);
});
it("rejects out-of-order records and delivers nothing from them", async () => {
const pair = await openProtectedPair();
const held: Frame[] = [];
pair.link.setToDaemon((frame) => {
held.push(frame);
return null;
});
await pair.client.send("first");
await pair.client.send("second");
await settle();
expect(held).toHaveLength(2);
pair.link.daemon.onmessage?.(held[1]);
await settle();
expect(pair.daemonReceived).toEqual([]);
expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String));
pair.link.daemon.onmessage?.(held[0]);
await settle();
expect(pair.daemonReceived).toEqual([]);
});
it("rejects a tampered record without delivering it", async () => {
const pair = await openProtectedPair();
pair.link.setToDaemon((frame) => {
const tampered = new Uint8Array((frame.data as ArrayBuffer).slice(0));
tampered[tampered.byteLength - 1] ^= 0x01;
return { data: tampered.buffer, isBinary: true };
});
await pair.client.send(bytes(10));
await settle();
expect(pair.daemonReceived).toEqual([]);
expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String));
});
it("closes on plaintext application traffic", async () => {
const pair = await openProtectedPair();
pair.link.daemon.onmessage?.({ data: JSON.stringify({ type: "hello" }), isBinary: false });
await settle();
expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String));
expect(pair.daemonReceived).toEqual([]);
});
it("answers an identical hello retry with the identical ready and keeps counters", async () => {
const pair = await openProtectedPair();
const hello = pair.link.recordedToDaemon.find(
(frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_hello"'),
);
const readyFrames = () =>
(pair.link.daemon.send as ReturnType<typeof vi.fn>).mock.calls
.map(([data]) => data)
.filter((data) => typeof data === "string" && data.includes('"e2ee_ready"'));
expect(hello).toBeDefined();
expect(readyFrames()).toHaveLength(1);
pair.link.daemon.onmessage?.(hello!);
await settle();
expect(readyFrames()).toHaveLength(2);
expect(readyFrames()[0]).toBe(readyFrames()[1]);
expect(pair.link.daemon.close).not.toHaveBeenCalled();
await pair.client.send("still protected");
await pair.daemon.send("both ways");
await settle();
expect(pair.daemonReceived).toEqual(["still protected"]);
expect(pair.clientReceived).toEqual(["both ways"]);
});
it("closes when a different hello arrives on an open channel", async () => {
const pair = await openProtectedPair();
const hello = pair.link.recordedToDaemon.find(
(frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_hello"'),
)!;
const changed = (hello.data as string).replace('"v":1', '"v":1 ');
expect(changed).not.toBe(hello.data);
pair.link.daemon.onmessage?.({ data: changed, isBinary: false });
await settle();
expect(pair.link.daemon.close).toHaveBeenCalledWith(1008, expect.stringContaining("mismatch"));
});
it("answers an identical hello that arrives while the ready send is in flight", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
const sendFromDaemon = link.daemon.send;
link.daemon.send = vi.fn(async (data: string | ArrayBuffer) => {
await settle(20);
sendFromDaemon(data);
});
link.setToDaemon((frame) => {
// The relay delivers the client's 1s retry while the daemon is still sending ready.
if (typeof frame.data === "string" && frame.data.includes('"e2ee_hello"')) {
deliverLater(link.daemon, frame);
}
return frame;
});
const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair });
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
});
const daemon = await daemonPromise;
await settle(80);
const readyFrames = link.recordedToClient.filter(
(frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_ready"'),
);
expect(readyFrames).toHaveLength(2);
expect(readyFrames[0].data).toBe(readyFrames[1].data);
expect(link.daemon.close).not.toHaveBeenCalled();
expect(client.isOpen()).toBe(true);
expect(daemon.isOpen()).toBe(true);
});
it("closes when a different hello arrives while the ready send is in flight", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
const sendFromDaemon = link.daemon.send;
link.daemon.send = vi.fn(async (data: string | ArrayBuffer) => {
await settle(20);
sendFromDaemon(data);
});
link.setToDaemon((frame) => {
if (typeof frame.data === "string" && frame.data.includes('"e2ee_hello"')) {
const changed = frame.data.replace('"v":1', '"v":1 ');
deliverLater(link.daemon, { data: changed, isBinary: false });
}
return frame;
});
const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair });
await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
});
const daemon = await daemonPromise;
await settle(80);
expect(link.daemon.close).toHaveBeenCalledWith(1008, expect.stringContaining("mismatch"));
expect(daemon.isOpen()).toBe(false);
});
it("does not let a recorded hello and records replay into a fresh daemon channel", async () => {
// Session 1: a legitimate client talks to the daemon; the relay records everything.
const first = await openProtectedPair();
await first.client.send("secret command");
await first.client.send(bytes(4));
await settle();
expect(first.daemonReceived).toHaveLength(2);
const transcript = first.link.recordedToDaemon.slice();
expect(transcript.length).toBeGreaterThanOrEqual(3);
// Session 2: the same daemon key pair (restart or reconnect), attacker replays the transcript.
const link = createLink();
const replayed: Array<string | ArrayBuffer> = [];
const daemonPromise = openDaemonSecureChannel(link.daemon, {
daemonKeyPair: first.daemonKeyPair,
events: { onmessage: (data) => replayed.push(data) },
});
for (const frame of transcript) link.daemon.onmessage?.(frame);
const daemon = await daemonPromise;
await settle();
expect(daemon.securityMode()).toBe("protected");
expect(replayed).toEqual([]);
expect(link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String));
});
});
describe("secure channel: negotiation and policy", () => {
it("rejects a present but invalid protected hello instead of falling back", async () => {
for (const mutate of [
(hello: Record<string, unknown>) => ({ ...hello, protected: { v: 2, message: "AAAA" } }),
(hello: Record<string, unknown>) => ({ ...hello, protected: { v: 1, message: "AAAA" } }),
(hello: Record<string, unknown>) => ({ ...hello, protected: "nope" }),
]) {
const link = createLink();
const daemonKeyPair = generateKeyPair();
link.setToDaemon((frame) => {
if (typeof frame.data !== "string") return frame;
return { data: JSON.stringify(mutate(JSON.parse(frame.data))), isBinary: false };
});
const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair });
await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
});
await expect(daemonPromise).rejects.toThrow();
expect((link.daemon.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
}
});
it("rejects a malformed protected ready instead of waiting for a legacy one", async () => {
for (const protectedValue of [
null,
{},
{ v: 1, message: "" },
{ v: "1", message: "AAAA" },
{ v: 1, message: 7 },
[],
]) {
const link = createLink();
const daemonKeyPair = generateKeyPair();
link.setToClient((frame) => {
if (typeof frame.data !== "string") return frame;
const ready = JSON.parse(frame.data);
// A tampered ready first, then the daemon's genuine legacy-shaped ready.
deliverLater(link.client, {
data: JSON.stringify({ type: "e2ee_ready" }),
isBinary: false,
});
return { data: JSON.stringify({ ...ready, protected: protectedValue }), isBinary: false };
});
const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair });
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
});
await daemonPromise;
await settle(40);
expect(link.client.close).toHaveBeenCalledWith(4002, expect.stringContaining("malformed"));
expect(client.isOpen()).toBe(false);
await expect(client.send("never")).rejects.toThrow("Channel not open");
}
});
it("rejects a tampered protected ready and never retries legacy", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
link.setToClient((frame) => {
if (typeof frame.data !== "string") return frame;
const ready = JSON.parse(frame.data);
const message = Buffer.from(ready.protected.message, "base64");
message[message.byteLength - 1] ^= 0x01;
ready.protected.message = message.toString("base64");
return { data: JSON.stringify(ready), isBinary: false };
});
const errors: Error[] = [];
const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair });
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
events: { onerror: (error) => errors.push(error) },
});
await daemonPromise;
await settle();
expect(client.isOpen()).toBe(false);
expect(link.client.close).toHaveBeenCalledWith(4002, expect.any(String));
expect(errors).toHaveLength(1);
await expect(client.send("x")).rejects.toThrow("Channel not open");
});
it("rejects a ready that selects an unoffered parameter", async () => {
// The daemon cannot do this by itself; a forged msg2 would need the keys, so the
// check is exercised by driving the decoder with a crafted selection.
const link = createLink();
const daemonKeyPair = generateKeyPair();
link.setToClient((frame) => {
if (typeof frame.data !== "string") return frame;
const ready = JSON.parse(frame.data);
ready.capabilities = { binaryCiphertext: true };
return { data: JSON.stringify(ready), isBinary: false };
});
const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair });
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
});
await daemonPromise;
await settle();
// Unauthenticated outer capabilities are ignored in protected mode.
expect(client.isOpen()).toBe(true);
expect(client.securityMode()).toBe("protected");
});
it("stripping protected from the ready does not downgrade a protected daemon", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
link.setToClient((frame) => {
if (typeof frame.data !== "string") return frame;
const ready = JSON.parse(frame.data);
delete ready.protected;
return { data: JSON.stringify(ready), isBinary: false };
});
const daemonReceived: unknown[] = [];
const daemonPromise = openDaemonSecureChannel(link.daemon, {
daemonKeyPair,
events: { onmessage: (data) => daemonReceived.push(data) },
});
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
});
await daemonPromise;
await settle();
expect(client.securityMode()).toBe("legacy");
await client.send("legacy record");
await settle();
expect(daemonReceived).toEqual([]);
expect(link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String));
});
it("client with a protected minimum refuses a legacy-only daemon", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
const errors: Error[] = [];
const daemonPromise = createDaemonChannel(link.daemon, daemonKeyPair);
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
minimumMode: "protected",
events: { onerror: (error) => errors.push(error) },
});
await daemonPromise;
await settle();
expect(client.isOpen()).toBe(false);
expect(link.client.close).toHaveBeenCalledWith(4002, expect.stringContaining("legacy"));
});
it("daemon with a protected minimum refuses a legacy-only client", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
const daemonPromise = openDaemonSecureChannel(link.daemon, {
daemonKeyPair,
minimumMode: () => "protected",
});
await createClientChannel(link.client, exportPublicKey(daemonKeyPair.publicKey));
await expect(daemonPromise).rejects.toBeInstanceOf(SecureChannelRejectedError);
await expect(daemonPromise).rejects.toMatchObject({
closeCode: 1008,
closeReason: "protected endpoint security required",
});
expect((link.daemon.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
});
});
describe("secure channel: legacy compatibility", () => {
it("new client against an old daemon negotiates legacy and exchanges records", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
const daemonReceived: unknown[] = [];
const clientReceived: unknown[] = [];
const opened = deferred();
const daemonPromise = createDaemonChannel(link.daemon, daemonKeyPair, {
onmessage: (data) => daemonReceived.push(data),
});
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
events: { onopen: () => opened.resolve(), onmessage: (data) => clientReceived.push(data) },
});
const daemon = await daemonPromise;
await opened.promise;
expect(client.securityMode()).toBe("legacy");
await client.send("from new client");
await daemon.send("from old daemon");
await settle();
expect(daemonReceived).toEqual(["from new client"]);
expect(clientReceived).toEqual(["from old daemon"]);
});
it("old client against a new daemon negotiates legacy and keeps legacy retry semantics", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
const daemonReceived: unknown[] = [];
const opened = deferred();
const daemonPromise = openDaemonSecureChannel(link.daemon, {
daemonKeyPair,
events: { onmessage: (data) => daemonReceived.push(data) },
});
const client = await createClientChannel(
link.client,
exportPublicKey(daemonKeyPair.publicKey),
{
onopen: () => opened.resolve(),
},
);
const daemon = await daemonPromise;
await opened.promise;
expect(daemon.securityMode()).toBe("legacy");
const hello = (link.client.send as ReturnType<typeof vi.fn>).mock.calls.find(
([data]) => typeof data === "string" && data.includes('"e2ee_hello"'),
)?.[0] as string;
link.daemon.onmessage?.({ data: hello, isBinary: false });
await settle();
expect(link.daemon.close).not.toHaveBeenCalled();
await client.send("legacy still works");
await settle();
expect(daemonReceived).toEqual(["legacy still works"]);
});
});