mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* docs(b2-7): record the #1443 squash SHA in the evidence block * docs(b2-9): SEC-03 sized and re-tagged to B5, verdict recorded * test(e2ee): HP-2 adversarial membership and key-change cases Three cases from docs/trust-model.md that had no dedicated test: a modified server adding an unknown member at first contact (pinned as today's behaviour, a known gap with its RED recorded in the HP-2 scorecard), a second device's key overwriting the one-per-account pin so the first device mismatches, and a peer resumed across a rotation being re-keyed with the rotated key (OC-0316, holder side). The last two were proven able to fail by temporary code mutation. * docs(b2-9): close the owner table; advisory placeholders for SEC-01/SEC-04 * docs(hp-2): protocol and threat-model sign-off scorecard Seven questions answered with commands and their output; B2 exit gate walked (nine conditions). Adds the trust-model anchor checker beside the scorecard, the HP-2 evidence block, and the plan index / roadmap slice updates. Owner lines (reader, review date, decision, signature) are left blank on purpose.
1991 lines
85 KiB
TypeScript
1991 lines
85 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// --- Mocks must be declared before imports ---
|
|
// The full E2EE protocol (announce verification, TOFU pinning, rotation-on-leave,
|
|
// timeout paths) is exercised end-to-end through the LiveKitSession facade in
|
|
// livekit-session.test.ts. This file is a focused smoke test of the extracted
|
|
// E2EEManager module surface.
|
|
|
|
const mockSetKey = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("livekit-client", () => ({
|
|
// vitest 4 mocks honor construct semantics — `new` needs a real function, not an arrow.
|
|
ExternalE2EEKeyProvider: vi.fn(function () {
|
|
return {
|
|
setKey: mockSetKey,
|
|
getKeys: vi.fn().mockReturnValue([]),
|
|
};
|
|
}),
|
|
}));
|
|
|
|
const mockKeyPair = vi.hoisted(() => ({
|
|
publicKey: { type: "public" } as unknown as CryptoKey,
|
|
privateKey: { type: "private" } as unknown as CryptoKey,
|
|
}));
|
|
|
|
const mockIdentityKeyPair = vi.hoisted(() => ({
|
|
publicKey: { type: "id-public" } as unknown as CryptoKey,
|
|
privateKey: { type: "id-private" } as unknown as CryptoKey,
|
|
}));
|
|
|
|
vi.mock("@lib/e2eeCrypto", () => ({
|
|
generateECDHKeyPair: vi.fn(async () => mockKeyPair),
|
|
exportPublicKey: vi.fn(async () => "bW9ja2VwaGVtZXJhbA=="),
|
|
importPublicKey: vi.fn(async () => ({ type: "public" }) as unknown as CryptoKey),
|
|
generateRoomKey: vi.fn(() => new Uint8Array(32)),
|
|
roomKeyToBase64: vi.fn(() => "mock-room-key-base64"),
|
|
wrapRoomKey: vi.fn(async () => ({ encryptedKey: "enc", iv: "iv" })),
|
|
unwrapRoomKey: vi.fn(async () => ({ roomKey: new Uint8Array(32), epoch: 0 })),
|
|
signEphemeralKey: vi.fn(async () => "mock-signature"),
|
|
verifyEphemeralKeySignature: vi.fn(async () => true),
|
|
importIdentityPublicKey: vi.fn(
|
|
async () => ({ type: "id-public-imported" }) as unknown as CryptoKey,
|
|
),
|
|
computeKeyFingerprint: vi.fn(async () => "AB12 CD34 EF56 7890"),
|
|
computeRawKeyFingerprint: vi.fn(async () => "5E55 1234 5678 9ABC"),
|
|
}));
|
|
|
|
vi.mock("@lib/identity", () => ({
|
|
getOrCreateIdentityKeyPair: vi.fn(async () => mockIdentityKeyPair),
|
|
getIdentityPin: vi.fn(async () => ({ status: "unpinned" })),
|
|
storeIdentityPin: vi.fn(async () => "stored"),
|
|
}));
|
|
|
|
vi.mock("@stores/auth.store", () => ({
|
|
authStore: { getState: vi.fn(() => ({ user: { id: 1 } })) },
|
|
}));
|
|
|
|
const mockMembers = vi.hoisted(() => new Map<number, { identityPublicKey: string | null }>());
|
|
|
|
vi.mock("@stores/members.store", () => ({
|
|
membersStore: { getState: vi.fn(() => ({ members: mockMembers })) },
|
|
}));
|
|
|
|
const mockVoiceState = vi.hoisted(() => ({
|
|
voiceUsers: new Map<number, Map<number, unknown>>(),
|
|
}));
|
|
|
|
vi.mock("@stores/voice.store", () => ({
|
|
voiceStore: { getState: vi.fn(() => mockVoiceState) },
|
|
setPeerVerification: vi.fn(),
|
|
clearPeerVerification: vi.fn(),
|
|
clearPeerVerifications: vi.fn(),
|
|
setLocalSessionFingerprint: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@lib/logger", () => ({
|
|
createLogger: () => ({
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
// Now import
|
|
import { E2EEManager } from "../../src/lib/livekitE2EE";
|
|
import {
|
|
setPeerVerification,
|
|
clearPeerVerification,
|
|
setLocalSessionFingerprint,
|
|
} from "@stores/voice.store";
|
|
import {
|
|
unwrapRoomKey,
|
|
roomKeyToBase64,
|
|
wrapRoomKey,
|
|
generateECDHKeyPair,
|
|
generateRoomKey,
|
|
importPublicKey,
|
|
exportPublicKey,
|
|
computeRawKeyFingerprint,
|
|
} from "@lib/e2eeCrypto";
|
|
import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity";
|
|
import { authStore } from "@stores/auth.store";
|
|
|
|
const PEER_ID = 42;
|
|
|
|
function createManager(ws: { send: ReturnType<typeof vi.fn> }): E2EEManager {
|
|
return new E2EEManager({
|
|
getWs: () => ws as never,
|
|
getServerHost: () => "localhost:7880",
|
|
getCurrentChannelId: () => 1,
|
|
});
|
|
}
|
|
|
|
function sendsOfType(ws: { send: ReturnType<typeof vi.fn> }, type: string): unknown[] {
|
|
return ws.send.mock.calls.map((c) => c[0]).filter((m: any) => m?.type === type);
|
|
}
|
|
|
|
describe("E2EEManager", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockMembers.clear();
|
|
mockMembers.set(PEER_ID, { identityPublicKey: "peer-identity-b64" });
|
|
mockVoiceState.voiceUsers.clear();
|
|
});
|
|
|
|
it("setupKeyExchange as key holder generates the room key and sends a signed announce", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
const ok = await mgr.setupKeyExchange(true, 1);
|
|
|
|
expect(ok).toBe(true);
|
|
expect(mgr.epoch).toBe(1);
|
|
expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64");
|
|
const announces = sendsOfType(ws, "voice_e2ee_announce");
|
|
expect(announces).toHaveLength(1);
|
|
expect((announces[0] as any).payload.signature).toBe("mock-signature");
|
|
});
|
|
|
|
it("queues an announce before the keypair exists and drains it on setup, sending an offer", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
expect(mgr.pendingAnnounces).toHaveLength(1);
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
|
|
|
|
await mgr.setupKeyExchange(true, 1);
|
|
|
|
// Drained through the verifying receive path and stored. The room key is
|
|
// generated BEFORE the drain, so each drained peer gets its offer right
|
|
// here — mid-call peers never re-announce, and the only other delivery
|
|
// is the 5-minute rotation timer, which would strand them on a dead key
|
|
// whenever a new key holder joins an ongoing call.
|
|
expect(mgr.pendingAnnounces).toHaveLength(0);
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
|
|
expect(setPeerVerification).toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: PEER_ID, status: "verified" }),
|
|
);
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
|
|
|
|
// A repeat announce after keying (dedupe path) re-sends the room-key offer.
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(2);
|
|
});
|
|
|
|
it("setupKeyExchange as non-key-holder resolves once the key holder's offer arrives", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
// Seed the peer's ECDH key so the offer sender is known.
|
|
await mgr.setupKeyExchange(true, 1);
|
|
mgr.clearState();
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
ws.send.mockClear();
|
|
|
|
const setupPromise = mgr.setupKeyExchange(false, 1);
|
|
// Announce goes out first so the key holder can offer immediately.
|
|
await vi.waitFor(() => {
|
|
expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0);
|
|
});
|
|
await mgr.handleOffer(PEER_ID, "enc", "iv");
|
|
|
|
await expect(setupPromise).resolves.toBe(true);
|
|
expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64");
|
|
});
|
|
|
|
it("clearState aborts a waiting key exchange so setup fails instead of hanging", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
const setupPromise = mgr.setupKeyExchange(false, 1);
|
|
await vi.waitFor(() => {
|
|
expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0);
|
|
});
|
|
mgr.clearState();
|
|
|
|
await expect(setupPromise).resolves.toBe(false);
|
|
expect(mgr.epoch).toBe(0);
|
|
expect(mgr.peerPublicKeys.size).toBe(0);
|
|
});
|
|
|
|
it("elects a still-connecting client when the holder leaves mid-setup, instead of stranding it", async () => {
|
|
const ws = { send: vi.fn() };
|
|
// The session publishes no channel id for the whole "connecting" phase,
|
|
// which spans the entire key-exchange wait.
|
|
const mgr = new E2EEManager({
|
|
getWs: () => ws as never,
|
|
getServerHost: () => "localhost:7880",
|
|
getCurrentChannelId: () => null,
|
|
});
|
|
// After the old holder (PEER_ID) leaves, we (uid 1) are the only participant.
|
|
mockVoiceState.voiceUsers.set(1, new Map([[1, {}]]));
|
|
|
|
const setupPromise = mgr.setupKeyExchange(false, 1);
|
|
await vi.waitFor(() => {
|
|
expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
// The holder leaves before offering, while we are still inside
|
|
// setupKeyExchange. The re-election must use the channel id the exchange
|
|
// was started with (getCurrentChannelId is still null) and must unblock
|
|
// the wait — the offer we were waiting for will never arrive.
|
|
await mgr.handleParticipantLeft(PEER_ID);
|
|
|
|
await expect(setupPromise).resolves.toBe(true);
|
|
expect(mgr.epoch).toBe(1); // became holder and generated a fresh key
|
|
expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64");
|
|
});
|
|
|
|
it("applies concurrent offers in arrival order, not completion order", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1); // establishes our keypair
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); // known peer
|
|
mockSetKey.mockClear();
|
|
|
|
// The first offer's unwrap stalls (WebCrypto gives no cross-operation
|
|
// ordering guarantee); the second resolves immediately. Delivery order
|
|
// must still win — otherwise the receiver ends on the first (dead) key.
|
|
let releaseFirst!: () => void;
|
|
const firstUnwrap = new Promise<{ roomKey: Uint8Array; epoch: number | null }>((resolve) => {
|
|
releaseFirst = () => resolve({ roomKey: new Uint8Array(32).fill(1), epoch: 0 });
|
|
});
|
|
vi.mocked(unwrapRoomKey)
|
|
.mockReturnValueOnce(firstUnwrap)
|
|
.mockResolvedValueOnce({ roomKey: new Uint8Array(32).fill(2), epoch: 0 });
|
|
vi.mocked(roomKeyToBase64).mockImplementation((k: Uint8Array) => `key-${k[0]}`);
|
|
try {
|
|
const first = mgr.handleOffer(PEER_ID, "enc1", "iv1");
|
|
const second = mgr.handleOffer(PEER_ID, "enc2", "iv2");
|
|
releaseFirst();
|
|
await Promise.all([first, second]);
|
|
|
|
// The second (newer) key must be the one left applied.
|
|
expect(mockSetKey).toHaveBeenLastCalledWith("key-2");
|
|
} finally {
|
|
vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64");
|
|
}
|
|
});
|
|
|
|
it("stops rotating once it accepts an offer, so a re-elected holder is not fought", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
// We joined first, so the server elected us key holder.
|
|
await mgr.setupKeyExchange(true, 1);
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
|
|
// A lower-userID participant then joined; the server re-elected them and
|
|
// they sent us the room key. Accepting an offer proves they are the
|
|
// server-authoritative holder (the server gates offers on IsVoiceKeyHolder).
|
|
await mgr.handleOffer(PEER_ID, "enc", "iv");
|
|
const epochAfterOffer = mgr.epoch;
|
|
ws.send.mockClear();
|
|
|
|
// The stale rotation timer must no longer rotate: the server rejects those
|
|
// offers with NOT_KEY_HOLDER, but only after we have already applied the new
|
|
// key locally — leaving us deaf and mute until the real holder rotates again.
|
|
await mgr.rotateKeyPeriodically();
|
|
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0);
|
|
expect(mgr.epoch).toBe(epochAfterOffer);
|
|
});
|
|
|
|
it("keeps peer public keys across a reconnect so a later offer can still be unwrapped", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
|
|
|
|
await mgr.reannounceForReconnect();
|
|
|
|
// Nothing repopulates this map after an SFU-level reconnect: handleAnnounce
|
|
// replies with an offer rather than a counter-announce, and the server
|
|
// relays stored peer keys only on voice_join. Peers' public keys stay valid
|
|
// when we regenerate our own pair, so dropping them only strands us on the
|
|
// pre-reconnect key.
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
|
|
|
|
mockSetKey.mockClear();
|
|
await mgr.handleOffer(PEER_ID, "enc", "iv");
|
|
expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64");
|
|
});
|
|
|
|
it("rotateKeyPeriodically advances the epoch and redistributes the key to peers", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
ws.send.mockClear();
|
|
|
|
await mgr.rotateKeyPeriodically();
|
|
|
|
expect(mgr.epoch).toBe(2);
|
|
const offers = sendsOfType(ws, "voice_e2ee_offer");
|
|
expect(offers).toHaveLength(1);
|
|
expect((offers[0] as any).payload.target_user_id).toBe(PEER_ID);
|
|
});
|
|
|
|
// ── Batch C3 findings ───────────────────────────────────────────────────
|
|
|
|
it("[finding 1] resumes as key holder when re-elected while a prior rotation is still in flight", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
mockVoiceState.voiceUsers.set(1, new Map([[1, {}]])); // we (uid 1) are always lowest
|
|
|
|
await mgr.setupKeyExchange(true, 1); // epoch 1, holder
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); // peer key known
|
|
|
|
// Start a periodic rotation and stall it right after the epoch bump, at
|
|
// the keyProvider.setKey await — mirrors an in-flight become-holder
|
|
// rotation (same _rotatingKey guard).
|
|
let releaseRotationSetKey!: () => void;
|
|
const stall = new Promise<void>((resolve) => {
|
|
releaseRotationSetKey = resolve;
|
|
});
|
|
mockSetKey.mockImplementationOnce(() => stall);
|
|
const rotationPromise = mgr.rotateKeyPeriodically();
|
|
expect(mgr.rotatingKey).toBe(true);
|
|
expect(mgr.epoch).toBe(2);
|
|
|
|
// While that rotation is in flight, an offer from the "real" elected
|
|
// holder arrives and stands us down (handleOfferInner clears
|
|
// _isKeyHolder but not _rotatingKey).
|
|
await mgr.handleOffer(PEER_ID, "enc", "iv");
|
|
|
|
// The new holder immediately leaves — we are re-elected. This must not
|
|
// be a silent no-op just because a rotation is still in flight.
|
|
await mgr.handleParticipantLeft(99);
|
|
|
|
// Let the original (stalled) rotation finish.
|
|
releaseRotationSetKey();
|
|
await rotationPromise;
|
|
|
|
// The re-election's finally -> drainPendingRotationOrArmTimer must have
|
|
// run a fresh rotation as holder: one more epoch bump and key-provider
|
|
// update beyond the two already accounted for (initial holder setup +
|
|
// the stalled rotation).
|
|
expect(mgr.epoch).toBe(3);
|
|
expect(mgr.rotatingKey).toBe(false);
|
|
});
|
|
|
|
it("[finding 2] marks a first-sight peer 'unverified' (not 'verified') when the identity-pin write fails", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1); // establishes our keypair
|
|
vi.mocked(storeIdentityPin).mockResolvedValueOnce("failed");
|
|
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
|
|
expect(storeIdentityPin).toHaveBeenCalledWith(
|
|
"localhost:7880",
|
|
String(PEER_ID),
|
|
"peer-identity-b64",
|
|
);
|
|
expect(setPeerVerification).toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: PEER_ID, status: "unverified", safetyNumber: null }),
|
|
);
|
|
expect(setPeerVerification).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: PEER_ID, status: "verified" }),
|
|
);
|
|
});
|
|
|
|
it("[finding 3] discards a stale offer even when epoch is unchanged, because the keypair belongs to a cleared session", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
// Session 1 (non-key-holder): our keypair + the peer's key.
|
|
const setup1 = mgr.setupKeyExchange(false, 1);
|
|
await vi.waitFor(() => {
|
|
expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0);
|
|
});
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
|
|
// This offer's unwrap stalls — still in flight when the user leaves voice.
|
|
let releaseUnwrap!: (v: { roomKey: Uint8Array; epoch: number | null }) => void;
|
|
const stalledUnwrap = new Promise<{ roomKey: Uint8Array; epoch: number | null }>((resolve) => {
|
|
releaseUnwrap = resolve;
|
|
});
|
|
vi.mocked(unwrapRoomKey).mockReturnValueOnce(stalledUnwrap);
|
|
const offerPromise = mgr.handleOffer(PEER_ID, "enc-old", "iv-old");
|
|
await vi.waitFor(() => expect(unwrapRoomKey).toHaveBeenCalled());
|
|
|
|
// Leave voice mid-unwrap.
|
|
mgr.clearState();
|
|
await expect(setup1).resolves.toBe(false);
|
|
|
|
// A brand-new voice session generates a fresh keypair (forward secrecy).
|
|
// Epoch is 0 in both sessions (non-key-holder never bumps it), so only
|
|
// keypair identity can distinguish session 1's stale offer from session 2.
|
|
const newKeyPair = {
|
|
publicKey: { type: "public-2" } as unknown as CryptoKey,
|
|
privateKey: { type: "private-2" } as unknown as CryptoKey,
|
|
};
|
|
vi.mocked(generateECDHKeyPair).mockResolvedValueOnce(newKeyPair);
|
|
await mgr.reannounceForReconnect();
|
|
mockSetKey.mockClear();
|
|
|
|
// The stale session-1 offer now resolves.
|
|
releaseUnwrap({ roomKey: new Uint8Array(32).fill(9), epoch: 0 });
|
|
await offerPromise;
|
|
|
|
// Must be discarded: epoch alone (0 === 0) would have let it through.
|
|
expect(mockSetKey).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("[finding 4] discards a stale announce-offer if the room key rotates while wrapRoomKey is in flight", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1); // holder, epoch 1
|
|
|
|
let releaseWrap!: (v: { encryptedKey: string; iv: string }) => void;
|
|
const stalledWrap = new Promise<{ encryptedKey: string; iv: string }>((resolve) => {
|
|
releaseWrap = resolve;
|
|
});
|
|
vi.mocked(wrapRoomKey).mockReturnValueOnce(stalledWrap);
|
|
|
|
const announcePromise = mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
await vi.waitFor(() => expect(wrapRoomKey).toHaveBeenCalled());
|
|
|
|
// A rotation completes while the announce's own wrap is still pending —
|
|
// it sees PEER_ID already in _peerPublicKeys (stored synchronously before
|
|
// the stalled wrap) and sends it the fresh key.
|
|
await mgr.rotateKeyPeriodically();
|
|
ws.send.mockClear();
|
|
|
|
// The stale (pre-rotation) wrap now resolves.
|
|
releaseWrap({ encryptedKey: "stale-enc", iv: "stale-iv" });
|
|
await announcePromise;
|
|
|
|
// Must be discarded — the peer already has the fresh key from rotation.
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0);
|
|
});
|
|
|
|
it("[finding 5] retries with a fresh promise after a decrypt failure, so a second good offer still completes setup", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
vi.mocked(unwrapRoomKey).mockRejectedValueOnce(new Error("bad offer"));
|
|
|
|
const setupPromise = mgr.setupKeyExchange(false, 1);
|
|
await vi.waitFor(() => {
|
|
expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0);
|
|
});
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
|
|
// A corrupt/undecryptable offer arrives — handleOfferInner's catch
|
|
// rejects the wait.
|
|
await mgr.handleOffer(PEER_ID, "enc-bad", "iv-bad");
|
|
|
|
// The retry must re-announce and give a FRESH window — a second, valid
|
|
// offer must still be able to complete setup.
|
|
await vi.waitFor(() => {
|
|
expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(1);
|
|
});
|
|
await mgr.handleOffer(PEER_ID, "enc-good", "iv-good");
|
|
|
|
await expect(setupPromise).resolves.toBe(true);
|
|
});
|
|
|
|
it("[finding 6] queues (does not drop) a live announce that arrives during setup, before isKeyHolder/roomKey are ready", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
// Stall the identity-key load inside buildAnnouncePayload so a "live"
|
|
// handleAnnounce can land while setup is still in its prefix — before
|
|
// _isKeyHolder/_roomKey (and, with the fix, _ecdhKeyPair) are ready.
|
|
let releaseIdentity!: () => void;
|
|
const stalledIdentity = new Promise<typeof mockIdentityKeyPair>((resolve) => {
|
|
releaseIdentity = () => resolve(mockIdentityKeyPair);
|
|
});
|
|
vi.mocked(getOrCreateIdentityKeyPair).mockReturnValueOnce(stalledIdentity);
|
|
|
|
const setupPromise = mgr.setupKeyExchange(true, 1); // becoming key holder
|
|
await vi.waitFor(() => expect(getOrCreateIdentityKeyPair).toHaveBeenCalled());
|
|
|
|
// A peer announces "live" (server-relayed) while our own setup is still
|
|
// in flight.
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
|
|
releaseIdentity();
|
|
await setupPromise;
|
|
|
|
// The peer must receive a room-key offer — either queued-then-drained, or
|
|
// handled live after isKeyHolder/roomKey became ready. It must never be
|
|
// silently stored with no offer ever sent.
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
|
|
});
|
|
|
|
// ── Batch ts:livekit-e2ee findings ──────────────────────────────────────
|
|
|
|
it("[finding v011] keeps the mismatch block when the re-pin write fails", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
vi.mocked(storeIdentityPin).mockResolvedValueOnce("failed");
|
|
|
|
const result = await mgr.rePinPeerIdentity(PEER_ID, "verified-key-b64");
|
|
|
|
// A failed write must not report the re-pin as successful — the OLD pin
|
|
// is still on disk, so clearing the mismatch block here would let the
|
|
// UI claim trust was re-established when it wasn't.
|
|
expect(result).toBe(false);
|
|
expect(clearPeerVerification).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("[finding v011] clears the mismatch block when the re-pin write succeeds", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
const result = await mgr.rePinPeerIdentity(PEER_ID, "verified-key-b64");
|
|
|
|
expect(result).toBe(true);
|
|
expect(clearPeerVerification).toHaveBeenCalledWith(PEER_ID);
|
|
});
|
|
|
|
it("[finding v043] a superseded attempt's retry guard checks keypair ownership, not just null, so it never re-announces a dead key over a live session", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
const keypairA = {
|
|
publicKey: { type: "pubA" } as unknown as CryptoKey,
|
|
privateKey: { type: "privA" } as unknown as CryptoKey,
|
|
};
|
|
const keypairB = {
|
|
publicKey: { type: "pubB" } as unknown as CryptoKey,
|
|
privateKey: { type: "privB" } as unknown as CryptoKey,
|
|
};
|
|
vi.mocked(generateECDHKeyPair).mockResolvedValueOnce(keypairA).mockResolvedValueOnce(keypairB);
|
|
|
|
// Session A: non-key-holder, waiting for the key holder's offer.
|
|
const setupA = mgr.setupKeyExchange(false, 1);
|
|
await vi.waitFor(() => {
|
|
expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
// Session A is superseded by a fresh session B (e.g. the user left and
|
|
// rejoined) before A's 10s window elapses. B publishes its own keypair,
|
|
// overwriting A's — a plain `=== null` check on _ecdhKeyPair can't see
|
|
// this, since it is now non-null (B's).
|
|
const setupB = mgr.setupKeyExchange(true, 2);
|
|
await expect(setupB).resolves.toBe(true);
|
|
|
|
// Re-populate the peer key B's setup wiped, so a decrypt failure can
|
|
// reach A's still-installed rejector without an early return.
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
ws.send.mockClear();
|
|
|
|
// A corrupt offer rejects A's still-pending roomKeyPromise via its
|
|
// rejector — the only field B's (key-holder) setup never touches.
|
|
vi.mocked(unwrapRoomKey).mockRejectedValueOnce(new Error("bad offer"));
|
|
await mgr.handleOffer(PEER_ID, "enc-bad", "iv-bad");
|
|
|
|
// A must give up instead of re-announcing its dead ephemeral key over
|
|
// B's live session and reinstalling a resolver nothing will ever call.
|
|
await expect(setupA).resolves.toBe(false);
|
|
expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(0);
|
|
});
|
|
|
|
it("[finding v043] a superseded setup does not wipe the live session's peer keys", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
// Attempt A stalls inside generateECDHKeyPair — before it has written
|
|
// anything, so clearState() has no rejector to reject and A survives its
|
|
// own teardown.
|
|
let releaseGen!: (v: CryptoKeyPair) => void;
|
|
const stalledGen = new Promise<CryptoKeyPair>((resolve) => {
|
|
releaseGen = resolve;
|
|
});
|
|
vi.mocked(generateECDHKeyPair).mockReturnValueOnce(stalledGen);
|
|
const setupA = mgr.setupKeyExchange(false, 1);
|
|
await vi.waitFor(() => expect(generateECDHKeyPair).toHaveBeenCalled());
|
|
|
|
// The user leaves and joins another channel; that session completes and
|
|
// learns a peer's ECDH key.
|
|
mgr.clearState();
|
|
await expect(mgr.setupKeyExchange(true, 2)).resolves.toBe(true);
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
|
|
// Attempt A only now resumes, long after its own teardown.
|
|
releaseGen({
|
|
publicKey: { type: "stale-pub" } as unknown as CryptoKey,
|
|
privateKey: { type: "stale-priv" } as unknown as CryptoKey,
|
|
});
|
|
await expect(setupA).resolves.toBe(false);
|
|
|
|
// The live session's peer key must survive: nothing repopulates this map
|
|
// mid-call, so clearing it here would make handleOffer's unknown-peer
|
|
// guard drop every later rotation from that peer.
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
|
|
});
|
|
|
|
it("[finding v043] a superseded setup does not clobber the live session's key-holder role", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
// Attempt A (non-key-holder) stalls inside buildAnnouncePayload's keyring
|
|
// round trip — the widest pre-publication window.
|
|
let releaseIdentity!: () => void;
|
|
const stalledIdentity = new Promise<typeof mockIdentityKeyPair>((resolve) => {
|
|
releaseIdentity = () => resolve(mockIdentityKeyPair);
|
|
});
|
|
vi.mocked(getOrCreateIdentityKeyPair).mockReturnValueOnce(stalledIdentity);
|
|
const setupA = mgr.setupKeyExchange(false, 1);
|
|
await vi.waitFor(() => expect(getOrCreateIdentityKeyPair).toHaveBeenCalled());
|
|
|
|
// The user leaves and joins another channel as key holder.
|
|
mgr.clearState();
|
|
await expect(mgr.setupKeyExchange(true, 2)).resolves.toBe(true);
|
|
const epochAfterLiveSetup = mgr.epoch;
|
|
|
|
releaseIdentity();
|
|
await expect(setupA).resolves.toBe(false);
|
|
ws.send.mockClear();
|
|
|
|
// A's `_isKeyHolder = false` must never land on the live session: it
|
|
// would silently stop its rotations AND its offers to peers that
|
|
// announce later, with nothing to re-elect it.
|
|
expect(mgr.epoch).toBe(epochAfterLiveSetup);
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
|
|
});
|
|
|
|
it("[finding v015] applies concurrent announces in arrival order, not completion order", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1); // establishes our keypair + room key
|
|
|
|
// The first announce's key import stalls; the second is unimpeded. WS
|
|
// delivery order must still win — otherwise _peerPublicKeys ends up on
|
|
// the peer's superseded key and every later rotation is wrapped for a
|
|
// private key they no longer hold.
|
|
const keyOne = { type: "peer-key-1" } as unknown as CryptoKey;
|
|
const keyTwo = { type: "peer-key-2" } as unknown as CryptoKey;
|
|
let releaseFirst!: (v: CryptoKey) => void;
|
|
const firstImport = new Promise<CryptoKey>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
vi.mocked(importPublicKey).mockReturnValueOnce(firstImport).mockResolvedValueOnce(keyTwo);
|
|
|
|
const first = mgr.handleAnnounce(PEER_ID, "b2xk", "sig1");
|
|
const second = mgr.handleAnnounce(PEER_ID, "bmV3", "sig2");
|
|
// Drain every pending microtask: unserialized, the second announce runs
|
|
// to completion right here while the first is still stalled.
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
releaseFirst(keyOne);
|
|
await Promise.all([first, second]);
|
|
|
|
expect(mgr.peerPublicKeys.get(PEER_ID)).toBe(keyTwo);
|
|
});
|
|
|
|
it("[finding v093] does not resurrect the keypair or send a stray announce if clearState() runs during reannounceForReconnect", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
ws.send.mockClear();
|
|
|
|
let releaseGen!: (v: CryptoKeyPair) => void;
|
|
const stalledGen = new Promise<CryptoKeyPair>((resolve) => {
|
|
releaseGen = resolve;
|
|
});
|
|
vi.mocked(generateECDHKeyPair).mockReturnValueOnce(stalledGen);
|
|
|
|
const reconnectPromise = mgr.reannounceForReconnect();
|
|
await vi.waitFor(() => expect(generateECDHKeyPair).toHaveBeenCalled());
|
|
|
|
// The user disconnects while the reconnect's keypair generation is
|
|
// still in flight.
|
|
mgr.clearState();
|
|
|
|
releaseGen({
|
|
publicKey: { type: "reconnect-pub" } as unknown as CryptoKey,
|
|
privateKey: { type: "reconnect-priv" } as unknown as CryptoKey,
|
|
});
|
|
await reconnectPromise;
|
|
|
|
// Must not resurrect the torn-down keypair or send a stray announce for
|
|
// a channel we already left.
|
|
expect((mgr as unknown as { _ecdhKeyPair: unknown })._ecdhKeyPair).toBeNull();
|
|
expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(0);
|
|
});
|
|
|
|
it("[finding v101] discards a stale announce-offer if the keypair (not epoch) changes while wrapRoomKey is in flight", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1); // holder, epoch 1
|
|
|
|
let releaseWrap!: (v: { encryptedKey: string; iv: string }) => void;
|
|
const stalledWrap = new Promise<{ encryptedKey: string; iv: string }>((resolve) => {
|
|
releaseWrap = resolve;
|
|
});
|
|
vi.mocked(wrapRoomKey).mockReturnValueOnce(stalledWrap);
|
|
|
|
const announcePromise = mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
await vi.waitFor(() => expect(wrapRoomKey).toHaveBeenCalled());
|
|
|
|
// A concurrent reconnect swaps the keypair WITHOUT bumping the epoch —
|
|
// an epoch-only staleness check would miss this entirely.
|
|
vi.mocked(generateECDHKeyPair).mockResolvedValueOnce({
|
|
publicKey: { type: "public-2" } as unknown as CryptoKey,
|
|
privateKey: { type: "private-2" } as unknown as CryptoKey,
|
|
});
|
|
await mgr.reannounceForReconnect();
|
|
ws.send.mockClear();
|
|
|
|
// The stale (pre-reconnect) wrap now resolves.
|
|
releaseWrap({ encryptedKey: "stale-enc", iv: "stale-iv" });
|
|
await announcePromise;
|
|
|
|
expect(mgr.epoch).toBe(1); // epoch never moved — proves the epoch-only check would miss this
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0);
|
|
});
|
|
|
|
it("[finding v045] aborts room-key distribution mid-loop when a concurrent reconnect swaps the keypair", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
mockVoiceState.voiceUsers.set(1, new Map([[1, {}]])); // we (uid 1) are always lowest
|
|
|
|
await mgr.setupKeyExchange(true, 1); // epoch 1, holder
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
await mgr.handleAnnounce(PEER_ID + 1, "cGVlcjI=", "sig2");
|
|
|
|
// A lower-userID peer's offer stands us down (mirrors the "stops
|
|
// rotating" test above), so the next participant-left re-elects us.
|
|
await mgr.handleOffer(PEER_ID, "enc", "iv");
|
|
expect(mgr.rotatingKey).toBe(false);
|
|
|
|
// Stall the first peer's wrap in the become-holder redistribution loop.
|
|
// (wrapRoomKey was already called twice above, wrapping for each peer's
|
|
// announce — clear that history so the count below tracks only the
|
|
// redistribution loop.)
|
|
vi.mocked(wrapRoomKey).mockClear();
|
|
let releaseWrap!: (v: { encryptedKey: string; iv: string }) => void;
|
|
const stalledWrap = new Promise<{ encryptedKey: string; iv: string }>((resolve) => {
|
|
releaseWrap = resolve;
|
|
});
|
|
vi.mocked(wrapRoomKey).mockReturnValueOnce(stalledWrap);
|
|
|
|
const leftPromise = mgr.handleParticipantLeft(99); // re-elects us as holder
|
|
await vi.waitFor(() => expect(wrapRoomKey).toHaveBeenCalledTimes(1));
|
|
ws.send.mockClear();
|
|
|
|
// A concurrent reconnect swaps the keypair mid-loop, without bumping
|
|
// the epoch.
|
|
vi.mocked(generateECDHKeyPair).mockResolvedValueOnce({
|
|
publicKey: { type: "reconnect-pub" } as unknown as CryptoKey,
|
|
privateKey: { type: "reconnect-priv" } as unknown as CryptoKey,
|
|
});
|
|
await mgr.reannounceForReconnect();
|
|
ws.send.mockClear();
|
|
|
|
releaseWrap({ encryptedKey: "stale-enc", iv: "stale-iv" });
|
|
await leftPromise;
|
|
|
|
// Neither peer should receive an offer wrapped under the abandoned
|
|
// keypair — the loop must abort as soon as it notices the swap.
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0);
|
|
});
|
|
|
|
// ── Batch B3 findings ───────────────────────────────────────────────────
|
|
|
|
it("[B3-2] preserves a key-holder promotion that lands during setupKeyExchange's pre-publish awaits, instead of clobbering it with the stale server value", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
// After the real holder (PEER_ID) leaves, we (uid 1) are the only
|
|
// remaining participant — client-side election promotes us.
|
|
mockVoiceState.voiceUsers.set(1, new Map([[1, {}]]));
|
|
|
|
// Stall the identity-key load inside buildAnnouncePayload so a
|
|
// participant-left promotion can land BEFORE setupKeyExchange assigns
|
|
// this._isKeyHolder from the (now-stale) server value.
|
|
let releaseIdentity!: () => void;
|
|
const stalledIdentity = new Promise<typeof mockIdentityKeyPair>((resolve) => {
|
|
releaseIdentity = () => resolve(mockIdentityKeyPair);
|
|
});
|
|
vi.mocked(getOrCreateIdentityKeyPair).mockReturnValueOnce(stalledIdentity);
|
|
|
|
// Server said we are NOT the key holder when we started joining...
|
|
const setupPromise = mgr.setupKeyExchange(false, 1);
|
|
await vi.waitFor(() => expect(getOrCreateIdentityKeyPair).toHaveBeenCalled());
|
|
|
|
// ...but the real holder leaves before we finish setting up, and since we
|
|
// are the only participant left, client-side election promotes us.
|
|
await mgr.handleParticipantLeft(PEER_ID);
|
|
|
|
releaseIdentity();
|
|
// On the buggy path this falls through to the non-holder wait-for-offer
|
|
// branch and burns the full 10s + 5s timeout before resolving false —
|
|
// fast-forward past it so the test does not block on a real 15s wait.
|
|
vi.useFakeTimers();
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(20_000);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
|
|
// The promotion must win: we end up as key holder (generated + applied a
|
|
// room key and announced) instead of waiting for an offer that only WE
|
|
// could have sent — the exact interleaving that times out and gets the
|
|
// joiner ejected from voice.
|
|
await expect(setupPromise).resolves.toBe(true);
|
|
expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64");
|
|
});
|
|
|
|
it("[B3-7] does not resurrect peer key/verification state into a torn-down session when clearState() runs during verifyPeerAnnounce's pin lookup", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1); // establishes our keypair
|
|
|
|
let releasePin!: (v: { status: "unpinned" }) => void;
|
|
const stalledPin = new Promise<{ status: "unpinned" }>((resolve) => {
|
|
releasePin = resolve;
|
|
});
|
|
vi.mocked(getIdentityPin).mockReturnValueOnce(stalledPin);
|
|
|
|
const announcePromise = mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
await vi.waitFor(() => expect(getIdentityPin).toHaveBeenCalled());
|
|
|
|
// Disconnect mid-verify.
|
|
mgr.clearState();
|
|
vi.mocked(setPeerVerification).mockClear();
|
|
|
|
releasePin({ status: "unpinned" });
|
|
await announcePromise;
|
|
|
|
// The torn-down session's peer map and verification state must not be
|
|
// resurrected by a continuation that resumes after teardown.
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
|
|
expect(setPeerVerification).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("[identity-scope guard] does not mint an identity keypair when no user is authenticated yet — announce goes out unsigned instead of under a placeholder host:0 scope", async () => {
|
|
// If this ever ran before auth state landed, falling back to `?? 0`
|
|
// would mint (or migrate-and-DELETE the real legacy key into) a bogus
|
|
// `host:0` keyring scope; the ready hook's later, authenticated call
|
|
// then mints a SECOND, DIFFERENT keypair under `host:<realId>` — so the
|
|
// published key and the announce signing key permanently disagree and
|
|
// every peer's verifyPeerAnnounce reports a false MITM "mismatch".
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
vi.mocked(authStore.getState).mockReturnValueOnce({ user: null } as never);
|
|
|
|
const ok = await mgr.setupKeyExchange(true, 1);
|
|
|
|
expect(ok).toBe(true);
|
|
expect(getOrCreateIdentityKeyPair).not.toHaveBeenCalled();
|
|
const announces = sendsOfType(ws, "voice_e2ee_announce");
|
|
expect(announces).toHaveLength(1);
|
|
// Same contract as "no server host": degrade to an unsigned announce
|
|
// rather than sign/scope under a placeholder id.
|
|
expect((announces[0] as any).payload.signature).toBeUndefined();
|
|
});
|
|
|
|
// ── Ledger findings OC-0098 / OC-0004 / OC-0006 / OC-0005 / OC-0007 ──
|
|
|
|
it("[OC-0098] sends its own announce before offering the room key to a drained peer", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
// A peer's announce arrives (relayed by voice_join sync) before our own
|
|
// keypair is ready — it queues.
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
expect(mgr.pendingAnnounces).toHaveLength(1);
|
|
|
|
// We become key holder and drain the queued announce, which sends that
|
|
// peer a voice_e2ee_offer. The peer can only unwrap it if it already has
|
|
// OUR ephemeral public key on file — which only our own announce
|
|
// provides. Both land in the same inbound WS queue on the peer's side,
|
|
// so the order we send them in here IS the order they arrive there.
|
|
await mgr.setupKeyExchange(true, 1);
|
|
|
|
const calls = ws.send.mock.calls.map((c) => c[0] as { type: string });
|
|
const announceIndex = calls.findIndex((m) => m.type === "voice_e2ee_announce");
|
|
const offerIndex = calls.findIndex((m) => m.type === "voice_e2ee_offer");
|
|
expect(announceIndex).toBeGreaterThanOrEqual(0);
|
|
expect(offerIndex).toBeGreaterThanOrEqual(0);
|
|
expect(announceIndex).toBeLessThan(offerIndex);
|
|
});
|
|
|
|
it("[OC-0004] elects us key holder on a participant-left even when our own voice_state hasn't landed in the roster yet", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws); // getCurrentChannelId() => 1
|
|
// mockVoiceState.voiceUsers has NO entry for channel 1: our own
|
|
// voice_state broadcast hasn't landed (it's still queued behind the
|
|
// leaver's on the server), and the leaver's own roster entry was just
|
|
// deleted by removeVoiceUser before this handler runs.
|
|
expect(mockVoiceState.voiceUsers.has(1)).toBe(false);
|
|
|
|
await mgr.handleParticipantLeft(PEER_ID);
|
|
|
|
// Client-side election must still run using our own authenticated id
|
|
// (uid 1) even though the local roster has nothing recorded for the
|
|
// channel yet — otherwise the server's promotion is silently missed and
|
|
// the join that provoked it times out 15s later.
|
|
expect(mgr.epoch).toBe(1);
|
|
expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64");
|
|
});
|
|
|
|
it("[OC-0006] self-heals the shared key provider when a rotation's setKey resolves after clearState() tore the session down", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
mockVoiceState.voiceUsers.set(1, new Map([[1, {}]]));
|
|
|
|
await mgr.setupKeyExchange(true, 1); // epoch 1, holder
|
|
|
|
vi.mocked(roomKeyToBase64).mockImplementation((k: Uint8Array) => `key-${k[0]}`);
|
|
try {
|
|
vi.mocked(generateRoomKey).mockReturnValueOnce(new Uint8Array(32).fill(9)); // this rotation's key
|
|
let releaseStale!: () => void;
|
|
const staleSetKey = new Promise<void>((resolve) => {
|
|
releaseStale = resolve;
|
|
});
|
|
mockSetKey.mockImplementationOnce(() => staleSetKey);
|
|
|
|
const rotationPromise = mgr.rotateKeyPeriodically();
|
|
await vi.waitFor(() => expect(mockSetKey).toHaveBeenCalledWith("key-9"));
|
|
|
|
// The session is torn down (user hit Disconnect) while that setKey
|
|
// call is still in flight, and a brand-new session becomes holder
|
|
// with its OWN key before the stale call resolves.
|
|
mgr.clearState();
|
|
vi.mocked(generateRoomKey).mockReturnValueOnce(new Uint8Array(32).fill(7)); // live session's key
|
|
mockSetKey.mockClear();
|
|
await mgr.setupKeyExchange(true, 2);
|
|
expect(mockSetKey).toHaveBeenCalledWith("key-7");
|
|
mockSetKey.mockClear();
|
|
|
|
// The abandoned rotation's setKey call now resolves.
|
|
releaseStale();
|
|
await rotationPromise;
|
|
|
|
// The shared key provider must end up on the LIVE session's key, not
|
|
// silently left on the abandoned one — narrow race, but real: nothing
|
|
// else re-applies the live key once the stale call lands.
|
|
expect(mockSetKey).toHaveBeenCalledWith("key-7");
|
|
} finally {
|
|
vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64");
|
|
}
|
|
});
|
|
|
|
it("[OC-0005] paces room-key offers so a large channel's rotation stays under the server's per-second rate limit", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
mockVoiceState.voiceUsers.set(1, new Map([[1, {}]]));
|
|
await mgr.setupKeyExchange(true, 1); // holder
|
|
|
|
// Seed 62 peers directly — well past the server's 64/sec cap for a
|
|
// single rotation's worth of offers.
|
|
for (let i = 0; i < 62; i++) {
|
|
mgr.peerPublicKeys.set(1000 + i, { type: `peer-${i}` } as unknown as CryptoKey);
|
|
}
|
|
ws.send.mockClear();
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
const rotationPromise = mgr.rotateKeyPeriodically();
|
|
// Let every microtask-bound send that doesn't need a real timer run.
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
const sentBeforePause = sendsOfType(ws, "voice_e2ee_offer").length;
|
|
// Must not have blown through the whole 62 in one burst.
|
|
expect(sentBeforePause).toBeLessThan(62);
|
|
expect(sentBeforePause).toBeGreaterThan(0);
|
|
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
await rotationPromise;
|
|
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(62);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("[OC-0155] shares the offer-pacing budget across back-to-back rotations instead of resetting it per call", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
mockVoiceState.voiceUsers.set(1, new Map([[1, {}]]));
|
|
await mgr.setupKeyExchange(true, 1); // holder
|
|
|
|
// Seed 40 peers — under the server's 60-offer cap for a SINGLE rotation,
|
|
// but two back-to-back rotations of 40 each (80 offers total) blow
|
|
// through the server's shared per-second window if each rotation gets
|
|
// its own fresh pacing budget instead of sharing one.
|
|
for (let i = 0; i < 40; i++) {
|
|
mgr.peerPublicKeys.set(2000 + i, { type: `peer-${i}` } as unknown as CryptoKey);
|
|
}
|
|
ws.send.mockClear();
|
|
|
|
// A second keyed-peer leave lands while this rotation is conceptually
|
|
// in flight — drainPendingRotationOrArmTimer (livekitE2EE.ts:1256-1263)
|
|
// runs a second rotation immediately once this one finishes, exactly
|
|
// like handleParticipantLeft's wasKeyHolder branch does.
|
|
mgr.rotationPending = true;
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
const rotationPromise = mgr.rotateKeyPeriodically();
|
|
// Let every microtask-bound send that doesn't need a real timer run —
|
|
// this covers BOTH rotations if neither individually hits the cap.
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
const sentBeforePause = sendsOfType(ws, "voice_e2ee_offer").length;
|
|
// The combined 80 offers across both rotations must not all go out
|
|
// unpaced just because neither rotation's own 40-offer batch exceeds
|
|
// the 60 cap in isolation — the budget must be shared.
|
|
expect(sentBeforePause).toBeLessThanOrEqual(60);
|
|
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
await rotationPromise;
|
|
|
|
// Both rotations' offers (40 + 40) eventually go out.
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(80);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("[OC-0167] paces announce-driven offers through the same shared budget as rotation offers", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
// 70 existing participants' announces are relayed to the future key
|
|
// holder before its own keypair is ready — exactly what voiceJoinComplete
|
|
// does for a joiner elected key holder in a large ongoing channel
|
|
// (Server/ws/voice_join.go:490-495). They queue.
|
|
for (let i = 0; i < 70; i++) {
|
|
await mgr.handleAnnounce(3000 + i, "cGVlcg==", "sig");
|
|
}
|
|
expect(mgr.pendingAnnounces).toHaveLength(70);
|
|
|
|
mockVoiceState.voiceUsers.set(1, new Map([[1, {}]]));
|
|
ws.send.mockClear();
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
// setupKeyExchange generates the room key, then drains all 70 queued
|
|
// announces in a tight loop — each drained announce sends a
|
|
// voice_e2ee_offer directly (handleAnnounceInner), bypassing
|
|
// distributeRoomKey's pacing entirely.
|
|
const setupPromise = mgr.setupKeyExchange(true, 1);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
const sentBeforePause = sendsOfType(ws, "voice_e2ee_offer").length;
|
|
// Must not blow through all 70 announce-driven offers in one unpaced
|
|
// burst.
|
|
expect(sentBeforePause).toBeLessThan(70);
|
|
expect(sentBeforePause).toBeGreaterThan(0);
|
|
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
await setupPromise;
|
|
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(70);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
// ── Ledger findings OC-0010 / OC-0011 ─────────────────────────────────
|
|
|
|
it("[OC-0010] does not stand down a new session's key-holder role when a stale offer's setKey resolves after teardown+rejoin", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
// Session A: we are the holder in channel 1, with PEER_ID's key on file.
|
|
await mgr.setupKeyExchange(true, 1);
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
|
|
// An offer from PEER_ID arrives and stalls at the keyProvider.setKey
|
|
// await — AFTER the epoch/keypair guard (checked right after unwrap) has
|
|
// already passed.
|
|
let releaseSetKey!: () => void;
|
|
const stalledSetKey = new Promise<void>((resolve) => {
|
|
releaseSetKey = resolve;
|
|
});
|
|
mockSetKey.mockClear();
|
|
mockSetKey.mockImplementationOnce(() => stalledSetKey);
|
|
const offerPromise = mgr.handleOffer(PEER_ID, "enc", "iv");
|
|
await vi.waitFor(() => expect(mockSetKey).toHaveBeenCalled());
|
|
|
|
// Mid-flight: the user leaves channel 1 and rejoins channel 2 as the new
|
|
// key holder — a distinct keypair, exactly as real ECDH keygen produces.
|
|
mgr.clearState();
|
|
vi.mocked(generateECDHKeyPair).mockResolvedValueOnce({
|
|
publicKey: { type: "chan2-pub" } as unknown as CryptoKey,
|
|
privateKey: { type: "chan2-priv" } as unknown as CryptoKey,
|
|
});
|
|
await mgr.setupKeyExchange(true, 2);
|
|
expect((mgr as unknown as { _isKeyHolder: boolean })._isKeyHolder).toBe(true);
|
|
|
|
// The stale (session-1) offer's setKey now resolves.
|
|
releaseSetKey();
|
|
await offerPromise;
|
|
|
|
// Channel 2's holder role must survive — the stale continuation must not
|
|
// stand it down (it re-checks staleness before the setKey await, not
|
|
// after — the write happens on the far side of that await).
|
|
expect((mgr as unknown as { _isKeyHolder: boolean })._isKeyHolder).toBe(true);
|
|
});
|
|
|
|
it("[OC-0010] does not write a stale peer key into a new session's map when clearState()+rejoin lands during the announce's key-import await", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
// Session A: holder in channel 1.
|
|
await mgr.setupKeyExchange(true, 1);
|
|
|
|
// The announce's importPublicKey stalls — the "final await" before the
|
|
// _peerPublicKeys.set write, which today has no re-check after it.
|
|
let releaseImport!: (v: CryptoKey) => void;
|
|
const stalledImport = new Promise<CryptoKey>((resolve) => {
|
|
releaseImport = resolve;
|
|
});
|
|
vi.mocked(importPublicKey).mockReturnValueOnce(stalledImport);
|
|
|
|
const announcePromise = mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
await vi.waitFor(() => expect(importPublicKey).toHaveBeenCalled());
|
|
|
|
// Mid-flight: the user leaves channel 1 and rejoins channel 2.
|
|
mgr.clearState();
|
|
await mgr.setupKeyExchange(true, 2);
|
|
|
|
// The stale announce's import now resolves.
|
|
releaseImport({ type: "stale-peer-key" } as unknown as CryptoKey);
|
|
await announcePromise;
|
|
|
|
// The new session's peer map must not be polluted by the torn-down
|
|
// session's announce.
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
|
|
});
|
|
|
|
it("[OC-0011] rejects a replayed announce carrying a previously-retired peer key instead of overwriting the live key", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1); // establishes our keypair
|
|
|
|
// Make import/export round-trip faithfully on the announced base64
|
|
// string (the shared mock default returns a fixed constant from
|
|
// exportPublicKey regardless of input, which would mask this bug).
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
|
|
(key as unknown as { type: string }).type.replace("peer-key-", ""),
|
|
);
|
|
|
|
// Valid base64 (must decode cleanly — rawFromBase64 uses atob() to build
|
|
// the signed message bytes). "b2xk"/"bmV3" already prove out elsewhere in
|
|
// this suite as distinct valid ephemeral-key payloads.
|
|
const KEY_A = "b2xk";
|
|
const KEY_B = "bmV3";
|
|
|
|
try {
|
|
// Peer announces key A — accepted as their first (live) key.
|
|
await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA");
|
|
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_A}` });
|
|
|
|
// Peer reconnects and announces a genuinely new key B — a legitimate
|
|
// change, so key A is now retired.
|
|
await mgr.handleAnnounce(PEER_ID, KEY_B, "sigB");
|
|
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` });
|
|
|
|
// A malicious relay re-emits the OLD, still validly-signed announce for
|
|
// key A. No channel/epoch/nonce binds the signed message, so it
|
|
// verifies cleanly — it must still be rejected as a replay, not
|
|
// overwrite the live key B.
|
|
await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA");
|
|
|
|
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` });
|
|
} finally {
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async () => ({ type: "public" }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
|
|
}
|
|
});
|
|
|
|
it("[OC-0007] confirms the room key after a reconnect re-announce instead of declaring it fresh unconditionally", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
// Non-key-holder session with an already-established room key from
|
|
// before the (simulated) disconnect.
|
|
await mgr.setupKeyExchange(true, 1);
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); // peer key known, so the offer below is accepted
|
|
await mgr.handleOffer(PEER_ID, "enc", "iv"); // stands us down — now a non-holder
|
|
ws.send.mockClear();
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
await mgr.reannounceForReconnect();
|
|
// Nothing has confirmed the re-applied key is current yet. If the
|
|
// holder's fresh offer never arrives, this must not be a silent,
|
|
// unbounded wait for the next 5-minute rotation — it must retry.
|
|
await vi.advanceTimersByTimeAsync(6000);
|
|
|
|
const announces = sendsOfType(ws, "voice_e2ee_announce");
|
|
expect(announces.length).toBeGreaterThan(1); // the reconnect announce PLUS a retry
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
// ── Ledger findings OC-0002 / OC-0020 ─────────────────────────────────
|
|
|
|
it("[OC-0002] applies an offer that arrives while its sender's own announce is still verifying, instead of dropping it as an unknown peer", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
// We were previously key holder (mirrors B in the repro): own keypair +
|
|
// room key already established.
|
|
await mgr.setupKeyExchange(true, 1);
|
|
mockSetKey.mockClear();
|
|
vi.mocked(unwrapRoomKey).mockClear();
|
|
|
|
// Stall the identity-pin lookup inside verifyPeerAnnounce so the
|
|
// announce is still mid-flight (queued on _announceChain, not yet
|
|
// applied to _peerPublicKeys) when the offer from the SAME sender
|
|
// arrives right behind it — exactly the WS delivery order OC-0098
|
|
// guarantees the sender used.
|
|
let releasePin!: (v: { status: "unpinned" }) => void;
|
|
const stalledPin = new Promise<{ status: "unpinned" }>((resolve) => {
|
|
releasePin = resolve;
|
|
});
|
|
vi.mocked(getIdentityPin).mockReturnValueOnce(stalledPin);
|
|
|
|
const announcePromise = mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
await vi.waitFor(() => expect(getIdentityPin).toHaveBeenCalled());
|
|
|
|
// The offer is dispatched immediately behind the announce, before the
|
|
// announce has stored the peer's ECDH key.
|
|
const offerPromise = mgr.handleOffer(PEER_ID, "enc", "iv");
|
|
|
|
releasePin({ status: "unpinned" });
|
|
await Promise.all([announcePromise, offerPromise]);
|
|
|
|
// The offer must have been applied once the announce (which arrived
|
|
// first) finished verifying — not silently dropped as "unknown peer"
|
|
// with no retry until the next 5-minute rotation.
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
|
|
expect(unwrapRoomKey).toHaveBeenCalled();
|
|
expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64");
|
|
});
|
|
|
|
it("[OC-0020] retires a departed peer's key on leave, so a replay of it after they rejoin with a fresh key cannot resurrect it", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1); // establishes our keypair, holder
|
|
|
|
// Make import/export round-trip faithfully on the announced base64
|
|
// string (the shared mock default returns a fixed constant regardless
|
|
// of input, which would mask this bug).
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
|
|
(key as unknown as { type: string }).type.replace("peer-key-", ""),
|
|
);
|
|
|
|
const KEY_A = "b2xk";
|
|
const KEY_B = "bmV3";
|
|
|
|
try {
|
|
// Peer announces key A — accepted as their first (live) key.
|
|
await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA");
|
|
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_A}` });
|
|
|
|
// Peer leaves the channel.
|
|
await mgr.handleParticipantLeft(PEER_ID);
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
|
|
|
|
// Peer rejoins and announces a fresh key B.
|
|
await mgr.handleAnnounce(PEER_ID, KEY_B, "sigB");
|
|
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` });
|
|
|
|
// A malicious relay re-emits the pre-leave, still validly-signed
|
|
// announce for key A. No channel/epoch/nonce binds the signed
|
|
// message, so it verifies cleanly — it must still be rejected as a
|
|
// replay of a retired key, not overwrite the live key B.
|
|
await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA");
|
|
|
|
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` });
|
|
} finally {
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async () => ({ type: "public" }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
|
|
}
|
|
});
|
|
|
|
// ── OC-0001: per-sender offer epoch high-water mark ───────────────────────
|
|
|
|
const unwrapAt = (epoch: number | null, fill = 1) =>
|
|
vi.mocked(unwrapRoomKey).mockResolvedValueOnce({
|
|
roomKey: new Uint8Array(32).fill(fill),
|
|
epoch,
|
|
});
|
|
|
|
async function holderWithPeer(): Promise<{
|
|
ws: { send: ReturnType<typeof vi.fn> };
|
|
mgr: E2EEManager;
|
|
}> {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
mockSetKey.mockClear();
|
|
vi.mocked(roomKeyToBase64).mockImplementation((k: Uint8Array) => `key-${k[0]}`);
|
|
return { ws, mgr };
|
|
}
|
|
|
|
it("[OC-0001] wraps each offer with the sender's current epoch", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
expect(wrapRoomKey).toHaveBeenLastCalledWith(
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
mgr.epoch,
|
|
);
|
|
|
|
await mgr.rotateKeyPeriodically();
|
|
expect(wrapRoomKey).toHaveBeenLastCalledWith(
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
mgr.epoch,
|
|
);
|
|
});
|
|
|
|
it("[OC-0001] discards an offer carrying an older epoch than one already applied from that sender", async () => {
|
|
const { mgr } = await holderWithPeer();
|
|
try {
|
|
unwrapAt(3, 3);
|
|
await mgr.handleOffer(PEER_ID, "enc3", "iv3");
|
|
expect(mockSetKey).toHaveBeenLastCalledWith("key-3");
|
|
|
|
unwrapAt(2, 2);
|
|
await mgr.handleOffer(PEER_ID, "enc2", "iv2");
|
|
expect(mockSetKey).toHaveBeenLastCalledWith("key-3");
|
|
expect(mockSetKey).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64");
|
|
}
|
|
});
|
|
|
|
it("[OC-0001] applies an offer at the same epoch as the last applied one (holder re-sends the current key)", async () => {
|
|
const { mgr } = await holderWithPeer();
|
|
try {
|
|
unwrapAt(3, 3);
|
|
await mgr.handleOffer(PEER_ID, "enc3", "iv3");
|
|
unwrapAt(3, 4);
|
|
await mgr.handleOffer(PEER_ID, "enc3b", "iv3b");
|
|
expect(mockSetKey).toHaveBeenLastCalledWith("key-4");
|
|
} finally {
|
|
vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64");
|
|
}
|
|
});
|
|
|
|
it("[OC-0001] resets the sender's high-water mark when they announce a fresh ephemeral key", async () => {
|
|
const { mgr } = await holderWithPeer();
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
|
|
(key as unknown as { type: string }).type.replace("peer-key-", ""),
|
|
);
|
|
try {
|
|
unwrapAt(5, 5);
|
|
await mgr.handleOffer(PEER_ID, "enc5", "iv5");
|
|
expect(mockSetKey).toHaveBeenLastCalledWith("key-5");
|
|
|
|
// Peer rejoined: new ephemeral key, new (local) epoch counter from 1.
|
|
await mgr.handleAnnounce(PEER_ID, "bmV3", "sigB");
|
|
unwrapAt(1, 1);
|
|
await mgr.handleOffer(PEER_ID, "enc1", "iv1");
|
|
expect(mockSetKey).toHaveBeenLastCalledWith("key-1");
|
|
} finally {
|
|
vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64");
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async () => ({ type: "public" }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
|
|
}
|
|
});
|
|
|
|
it("[OC-0001] still applies a legacy offer (no epoch) from a holder on the old build", async () => {
|
|
const { mgr } = await holderWithPeer();
|
|
try {
|
|
unwrapAt(null, 7);
|
|
await mgr.handleOffer(PEER_ID, "legacy", "iv");
|
|
expect(mockSetKey).toHaveBeenLastCalledWith("key-7");
|
|
} finally {
|
|
vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64");
|
|
}
|
|
});
|
|
|
|
// ── OC-0003: per-session fingerprint for every peer ───────────────────────
|
|
|
|
it("[OC-0003] publishes a session fingerprint of the ephemeral key for a legacy (unverified) peer while safetyNumber stays null", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
mockMembers.set(PEER_ID, { identityPublicKey: null });
|
|
await mgr.setupKeyExchange(true, 1);
|
|
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", undefined);
|
|
|
|
expect(setPeerVerification).toHaveBeenCalledWith({
|
|
userId: PEER_ID,
|
|
status: "unverified",
|
|
safetyNumber: null,
|
|
sessionFingerprint: "5E55 1234 5678 9ABC",
|
|
});
|
|
});
|
|
|
|
it("[OC-0003] publishes the session fingerprint alongside the safety number for a verified peer", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
|
|
expect(setPeerVerification).toHaveBeenCalledWith({
|
|
userId: PEER_ID,
|
|
status: "verified",
|
|
safetyNumber: "AB12 CD34 EF56 7890",
|
|
sessionFingerprint: "5E55 1234 5678 9ABC",
|
|
});
|
|
});
|
|
|
|
it("[OC-0003] publishes the local session fingerprint on setup and clears it on teardown", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
await mgr.setupKeyExchange(true, 1);
|
|
expect(setLocalSessionFingerprint).toHaveBeenLastCalledWith("5E55 1234 5678 9ABC");
|
|
|
|
mgr.clearState();
|
|
expect(setLocalSessionFingerprint).toHaveBeenLastCalledWith(null);
|
|
});
|
|
|
|
// ── Guards the suite reached but never pinned (mutation audit T-2026-08-19-47) ──
|
|
|
|
it("[T-47] aborts setup when a teardown lands during the key holder's keyProvider.setKey", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
|
|
// Stall the holder's setKey — the last await before the keypair is
|
|
// published. A teardown here leaves nothing about our local state null,
|
|
// so only the session-generation check can see it.
|
|
let releaseSetKey!: () => void;
|
|
mockSetKey.mockReturnValueOnce(
|
|
new Promise<void>((resolve) => {
|
|
releaseSetKey = resolve;
|
|
}),
|
|
);
|
|
|
|
const setupPromise = mgr.setupKeyExchange(true, 1);
|
|
await vi.waitFor(() => expect(mockSetKey).toHaveBeenCalled());
|
|
|
|
mgr.clearState();
|
|
releaseSetKey();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
// Publishing the keypair here would defeat handleAnnounceInner's queue
|
|
// guard and go on to announce a dead ephemeral key over a live call.
|
|
expect((mgr as unknown as { _ecdhKeyPair: unknown })._ecdhKeyPair).toBeNull();
|
|
expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(0);
|
|
await expect(setupPromise).resolves.toBe(false);
|
|
});
|
|
|
|
it("[T-47] a superseded reconnect re-announce publishes neither its fingerprint nor a stray announce", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
ws.send.mockClear();
|
|
vi.mocked(setLocalSessionFingerprint).mockClear();
|
|
|
|
// Attempt A stalls after publishing its keypair, while computing its own
|
|
// session fingerprint.
|
|
let releaseFingerprint!: (v: string) => void;
|
|
const stalledFingerprint = new Promise<string>((resolve) => {
|
|
releaseFingerprint = resolve;
|
|
});
|
|
vi.mocked(computeRawKeyFingerprint)
|
|
.mockReturnValueOnce(stalledFingerprint)
|
|
.mockResolvedValueOnce("FP-B");
|
|
|
|
const reconnectA = mgr.reannounceForReconnect();
|
|
await vi.waitFor(() => expect(computeRawKeyFingerprint).toHaveBeenCalled());
|
|
|
|
// A second reconnect wins the race and publishes its own keypair.
|
|
vi.mocked(generateECDHKeyPair).mockResolvedValueOnce({
|
|
publicKey: { type: "pub-B" } as unknown as CryptoKey,
|
|
privateKey: { type: "priv-B" } as unknown as CryptoKey,
|
|
});
|
|
await mgr.reannounceForReconnect();
|
|
|
|
releaseFingerprint("FP-A-STALE");
|
|
await reconnectA;
|
|
|
|
// A owns neither the published keypair nor the announce: showing the user
|
|
// A's fingerprint (or re-announcing A's dead key) misrepresents the live
|
|
// session's identity.
|
|
expect(setLocalSessionFingerprint).toHaveBeenCalledWith("FP-B");
|
|
expect(setLocalSessionFingerprint).not.toHaveBeenCalledWith("FP-A-STALE");
|
|
expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(1);
|
|
});
|
|
|
|
it("[T-47] does not arm a reconnect-confirmation retry when there was no room key to go stale", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws); // never keyed: non-holder, no room key
|
|
|
|
await mgr.reannounceForReconnect();
|
|
expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(1);
|
|
|
|
// Nothing could have gone stale — waiting for the first offer is
|
|
// setupKeyExchange's concern, so no confirmation retry belongs here.
|
|
await vi.advanceTimersByTimeAsync(6000);
|
|
expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("[T-47] skips the reconnect-confirmation retry once a fresh offer replaced the room key", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
await mgr.handleOffer(PEER_ID, "enc", "iv"); // stands us down — now a non-holder with a key
|
|
ws.send.mockClear();
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
await mgr.reannounceForReconnect();
|
|
// The holder's confirming offer arrives well inside the window.
|
|
vi.mocked(unwrapRoomKey).mockResolvedValueOnce({
|
|
roomKey: new Uint8Array(32).fill(9),
|
|
epoch: 1,
|
|
});
|
|
await mgr.handleOffer(PEER_ID, "enc2", "iv2");
|
|
|
|
await vi.advanceTimersByTimeAsync(6000);
|
|
expect(sendsOfType(ws, "voice_e2ee_announce")).toHaveLength(1); // no retry
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("[T-47] a duplicate announce does not reset the sender's offer high-water mark", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
// Announce the exact key the export mock echoes back, so the repeat below
|
|
// takes the dedupe path rather than the key-changed path.
|
|
await mgr.handleAnnounce(PEER_ID, "bW9ja2VwaGVtZXJhbA==", "sig");
|
|
mockSetKey.mockClear();
|
|
vi.mocked(roomKeyToBase64).mockImplementation((k: Uint8Array) => `key-${k[0]}`);
|
|
|
|
try {
|
|
unwrapAt(5, 5);
|
|
await mgr.handleOffer(PEER_ID, "enc5", "iv5");
|
|
expect(mockSetKey).toHaveBeenLastCalledWith("key-5");
|
|
|
|
// A replayed (validly signed) announce carrying the peer's CURRENT key
|
|
// must not clear the high-water mark — that would re-open the OC-0001
|
|
// downgrade window for every superseded offer of theirs.
|
|
await mgr.handleAnnounce(PEER_ID, "bW9ja2VwaGVtZXJhbA==", "sig");
|
|
|
|
unwrapAt(2, 2);
|
|
await mgr.handleOffer(PEER_ID, "enc2", "iv2");
|
|
expect(mockSetKey).toHaveBeenLastCalledWith("key-5");
|
|
} finally {
|
|
vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64");
|
|
}
|
|
});
|
|
|
|
it("[T-47] does not promote itself on a leave while a lower-id participant remains", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
// We are uid 50; uid 10 stays behind, so the election must pick them.
|
|
vi.mocked(authStore.getState).mockImplementation(() => ({ user: { id: 50 } }) as never);
|
|
mockVoiceState.voiceUsers.set(1, new Map([[10, {}] as const, [50, {}] as const]));
|
|
|
|
try {
|
|
await mgr.handleParticipantLeft(PEER_ID);
|
|
|
|
// Self-promoting here means two holders rotating against each other:
|
|
// our offers get NOT_KEY_HOLDER'd only after we applied our own key.
|
|
expect(mgr.epoch).toBe(0);
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0);
|
|
} finally {
|
|
vi.mocked(authStore.getState).mockImplementation(() => ({ user: { id: 1 } }) as never);
|
|
}
|
|
});
|
|
|
|
it("[T-47] arms the periodic rotation timer for the key holder so forward secrecy actually fires", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
ws.send.mockClear();
|
|
|
|
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
|
|
|
expect(mgr.epoch).toBe(2);
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
// ── Ledger findings OC-0209 / OC-0212 / OC-0213 ───────────────────────────
|
|
|
|
it("[OC-0209] rejects a replayed retired-key announce before it overwrites the peer's verification badge", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1); // establishes our keypair, holder
|
|
|
|
// Make import/export round-trip faithfully on the announced base64
|
|
// string (the shared mock default returns a fixed constant regardless
|
|
// of input, which would mask this bug).
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
|
|
(key as unknown as { type: string }).type.replace("peer-key-", ""),
|
|
);
|
|
|
|
const KEY_A = "b2xk";
|
|
const KEY_B = "bmV3";
|
|
|
|
try {
|
|
// Peer announces key A, then a genuine key change to B — A is now retired.
|
|
await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA");
|
|
await mgr.handleAnnounce(PEER_ID, KEY_B, "sigB");
|
|
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` });
|
|
|
|
vi.mocked(setPeerVerification).mockClear();
|
|
|
|
// A malicious relay replays the old, still validly-signed announce for
|
|
// the retired key A. The replay guard must reject it BEFORE any
|
|
// verification write — a replay that reaches verifyPeerAnnounce first
|
|
// would overwrite the peer's badge (status + sessionFingerprint) with
|
|
// the retired key's, even though the guard then rejects the announce
|
|
// and _peerPublicKeys is left untouched.
|
|
await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA");
|
|
|
|
expect(setPeerVerification).not.toHaveBeenCalled();
|
|
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` });
|
|
} finally {
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async () => ({ type: "public" }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
|
|
}
|
|
});
|
|
|
|
it("[OC-0212] replays the blocked announce after a successful re-pin, restoring the peer instead of leaving them un-keyed with the badge cleared", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1); // holder in channel 1
|
|
|
|
const NEW_IDENTITY = "new-identity-key-b64";
|
|
mockMembers.set(PEER_ID, { identityPublicKey: NEW_IDENTITY });
|
|
|
|
// The peer reinstalled (new identity key). A still has them pinned to
|
|
// their OLD identity key, so the announce under the new identity is
|
|
// blocked as a TOFU mismatch.
|
|
vi.mocked(getIdentityPin).mockResolvedValueOnce({
|
|
status: "pinned",
|
|
pin: "old-identity-key-b64",
|
|
});
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
|
|
expect(setPeerVerification).toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
|
|
);
|
|
vi.mocked(setPeerVerification).mockClear();
|
|
|
|
// The user confirms the fingerprint out of band and re-pins to the
|
|
// peer's new identity key.
|
|
vi.mocked(getIdentityPin).mockResolvedValueOnce({ status: "pinned", pin: NEW_IDENTITY });
|
|
const result = await mgr.rePinPeerIdentity(PEER_ID, NEW_IDENTITY);
|
|
|
|
expect(result).toBe(true);
|
|
// The blocked announce must be replayed against the new pin — not just
|
|
// discarded with the badge cleared — so the peer actually re-enters
|
|
// _peerPublicKeys and is offered the room key for the rest of the call.
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
|
|
expect(setPeerVerification).toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: PEER_ID, status: "verified" }),
|
|
);
|
|
});
|
|
|
|
it("[OC-0213] does not permanently retire a peer's key on a stale voice_leave when the peer is still listed as present in the channel roster", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1); // holder in channel 1
|
|
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
|
|
(key as unknown as { type: string }).type.replace("peer-key-", ""),
|
|
);
|
|
|
|
const KEY = "b2xk";
|
|
|
|
try {
|
|
// The peer's rejoin announce (carrying a fresh key) arrives first —
|
|
// the OC-0213 repro's reordering, where the directly-published
|
|
// voice_e2ee_announce overtakes the still-queued voice_leave for the
|
|
// join instance it superseded.
|
|
await mgr.handleAnnounce(PEER_ID, KEY, "sig");
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
|
|
|
|
// voice_state (the local roster) still lists the peer as present in
|
|
// the channel — this is what tells apart a stale, lagging leave for a
|
|
// superseded join instance from a genuine departure.
|
|
mockVoiceState.voiceUsers.set(1, new Map([[PEER_ID, {}]]));
|
|
|
|
// The stale voice_leave for the superseded join instance now arrives.
|
|
await mgr.handleParticipantLeft(PEER_ID);
|
|
|
|
// Removed from the live peer map (unchanged behavior)...
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
|
|
|
|
// ...but the key must not be permanently retired: the peer's own,
|
|
// still-valid re-announce of the SAME key must be accepted again, not
|
|
// rejected as a replay of a "retired" key — otherwise the peer is
|
|
// stranded, un-re-announceable, for the rest of the call.
|
|
await mgr.handleAnnounce(PEER_ID, KEY, "sig");
|
|
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY}` });
|
|
} finally {
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async () => ({ type: "public" }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
|
|
}
|
|
});
|
|
|
|
// OC-0239 tried to have the caller pass a pre-mutation "was this peer
|
|
// still in the roster?" snapshot into handleParticipantLeft so a stale,
|
|
// superseded-rejoin voice_leave (OC-0213) would not retire a peer's live
|
|
// key. OC-0283 found that snapshot is true on every genuine departure too
|
|
// — a departing peer's roster entry is only ever removed by the very
|
|
// voice_leave event being handled, so a read taken relative to it, before
|
|
// or after the mutation, cannot distinguish the two cases. Threading that
|
|
// flag through therefore disabled the OC-0020 retirement defense in
|
|
// production outright. The parameter is gone; this test now pins the
|
|
// corrected behavior: a genuine departure retires the key even though the
|
|
// roster looked "still present" right up until this event, matching what
|
|
// production's real (post-mutation) roster read looks like.
|
|
it("[OC-0283] retires a departed peer's key even though the roster listed them as present right up until this event", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1); // holder in channel 1
|
|
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
|
|
(key as unknown as { type: string }).type.replace("peer-key-", ""),
|
|
);
|
|
|
|
const KEY = "b2xk";
|
|
|
|
try {
|
|
await mgr.handleAnnounce(PEER_ID, KEY, "sig");
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
|
|
|
|
// Reproduce dispatcher.ts's real VOICE_LEAVE ordering: removeVoiceUser()
|
|
// deletes the peer from the roster BEFORE handleParticipantLeft runs, so
|
|
// by the time this fires the local roster no longer lists them.
|
|
mockVoiceState.voiceUsers.set(1, new Map()); // peer already removed from roster
|
|
await mgr.handleParticipantLeft(PEER_ID);
|
|
|
|
// Removed from the live peer map...
|
|
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
|
|
|
|
// ...AND retired: a replay of the peer's old, still-validly-signed
|
|
// announce must now be rejected, not accepted as if they never left.
|
|
await mgr.handleAnnounce(PEER_ID, KEY, "sig");
|
|
expect(mgr.peerPublicKeys.get(PEER_ID)).toBeUndefined();
|
|
} finally {
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async () => ({ type: "public" }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
|
|
}
|
|
});
|
|
|
|
it("[OC-0257] a stale (demoted) key holder must not send a doomed voice_e2ee_offer to a peer with a lower user id", async () => {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
// We are uid 10 and became holder while we were the lowest connected id.
|
|
vi.mocked(authStore.getState).mockImplementation(() => ({ user: { id: 10 } }) as never);
|
|
|
|
try {
|
|
const ok = await mgr.setupKeyExchange(true, 1);
|
|
expect(ok).toBe(true);
|
|
ws.send.mockClear();
|
|
|
|
// A peer with a LOWER user id (5) joins and announces. The server
|
|
// always elects the lowest connected user id as holder
|
|
// (Server/ws/voice_e2ee.go) and re-runs election on every join, but
|
|
// sends no demotion message — voice_token_refresh's corrected
|
|
// is_key_holder is thrown away too (handleVoiceTokenRefresh) — so our
|
|
// stale _isKeyHolder flag would otherwise wrap the room key and send a
|
|
// voice_e2ee_offer the server refuses with NOT_KEY_HOLDER, surfacing
|
|
// as a spurious error toast for a call that is actually fine.
|
|
await mgr.handleAnnounce(5, "cGVlcg==", "sig");
|
|
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0);
|
|
} finally {
|
|
vi.mocked(authStore.getState).mockImplementation(() => ({ user: { id: 1 } }) as never);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── HP-2 question 4: adversarial membership and key-change rules ───────────
|
|
// Each test pins one rule from docs/trust-model.md §"What is end-to-end
|
|
// encrypted" that had no dedicated test before HP-2, or records a known gap
|
|
// so the fix has a RED waiting for it.
|
|
|
|
describe("E2EEManager — HP-2 adversarial membership and key-change rules", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockMembers.clear();
|
|
mockMembers.set(PEER_ID, { identityPublicKey: "peer-identity-b64" });
|
|
mockVoiceState.voiceUsers.clear();
|
|
vi.mocked(getIdentityPin).mockResolvedValue({ status: "unpinned" });
|
|
vi.mocked(storeIdentityPin).mockResolvedValue("stored");
|
|
});
|
|
|
|
it("[HP-2 known gap] a modified server that adds an unknown member at first contact gets the room key wrapped to it", async () => {
|
|
// Membership is server-controlled and the client accepts any first-sight
|
|
// identity (verifyPeerAnnounce). A server that inserts a member row it
|
|
// holds the identity key for, then relays a well-signed announce for it,
|
|
// is keyed by the holder like any real peer. The client has no
|
|
// independent membership evidence — the voice roster is server state
|
|
// too, and here it does not even list the newcomer.
|
|
//
|
|
// This pins TODAY's behaviour. When authenticated membership (or
|
|
// "refuse unrecognised participants") lands, this test goes RED and the
|
|
// expectations below invert. docs/trust-model.md §"What beta does not
|
|
// claim" names the gap.
|
|
const INTRUDER = 99;
|
|
mockMembers.set(INTRUDER, { identityPublicKey: "server-supplied-identity-b64" });
|
|
expect(mockVoiceState.voiceUsers.size).toBe(0);
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
ws.send.mockClear();
|
|
|
|
await mgr.handleAnnounce(INTRUDER, "aW50cnVkZXI=", "sig-the-server-can-make");
|
|
|
|
// Today: keyed, pinned, verified — the RED of the desired rule (no offer,
|
|
// no pin) is recorded in docs/plans/hp-2-scorecard-2026-08-29.md Q4.
|
|
const offers = sendsOfType(ws, "voice_e2ee_offer");
|
|
expect(offers).toHaveLength(1);
|
|
expect((offers[0] as any).payload.target_user_id).toBe(INTRUDER);
|
|
expect(mgr.peerPublicKeys.has(INTRUDER)).toBe(true);
|
|
expect(storeIdentityPin).toHaveBeenCalledWith(
|
|
"localhost:7880",
|
|
String(INTRUDER),
|
|
"server-supplied-identity-b64",
|
|
);
|
|
expect(setPeerVerification).toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: INTRUDER, status: "verified" }),
|
|
);
|
|
});
|
|
|
|
it("[HP-2] a second device's key, once trusted, overwrites the account pin — the first device then mismatches", async () => {
|
|
// Pins are one per account ({host}:{userId}) while identity keys are per
|
|
// install (identity.ts; migration 017 holds one identity_public_key per
|
|
// user). Trusting device 2 therefore evicts device 1's pin, and device
|
|
// 1's next announce is blocked as a mismatch. docs/trust-model.md
|
|
// §"What is end-to-end encrypted" states the flip-flop; this pins it.
|
|
const DEVICE1 = "device1-identity-b64";
|
|
const DEVICE2 = "device2-identity-b64";
|
|
vi.mocked(getIdentityPin).mockResolvedValue({ status: "pinned", pin: DEVICE1 });
|
|
mockMembers.set(PEER_ID, { identityPublicKey: DEVICE2 }); // server row: last announcer wins
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
ws.send.mockClear();
|
|
|
|
// Device 2 announces: pinned key differs → blocked, nothing wrapped.
|
|
await mgr.handleAnnounce(PEER_ID, "ZGV2aWNlMg==", "sig2");
|
|
expect(setPeerVerification).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
|
|
);
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0);
|
|
|
|
// The human clicks "Trust new key": the ONE slot is overwritten and the
|
|
// buffered announce replays against the new pin, so device 2 is keyed.
|
|
vi.mocked(getIdentityPin).mockResolvedValue({ status: "pinned", pin: DEVICE2 });
|
|
expect(await mgr.rePinPeerIdentity(PEER_ID, DEVICE2)).toBe(true);
|
|
expect(storeIdentityPin).toHaveBeenCalledTimes(1);
|
|
expect(storeIdentityPin).toHaveBeenCalledWith("localhost:7880", String(PEER_ID), DEVICE2);
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
|
|
const importsBefore = vi.mocked(importPublicKey).mock.calls.length;
|
|
|
|
// Device 1 comes back (the server row carries its key again) and is the
|
|
// one that mismatches now — no offer, no key imported, no second pin.
|
|
mockMembers.set(PEER_ID, { identityPublicKey: DEVICE1 });
|
|
await mgr.handleAnnounce(PEER_ID, "ZGV2aWNlMQ==", "sig1");
|
|
expect(setPeerVerification).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
|
|
);
|
|
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
|
|
expect(vi.mocked(importPublicKey).mock.calls.length).toBe(importsBefore);
|
|
expect(storeIdentityPin).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("[HP-2 / OC-0316] a peer whose socket dropped across a rotation is re-keyed with the rotated key when the server replays its announce", async () => {
|
|
// Server side: hub.go re-relays the resumed client's stored announce to
|
|
// the channel (TestRegisterNow_ReannouncesOwnKeyOnResume). Holder side,
|
|
// pinned here: that replay is a duplicate announce, and the offer it
|
|
// triggers must carry the CURRENT room key and epoch — not the key the
|
|
// peer held before its outage.
|
|
// Round-trip import/export so the replayed announce is recognised as the
|
|
// SAME ephemeral key (the duplicate path), not a changed one.
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
|
|
(key as unknown as { type: string }).type.replace("peer-key-", ""),
|
|
);
|
|
try {
|
|
const ws = { send: vi.fn() };
|
|
const mgr = createManager(ws);
|
|
await mgr.setupKeyExchange(true, 1);
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
expect(mgr.epoch).toBe(1);
|
|
|
|
// Peer's WebSocket drops (media stays up, so no participant-left); the
|
|
// periodic rotation fires meanwhile.
|
|
await mgr.rotateKeyPeriodically();
|
|
expect(mgr.epoch).toBe(2);
|
|
const rotatedKey = (mgr as any)._roomKey as Uint8Array;
|
|
ws.send.mockClear();
|
|
vi.mocked(wrapRoomKey).mockClear();
|
|
vi.mocked(importPublicKey).mockClear();
|
|
|
|
// Peer resumes; the server replays its unchanged announce to us.
|
|
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
|
|
|
expect(importPublicKey).not.toHaveBeenCalled(); // duplicate, not a new key
|
|
const offers = sendsOfType(ws, "voice_e2ee_offer");
|
|
expect(offers).toHaveLength(1);
|
|
expect((offers[0] as any).payload.target_user_id).toBe(PEER_ID);
|
|
expect(wrapRoomKey).toHaveBeenCalledTimes(1);
|
|
const [, , wrappedKey, wrappedEpoch] = vi.mocked(wrapRoomKey).mock.calls[0]!;
|
|
expect(wrappedKey).toBe(rotatedKey);
|
|
expect(wrappedEpoch).toBe(2);
|
|
} finally {
|
|
vi.mocked(importPublicKey).mockImplementation(
|
|
async () => ({ type: "public" }) as unknown as CryptoKey,
|
|
);
|
|
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
|
|
}
|
|
});
|
|
});
|