Files
paseo/packages/relay/src/encrypted-channel.test.ts
T
Mohamed BoudraandGitHub e1b1ca569d Keep large file views from disconnecting (#2482)
* fix(files): keep large downloads connected

Large file reads were emitted as one frame, crossing the physical socket high-water mark and terminating otherwise healthy connections. Stream bounded chunks from one file handle and pace each transfer by its own send completion without globally queueing unrelated traffic.

* fix(files): keep paced relay transfers bounded

Carry send completion through encryption to the physical WebSocket callback, cap growing files at their advertised size, and preserve text classification when a UTF-8 code point crosses the sample boundary.

* fix(files): reject changing transfer snapshots

Abort when a file shrinks below its advertised size, finalize UTF-8 validation for complete samples, and let the physical relay socket remain the sole authority for queued-byte accounting.

* fix(files): validate metadata before transfer

Keep the daemon handshake pending until the ready frame is physically sent, and classify file content with a bounded full-handle scan so streaming preserves the previous binary/text behavior.

* fix(files): detect changing transfer sources

* fix(files): enforce transfer snapshot integrity
2026-07-27 13:52:57 +02:00

535 lines
18 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import {
createClientChannel,
createDaemonChannel,
EncryptedChannel,
Transport,
} from "./encrypted-channel.js";
import {
deriveSharedKey,
encrypt,
exportPublicKey,
generateKeyPair,
importPublicKey,
} from "./crypto.js";
import { arrayBufferToBase64 } from "./base64.js";
/**
* Creates a pair of connected mock transports.
* Messages sent on one are received on the other.
*/
function createMockTransportPair(): [Transport, Transport] {
const transportA: Transport = {
send: vi.fn(),
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
const transportB: Transport = {
send: vi.fn(),
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
// Wire them together
(transportA.send as ReturnType<typeof vi.fn>).mockImplementation((data: string | ArrayBuffer) => {
setTimeout(() => transportB.onmessage?.({ data, isBinary: data instanceof ArrayBuffer }), 0);
});
(transportB.send as ReturnType<typeof vi.fn>).mockImplementation((data: string | ArrayBuffer) => {
setTimeout(() => transportA.onmessage?.({ data, isBinary: data instanceof ArrayBuffer }), 0);
});
return [transportA, transportB];
}
async function waitForAsyncDelivery(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 50));
}
describe("EncryptedChannel", () => {
it("rejects the daemon handshake when the ready frame fails to send", async () => {
const daemonKeyPair = generateKeyPair();
const clientKeyPair = generateKeyPair();
const transport: Transport = {
send: () => Promise.reject(new Error("ready send failed")),
close: () => undefined,
onmessage: null,
onclose: null,
onerror: null,
};
const channel = createDaemonChannel(transport, daemonKeyPair);
transport.onmessage?.({
data: JSON.stringify({
type: "e2ee_hello",
key: exportPublicKey(clientKeyPair.publicKey),
}),
isBinary: false,
});
await expect(channel).rejects.toThrow("ready send failed");
});
it("waits for transport send completion", async () => {
let completeSend: (() => void) | undefined;
const transport: Transport = {
send: () =>
new Promise<void>((resolve) => {
completeSend = resolve;
}),
close: () => undefined,
onmessage: null,
onclose: null,
onerror: null,
};
const first = generateKeyPair();
const second = generateKeyPair();
const channel = new EncryptedChannel(
transport,
deriveSharedKey(first.secretKey, second.publicKey),
{},
{ binaryCiphertext: true },
);
channel.setState("open");
let completed = false;
const sending = channel.send(new Uint8Array([1, 2, 3]).buffer).then(() => {
completed = true;
return undefined;
});
await Promise.resolve();
expect(completed).toBe(false);
completeSend?.();
await sending;
expect(completed).toBe(true);
});
it("establishes encrypted channel between daemon and client", async () => {
const [daemonTransport, clientTransport] = createMockTransportPair();
// Daemon generates keypair (public key goes in QR)
const daemonKeyPair = generateKeyPair();
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
let clientOpenedResolve: (() => void) | null = null;
const clientOpened = new Promise<void>((resolve) => {
clientOpenedResolve = resolve;
});
// Start daemon waiting for client
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
// Client connects (scanned QR, got daemon's public key)
const clientChannel = await createClientChannel(clientTransport, daemonPubKeyB64, {
onopen: () => clientOpenedResolve?.(),
});
// Daemon receives hello and completes handshake
const daemonChannel = await daemonChannelPromise;
await clientOpened;
expect(clientChannel.isOpen()).toBe(true);
expect(daemonChannel.isOpen()).toBe(true);
});
it("exchanges encrypted messages bidirectionally", async () => {
const [daemonTransport, clientTransport] = createMockTransportPair();
const daemonKeyPair = generateKeyPair();
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
const daemonMessages: (string | ArrayBuffer)[] = [];
const clientMessages: (string | ArrayBuffer)[] = [];
let clientOpenedResolve: (() => void) | null = null;
const clientOpened = new Promise<void>((resolve) => {
clientOpenedResolve = resolve;
});
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, {
onmessage: (data) => daemonMessages.push(data),
});
const clientChannel = await createClientChannel(clientTransport, daemonPubKeyB64, {
onmessage: (data) => clientMessages.push(data),
onopen: () => clientOpenedResolve?.(),
});
const daemonChannel = await daemonChannelPromise;
await clientOpened;
// Send messages both directions
await clientChannel.send("Hello from client");
await daemonChannel.send("Hello from daemon");
await clientChannel.send("Second message from client");
// Wait for async delivery
await waitForAsyncDelivery();
expect(daemonMessages).toEqual(["Hello from client", "Second message from client"]);
expect(clientMessages).toEqual(["Hello from daemon"]);
});
it("encrypted messages are opaque to transport", async () => {
const [daemonTransport, clientTransport] = createMockTransportPair();
const daemonKeyPair = generateKeyPair();
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
let clientOpenedResolve: (() => void) | null = null;
const clientOpened = new Promise<void>((resolve) => {
clientOpenedResolve = resolve;
});
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
const clientChannel = await createClientChannel(clientTransport, daemonPubKeyB64, {
onopen: () => clientOpenedResolve?.(),
});
await daemonChannelPromise;
await clientOpened;
// Clear mock call history
(clientTransport.send as ReturnType<typeof vi.fn>).mockClear();
// Send a plaintext message
const plaintext = "Secret message";
await clientChannel.send(plaintext);
// 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];
// Should be base64 string (encrypted)
expect(typeof sentData).toBe("string");
// Should NOT contain the plaintext
expect(sentData).not.toContain(plaintext);
// Should be significantly longer than plaintext (IV + auth tag overhead)
expect(sentData.length).toBeGreaterThan(plaintext.length + 20);
});
it("does not throw uncaught when handshake hello retry send fails", async () => {
vi.useFakeTimers();
try {
const daemonKeyPair = generateKeyPair();
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
const transport: Transport = {
send: vi.fn(),
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
let sendAttempts = 0;
(transport.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
sendAttempts += 1;
if (sendAttempts >= 2) {
throw new Error("WebSocket not open (readyState=2)");
}
});
const onerror = vi.fn();
await createClientChannel(transport, daemonPubKeyB64, { onerror });
expect(() => {
vi.advanceTimersByTime(1000);
}).not.toThrow();
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");
// Close the transport to stop retry timer.
transport.onclose?.(1000, "closed");
vi.runOnlyPendingTimers();
} finally {
vi.useRealTimers();
}
});
it("reports rejected handshake hello sends", async () => {
const daemonKeyPair = generateKeyPair();
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
const transport: Transport = {
send: () => Promise.reject(new Error("hello send failed")),
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
const onerror = vi.fn();
await createClientChannel(transport, daemonPubKeyB64, { onerror });
await Promise.resolve();
expect(onerror).toHaveBeenCalledTimes(1);
expect((onerror.mock.calls[0][0] as Error).message).toBe("hello send failed");
transport.onclose?.(1000, "closed");
});
it("reports rejected sends while flushing the handshake backlog", async () => {
const daemonKeyPair = generateKeyPair();
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
let sendAttempts = 0;
const transport: Transport = {
send: () => {
sendAttempts += 1;
return sendAttempts === 1 ? undefined : Promise.reject(new Error("backlog send failed"));
},
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
const onerror = vi.fn();
const channel = await createClientChannel(transport, daemonPubKeyB64, { onerror });
await channel.send(new ArrayBuffer(8));
transport.onmessage?.({
data: JSON.stringify({
type: "e2ee_ready",
capabilities: { binaryCiphertext: true },
}),
isBinary: false,
});
await waitForAsyncDelivery();
expect(onerror).toHaveBeenCalledTimes(1);
expect((onerror.mock.calls[0][0] as Error).message).toBe("backlog send failed");
expect(transport.close).toHaveBeenCalledWith(1011, "backlog send failed");
});
it("fails handshake on invalid hello", async () => {
const [daemonTransport] = createMockTransportPair();
const daemonKeyPair = generateKeyPair();
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
// Send invalid hello
setTimeout(() => {
daemonTransport.onmessage?.({ data: '{"type":"invalid"}', isBinary: false });
}, 0);
await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message");
});
it("accepts duplicate hello from the same client without re-keying", async () => {
const [daemonTransport, clientTransport] = createMockTransportPair();
const daemonKeyPair = generateKeyPair();
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
const daemonMessages: (string | ArrayBuffer)[] = [];
let clientOpenedResolve: (() => void) | null = null;
const clientOpened = new Promise<void>((resolve) => {
clientOpenedResolve = resolve;
});
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, {
onmessage: (data) => daemonMessages.push(data),
});
const clientChannel = await createClientChannel(clientTransport, daemonPubKeyB64, {
onopen: () => clientOpenedResolve?.(),
});
await daemonChannelPromise;
await clientOpened;
const firstHello = (clientTransport.send as ReturnType<typeof vi.fn>).mock.calls.find(
([data]) => typeof data === "string" && data.includes('"type":"e2ee_hello"'),
)?.[0];
expect(typeof firstHello).toBe("string");
daemonTransport.onmessage?.({ data: firstHello as string, isBinary: false });
await waitForAsyncDelivery();
expect(daemonTransport.close).not.toHaveBeenCalled();
await clientChannel.send("still encrypted with original key");
await waitForAsyncDelivery();
expect(daemonMessages).toEqual(["still encrypted with original key"]);
});
it("closes an open daemon channel when a different client key sends hello", async () => {
const [daemonTransport, clientTransport] = createMockTransportPair();
const daemonKeyPair = generateKeyPair();
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
let clientOpenedResolve: (() => void) | null = null;
const clientOpened = new Promise<void>((resolve) => {
clientOpenedResolve = resolve;
});
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
await createClientChannel(clientTransport, daemonPubKeyB64, {
onopen: () => clientOpenedResolve?.(),
});
await daemonChannelPromise;
await clientOpened;
const attackerKeyPair = generateKeyPair();
const attackerHello = JSON.stringify({
type: "e2ee_hello",
key: exportPublicKey(attackerKeyPair.publicKey),
});
daemonTransport.onmessage?.({ data: attackerHello, isBinary: false });
await waitForAsyncDelivery();
expect(daemonTransport.close).toHaveBeenCalledWith(1008, "E2EE re-handshake key mismatch");
});
it("preserves ASCII-only binary frames in negotiated mode", async () => {
const [daemonTransport, clientTransport] = createMockTransportPair();
const daemonKeyPair = generateKeyPair();
const daemonMessages: (string | ArrayBuffer)[] = [];
let resolveOpen: (() => void) | null = null;
const opened = new Promise<void>((resolve) => {
resolveOpen = resolve;
});
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, {
onmessage: (data) => daemonMessages.push(data),
});
const clientChannel = await createClientChannel(
clientTransport,
exportPublicKey(daemonKeyPair.publicKey),
{ onopen: () => resolveOpen?.() },
);
await daemonChannelPromise;
await opened;
const binary = new TextEncoder().encode("ASCII terminal output").buffer;
(clientTransport.send as ReturnType<typeof vi.fn>).mockClear();
await clientChannel.send(binary);
await waitForAsyncDelivery();
const ciphertext = (clientTransport.send as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
expect(ciphertext).toBeInstanceOf(ArrayBuffer);
expect((ciphertext as ArrayBuffer).byteLength).toBe(binary.byteLength + 40);
expect(daemonMessages[0]).toBeInstanceOf(ArrayBuffer);
expect(new Uint8Array(daemonMessages[0] as ArrayBuffer)).toEqual(new Uint8Array(binary));
});
it("keeps negotiated text as base64 text frames", async () => {
const [daemonTransport, clientTransport] = createMockTransportPair();
const daemonKeyPair = generateKeyPair();
const daemonMessages: (string | ArrayBuffer)[] = [];
let resolveOpen: (() => void) | null = null;
const opened = new Promise<void>((resolve) => {
resolveOpen = resolve;
});
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, {
onmessage: (data) => daemonMessages.push(data),
});
const clientChannel = await createClientChannel(
clientTransport,
exportPublicKey(daemonKeyPair.publicKey),
{ onopen: () => resolveOpen?.() },
);
await daemonChannelPromise;
await opened;
(clientTransport.send as ReturnType<typeof vi.fn>).mockClear();
await clientChannel.send("text payload");
await waitForAsyncDelivery();
expect(typeof (clientTransport.send as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toBe(
"string",
);
expect(daemonMessages).toEqual(["text payload"]);
});
it("new client stays base64-only when an old daemon does not accept the capability", async () => {
const daemonKeyPair = generateKeyPair();
const clientTransport: Transport = {
send: vi.fn(),
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
let resolveOpen: (() => void) | null = null;
const opened = new Promise<void>((resolve) => {
resolveOpen = resolve;
});
const clientChannel = await createClientChannel(
clientTransport,
exportPublicKey(daemonKeyPair.publicKey),
{ onopen: () => resolveOpen?.() },
);
const hello = JSON.parse(
(clientTransport.send as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as string,
) as { key: string };
expect(hello).toMatchObject({ capabilities: { binaryCiphertext: true } });
clientTransport.onmessage?.({ data: JSON.stringify({ type: "e2ee_ready" }), isBinary: false });
await opened;
(clientTransport.send as ReturnType<typeof vi.fn>).mockClear();
await clientChannel.send(new TextEncoder().encode("legacy binary").buffer);
expect(typeof (clientTransport.send as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toBe(
"string",
);
});
it("new daemon stays base64-only for an old client and drains pipelined traffic", async () => {
const daemonKeyPair = generateKeyPair();
const clientKeyPair = generateKeyPair();
const daemonMessages: (string | ArrayBuffer)[] = [];
const daemonTransport: Transport = {
send: vi.fn(),
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
const channelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, {
onmessage: (data) => daemonMessages.push(data),
});
const sharedKey = deriveSharedKey(
clientKeyPair.secretKey,
importPublicKey(exportPublicKey(daemonKeyPair.publicKey)),
);
daemonTransport.onmessage?.({
data: JSON.stringify({
type: "e2ee_hello",
key: exportPublicKey(clientKeyPair.publicKey),
}),
isBinary: false,
});
daemonTransport.onmessage?.({
data: arrayBufferToBase64(encrypt(sharedKey, "pipelined legacy text")),
isBinary: false,
});
const daemonChannel = await channelPromise;
await waitForAsyncDelivery();
expect(daemonMessages).toEqual(["pipelined legacy text"]);
expect(
JSON.parse((daemonTransport.send as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]),
).toEqual({
type: "e2ee_ready",
});
(daemonTransport.send as ReturnType<typeof vi.fn>).mockClear();
await daemonChannel.send(new Uint8Array([1, 2, 3]).buffer);
expect(typeof (daemonTransport.send as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toBe(
"string",
);
});
});