112 lines
3.8 KiB
TypeScript
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() };
|
|
}
|