mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix(client): preserve a mid-setup key-holder promotion, and route the
audio graph through the noise suppressor setupKeyExchange unconditionally wrote the server's key-holder value captured at join, clobbering a handleParticipantLeft promotion that landed during its pre-publish awaits. The joiner then waited for an offer only it could send, timed out, and was ejected from voice. The write now preserves an existing promotion; it sits after the existing session-generation check, and clearState bumps that generation and resets the flag synchronously, so stale state cannot survive a teardown. Enhanced Noise Suppression silently disabled the input-volume slider and the VAD gate: livekit-client's setProcessor() does its own internal replaceTrack(processedTrack) after awaiting addModule and a fetch, so it landed after ours and wired the sender straight to the raw mic. The pipeline now sources from the processed track and re-runs after attaching, so our replaceTrack wins. Also scopes the voice identity keypair by host AND user id so two accounts sharing one OS profile stop sharing an identity keypair, guards peer-key and TOFU writes against a clearState during their IPC awaits, and seeds VideoGrid tiles from the persisted per-user volume. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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" });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<Iden
|
||||
// ── High-level lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One identity keypair per host, shared by every caller in this process.
|
||||
* One identity keypair per host+user, shared by every caller in this process.
|
||||
*
|
||||
* Scoped by BOTH host and user id (B3-3), not host alone: two different
|
||||
* accounts signed into the same host — including two people sharing one OS
|
||||
* profile/keyring, or one client used to log into several accounts on the
|
||||
* same server without a restart — must never share a voice-E2EE identity
|
||||
* keypair. Sharing one would make their announces verify against each
|
||||
* other's TOFU pin, silently defeating the identity model's distinctness
|
||||
* guarantee. (Pre-existing installs mint a fresh per-account keypair the
|
||||
* first time they run this scoping — a one-time re-verify for their peers,
|
||||
* traded for closing the cross-account sharing hole.)
|
||||
*
|
||||
* The keypair has two independent consumers: the ready hook publishes its
|
||||
* public half (`ensureIdentityKeyPublished`) and the voice session signs
|
||||
@@ -191,45 +204,59 @@ export async function getIdentityPin(host: string, userId: string): Promise<Iden
|
||||
*/
|
||||
const identityKeyPairCache = new Map<string, Promise<CryptoKeyPair>>();
|
||||
|
||||
/** 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<CryptoKeyPair> {
|
||||
let pending = identityKeyPairCache.get(host);
|
||||
export function getOrCreateIdentityKeyPair(host: string, userId: number): Promise<CryptoKeyPair> {
|
||||
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<CryptoKeyPair> {
|
||||
const stored = await loadIdentityKey(host);
|
||||
async function loadOrGenerateIdentityKeyPair(host: string, userId: number): Promise<CryptoKeyPair> {
|
||||
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<CryptoKeyPai
|
||||
// nothing left to abort.
|
||||
let persisted: boolean;
|
||||
try {
|
||||
persisted = (await loadIdentityKey(host)) === blob;
|
||||
persisted = (await loadIdentityKey(scope)) === blob;
|
||||
} catch {
|
||||
persisted = false;
|
||||
}
|
||||
@@ -251,7 +278,7 @@ async function loadOrGenerateIdentityKeyPair(host: string): Promise<CryptoKeyPai
|
||||
log.error(
|
||||
"Identity key did not persist — the credential store accepted the write but did not return it. " +
|
||||
"This session works, but peers will see a new identity (and prompt to re-verify) every restart.",
|
||||
{ host },
|
||||
{ host, userId },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -281,11 +308,17 @@ export async function publishIdentityKey(
|
||||
|
||||
/**
|
||||
* Login/ready hook: ensure the server holds this client's identity public key.
|
||||
* Loads (or generates) the host keypair and publishes it via the REST profile
|
||||
* update when the server's stored copy is absent or stale — idempotent, so it
|
||||
* runs at most once per key. The server's PATCH /users/me requires a username,
|
||||
* so `username` is sent alongside the key. Fire-and-forget: errors are logged
|
||||
* and swallowed (returns false) so the connect/voice flow is never blocked.
|
||||
* Loads (or generates) the host+user keypair and publishes it via the REST
|
||||
* profile update when the server's stored copy is absent or stale —
|
||||
* idempotent, so it runs at most once per key. The server's PATCH /users/me
|
||||
* requires a username, so `username` is sent alongside the key. Fire-and-forget:
|
||||
* errors are logged and swallowed (returns false) so the connect/voice flow is
|
||||
* never blocked.
|
||||
*
|
||||
* The user id is read from `authStore` rather than taken as a parameter: this
|
||||
* is called from the ready hook, by which point auth state is populated, and
|
||||
* keeping the signature unchanged avoids threading the id through every call
|
||||
* site just to scope the keyring lookup (B3-3).
|
||||
*/
|
||||
export async function ensureIdentityKeyPublished(
|
||||
host: string,
|
||||
@@ -294,7 +327,8 @@ export async function ensureIdentityKeyPublished(
|
||||
updateProfile: (data: { username: string; identity_public_key: string }) => Promise<unknown>,
|
||||
): Promise<boolean> {
|
||||
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,
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<typeof setPeerVerification>[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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user