mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
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:
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user