Files
ChatApp/apps/desktop/src/lib/callE2EE.ts
T
2026-04-18 23:11:35 +02:00

112 lines
3.8 KiB
TypeScript

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<Listener>();
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<CallE2EESettings>;
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>): 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<string, unknown>);
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<E2EEBundle> {
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() };
}