feat: implement true E2EE for voice via client-side ECDH key exchange

Replace server-generated symmetric keys with client-side ECDH P-256 key
exchange. The server now only relays opaque public keys and encrypted
room key blobs — it never sees the actual room encryption key.

Protocol:
- voice_e2ee_announce: clients broadcast ECDH public keys
- voice_e2ee_offer: key holder wraps room key for each peer via ECDH+HKDF+AES-GCM
- Key rotation on participant leave (forward secrecy)

Server changes:
- Remove VoiceE2EEKeys (server-side key generation)
- Add relay handlers for announce/offer messages
- Store per-client ECDH public keys on Client struct
- Send existing public keys to new joiners during voice state sync

Client changes:
- New e2eeCrypto.ts: ECDH P-256, HKDF-SHA256, AES-256-GCM key wrapping
- LiveKitSession generates keypair on join, manages key holder election
- Key holder generates room key and wraps for each peer
- Non-holders wait for offer before connecting to LiveKit
- Room key rotated when any participant leaves

https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
This commit is contained in:
Claude
2026-04-04 19:02:21 +00:00
parent 1d8bfe2d6a
commit 0c4d9f702c
13 changed files with 699 additions and 136 deletions
+24 -2
View File
@@ -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);
}),
);
+165
View File
@@ -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<CryptoKeyPair> {
return crypto.subtle.generateKey({ name: "ECDH", namedCurve: ECDH_CURVE }, true, [
"deriveBits",
]) as Promise<CryptoKeyPair>;
}
/** Export a CryptoKey (public) to base64 for transmission. */
export async function exportPublicKey(key: CryptoKey): Promise<string> {
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<CryptoKey> {
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<Uint8Array> {
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<CryptoKey> {
// 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");
+221 -35
View File
@@ -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<number, CryptoKey> = 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<boolean | "superseded"> {
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<void>((resolve) => {
this._roomKeyResolver = resolve;
});
// Don't block forever — timeout after 10 seconds.
const timeout = new Promise<void>((_, 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
+22 -3
View File
@@ -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<VoiceConfigPayload> & { readonly type: "voice_config" })
| (WsEnvelope<VoiceSpeakersPayload> & { readonly type: "voice_speakers" })
| (WsEnvelope<VoiceTokenPayload> & { readonly type: "voice_token" })
| (WsEnvelope<VoiceE2EEAnnouncePayload> & { readonly type: "voice_e2ee_announce" })
| (WsEnvelope<VoiceE2EEOfferPayload> & { readonly type: "voice_e2ee_offer" })
| (WsEnvelope<MemberJoinPayload> & { readonly type: "member_join" })
| (WsEnvelope<MemberLeavePayload> & { readonly type: "member_leave" })
| (WsEnvelope<MemberUpdatePayload> & { readonly type: "member_update" })
@@ -475,7 +490,11 @@ export type ClientMessage =
| (WsEnvelope<VoiceDeafenPayload> & { readonly type: "voice_deafen" })
| (WsEnvelope<VoiceCameraPayload> & { readonly type: "voice_camera" })
| (WsEnvelope<VoiceScreensharePayload> & { readonly type: "voice_screenshare" })
| (WsEnvelope<Record<string, never>> & { readonly type: "voice_token_refresh" });
| (WsEnvelope<Record<string, never>> & { 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
+16
View File
@@ -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
+6
View File
@@ -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)
})
}
-8
View File
@@ -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
+6 -2
View File
@@ -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)
)
+51 -3
View File
@@ -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,
},
})
}
+76 -6
View File
@@ -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)
}
}
+98 -54
View File
@@ -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()
}
+11 -18
View File
@@ -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)
}
+3 -5
View File
@@ -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 {