diff --git a/Client/tauri-client/src/components/VideoGrid.ts b/Client/tauri-client/src/components/VideoGrid.ts index 19cf4391..a682fc0b 100644 --- a/Client/tauri-client/src/components/VideoGrid.ts +++ b/Client/tauri-client/src/components/VideoGrid.ts @@ -8,6 +8,7 @@ import { createIcon } from "@lib/icons"; import { getScreenshareAudioMuted, getScreenshareAudioVolume, + getUserVolume, muteScreenshareAudio, setScreenshareAudioVolume, setUserVolume, @@ -306,13 +307,18 @@ export function createVideoGrid(): VideoGridComponent { // Add audio control overlay for remote tiles if (config !== undefined && !config.isSelf) { - // Screenshare audio state survives tile rebuilds — initialize from it. - // Screenshare sliders are 0-100 (HTMLAudioElement.volume caps at 1.0); - // mic sliders keep 0-200 (LiveKit setVolume supports boost up to 2.0). - let muted = config.isScreenshare ? getScreenshareAudioMuted(config.audioUserId) : false; - let currentVolume = config.isScreenshare + // Mic and screenshare audio state both survive tile rebuilds — + // initialize from the same persisted values the sidebar volume menu + // reads, instead of hardcoding "unmuted at 100%" (B3-5). Screenshare + // sliders are 0-100 (HTMLAudioElement.volume caps at 1.0); mic sliders + // keep 0-200 (LiveKit setVolume supports boost up to 2.0). + const savedVolume = config.isScreenshare ? Math.round(getScreenshareAudioVolume(config.audioUserId) * 100) - : 100; + : getUserVolume(config.audioUserId); + let currentVolume = savedVolume; + let muted = config.isScreenshare + ? getScreenshareAudioMuted(config.audioUserId) + : savedVolume === 0; const overlay = createElement("div", { class: "video-tile-overlay" }); diff --git a/Client/tauri-client/src/components/channel-sidebar/volume-menu.ts b/Client/tauri-client/src/components/channel-sidebar/volume-menu.ts index f799bf67..06e7e03b 100644 --- a/Client/tauri-client/src/components/channel-sidebar/volume-menu.ts +++ b/Client/tauri-client/src/components/channel-sidebar/volume-menu.ts @@ -128,11 +128,19 @@ export function showUserVolumeMenu( ); }, 0); - // Also clean up if the parent component is destroyed - signal.addEventListener("abort", () => { - menu.remove(); - dismissAc.abort(); - }); + // Also clean up if the parent component is destroyed. Tied to dismissAc's + // own signal (mirrors context-menu.ts's menuAc pattern) so this bridge + // listener is torn down with the menu itself — otherwise it never runs + // (the parent signal is long-lived) and every right-click permanently + // accumulates one closure retaining a detached .user-vol-menu subtree. + signal.addEventListener( + "abort", + () => { + menu.remove(); + dismissAc.abort(); + }, + { signal: dismissAc.signal }, + ); } /** Builds the moderation rows. close() runs after any action so the menu does diff --git a/Client/tauri-client/src/lib/audioPipeline.ts b/Client/tauri-client/src/lib/audioPipeline.ts index 272abb23..c0262efe 100644 --- a/Client/tauri-client/src/lib/audioPipeline.ts +++ b/Client/tauri-client/src/lib/audioPipeline.ts @@ -79,6 +79,10 @@ export class AudioPipeline { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- LocalTrack.setProcessor uses wide generic, but AudioProcessorOptions is guaranteed at runtime with webAudioMix await micPub.track.setProcessor(processor as any); log.info("RNNoise processor attached to mic track"); + // Rebuild so the gain/VAD chain sources from the processor's output and + // its own sender.replaceTrack runs last, winning over setProcessor's + // internal replaceTrack to the raw processed track (B3-1). + this.setupAudioPipeline(); } /** Remove RNNoise processor from the local mic track. Safe to call if none attached. */ @@ -89,6 +93,9 @@ export class AudioPipeline { if (micPub.track.getProcessor() === undefined) return; await micPub.track.stopProcessor(); log.info("RNNoise processor removed from mic track"); + // Rebuild so the sender ends back on the gain/VAD chain over the raw mic, + // not whatever track stopProcessor's own internals left wired (B3-1). + this.setupAudioPipeline(); } // --- Pipeline setup/teardown --- @@ -101,7 +108,15 @@ export class AudioPipeline { if (micPub?.track === undefined) return; try { - const mediaTrack = micPub.track.mediaStreamTrack; + // Source from the NS processor's output when one is attached, not the + // raw mic track — livekit-client's LocalTrack.setProcessor() does its + // own (internal, unawaited) sender.replaceTrack(processedTrack) once + // the worklet loads, and that call lands AFTER this one (it awaits + // addModule+fetch first). Sourcing from mediaStreamTrack unconditionally + // meant that call always won, silently rewiring the sender straight to + // the raw mic and bypassing this pipeline's gain/VAD entirely (B3-1). + const mediaTrack = + micPub.track.getProcessor()?.processedTrack ?? micPub.track.mediaStreamTrack; const ctx = new AudioContext({ sampleRate: 48000 }); void ctx.resume(); // Ensure not suspended (WebView2 autoplay policy) @@ -168,7 +183,11 @@ export class AudioPipeline { if (this.room !== null) { const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone); if (micPub?.track?.sender !== undefined) { - const originalTrack = micPub.track.mediaStreamTrack; + // Restore to the NS processor's output when one is still attached, not + // the raw mic — otherwise tearing down just the gain/VAD wrapper (e.g. + // muting) would also silently bypass an active noise suppressor (B3-1). + const originalTrack = + micPub.track.getProcessor()?.processedTrack ?? micPub.track.mediaStreamTrack; void micPub.track.sender .replaceTrack(originalTrack) .then(() => { diff --git a/Client/tauri-client/src/lib/identity.ts b/Client/tauri-client/src/lib/identity.ts index e1808b23..b530bcb5 100644 --- a/Client/tauri-client/src/lib/identity.ts +++ b/Client/tauri-client/src/lib/identity.ts @@ -3,9 +3,11 @@ * layer (F3). Mirrors credentials.ts: dynamically imports Tauri `invoke` and * no-ops in non-Tauri environments (tests, browser). * - * Two backing stores, both keyed by connection host: - * - OS keyring (save/load/delete_identity_key, account `identity:{host}`): - * the client's own long-term identity PRIVATE key (base64 JWK blob). + * Two backing stores: + * - OS keyring (save/load/delete_identity_key, account `identity:{host}:{uid}`): + * the client's own long-term identity PRIVATE key (base64 JWK blob), + * scoped by host AND user id (see `identityKeyPairCache` below — two + * accounts must never share one identity keypair). * - identity_pins.json (store/get_identity_pin, key `{host}:{userId}`): * peers' pinned identity PUBLIC keys (base64), for TOFU verification. */ @@ -17,6 +19,7 @@ import { generateIdentityKeyPair, importIdentityKeyPair, } from "./e2eeCrypto"; +import { authStore } from "@stores/auth.store"; const log = createLogger("identity"); @@ -173,7 +176,17 @@ export async function getIdentityPin(host: string, userId: string): Promise>(); +/** Composite keyring/memo key scoping the identity keypair by host AND user + * id. The keyring commands only take a single opaque `host` string, so the + * scope is folded into that one field rather than requiring a Rust-side + * change. */ +function identityScopeKey(host: string, userId: number): string { + return `${host}:${userId}`; +} + /** - * Load this host's identity keypair from the keyring, generating and saving a - * fresh one on first login (or when the stored blob is corrupt). In non-Tauri - * environments the keypair is in-memory only (not persisted). + * Load this host+user's identity keypair from the keyring, generating and + * saving a fresh one on first login (or when the stored blob is corrupt). In + * non-Tauri environments the keypair is in-memory only (not persisted). * * Stable for the lifetime of the process: repeat callers get the same keypair * even when the keyring is unavailable (see `identityKeyPairCache`). */ -export function getOrCreateIdentityKeyPair(host: string): Promise { - let pending = identityKeyPairCache.get(host); +export function getOrCreateIdentityKeyPair(host: string, userId: number): Promise { + const scope = identityScopeKey(host, userId); + let pending = identityKeyPairCache.get(scope); if (pending === undefined) { - // A rejected load must not be cached, or the host is poisoned for the + // A rejected load must not be cached, or the scope is poisoned for the // rest of the session; drop it so the next caller can retry. - pending = loadOrGenerateIdentityKeyPair(host).catch((err: unknown) => { - identityKeyPairCache.delete(host); + pending = loadOrGenerateIdentityKeyPair(host, userId).catch((err: unknown) => { + identityKeyPairCache.delete(scope); throw err; }); - identityKeyPairCache.set(host, pending); + identityKeyPairCache.set(scope, pending); } return pending; } -/** Test-only: drop the per-host keypair memo so each case starts clean. */ +/** Test-only: drop the per-host+user keypair memo so each case starts clean. */ export function resetIdentityKeyPairCache(): void { identityKeyPairCache.clear(); } -async function loadOrGenerateIdentityKeyPair(host: string): Promise { - const stored = await loadIdentityKey(host); +async function loadOrGenerateIdentityKeyPair(host: string, userId: number): Promise { + const scope = identityScopeKey(host, userId); + const stored = await loadIdentityKey(scope); if (stored) { try { return await importIdentityKeyPair(stored); } catch (err) { - log.error("Stored identity key is corrupt — regenerating", { host, error: String(err) }); + log.error("Stored identity key is corrupt — regenerating", { + host, + userId, + error: String(err), + }); } } const keyPair = await generateIdentityKeyPair(); const blob = await exportIdentityKeyPair(keyPair.privateKey); - if (await saveIdentityKey(host, blob)) { + if (await saveIdentityKey(scope, blob)) { // Outer half of a two-layer check. `save_identity_key` already reads its own // write back and falls through to the DPAPI file if the OS credential store // does not return it (see src-tauri/src/secret_store.rs and @@ -243,7 +270,7 @@ async function loadOrGenerateIdentityKeyPair(host: string): Promise Promise, ): Promise { try { - const keyPair = await getOrCreateIdentityKeyPair(host); + const userId = authStore.getState().user?.id ?? 0; + const keyPair = await getOrCreateIdentityKeyPair(host, userId); return await publishIdentityKey( (data) => updateProfile({ username, ...data }), serverCopy, diff --git a/Client/tauri-client/src/lib/livekitE2EE.ts b/Client/tauri-client/src/lib/livekitE2EE.ts index d668bdbc..e1386e5c 100644 --- a/Client/tauri-client/src/lib/livekitE2EE.ts +++ b/Client/tauri-client/src/lib/livekitE2EE.ts @@ -176,8 +176,16 @@ export class E2EEManager { return false; } - // Use server-authoritative is_key_holder from voice_token payload. - this._isKeyHolder = isKeyHolder; + // Use server-authoritative is_key_holder from voice_token payload — OR'd + // with whatever this._isKeyHolder already is. The server value was + // captured when we started joining and cannot see a handleParticipantLeft + // promotion that landed during the awaits above: the generation check + // just above proves no clearState() ran since myGeneration was captured, + // so the only other writer of this field for THIS generation is that + // promotion — unconditionally overwriting it with the stale server value + // strands the newly-elected holder waiting for an offer nobody (least of + // all itself) will ever send, timing out and ejecting it from voice. + this._isKeyHolder = isKeyHolder || this._isKeyHolder; if (this._isKeyHolder) { // Generate the room key BEFORE draining queued announces, so the @@ -349,7 +357,8 @@ export class E2EEManager { if (this._identityKeyPair) return this._identityKeyPair; const host = this.deps.getServerHost(); if (host === null) return null; - this._identityKeyPair = await getOrCreateIdentityKeyPair(host); + const myUserId = authStore.getState().user?.id ?? 0; + this._identityKeyPair = await getOrCreateIdentityKeyPair(host, myUserId); return this._identityKeyPair; } @@ -397,7 +406,8 @@ export class E2EEManager { private async verifyPeerAnnounce( userId: number, publicKeyBase64: string, - signatureBase64?: string, + signatureBase64: string | undefined, + myGeneration: number, ): Promise { const publishedIdentity = membersStore.getState().members.get(userId)?.identityPublicKey ?? null; @@ -416,7 +426,11 @@ export class E2EEManager { // the server delivered. Reject the announce and surface the distinct // "unknown" state; the peer stays blocked for E2EE until the store recovers. if (lookup.status === "unavailable") { - setPeerVerification({ userId, status: "unknown", safetyNumber: null }); + this.setPeerVerificationIfCurrent(myGeneration, { + userId, + status: "unknown", + safetyNumber: null, + }); log.error("E2EE: identity pin store unreadable — rejecting announce (fail closed)", { userId, }); @@ -428,7 +442,11 @@ export class E2EEManager { // Pinned peer whose delivered key is absent or differs from the pin — // possible server MITM. Block until the user re-pins. if (pin !== null && publishedIdentity !== pin) { - setPeerVerification({ userId, status: "mismatch", safetyNumber: null }); + this.setPeerVerificationIfCurrent(myGeneration, { + userId, + status: "mismatch", + safetyNumber: null, + }); log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", { userId, }); @@ -439,7 +457,11 @@ export class E2EEManager { // but mark unverified (pin-pending). This is the only case the compatibility // posture keeps open. if (!publishedIdentity) { - setPeerVerification({ userId, status: "unverified", safetyNumber: null }); + this.setPeerVerificationIfCurrent(myGeneration, { + userId, + status: "unverified", + safetyNumber: null, + }); log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId }); return true; } @@ -454,7 +476,11 @@ export class E2EEManager { : false; if (!ok) { // Fail closed: peer has an identity key but no valid signature (MITM). - setPeerVerification({ userId, status: "mismatch", safetyNumber: null }); + this.setPeerVerificationIfCurrent(myGeneration, { + userId, + status: "mismatch", + safetyNumber: null, + }); log.error("E2EE: peer announce signature invalid — rejecting (MITM?)", { userId }); return false; } @@ -479,14 +505,32 @@ export class E2EEManager { } } if (pinWriteFailed) { - setPeerVerification({ userId, status: "unverified", safetyNumber: null }); + this.setPeerVerificationIfCurrent(myGeneration, { + userId, + status: "unverified", + safetyNumber: null, + }); return true; // still accept the announce — the write failure alone shouldn't block the call } const safetyNumber = await computeKeyFingerprint(identityKey); - setPeerVerification({ userId, status: "verified", safetyNumber }); + this.setPeerVerificationIfCurrent(myGeneration, { userId, status: "verified", safetyNumber }); return true; } + /** setPeerVerification, but a no-op if a clearState() teardown happened + * since myGeneration was captured. verifyPeerAnnounce awaits a Tauri IPC + * (identity pin lookup) internally and writes verification state on every + * branch, so a Disconnect mid-await must not let the resumed continuation + * resurrect voice-store state for a session that no longer exists + * (finding B3-7). */ + private setPeerVerificationIfCurrent( + myGeneration: number, + verification: Parameters[0], + ): void { + if (this._sessionGeneration !== myGeneration) return; + setPeerVerification(verification); + } + /** * F3 TOFU re-pin recovery (finding #4). Pin the EXACT identity key * `verifiedKey` — the bytes whose fingerprint the caller displayed and the @@ -566,15 +610,27 @@ export class E2EEManager { log.info("E2EE: queued announce (keypair not ready)", { userId }); return; } + // Captured before verifyPeerAnnounce's awaits (a Tauri IPC pin lookup) so + // a clearState() that lands during them — e.g. Disconnect mid-verify — + // can be detected before this continuation writes into a session a newer + // (or no) attempt now owns (finding B3-7). + const myGeneration = this._sessionGeneration; try { // ── F3 TOFU verification gate ────────────────────────────────────── // Resolve the peer's identity key and verify the announce signature // BEFORE storing the ECDH key or wrapping the room key. A malicious // server that swaps user_id↔ephemeral-key or forges keys fails here. - if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64))) { + if ( + !(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64, myGeneration)) + ) { return; // rejected/blocked — do not store or wrap } + if (this._sessionGeneration !== myGeneration) { + log.info("E2EE: discarding stale announce (session torn down during verify)", { userId }); + return; + } + // Deduplicate: if the key is identical, skip the import but still // re-send the room key offer (the peer may be re-requesting after a // missed offer or reconnect). diff --git a/Client/tauri-client/tests/unit/audio-pipeline-core.test.ts b/Client/tauri-client/tests/unit/audio-pipeline-core.test.ts index c4722800..0e49fc20 100644 --- a/Client/tauri-client/tests/unit/audio-pipeline-core.test.ts +++ b/Client/tauri-client/tests/unit/audio-pipeline-core.test.ts @@ -555,6 +555,118 @@ describe("AudioPipeline", () => { }); }); + // --- B3-1: pipeline must source from (and restore to) the NS processor's + // output when one is attached, or the gain/VAD chain gets silently bypassed + // by the processor's own replaceTrack --- + + describe("AudioPipeline sourcing when an NS processor is attached (B3-1)", () => { + afterEach(() => { + pipeline.teardownAudioPipeline(); + vi.unstubAllGlobals(); + }); + + function stubAudioContext(): { mockSender: any } { + const mockSender = { replaceTrack: vi.fn().mockResolvedValue(undefined) }; + const mockAudioCtx = { + resume: vi.fn().mockResolvedValue(undefined), + createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }), + createAnalyser: vi.fn().mockReturnValue({ + fftSize: 0, + smoothingTimeConstant: 0, + connect: vi.fn(), + disconnect: vi.fn(), + getFloatTimeDomainData: vi.fn(), + }), + createGain: vi.fn().mockReturnValue({ + gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() }, + connect: vi.fn(), + disconnect: vi.fn(), + }), + createMediaStreamDestination: vi.fn().mockReturnValue({ + stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "adjusted" }]) }, + disconnect: vi.fn(), + }), + currentTime: 0, + close: vi.fn().mockResolvedValue(undefined), + state: "running", + audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) }, + }; + vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx)); + vi.stubGlobal( + "MediaStream", + vi.fn().mockImplementation((tracks: unknown) => ({ tracks })), + ); + return { mockSender }; + } + + it("setupAudioPipeline sources from the processor's processedTrack, not the raw mic track", () => { + const { mockSender } = stubAudioContext(); + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + mediaStreamTrack: { id: "raw-track" }, + sender: mockSender, + getProcessor: vi.fn().mockReturnValue({ processedTrack: { id: "processed-track" } }), + }, + }), + }, + } as any; + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + + expect(MediaStream).toHaveBeenCalledWith([{ id: "processed-track" }]); + }); + + it("teardownAudioPipeline restores the sender to the processor's processedTrack, not the raw mic track, when NS is still attached", () => { + const { mockSender } = stubAudioContext(); + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + mediaStreamTrack: { id: "raw-track" }, + sender: mockSender, + getProcessor: vi.fn().mockReturnValue({ processedTrack: { id: "processed-track" } }), + }, + }), + }, + } as any; + pipeline.setRoom(mockRoom); + pipeline.setupAudioPipeline(); + mockSender.replaceTrack.mockClear(); + pipeline.teardownAudioPipeline(); + + expect(mockSender.replaceTrack).toHaveBeenCalledWith({ id: "processed-track" }); + }); + + it("applyNoiseSuppressor rebuilds the pipeline after attaching, so the sender ends on the gain/VAD chain instead of the processor's raw output winning", async () => { + const { mockSender } = stubAudioContext(); + const setProcessor = vi.fn().mockResolvedValue(undefined); + const mockRoom = { + localParticipant: { + getTrackPublication: vi.fn().mockReturnValue({ + track: { + mediaStreamTrack: { id: "raw-track" }, + sender: mockSender, + getProcessor: vi.fn().mockReturnValue(undefined), // no processor yet + setProcessor, + }, + }), + }, + } as any; + pipeline.setRoom(mockRoom); + + await pipeline.applyNoiseSuppressor(); + + expect(setProcessor).toHaveBeenCalled(); + // The rebuilt pipeline's own replaceTrack (dest/adjusted track) must be + // the LAST sender.replaceTrack call, so it wins over setProcessor's own + // (unawaited, internal) replaceTrack to the raw processed track. + const calls = mockSender.replaceTrack.mock.calls; + expect(calls.at(-1)?.[0]).toEqual({ id: "adjusted" }); + }); + }); + describe("setupAudioPipeline AudioContext configuration", () => { let mockAudioCtx: any; diff --git a/Client/tauri-client/tests/unit/identity.test.ts b/Client/tauri-client/tests/unit/identity.test.ts index 8477c083..55199042 100644 --- a/Client/tauri-client/tests/unit/identity.test.ts +++ b/Client/tauri-client/tests/unit/identity.test.ts @@ -135,13 +135,15 @@ describe("getOrCreateIdentityKeyPair", () => { return Promise.resolve(undefined); }); - const kp = await getOrCreateIdentityKeyPair("chat.example"); + const kp = await getOrCreateIdentityKeyPair("chat.example", 1); expect(kp.privateKey).toBeDefined(); expect(kp.publicKey).toBeDefined(); const saveCall = invokeMock.mock.calls.find((c) => c[0] === "save_identity_key"); expect(saveCall).toBeDefined(); - expect((saveCall![1] as { host: string }).host).toBe("chat.example"); + // Scoped by host AND user id (B3-3) — not just host — so two accounts + // signed into the same host never share a keyring blob. + expect((saveCall![1] as { host: string }).host).toBe("chat.example:1"); }); it("reloads the persisted keypair on subsequent logins (no regenerate)", async () => { @@ -155,7 +157,7 @@ describe("getOrCreateIdentityKeyPair", () => { } return Promise.resolve(undefined); }); - const first = await getOrCreateIdentityKeyPair("chat.example"); + const first = await getOrCreateIdentityKeyPair("chat.example", 1); const firstPub = await exportPublicKey(first.publicKey); // Second login: keyring returns the saved blob → same public key, no save. @@ -167,7 +169,7 @@ describe("getOrCreateIdentityKeyPair", () => { if (cmd === "load_identity_key") return Promise.resolve(savedBlob); return Promise.resolve(undefined); }); - const second = await getOrCreateIdentityKeyPair("chat.example"); + const second = await getOrCreateIdentityKeyPair("chat.example", 1); expect(await exportPublicKey(second.publicKey)).toBe(firstPub); expect(invokeMock.mock.calls.some((c) => c[0] === "save_identity_key")).toBe(false); }); @@ -177,7 +179,7 @@ describe("getOrCreateIdentityKeyPair", () => { if (cmd === "load_identity_key") return Promise.resolve("!!not-valid-jwk!!"); return Promise.resolve(undefined); }); - const kp = await getOrCreateIdentityKeyPair("chat.example"); + const kp = await getOrCreateIdentityKeyPair("chat.example", 1); expect(kp.publicKey).toBeDefined(); expect(invokeMock.mock.calls.some((c) => c[0] === "save_identity_key")).toBe(true); }); @@ -194,10 +196,10 @@ describe("getOrCreateIdentityKeyPair", () => { }); const [publishPair, signingPair] = await Promise.all([ - getOrCreateIdentityKeyPair("chat.example"), - getOrCreateIdentityKeyPair("chat.example"), + getOrCreateIdentityKeyPair("chat.example", 1), + getOrCreateIdentityKeyPair("chat.example", 1), ]); - const laterPair = await getOrCreateIdentityKeyPair("chat.example"); + const laterPair = await getOrCreateIdentityKeyPair("chat.example", 1); expect(signingPair).toBe(publishPair); expect(laterPair).toBe(publishPair); @@ -210,21 +212,32 @@ describe("getOrCreateIdentityKeyPair", () => { if (cmd === "load_identity_key") return Promise.resolve(null); return Promise.resolve(undefined); }); - const a = await getOrCreateIdentityKeyPair("chat.example"); - const b = await getOrCreateIdentityKeyPair("other.example"); + const a = await getOrCreateIdentityKeyPair("chat.example", 1); + const b = await getOrCreateIdentityKeyPair("other.example", 1); expect(await exportPublicKey(b.publicKey)).not.toBe(await exportPublicKey(a.publicKey)); }); + it("[B3-3] keeps the memo per user id, not just per host — two accounts on the same host never share an identity keypair", async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "load_identity_key") return Promise.resolve(null); + return Promise.resolve(undefined); + }); + const userA = await getOrCreateIdentityKeyPair("chat.example", 1); + const userB = await getOrCreateIdentityKeyPair("chat.example", 2); + expect(await exportPublicKey(userB.publicKey)).not.toBe(await exportPublicKey(userA.publicKey)); + }); + it("reports a credential store that accepts the write but drops the value", async () => { invokeMock.mockImplementation((cmd: string) => { if (cmd === "load_identity_key") return Promise.resolve(null); return Promise.resolve(undefined); // save_identity_key "succeeds" }); - await getOrCreateIdentityKeyPair("chat.example"); + await getOrCreateIdentityKeyPair("chat.example", 1); expect(logMock.error).toHaveBeenCalledWith(expect.stringContaining("did not persist"), { host: "chat.example", + userId: 1, }); }); @@ -234,7 +247,7 @@ describe("getOrCreateIdentityKeyPair", () => { return Promise.resolve(undefined); }); - await expect(getOrCreateIdentityKeyPair("chat.example")).rejects.toThrow("keychain locked"); + await expect(getOrCreateIdentityKeyPair("chat.example", 1)).rejects.toThrow("keychain locked"); // Must not have minted and saved a brand-new identity over the top of an // unreadable (not necessarily absent) stored key. expect(invokeMock.mock.calls.some((c) => c[0] === "save_identity_key")).toBe(false); @@ -251,7 +264,7 @@ describe("getOrCreateIdentityKeyPair", () => { return Promise.resolve(undefined); }); - await getOrCreateIdentityKeyPair("chat.example"); + await getOrCreateIdentityKeyPair("chat.example", 1); expect(logMock.error).not.toHaveBeenCalled(); }); diff --git a/Client/tauri-client/tests/unit/livekit-e2ee.test.ts b/Client/tauri-client/tests/unit/livekit-e2ee.test.ts index 4924e1f4..f736f638 100644 --- a/Client/tauri-client/tests/unit/livekit-e2ee.test.ts +++ b/Client/tauri-client/tests/unit/livekit-e2ee.test.ts @@ -87,7 +87,7 @@ import { generateECDHKeyPair, importPublicKey, } from "@lib/e2eeCrypto"; -import { getOrCreateIdentityKeyPair, storeIdentityPin } from "@lib/identity"; +import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity"; const PEER_ID = 42; @@ -765,4 +765,76 @@ describe("E2EEManager", () => { // 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((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(); + }); }); diff --git a/Client/tauri-client/tests/unit/video-grid.test.ts b/Client/tauri-client/tests/unit/video-grid.test.ts index ccc80b7e..ec9d1d99 100644 --- a/Client/tauri-client/tests/unit/video-grid.test.ts +++ b/Client/tauri-client/tests/unit/video-grid.test.ts @@ -9,6 +9,7 @@ const mockSetScreenshareAudioVolume = vi.fn(); const mockSetUserVolume = vi.fn(); const mockGetScreenshareAudioMuted = vi.fn((_userId?: unknown) => false); const mockGetScreenshareAudioVolume = vi.fn((_userId?: unknown) => 1); +const mockGetUserVolume = vi.fn((_userId?: unknown) => 100); vi.mock("@lib/livekitSession", () => ({ muteScreenshareAudio: (...args: unknown[]) => mockMuteScreenshareAudio(...args), @@ -16,6 +17,7 @@ vi.mock("@lib/livekitSession", () => ({ setUserVolume: (...args: unknown[]) => mockSetUserVolume(...args), getScreenshareAudioMuted: (userId: unknown) => mockGetScreenshareAudioMuted(userId), getScreenshareAudioVolume: (userId: unknown) => mockGetScreenshareAudioVolume(userId), + getUserVolume: (userId: unknown) => mockGetUserVolume(userId), })); // --------------------------------------------------------------------------- @@ -454,6 +456,32 @@ describe("VideoGrid", () => { expect(mockSetUserVolume).toHaveBeenCalledWith(77, 50); }); + it("[B3-5] seeds the mic-tile slider from the persisted per-user volume, not a hardcoded 100%", () => { + mockGetUserVolume.mockReturnValueOnce(30); + const config = makeTileConfig({ isSelf: false, audioUserId: 88, isScreenshare: false }); + grid.addStream(88, "erin", fakeStream(), config); + + expect(mockGetUserVolume).toHaveBeenCalledWith(88); + const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement; + expect(slider.value).toBe("30"); + // Not muted at 30% — the mute button must reflect the real (unmuted) state. + const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement; + expect(muteBtn.getAttribute("aria-label")).toBe("Mute"); + }); + + it("[B3-5] starts a mic tile muted when the persisted per-user volume is 0", () => { + mockGetUserVolume.mockReturnValueOnce(0); + const config = makeTileConfig({ isSelf: false, audioUserId: 89, isScreenshare: false }); + grid.addStream(89, "frank", fakeStream(), config); + + const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement; + expect(slider.value).toBe("0"); + const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement; + expect(muteBtn.getAttribute("aria-label")).toBe("Unmute"); + const overlay = container.querySelector(".video-tile-overlay"); + expect(overlay!.classList.contains("muted")).toBe(true); + }); + it("volume slider at 0 triggers mute icon swap and calls setUserVolume(0)", () => { const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false }); grid.addStream(77, "dave", fakeStream(), config); diff --git a/Client/tauri-client/tests/unit/volume-menu.test.ts b/Client/tauri-client/tests/unit/volume-menu.test.ts index 286b28ea..cf61ba95 100644 --- a/Client/tauri-client/tests/unit/volume-menu.test.ts +++ b/Client/tauri-client/tests/unit/volume-menu.test.ts @@ -186,6 +186,24 @@ describe("dismissal", () => { expect(menuEl()).toBeNull(); }); + it("[B3-4] ties the parent-signal abort bridge to the menu's own dismiss signal, so it does not outlive a dismissed menu", () => { + // Without a { signal } option, this bridge listener (and the closure + // retaining a detached .user-vol-menu subtree) survives every future + // right-click for the parent's entire lifetime — the outside-click and + // replace-on-reopen dismiss paths remove the menu but cannot remove this + // listener, since it is registered directly on the caller's long-lived + // signal. Mirrors context-menu.ts's `{ signal: menuAc.signal }` pattern. + const parentAc = new AbortController(); + const addSpy = vi.spyOn(parentAc.signal, "addEventListener"); + + showUserVolumeMenu(7, "alice", 0, 0, parentAc.signal); + + expect(addSpy).toHaveBeenCalledTimes(1); + const [eventName, , options] = addSpy.mock.calls[0]!; + expect(eventName).toBe("abort"); + expect(options).toEqual(expect.objectContaining({ signal: expect.any(AbortSignal) })); + }); + it("does not re-attach the dismiss listener when aborted before the timer fires", () => { const ac = new AbortController(); showUserVolumeMenu(7, "alice", 0, 0, ac.signal);