@@ -35,6 +35,10 @@ function createMockTransportPair(): [Transport, Transport] {
|
|||||||
return [transportA, transportB];
|
return [transportA, transportB];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForAsyncDelivery(): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
describe("EncryptedChannel", () => {
|
describe("EncryptedChannel", () => {
|
||||||
it("establishes encrypted channel between daemon and client", async () => {
|
it("establishes encrypted channel between daemon and client", async () => {
|
||||||
const [daemonTransport, clientTransport] = createMockTransportPair();
|
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||||
@@ -96,7 +100,7 @@ describe("EncryptedChannel", () => {
|
|||||||
await clientChannel.send("Second message from client");
|
await clientChannel.send("Second message from client");
|
||||||
|
|
||||||
// Wait for async delivery
|
// Wait for async delivery
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await waitForAsyncDelivery();
|
||||||
|
|
||||||
expect(daemonMessages).toEqual(["Hello from client", "Second message from client"]);
|
expect(daemonMessages).toEqual(["Hello from client", "Second message from client"]);
|
||||||
expect(clientMessages).toEqual(["Hello from daemon"]);
|
expect(clientMessages).toEqual(["Hello from daemon"]);
|
||||||
@@ -194,4 +198,75 @@ describe("EncryptedChannel", () => {
|
|||||||
|
|
||||||
await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message");
|
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?.(firstHello as string);
|
||||||
|
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?.(attackerHello);
|
||||||
|
await waitForAsyncDelivery();
|
||||||
|
|
||||||
|
expect(daemonTransport.close).toHaveBeenCalledWith(1008, "E2EE re-handshake key mismatch");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ function buildInvalidHelloError(rawText: string, parsed?: unknown): Error {
|
|||||||
|
|
||||||
const HANDSHAKE_RETRY_MS = 1000;
|
const HANDSHAKE_RETRY_MS = 1000;
|
||||||
const MAX_PENDING_SENDS = 200;
|
const MAX_PENDING_SENDS = 200;
|
||||||
|
const REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE = 1008;
|
||||||
|
const REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON = "E2EE re-handshake key mismatch";
|
||||||
|
|
||||||
interface TimeoutWithUnref {
|
interface TimeoutWithUnref {
|
||||||
unref(): void;
|
unref(): void;
|
||||||
@@ -420,16 +422,14 @@ export class EncryptedChannel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Different key implies a new client connection (common with relays
|
// A different key on an already-open encrypted channel is not an
|
||||||
// where the daemon's socket stays open while the client reconnects).
|
// authenticated reconnect. Close and require a fresh transport instead of
|
||||||
// Re-key and re-send "ready". Drop any queued sends to avoid leaking
|
// allowing the relay to switch this channel to an attacker-chosen key.
|
||||||
// messages between logical client sessions.
|
this.state = "closed";
|
||||||
this.state = "handshaking";
|
this.transport.close(
|
||||||
this.sharedKey = nextSharedKey;
|
REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE,
|
||||||
this.pendingSends = [];
|
REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON,
|
||||||
this.transport.send(JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage));
|
);
|
||||||
this.state = "open";
|
|
||||||
await this.flushPendingSends();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
close(code = 1000, reason = "Normal closure"): void {
|
close(code = 1000, reason = "Normal closure"): void {
|
||||||
@@ -452,8 +452,9 @@ export class EncryptedChannel {
|
|||||||
|
|
||||||
function keysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
function keysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
if (a.byteLength !== b.byteLength) return false;
|
if (a.byteLength !== b.byteLength) return false;
|
||||||
|
let difference = 0;
|
||||||
for (let i = 0; i < a.byteLength; i += 1) {
|
for (let i = 0; i < a.byteLength; i += 1) {
|
||||||
if (a[i] !== b[i]) return false;
|
difference |= a[i] ^ b[i];
|
||||||
}
|
}
|
||||||
return true;
|
return difference === 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user