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
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createClientChannel, createDaemonChannel, Transport } from "./encrypted-channel.js";
|
||||
import {
|
||||
createClientChannel,
|
||||
createDaemonChannel,
|
||||
EncryptedChannel,
|
||||
Transport,
|
||||
} from "./encrypted-channel.js";
|
||||
import {
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
@@ -47,6 +52,64 @@ async function waitForAsyncDelivery(): Promise<void> {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -191,6 +254,58 @@ describe("EncryptedChannel", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
|
||||
|
||||
export interface Transport {
|
||||
send(data: string | ArrayBuffer): void;
|
||||
send(data: string | ArrayBuffer): void | Promise<void>;
|
||||
close(code?: number, reason?: string): void;
|
||||
onmessage: ((message: TransportMessage) => void) | null;
|
||||
onclose: ((code: number, reason: string) => void) | null;
|
||||
@@ -169,7 +169,10 @@ export async function createClientChannel(
|
||||
};
|
||||
const sendHello = () => {
|
||||
try {
|
||||
transport.send(helloText);
|
||||
const result = transport.send(helloText);
|
||||
if (result) {
|
||||
void result.catch(emitSendError);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
// This can happen during daemon restarts while the socket transitions
|
||||
@@ -263,11 +266,7 @@ export async function createDaemonChannel(
|
||||
const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
|
||||
|
||||
const binaryCiphertext = supportsBinaryCiphertext(msg);
|
||||
const channel = new EncryptedChannel(transport, sharedKey, events, {
|
||||
daemonKeyPair,
|
||||
binaryCiphertext,
|
||||
});
|
||||
transport.send(
|
||||
await transport.send(
|
||||
JSON.stringify({
|
||||
type: "e2ee_ready",
|
||||
...(binaryCiphertext
|
||||
@@ -276,6 +275,10 @@ export async function createDaemonChannel(
|
||||
} satisfies E2EEReadyMessage),
|
||||
);
|
||||
|
||||
const channel = new EncryptedChannel(transport, sharedKey, events, {
|
||||
daemonKeyPair,
|
||||
binaryCiphertext,
|
||||
});
|
||||
channel.setState("open");
|
||||
events.onopen?.();
|
||||
|
||||
@@ -354,7 +357,14 @@ export class EncryptedChannel {
|
||||
this.state = "open";
|
||||
this.events.onopen?.();
|
||||
for (const cb of this.onOpenCallbacks) cb();
|
||||
await this.flushPendingSends();
|
||||
try {
|
||||
await this.flushPendingSends();
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.events.onerror?.(err);
|
||||
this.state = "closed";
|
||||
this.transport.close(1011, err.message);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore non-ready handshake traffic
|
||||
@@ -455,12 +465,12 @@ export class EncryptedChannel {
|
||||
|
||||
const ciphertext = encrypt(this.sharedKey, data);
|
||||
if (this.options.binaryCiphertext && data instanceof ArrayBuffer) {
|
||||
this.transport.send(ciphertext);
|
||||
await this.transport.send(ciphertext);
|
||||
return;
|
||||
}
|
||||
// COMPAT(binaryCiphertext): added in v0.2.3, remove base64 binary sends
|
||||
// after 2027-01-27 once the supported peer floor includes negotiation.
|
||||
this.transport.send(arrayBufferToBase64(ciphertext));
|
||||
await this.transport.send(arrayBufferToBase64(ciphertext));
|
||||
}
|
||||
|
||||
outboundWireByteLength(data: string | ArrayBuffer): number {
|
||||
@@ -489,7 +499,7 @@ export class EncryptedChannel {
|
||||
// "ready" but do not re-key. Re-keying here would desync
|
||||
// the channel and cause decrypt failures.
|
||||
if (keysEqual(nextSharedKey, this.sharedKey)) {
|
||||
this.transport.send(
|
||||
await this.transport.send(
|
||||
JSON.stringify({
|
||||
type: "e2ee_ready",
|
||||
...(this.options.binaryCiphertext
|
||||
|
||||
Reference in New Issue
Block a user