import { ExternalE2EEKeyProvider } from 'livekit-client'; // Vite-native Worker import. `?worker` triggers a dedicated build chunk // shipped as a classic/module worker. The default export is the Worker // constructor; we instantiate once per tab and reuse. import LivekitE2EEWorker from 'livekit-client/e2ee-worker?worker'; const STORAGE_KEY = 'chatapp.e2ee'; export interface CallE2EESettings { enabled: boolean; } // Default ON — the whole point of this project is zero-knowledge, so honour // that for the SFU path too. Can be turned off for debugging or if a user's // browser lacks RTCRtpScriptTransform / Insertable Streams support. const DEFAULTS: CallE2EESettings = { enabled: true }; type Listener = (s: CallE2EESettings) => void; const listeners = new Set(); let cached: CallE2EESettings | null = null; function read(): CallE2EESettings { if (cached) return cached; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) { cached = DEFAULTS; return cached; } const parsed = JSON.parse(raw) as Partial; cached = { enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled, }; return cached; } catch { cached = DEFAULTS; return cached; } } function write(s: CallE2EESettings): void { cached = s; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch { /* quota / private mode */ } for (const l of listeners) l(s); } export function getCallE2EESettings(): CallE2EESettings { return read(); } export function updateCallE2EESettings(patch: Partial): CallE2EESettings { const next = { ...read(), ...patch }; write(next); return next; } export function subscribeCallE2EESettings(listener: Listener): () => void { listeners.add(listener); return () => listeners.delete(listener); } // Shared single Worker instance — LiveKit supports reusing it across rooms. let workerInstance: Worker | null = null; function getWorker(): Worker { if (!workerInstance) { workerInstance = new LivekitE2EEWorker(); } return workerInstance; } // Feature detection — Insertable Streams (RTCRtpScriptTransform or the older // encodedStreams API) is required for LiveKit E2EE. Returns false on browsers // that can't encrypt the media path (the caller then skips the `e2ee` option). export function isE2EESupported(): boolean { if (typeof window === 'undefined') return false; const hasScriptTransform = typeof (window as unknown as { RTCRtpScriptTransform?: unknown }).RTCRtpScriptTransform !== 'undefined'; const sender = (window as unknown as { RTCRtpSender?: { prototype?: unknown } }).RTCRtpSender; const hasEncodedStreams = !!sender?.prototype && 'createEncodedStreams' in (sender.prototype as Record); return hasScriptTransform || hasEncodedStreams; } interface E2EEBundle { keyProvider: ExternalE2EEKeyProvider; worker: Worker; } // Derives a stable per-conversation passphrase entirely client-side. The // passphrase is never transmitted anywhere; each member computes it locally // from the conversation id they already hold via RLS-protected Supabase data. // A stronger variant would ship a random per-conversation secret through the // existing E2E envelope system — deferred to M3. export async function createCallE2EE(conversationId: string): Promise { const keyProvider = new ExternalE2EEKeyProvider(); const salt = 'chat-app-voice-e2ee-v1'; const enc = new TextEncoder(); const buf = await crypto.subtle.digest('SHA-256', enc.encode(salt + ':' + conversationId)); const hex = Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); await keyProvider.setKey(hex); return { keyProvider, worker: getWorker() }; }