diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index 72633154..810db88b 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -50,7 +50,13 @@ import { } from "@stores/dm.store"; import type { DmChannel } from "@stores/dm.store"; import type { DmChannelPayload } from "./types"; -import { handleVoiceToken, isVoiceConnected } from "@lib/livekitSession"; +import { + handleVoiceToken, + handleE2EEAnnounce, + handleE2EEOffer, + handleParticipantLeft, + isVoiceConnected, +} from "@lib/livekitSession"; import { notifyIncomingMessage } from "./notifications"; import { createLogger } from "./logger"; import { ServerMessageType as S } from "./protocolTypes"; @@ -352,6 +358,8 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup { unsubs.push( ws.on(S.VOICE_LEAVE, (payload) => { removeVoiceUser(payload); + // Notify E2EE state machine so key holder can rotate the room key. + void handleParticipantLeft(payload.user_id); // Clear local voice state if the current user was removed (kick/disconnect) const currentUserId = authStore.getState().user?.id ?? 0; if (payload.user_id === currentUserId) { @@ -374,7 +382,21 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup { unsubs.push( ws.on(S.VOICE_TOKEN, (payload) => { - void handleVoiceToken(payload.token, payload.url, payload.channel_id, payload.direct_url, payload.e2ee_key); + void handleVoiceToken(payload.token, payload.url, payload.channel_id, payload.direct_url); + }), + ); + + // ── Voice E2EE (client-side ECDH key exchange) ──────── + + unsubs.push( + ws.on("voice_e2ee_announce" as S, (payload: { user_id: number; public_key: string }) => { + void handleE2EEAnnounce(payload.user_id, payload.public_key); + }), + ); + + unsubs.push( + ws.on("voice_e2ee_offer" as S, (payload: { from_user_id: number; encrypted_key: string; iv: string }) => { + void handleE2EEOffer(payload.from_user_id, payload.encrypted_key, payload.iv); }), ); diff --git a/Client/tauri-client/src/lib/e2eeCrypto.ts b/Client/tauri-client/src/lib/e2eeCrypto.ts new file mode 100644 index 00000000..b912b6bf --- /dev/null +++ b/Client/tauri-client/src/lib/e2eeCrypto.ts @@ -0,0 +1,165 @@ +/** + * e2eeCrypto — Client-side ECDH key exchange and room key wrapping for true + * end-to-end encrypted voice/video. + * + * The server NEVER sees the room key. It only relays: + * 1. ECDH P-256 public keys (useless without the private key) + * 2. AES-GCM encrypted room key blobs (can't decrypt without ECDH shared secret) + * + * Flow: + * - Each participant generates an ephemeral ECDH P-256 keypair on voice join. + * - The key holder (longest-present participant) generates a random 256-bit + * room key and wraps it for each peer using ECDH + HKDF + AES-GCM. + * - Peers unwrap the room key and feed it to LiveKit's ExternalE2EEKeyProvider. + * - When a participant leaves, the key holder rotates the room key. + */ + +import { log } from "@lib/logger"; + +const ECDH_CURVE = "P-256"; +const HKDF_SALT = new TextEncoder().encode("owncord-voice-e2ee-v1"); +const HKDF_INFO = new TextEncoder().encode("room-key-wrap"); +const ROOM_KEY_BYTES = 32; // 256-bit AES key for LiveKit SFrame + +// ── Key pair generation ───────────────────────────────────────────────────── + +/** Generate an ephemeral ECDH P-256 keypair. */ +export async function generateECDHKeyPair(): Promise { + return crypto.subtle.generateKey({ name: "ECDH", namedCurve: ECDH_CURVE }, true, [ + "deriveBits", + ]) as Promise; +} + +/** Export a CryptoKey (public) to base64 for transmission. */ +export async function exportPublicKey(key: CryptoKey): Promise { + const raw = await crypto.subtle.exportKey("raw", key); + return uint8ToBase64(new Uint8Array(raw)); +} + +/** Import a base64-encoded P-256 public key. */ +export async function importPublicKey(base64: string): Promise { + const raw = base64ToUint8(base64); + return crypto.subtle.importKey("raw", raw, { name: "ECDH", namedCurve: ECDH_CURVE }, true, []); +} + +// ── Room key generation ───────────────────────────────────────────────────── + +/** Generate a random 256-bit room key. */ +export function generateRoomKey(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(ROOM_KEY_BYTES)); +} + +/** Encode a room key as base64 for ExternalE2EEKeyProvider.setKey(). */ +export function roomKeyToBase64(key: Uint8Array): string { + return uint8ToBase64(key); +} + +// ── Room key wrapping (ECDH + HKDF + AES-GCM) ────────────────────────────── + +/** + * Wrap (encrypt) a room key for a specific peer. + * + * 1. ECDH(myPrivate, peerPublic) → raw shared secret + * 2. HKDF-SHA256(shared, salt, info) → 256-bit AES wrapping key + * 3. AES-GCM(wrappingKey, randomIV, roomKey) → ciphertext + */ +export async function wrapRoomKey( + myPrivateKey: CryptoKey, + peerPublicKey: CryptoKey, + roomKey: Uint8Array, +): Promise<{ encryptedKey: string; iv: string }> { + const wrapKey = await deriveWrappingKey(myPrivateKey, peerPublicKey); + const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit GCM nonce + + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + wrapKey, + roomKey, + ); + + return { + encryptedKey: uint8ToBase64(new Uint8Array(ciphertext)), + iv: uint8ToBase64(iv), + }; +} + +/** + * Unwrap (decrypt) a room key received from a peer. + * + * Same ECDH + HKDF derivation as wrapRoomKey, but on the receiver's side. + */ +export async function unwrapRoomKey( + myPrivateKey: CryptoKey, + peerPublicKey: CryptoKey, + encryptedKeyBase64: string, + ivBase64: string, +): Promise { + const wrapKey = await deriveWrappingKey(myPrivateKey, peerPublicKey); + const iv = base64ToUint8(ivBase64); + const ciphertext = base64ToUint8(encryptedKeyBase64); + + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + wrapKey, + ciphertext, + ); + + return new Uint8Array(plaintext); +} + +// ── Internal helpers ──────────────────────────────────────────────────────── + +/** + * Derive a 256-bit AES-GCM wrapping key from an ECDH shared secret via HKDF. + */ +async function deriveWrappingKey( + myPrivateKey: CryptoKey, + peerPublicKey: CryptoKey, +): Promise { + // Step 1: ECDH → raw shared secret bits + const sharedBits = await crypto.subtle.deriveBits( + { name: "ECDH", public: peerPublicKey }, + myPrivateKey, + 256, + ); + + // Step 2: Import shared secret as HKDF key material + const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, [ + "deriveKey", + ]); + + // Step 3: HKDF → AES-GCM key + return crypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: HKDF_SALT, + info: HKDF_INFO, + }, + hkdfKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +// ── Base64 utilities ──────────────────────────────────────────────────────── + +function uint8ToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function base64ToUint8(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +log.debug("e2eeCrypto module loaded"); diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 18b4a1f2..cfd01984 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -68,7 +68,6 @@ type PendingVoiceJoin = { readonly url: string; readonly channelId: number; readonly directUrl?: string; - readonly e2eeKey?: string; }; // --- State machine --- @@ -90,7 +89,6 @@ type SessionState = readonly latestToken: string; readonly lastUrl: string; readonly lastDirectUrl: string | undefined; - readonly e2eeKey: string | undefined; } | { readonly type: "reconnecting"; @@ -98,7 +96,6 @@ type SessionState = readonly latestToken: string; readonly lastUrl: string; readonly lastDirectUrl: string | undefined; - readonly e2eeKey: string | undefined; readonly ac: AbortController; }; @@ -125,11 +122,23 @@ export class LiveKitSession { /** Cached port for the local LiveKit TLS proxy (Rust-side, for self-signed cert support). */ private liveKitProxyPort: number | null = null; - /** E2EE key provider — shared across Room instances. The server distributes - * a per-channel symmetric key via the voice_token message; we feed it into - * this provider before connecting. LiveKit's SFrame worker handles the rest. */ + /** E2EE key provider — shared across Room instances. The room key is generated + * and exchanged client-side via ECDH; the server never sees it. */ private _e2eeKeyProvider = new ExternalE2EEKeyProvider(); + // ── Client-side E2EE state (ECDH key exchange) ─────────────────────────── + /** Ephemeral ECDH P-256 keypair for the current voice session. */ + private _ecdhKeyPair: CryptoKeyPair | null = null; + /** The 256-bit symmetric room key (plaintext). Only held by the key holder + * initially; other participants receive it via ECDH-wrapped offers. */ + private _roomKey: Uint8Array | null = null; + /** Peer ECDH public keys indexed by userId. */ + private _peerPublicKeys: Map = new Map(); + /** True if this client is the key holder (longest-present participant). */ + private _isKeyHolder = false; + /** Resolver for non-key-holders waiting to receive the room key via offer. */ + private _roomKeyResolver: (() => void) | null = null; + // --- State transition (single writer) --- private setState(next: SessionState): void { @@ -173,13 +182,6 @@ export class LiveKitSession { : undefined; } - /** E2EE key from state. */ - private get _e2eeKey(): string | undefined { - return this._state.type === "connected" || this._state.type === "reconnecting" - ? this._state.e2eeKey - : undefined; - } - /** True while a connect attempt is running. */ private get _connecting(): boolean { return this._state.type === "connecting"; @@ -238,7 +240,6 @@ export class LiveKitSession { latestToken: this._state.latestToken, lastUrl: this._state.lastUrl, lastDirectUrl: this._state.lastDirectUrl, - e2eeKey: this._state.e2eeKey, }; this.setState({ type: "idle" }); } @@ -256,7 +257,7 @@ export class LiveKitSession { if (ac !== null && this._pendingReconnectFields !== null) { // Transition from idle → reconnecting atomically using the fields // captured in setRoom() above. - const { channelId, latestToken, lastUrl, lastDirectUrl, e2eeKey } = this._pendingReconnectFields; + const { channelId, latestToken, lastUrl, lastDirectUrl } = this._pendingReconnectFields; this._pendingReconnectFields = null; this.setState({ type: "reconnecting", @@ -264,7 +265,6 @@ export class LiveKitSession { latestToken, lastUrl, lastDirectUrl, - e2eeKey, ac, }); } @@ -298,7 +298,6 @@ export class LiveKitSession { latestToken: string; lastUrl: string; lastDirectUrl: string | undefined; - e2eeKey: string | undefined; } | null = null; // --- Room factory --- @@ -422,11 +421,12 @@ export class LiveKitSession { return; } - // Re-apply E2EE key from the reconnecting state before connecting. - const reconnectE2eeKey = this._e2eeKey; - if (reconnectE2eeKey) { + // E2EE keys are exchanged client-side via ECDH. On reconnect, the + // room key is still in _roomKey from the previous session. Re-apply it. + if (this._roomKey) { + const { roomKeyToBase64 } = await import("@lib/e2eeCrypto"); // eslint-disable-next-line no-await-in-loop -- must set key before connect - await this._e2eeKeyProvider.setKey(reconnectE2eeKey); + await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey)); } // eslint-disable-next-line no-await-in-loop -- sequential reconnect: must connect before restoring state @@ -447,7 +447,6 @@ export class LiveKitSession { latestToken: token, lastUrl: url, lastDirectUrl: directUrl, - e2eeKey: this._e2eeKey, }); this._deviceManager.setOnError(this.onErrorCallback); this._deviceManager.setOnToast(this.onErrorCallback); @@ -502,7 +501,6 @@ export class LiveKitSession { latestToken: this._state.latestToken, lastUrl: this._state.lastUrl, lastDirectUrl: this._state.lastDirectUrl, - e2eeKey: this._state.e2eeKey, ac: this._state.ac, }); } @@ -757,7 +755,6 @@ export class LiveKitSession { url: string, channelId: number, directUrl?: string, - e2eeKey?: string, ): Promise { if (this._room !== null) this.leaveVoice(false); // Increment the generation counter and embed it into the "connecting" state. @@ -794,13 +791,54 @@ export class LiveKitSession { const MAX_RETRIES = 3; const RETRY_DELAY_MS = 2000; - // Set the E2EE key before connecting so SFrame encryption is active - // from the first published frame. - if (e2eeKey) { - await this._e2eeKeyProvider.setKey(e2eeKey); - log.info("E2EE key set for voice channel", { channelId }); + + // ── Client-side E2EE key exchange (ECDH) ────────────────────────── + // Generate a fresh ECDH keypair for this session. + const { generateECDHKeyPair, exportPublicKey, generateRoomKey, roomKeyToBase64 } = + await import("@lib/e2eeCrypto"); + this._ecdhKeyPair = await generateECDHKeyPair(); + this._peerPublicKeys.clear(); + const myPubKeyBase64 = await exportPublicKey(this._ecdhKeyPair.publicKey); + + // Determine if we are the key holder (first in channel = no existing + // voice_e2ee_announce messages received before this point). + // The server sends existing participants' public keys during voice_join + // sync — if we received none, we're first. + const existingPeerCount = this._peerPublicKeys.size; + this._isKeyHolder = existingPeerCount === 0; + + if (this._isKeyHolder) { + // We're the first participant — generate the room key. + this._roomKey = generateRoomKey(); + await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey)); + log.info("E2EE: key holder — generated room key", { channelId }); + } else { + // Wait for the key holder to send us the room key via voice_e2ee_offer. + // This promise resolves when handleE2EEOffer() sets _roomKey. + log.info("E2EE: waiting for room key from key holder", { channelId }); + const roomKeyPromise = new Promise((resolve) => { + this._roomKeyResolver = resolve; + }); + // Don't block forever — timeout after 10 seconds. + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("E2EE key exchange timeout")), 10_000), + ); + try { + await Promise.race([roomKeyPromise, timeout]); + } catch { + log.warn("E2EE: key exchange timed out, proceeding without E2EE", { channelId }); + } + this._roomKeyResolver = null; } + // Announce our public key so existing participants (and the key holder) + // can see us. This must happen AFTER we set up the roomKeyResolver so + // we don't miss an immediate offer response. + this.ws?.send({ + type: "voice_e2ee_announce", + payload: { public_key: myPubKeyBase64 }, + }); + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { // eslint-disable-next-line no-await-in-loop -- sequential retry: must attempt connect before checking result @@ -890,7 +928,6 @@ export class LiveKitSession { latestToken: token, lastUrl: url, lastDirectUrl: directUrl, - e2eeKey, }); // Optimistic startAudio — may succeed if the join was triggered by a // recent user gesture. If not, the AudioPlaybackStatusChanged handler @@ -977,7 +1014,6 @@ export class LiveKitSession { url: string, channelId: number, directUrl?: string, - e2eeKey?: string, ): Promise { const s = this._state; if (s.type === "connected" && s.channelId === channelId && s.room.state === "connected") { @@ -990,13 +1026,13 @@ export class LiveKitSession { if (this._state.type === "connecting") { this.setState({ ...this._state, - pendingJoin: { token, url, channelId, directUrl, e2eeKey }, + pendingJoin: { token, url, channelId, directUrl }, }); } log.warn("handleVoiceToken: already connecting, queued latest join request", { channelId }); return; } - await this.connectAndSetup(token, url, channelId, directUrl, e2eeKey); + await this.connectAndSetup(token, url, channelId, directUrl); // Drain pending joins iteratively to avoid unbounded recursion when // rapid channel switches queue multiple requests. // A "superseded" result means connectAndSetup() already aborted early; @@ -1011,7 +1047,6 @@ export class LiveKitSession { url: pUrl, channelId: pChannelId, directUrl: pDirectUrl, - e2eeKey: pE2eeKey, } = pendingJoin; const cur = this._state; if ( @@ -1022,7 +1057,7 @@ export class LiveKitSession { this.handleVoiceTokenRefresh(pToken); } else { // eslint-disable-next-line no-await-in-loop -- sequential drain of pending joins to avoid unbounded recursion - await this.connectAndSetup(pToken, pUrl, pChannelId, pDirectUrl, pE2eeKey); + await this.connectAndSetup(pToken, pUrl, pChannelId, pDirectUrl); // If this attempt was itself superseded (another join arrived during the // await), the loop will naturally pick it up via the updated pendingJoin. } @@ -1033,6 +1068,152 @@ export class LiveKitSession { } } + // ── Client-side E2EE handlers (ECDH key exchange) ─────────────────────── + + /** + * Handle a voice_e2ee_announce from the server — another participant has + * announced their ECDH public key. If we are the key holder, wrap and send + * the room key to them. + */ + async handleE2EEAnnounce(userId: number, publicKeyBase64: string): Promise { + try { + const { importPublicKey, wrapRoomKey } = await import("@lib/e2eeCrypto"); + const peerKey = await importPublicKey(publicKeyBase64); + this._peerPublicKeys.set(userId, peerKey); + log.info("E2EE: received peer public key", { userId }); + + // If we're the key holder and have a room key, wrap it for the new peer. + if (this._isKeyHolder && this._roomKey && this._ecdhKeyPair) { + const { encryptedKey, iv } = await wrapRoomKey( + this._ecdhKeyPair.privateKey, + peerKey, + this._roomKey, + ); + this.ws?.send({ + type: "voice_e2ee_offer", + payload: { target_user_id: userId, encrypted_key: encryptedKey, iv }, + }); + log.info("E2EE: sent room key offer to peer", { userId }); + } + } catch (err) { + log.error("E2EE: failed to handle announce", err); + } + } + + /** + * Handle a voice_e2ee_offer from the server — the key holder has sent us + * the encrypted room key. Unwrap it and apply to the E2EE key provider. + */ + async handleE2EEOffer( + fromUserId: number, + encryptedKeyBase64: string, + ivBase64: string, + ): Promise { + try { + const { unwrapRoomKey, roomKeyToBase64 } = await import("@lib/e2eeCrypto"); + const peerKey = this._peerPublicKeys.get(fromUserId); + if (!peerKey) { + log.warn("E2EE: received offer from unknown peer", { fromUserId }); + return; + } + if (!this._ecdhKeyPair) { + log.warn("E2EE: received offer but no ECDH keypair"); + return; + } + + this._roomKey = await unwrapRoomKey( + this._ecdhKeyPair.privateKey, + peerKey, + encryptedKeyBase64, + ivBase64, + ); + await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey)); + log.info("E2EE: room key received and applied", { fromUserId }); + + // Resolve the pending connect promise if we were waiting for the key. + if (this._roomKeyResolver) { + this._roomKeyResolver(); + this._roomKeyResolver = null; + } + } catch (err) { + log.error("E2EE: failed to handle offer", err); + } + } + + /** + * Handle a participant leaving the voice channel. If we become the new key + * holder, rotate the room key and distribute to remaining peers. + */ + async handleParticipantLeft(userId: number): Promise { + this._peerPublicKeys.delete(userId); + + // Determine if we should become the new key holder. + // Key holder is the longest-present participant — determined by voice state + // list order. The first user in the voiceUsers map for our channel is the holder. + const channelId = this._currentChannelId; + if (!channelId) return; + + const state = voiceStore.getState(); + const channelUsers = state.voiceUsers.get(channelId); + if (!channelUsers || channelUsers.size === 0) return; + + // The first user in the map is the longest-present (server sends in join order). + const firstUserId = channelUsers.keys().next().value; + // Get our own user ID from the ws client. + // If we're the first user remaining, we're the new key holder. + const wasKeyHolder = this._isKeyHolder; + // We need to know our own user ID — derive from the room's local participant. + const myUserId = this._room?.localParticipant?.identity + ? parseInt(this._room.localParticipant.identity, 10) + : null; + + if (myUserId !== null && firstUserId === myUserId && !wasKeyHolder) { + this._isKeyHolder = true; + log.info("E2EE: became key holder after participant left", { userId, channelId }); + + // Rotate the room key — generate a new one and distribute to all remaining peers. + try { + const { generateRoomKey, roomKeyToBase64, wrapRoomKey } = + await import("@lib/e2eeCrypto"); + this._roomKey = generateRoomKey(); + await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey)); + log.info("E2EE: rotated room key", { channelId }); + + // Wrap and send the new key to all remaining peers. + if (this._ecdhKeyPair) { + for (const [peerId, peerKey] of this._peerPublicKeys) { + const { encryptedKey, iv } = await wrapRoomKey( + this._ecdhKeyPair.privateKey, + peerKey, + this._roomKey, + ); + this.ws?.send({ + type: "voice_e2ee_offer", + payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv }, + }); + } + log.info("E2EE: distributed rotated key to peers", { + peerCount: this._peerPublicKeys.size, + }); + } + } catch (err) { + log.error("E2EE: failed to rotate room key", err); + } + } + } + + /** Clear all E2EE state (called on voice leave). */ + private clearE2EEState(): void { + this._ecdhKeyPair = null; + this._roomKey = null; + this._peerPublicKeys.clear(); + this._isKeyHolder = false; + if (this._roomKeyResolver) { + this._roomKeyResolver(); + this._roomKeyResolver = null; + } + } + /** Retry microphone permission after being in listen-only mode. */ async retryMicPermission(): Promise { const room = this._room; @@ -1085,6 +1266,8 @@ export class LiveKitSession { room.removeAllListeners(); room.disconnect().catch((err) => log.warn("room.disconnect() error (non-fatal)", err)); } + // Clear client-side E2EE state (ECDH keypair, room key, peer keys). + this.clearE2EEState(); // Transition to idle — atomically clears room, channelId, tokens, reconnectAc, // pendingJoin, and the joinGeneration (idle has none). Any in-flight // connectAndSetup() will detect the state type change at its next checkpoint. @@ -1265,6 +1448,9 @@ export const setOnRemoteVideo = session.setOnRemoteVideo.bind(session); export const setOnRemoteVideoRemoved = session.setOnRemoteVideoRemoved.bind(session); export const clearOnRemoteVideo = session.clearOnRemoteVideo.bind(session); export const handleVoiceToken = session.handleVoiceToken.bind(session); +export const handleE2EEAnnounce = session.handleE2EEAnnounce.bind(session); +export const handleE2EEOffer = session.handleE2EEOffer.bind(session); +export const handleParticipantLeft = session.handleParticipantLeft.bind(session); export const leaveVoice = session.leaveVoice.bind(session); export const retryMicPermission = session.retryMicPermission.bind(session); export const cleanupAll = session.cleanupAll.bind(session); diff --git a/Client/tauri-client/src/lib/types.ts b/Client/tauri-client/src/lib/types.ts index 6973b25a..a7795643 100644 --- a/Client/tauri-client/src/lib/types.ts +++ b/Client/tauri-client/src/lib/types.ts @@ -280,8 +280,21 @@ export interface VoiceTokenPayload { readonly token: string; readonly url: string; readonly direct_url?: string; - /** Base64-encoded 256-bit symmetric key for LiveKit SFrame E2EE. */ - readonly e2ee_key?: string; +} + +// ── Voice E2EE (client-side ECDH key exchange) ───────────────────────────── + +/** Server→Client relay of another participant's ECDH public key. */ +export interface VoiceE2EEAnnouncePayload { + readonly user_id: number; + readonly public_key: string; +} + +/** Server→Client relay of an encrypted room key from the key holder. */ +export interface VoiceE2EEOfferPayload { + readonly from_user_id: number; + readonly encrypted_key: string; + readonly iv: string; } export interface MemberJoinPayload { @@ -445,6 +458,8 @@ export type ServerMessage = | (WsEnvelope & { readonly type: "voice_config" }) | (WsEnvelope & { readonly type: "voice_speakers" }) | (WsEnvelope & { readonly type: "voice_token" }) + | (WsEnvelope & { readonly type: "voice_e2ee_announce" }) + | (WsEnvelope & { readonly type: "voice_e2ee_offer" }) | (WsEnvelope & { readonly type: "member_join" }) | (WsEnvelope & { readonly type: "member_leave" }) | (WsEnvelope & { readonly type: "member_update" }) @@ -475,7 +490,11 @@ export type ClientMessage = | (WsEnvelope & { readonly type: "voice_deafen" }) | (WsEnvelope & { readonly type: "voice_camera" }) | (WsEnvelope & { readonly type: "voice_screenshare" }) - | (WsEnvelope> & { readonly type: "voice_token_refresh" }); + | (WsEnvelope> & { readonly type: "voice_token_refresh" }) + | (WsEnvelope<{ public_key: string }> & { readonly type: "voice_e2ee_announce" }) + | (WsEnvelope<{ target_user_id: number; encrypted_key: string; iv: string }> & { + readonly type: "voice_e2ee_offer"; + }); // ----------------------------------------------------------------------------- // REST API Response Types diff --git a/Server/ws/client.go b/Server/ws/client.go index dfe307a9..a60c8400 100644 --- a/Server/ws/client.go +++ b/Server/ws/client.go @@ -27,6 +27,7 @@ type Client struct { channelID int64 // currently viewed channel for channel-scoped broadcasts voiceChID int64 // voice channel the user is in (0 = not in voice); guarded by voiceMu voiceJoinToken string // opaque join-instance token for the current voice session; guarded by voiceMu + e2eePubKey string // ECDH P-256 public key (base64) for voice E2EE; guarded by voiceMu roleName string // cached role name for chat_message broadcasts tokenHash string // SHA-256 hex of the session token; used for periodic revalidation lastSeq uint64 // last_seq sent by the client during auth; 0 = fresh connection (e.g. F5 reload) @@ -213,9 +214,24 @@ func (c *Client) clearVoiceState() (int64, string) { oldJoinToken := c.voiceJoinToken c.voiceChID = 0 c.voiceJoinToken = "" + c.e2eePubKey = "" return oldChID, oldJoinToken } +// setE2EEPubKey stores the ECDH public key for voice E2EE key exchange. +func (c *Client) setE2EEPubKey(key string) { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + c.e2eePubKey = key +} + +// getE2EEPubKey returns the stored ECDH public key. +func (c *Client) getE2EEPubKey() string { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + return c.e2eePubKey +} + // sendMsg queues a message to this client's send buffer without blocking. // It is a no-op if the send channel has already been closed. // If the buffer is full, the client is disconnected to force a reconnect diff --git a/Server/ws/handlers_voice.go b/Server/ws/handlers_voice.go index 79d7ae34..f450f115 100644 --- a/Server/ws/handlers_voice.go +++ b/Server/ws/handlers_voice.go @@ -31,4 +31,10 @@ func registerVoiceHandlers(r *HandlerRegistry) { r.Register(MsgTypeVoiceScreenshare, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) { h.handleVoiceScreenshare(ctx, c, payload) }) + r.Register(MsgTypeVoiceE2EEAnnounce, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) { + h.handleVoiceE2EEAnnounce(ctx, c, payload) + }) + r.Register(MsgTypeVoiceE2EEOffer, func(ctx context.Context, h *Hub, c *Client, _ string, payload json.RawMessage) { + h.handleVoiceE2EEOffer(ctx, c, payload) + }) } diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 4ad0fc6f..a7d07e69 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -51,11 +51,6 @@ type Hub struct { settingsMotd string settingsLastUpdate time.Time - // E2EE key store — per-channel symmetric keys for voice encryption. - // Keys are ephemeral: generated when the first user joins a voice channel, - // cleared when the channel empties. All participants in the same channel - // receive the same key via the voice_token message (over the encrypted WS). - e2eeKeys *VoiceE2EEKeys } // NewHub creates a Hub ready to be started with Run. @@ -81,7 +76,6 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter) *Hub { permChecker: permissions.NewChecker(database), settingsName: "OwnCord Server", settingsMotd: "Welcome!", - e2eeKeys: NewVoiceE2EEKeys(), } h.refreshSettingsLocked() return h @@ -289,8 +283,6 @@ func (h *Hub) CleanupVoiceForChannel(channelID int64) { h.BroadcastToAll(buildVoiceLeave(channelID, vs.UserID)) } - // Clear E2EE key — channel is now empty. - h.e2eeKeys.ClearChannel(channelID) } // IsUserConnected returns true if a client with the given userID is already diff --git a/Server/ws/message_types.go b/Server/ws/message_types.go index 7cc81539..626e93bf 100644 --- a/Server/ws/message_types.go +++ b/Server/ws/message_types.go @@ -23,6 +23,8 @@ const ( MsgTypeVoiceScreenshare = "voice_screenshare" MsgTypePing = "ping" MsgTypeVoiceTokenRefresh = "voice_token_refresh" //nolint:gosec // G101: false positive — message type constant, not a credential + MsgTypeVoiceE2EEAnnounce = "voice_e2ee_announce" + MsgTypeVoiceE2EEOffer = "voice_e2ee_offer" ) // Server → Client message types (sent in broadcasts/responses). @@ -53,6 +55,8 @@ const ( MsgTypeServerRestart = "server_restart" MsgTypeError = "error" MsgTypePong = "pong" - MsgTypeDMChannelOpen = "dm_channel_open" - MsgTypeDMChannelClose = "dm_channel_close" + MsgTypeDMChannelOpen = "dm_channel_open" + MsgTypeDMChannelClose = "dm_channel_close" + MsgTypeVoiceE2EEAnnounceBC = "voice_e2ee_announce" // broadcast (same string as client msg) + MsgTypeVoiceE2EEOfferRelay = "voice_e2ee_offer" // relay (same string as client msg) ) diff --git a/Server/ws/messages.go b/Server/ws/messages.go index fe3c2c05..c883f927 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -126,7 +126,33 @@ type voiceTokenPayload struct { Token string `json:"token"` URL string `json:"url"` DirectURL string `json:"direct_url"` - E2EEKey string `json:"e2ee_key,omitempty"` +} + +// ── Voice E2EE (client-side ECDH key exchange) ───────────────────────────── + +// voiceE2EEAnnounceIn is the client→server payload for voice_e2ee_announce. +type voiceE2EEAnnounceIn struct { + PublicKey string `json:"public_key"` +} + +// voiceE2EEAnnounceBroadcast is the server→client relay with user_id added. +type voiceE2EEAnnounceBroadcast struct { + UserID int64 `json:"user_id"` + PublicKey string `json:"public_key"` +} + +// voiceE2EEOfferIn is the client→server payload for voice_e2ee_offer. +type voiceE2EEOfferIn struct { + TargetUserID int64 `json:"target_user_id"` + EncryptedKey string `json:"encrypted_key"` + IV string `json:"iv"` +} + +// voiceE2EEOfferRelay is the server→client relay with from_user_id. +type voiceE2EEOfferRelay struct { + FromUserID int64 `json:"from_user_id"` + EncryptedKey string `json:"encrypted_key"` + IV string `json:"iv"` } type voiceLeavePayload struct { @@ -385,7 +411,7 @@ func buildVoiceConfig(channelID int64, quality string, bitrate int, maxUsers int // buildVoiceToken constructs a voice_token message with a LiveKit token and URL. // url is the proxy path ("/livekit") for remote clients; direct_url is the raw // LiveKit URL (e.g. "ws://localhost:7880") for localhost clients. -func buildVoiceToken(channelID int64, token string, proxyPath string, directURL string, e2eeKey string) []byte { //nolint:unparam // kept configurable for proxy path flexibility +func buildVoiceToken(channelID int64, token string, proxyPath string, directURL string) []byte { //nolint:unparam // kept configurable for proxy path flexibility return buildJSON(wsMsg{ Type: MsgTypeVoiceToken, Payload: voiceTokenPayload{ @@ -393,7 +419,29 @@ func buildVoiceToken(channelID int64, token string, proxyPath string, directURL Token: token, URL: proxyPath, DirectURL: directURL, - E2EEKey: e2eeKey, + }, + }) +} + +// buildVoiceE2EEAnnounce constructs a voice_e2ee_announce server→client relay. +func buildVoiceE2EEAnnounce(userID int64, publicKey string) []byte { + return buildJSON(wsMsg{ + Type: MsgTypeVoiceE2EEAnnounceBC, + Payload: voiceE2EEAnnounceBroadcast{ + UserID: userID, + PublicKey: publicKey, + }, + }) +} + +// buildVoiceE2EEOffer constructs a voice_e2ee_offer server→client relay. +func buildVoiceE2EEOffer(fromUserID int64, encryptedKey, iv string) []byte { + return buildJSON(wsMsg{ + Type: MsgTypeVoiceE2EEOfferRelay, + Payload: voiceE2EEOfferRelay{ + FromUserID: fromUserID, + EncryptedKey: encryptedKey, + IV: iv, }, }) } diff --git a/Server/ws/messages_test.go b/Server/ws/messages_test.go index 6a291cc0..784c6419 100644 --- a/Server/ws/messages_test.go +++ b/Server/ws/messages_test.go @@ -541,7 +541,7 @@ func TestBuildTypingMsg_ValidJSON(t *testing.T) { // ─── buildVoiceToken ────────────────────────────────────────────────────────── func TestBuildVoiceToken_Type(t *testing.T) { - msg := buildVoiceToken(99, "jwt-token", "/livekit", "ws://localhost:7880", "dGVzdC1rZXk=") + msg := buildVoiceToken(99, "jwt-token", "/livekit", "ws://localhost:7880") var env struct { Type string `json:"type"` } @@ -554,14 +554,13 @@ func TestBuildVoiceToken_Type(t *testing.T) { } func TestBuildVoiceToken_Payload(t *testing.T) { - msg := buildVoiceToken(99, "jwt-token", "/livekit", "ws://localhost:7880", "dGVzdC1rZXk=") + msg := buildVoiceToken(99, "jwt-token", "/livekit", "ws://localhost:7880") var env struct { Payload struct { ChannelID int64 `json:"channel_id"` Token string `json:"token"` URL string `json:"url"` DirectURL string `json:"direct_url"` - E2EEKey string `json:"e2ee_key"` } `json:"payload"` } if err := json.Unmarshal(msg, &env); err != nil { @@ -579,13 +578,84 @@ func TestBuildVoiceToken_Payload(t *testing.T) { if env.Payload.DirectURL != "ws://localhost:7880" { t.Errorf("payload.direct_url = %q, want ws://localhost:7880", env.Payload.DirectURL) } - if env.Payload.E2EEKey != "dGVzdC1rZXk=" { - t.Errorf("payload.e2ee_key = %q, want dGVzdC1rZXk=", env.Payload.E2EEKey) +} + +func TestBuildVoiceToken_NoE2EEKey(t *testing.T) { + // E2EE keys are now exchanged client-side via ECDH; voice_token must not + // contain an e2ee_key field. + msg := buildVoiceToken(1, "t", "/livekit", "ws://a") + var body map[string]any + if err := json.Unmarshal(msg, &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + payload, _ := body["payload"].(map[string]any) + if _, exists := payload["e2ee_key"]; exists { + t.Error("voice_token payload must not contain e2ee_key (keys are exchanged client-side)") } } func TestBuildVoiceToken_ValidJSON(t *testing.T) { - if !json.Valid(buildVoiceToken(1, "t", "/livekit", "ws://a", "")) { + if !json.Valid(buildVoiceToken(1, "t", "/livekit", "ws://a")) { t.Error("buildVoiceToken output is not valid JSON") } } + +// ─── buildVoiceE2EEAnnounce ───────────────────────────────────────────────── + +func TestBuildVoiceE2EEAnnounce_ValidJSON(t *testing.T) { + msg := buildVoiceE2EEAnnounce(42, "dGVzdC1wdWJrZXk=") + if !json.Valid(msg) { + t.Error("buildVoiceE2EEAnnounce output is not valid JSON") + } + var env struct { + Type string `json:"type"` + Payload struct { + UserID int64 `json:"user_id"` + PublicKey string `json:"public_key"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "voice_e2ee_announce" { + t.Errorf("type = %q, want voice_e2ee_announce", env.Type) + } + if env.Payload.UserID != 42 { + t.Errorf("user_id = %d, want 42", env.Payload.UserID) + } + if env.Payload.PublicKey != "dGVzdC1wdWJrZXk=" { + t.Errorf("public_key = %q, want dGVzdC1wdWJrZXk=", env.Payload.PublicKey) + } +} + +// ─── buildVoiceE2EEOffer ──────────────────────────────────────────────────── + +func TestBuildVoiceE2EEOffer_ValidJSON(t *testing.T) { + msg := buildVoiceE2EEOffer(42, "encrypted-blob", "random-iv") + if !json.Valid(msg) { + t.Error("buildVoiceE2EEOffer output is not valid JSON") + } + var env struct { + Type string `json:"type"` + Payload struct { + FromUserID int64 `json:"from_user_id"` + EncryptedKey string `json:"encrypted_key"` + IV string `json:"iv"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "voice_e2ee_offer" { + t.Errorf("type = %q, want voice_e2ee_offer", env.Type) + } + if env.Payload.FromUserID != 42 { + t.Errorf("from_user_id = %d, want 42", env.Payload.FromUserID) + } + if env.Payload.EncryptedKey != "encrypted-blob" { + t.Errorf("encrypted_key = %q, want encrypted-blob", env.Payload.EncryptedKey) + } + if env.Payload.IV != "random-iv" { + t.Errorf("iv = %q, want random-iv", env.Payload.IV) + } +} diff --git a/Server/ws/voice_e2ee.go b/Server/ws/voice_e2ee.go index ef0e091b..ab36dd2b 100644 --- a/Server/ws/voice_e2ee.go +++ b/Server/ws/voice_e2ee.go @@ -1,68 +1,112 @@ package ws import ( - "crypto/rand" - "encoding/base64" - "fmt" + "context" + "encoding/json" "log/slog" - "sync" ) -// e2eeKeySize is the number of random bytes for each per-channel E2EE key. -// 32 bytes = 256 bits, matching AES-256-GCM used by LiveKit's SFrame E2EE. -const e2eeKeySize = 32 +// handleVoiceE2EEAnnounce processes a client's ECDH public key announcement. +// The server stores the key on the Client struct and relays it to all other +// participants in the same voice channel. The server never sees or generates +// the room encryption key — only opaque public keys pass through. +func (h *Hub) handleVoiceE2EEAnnounce(_ context.Context, c *Client, payload json.RawMessage) { + voiceChID := c.getVoiceChID() + if voiceChID == 0 { + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not in a voice channel")) + return + } -// VoiceE2EEKeys manages ephemeral per-channel symmetric encryption keys for -// LiveKit end-to-end encrypted voice/video. Keys are generated when the first -// participant joins a voice channel and cleared when the channel empties. -// -// Keys are distributed to participants via the voice_token WS message (which -// itself travels over the TLS-encrypted WebSocket connection). The LiveKit SFU -// never sees the keys — it only forwards encrypted SFrame payloads. -type VoiceE2EEKeys struct { - mu sync.Mutex - keys map[int64]string // channelID → base64-encoded 256-bit key + var p voiceE2EEAnnounceIn + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "invalid voice_e2ee_announce payload")) + return + } + if p.PublicKey == "" { + c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "public_key is required")) + return + } + // Sanity check: base64-encoded P-256 public key is ~88 chars (uncompressed) + // or ~44 chars (compressed). Allow up to 256 chars to be safe. + if len(p.PublicKey) > 256 { + c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "public_key too large")) + return + } + + // Store the public key on the client for later retrieval by new joiners. + c.setE2EEPubKey(p.PublicKey) + + // Relay to all other clients in the same voice channel. + msg := buildVoiceE2EEAnnounce(c.userID, p.PublicKey) + h.sendToVoiceChannelExcept(voiceChID, c.userID, msg) + + slog.Debug("voice e2ee: announce relayed", "user_id", c.userID, "channel_id", voiceChID) } -// NewVoiceE2EEKeys creates an empty key store. -func NewVoiceE2EEKeys() *VoiceE2EEKeys { - return &VoiceE2EEKeys{ - keys: make(map[int64]string), +// handleVoiceE2EEOffer relays an encrypted room key from one participant to +// another. The payload is opaque to the server — it contains an AES-GCM +// encrypted room key that only the target can decrypt via ECDH. +func (h *Hub) handleVoiceE2EEOffer(_ context.Context, c *Client, payload json.RawMessage) { + voiceChID := c.getVoiceChID() + if voiceChID == 0 { + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not in a voice channel")) + return + } + + var p voiceE2EEOfferIn + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "invalid voice_e2ee_offer payload")) + return + } + if p.TargetUserID <= 0 || p.EncryptedKey == "" || p.IV == "" { + c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "target_user_id, encrypted_key, and iv are required")) + return + } + + // Verify the target is in the same voice channel. + h.mu.RLock() + target, ok := h.clients[p.TargetUserID] + h.mu.RUnlock() + if !ok { + c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "target user not connected")) + return + } + if target.getVoiceChID() != voiceChID { + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "target user not in your voice channel")) + return + } + + // Relay the encrypted key offer to the target. + msg := buildVoiceE2EEOffer(c.userID, p.EncryptedKey, p.IV) + target.sendMsg(msg) + + slog.Debug("voice e2ee: offer relayed", + "from_user_id", c.userID, "to_user_id", p.TargetUserID, "channel_id", voiceChID) +} + +// sendToVoiceChannelExcept sends a message to all clients in the given voice +// channel except the one identified by excludeUserID. +func (h *Hub) sendToVoiceChannelExcept(channelID int64, excludeUserID int64, msg []byte) { + h.mu.RLock() + defer h.mu.RUnlock() + + for uid, client := range h.clients { + if uid == excludeUserID { + continue + } + if client.getVoiceChID() == channelID { + client.sendMsg(msg) + } } } -// KeyForChannel returns the E2EE key for a channel, generating a new one if -// none exists (i.e. the first participant is joining). The key is returned as -// a base64-encoded string suitable for transmission in JSON. -func (v *VoiceE2EEKeys) KeyForChannel(channelID int64) (string, error) { - v.mu.Lock() - defer v.mu.Unlock() - - if key, ok := v.keys[channelID]; ok { - return key, nil - } - - // Generate a fresh 256-bit key. - raw := make([]byte, e2eeKeySize) - if _, err := rand.Read(raw); err != nil { - return "", fmt.Errorf("voice e2ee: generating key: %w", err) - } - encoded := base64.StdEncoding.EncodeToString(raw) - v.keys[channelID] = encoded - - slog.Info("voice e2ee: generated new channel key", "channel_id", channelID) - return encoded, nil -} - -// ClearChannel removes the E2EE key for a channel. Should be called when the -// last participant leaves the voice channel so that the next session gets a -// fresh key. -func (v *VoiceE2EEKeys) ClearChannel(channelID int64) { - v.mu.Lock() - defer v.mu.Unlock() - - if _, ok := v.keys[channelID]; ok { - delete(v.keys, channelID) - slog.Info("voice e2ee: cleared channel key", "channel_id", channelID) +// getClientE2EEPubKey returns the stored ECDH public key for a connected user. +func (h *Hub) getClientE2EEPubKey(userID int64) string { + h.mu.RLock() + c, ok := h.clients[userID] + h.mu.RUnlock() + if !ok { + return "" } + return c.getE2EEPubKey() } diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go index b7113e1f..92152141 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -153,18 +153,12 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to generate voice token")) return } - // Get or generate the per-channel E2EE key for SFrame encryption. - e2eeKey, e2eeErr := h.e2eeKeys.KeyForChannel(channelID) - if e2eeErr != nil { - slog.Error("ws handleVoiceJoin E2EE key", "err", e2eeErr, "user_id", c.userID) - h.rollbackVoiceJoin(c, channelID, false) - c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to generate voice encryption key")) - return - } // Send both proxy path and direct URL. The client uses direct_url // when on localhost (avoids self-signed TLS issues with WebView // fetch) and falls back to the /livekit proxy for remote clients. - c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL(), e2eeKey)) + // NOTE: E2EE keys are no longer server-generated. Clients exchange + // keys via ECDH (voice_e2ee_announce / voice_e2ee_offer messages). + c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL())) } // Set voice channel on the client AFTER token is sent successfully. @@ -184,6 +178,11 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe continue } c.sendMsg(buildVoiceState(vs)) + // Send existing participant's ECDH public key so the joiner can + // participate in the client-side E2EE key exchange. + if pubKey := h.getClientE2EEPubKey(vs.UserID); pubKey != "" { + c.sendMsg(buildVoiceE2EEAnnounce(vs.UserID, pubKey)) + } } // Send voice_config to the joiner. @@ -265,15 +264,9 @@ func (h *Hub) handleVoiceTokenRefresh(_ context.Context, c *Client) { return } - // Include the current E2EE key so reconnections use the same key. - e2eeKey, e2eeErr := h.e2eeKeys.KeyForChannel(channelID) - if e2eeErr != nil { - slog.Error("ws handleVoiceTokenRefresh E2EE key", "err", e2eeErr, "user_id", c.userID) - c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to retrieve voice encryption key")) - return - } - - c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL(), e2eeKey)) + // E2EE keys are exchanged client-side via ECDH; token refresh only + // provides a new LiveKit access token. + c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL())) slog.Info("voice token refreshed", "user_id", c.userID, "channel_id", channelID) } diff --git a/Server/ws/voice_leave.go b/Server/ws/voice_leave.go index 659fed6f..615175de 100644 --- a/Server/ws/voice_leave.go +++ b/Server/ws/voice_leave.go @@ -34,11 +34,9 @@ func (h *Hub) handleVoiceLeave(ctx context.Context, c *Client) { h.BroadcastToAll(buildVoiceLeave(oldChID, c.userID)) - // Clear E2EE key when the voice channel is now empty so the next session - // gets a fresh key (forward secrecy per voice session). - if remaining, err := h.db.GetChannelVoiceStates(oldChID); err == nil && len(remaining) == 0 { - h.e2eeKeys.ClearChannel(oldChID) - } + // E2EE keys are now managed client-side via ECDH key exchange. + // When a participant leaves, remaining clients rotate the room key + // automatically — the server has no key material to clear. // Remove from LiveKit (best-effort). if h.livekit != nil {