diff --git a/src/frontend/src/features/encryption/VaultE2EEManager.ts b/src/frontend/src/features/encryption/VaultE2EEManager.ts index d96a0201..dd73dd7b 100644 --- a/src/frontend/src/features/encryption/VaultE2EEManager.ts +++ b/src/frontend/src/features/encryption/VaultE2EEManager.ts @@ -1,16 +1,24 @@ /** - * E2EE Manager using VaultClient iframe for crypto. - * Preserves codec header bytes unencrypted (required for RTP packetization). - * TransformStream runs on main thread, crypto delegated to vault iframe. + * Custom E2EE Manager that delegates crypto to the VaultClient iframe. + * + * Uses XChaCha20-Poly1305 (libsodium) via the vault — the symmetric key + * never leaves the iframe. Preserves codec header bytes unencrypted so + * the WebRTC RTP packetizer can construct valid packets. + * + * Frame format (sender output / receiver input): + * [unencrypted codec header][vault-encrypted payload] + * + * Where vault-encrypted payload = [24B nonce][ciphertext + 16B Poly1305 MAC] + * + * Unencrypted header sizes (VP8): + * - keyframe: 10 bytes (VP8 payload descriptor) + * - delta: 3 bytes + * - audio: 1 byte (Opus TOC) */ import { EventEmitter } from 'events' import { Encryption_Type } from '@livekit/protocol' import type { Room, RemoteTrack, Track } from 'livekit-client' -import { - RoomEvent, - ParticipantEvent, - ConnectionState, -} from 'livekit-client' +import { RoomEvent, ParticipantEvent, ConnectionState } from 'livekit-client' import type { RTCEngine } from 'livekit-client/src/room/RTCEngine' const E2EE_FLAG = Symbol('e2ee') @@ -20,10 +28,11 @@ enum EncryptionEvent { EncryptionError = 'encryptionError', } -// VP8 unencrypted header bytes (same as LiveKit FrameCryptor) const UNENCRYPTED_BYTES = { key: 10, delta: 3, audio: 1 } -function getUnencryptedBytes(frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame): number { +function getUnencryptedBytes( + frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame +): number { if (!('type' in frame)) return UNENCRYPTED_BYTES.audio return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta } @@ -33,6 +42,11 @@ export class VaultE2EEManager extends EventEmitter { private room?: Room private encryptionEnabled = false private _isDataChannelEncryptionEnabled = false + + /** + * Encrypted symmetric key (wrapped for the user's vault public key). + * Stored as an independent copy so the original ArrayBuffer can't be detached. + */ private encryptedKeyBytes: Uint8Array | null = null constructor(vaultClient: VaultClient) { @@ -40,15 +54,19 @@ export class VaultE2EEManager extends EventEmitter { this.vaultClient = vaultClient } - get isEnabled() { return this.encryptionEnabled } + get isEnabled() { + return this.encryptionEnabled + } get isDataChannelEncryptionEnabled() { return this.isEnabled && this._isDataChannelEncryptionEnabled } + set isDataChannelEncryptionEnabled(enabled: boolean) { this._isDataChannelEncryptionEnabled = enabled } + /** Fresh ArrayBuffer copy of the key for each vault call (avoids postMessage detachment). */ private freshKeyBuffer(): ArrayBuffer { return new Uint8Array(this.encryptedKeyBytes!).buffer } @@ -57,6 +75,8 @@ export class VaultE2EEManager extends EventEmitter { this.encryptedKeyBytes = new Uint8Array(new Uint8Array(key)) } + // ── Lifecycle (mirrors built-in E2EEManager) ──────────────────────── + setup(room: Room): void { if (room !== this.room) { this.room = room @@ -66,39 +86,74 @@ export class VaultE2EEManager extends EventEmitter { setupEngine(_engine: RTCEngine): void {} - setParticipantCryptorEnabled(enabled: boolean, participantIdentity: string): void { + setParticipantCryptorEnabled( + enabled: boolean, + participantIdentity: string + ): void { if ( participantIdentity === this.room?.localParticipant.identity && this.encryptionEnabled !== enabled ) { this.encryptionEnabled = enabled - this.emit(EncryptionEvent.ParticipantEncryptionStatusChanged, enabled, this.room!.localParticipant) + this.emit( + EncryptionEvent.ParticipantEncryptionStatusChanged, + enabled, + this.room!.localParticipant + ) } else if (participantIdentity !== this.room?.localParticipant.identity) { const p = this.room?.getParticipantByIdentity(participantIdentity) - if (p) this.emit(EncryptionEvent.ParticipantEncryptionStatusChanged, enabled, p) + if (p) + this.emit( + EncryptionEvent.ParticipantEncryptionStatusChanged, + enabled, + p + ) } } setSifTrailer(_trailer: Uint8Array): void {} async encryptData(data: Uint8Array) { - if (!this.encryptedKeyBytes) throw new Error('No key') - const r = await this.vaultClient.encryptWithKey(data.slice().buffer, this.freshKeyBuffer()) - return { uuid: crypto.randomUUID(), payload: new Uint8Array(r.encryptedData).slice(), iv: new Uint8Array(0), keyIndex: 0 } + if (!this.encryptedKeyBytes) + throw new Error('No encrypted symmetric key set') + const r = await this.vaultClient.encryptWithKey( + data.slice().buffer, + this.freshKeyBuffer() + ) + return { + uuid: crypto.randomUUID(), + payload: new Uint8Array(r.encryptedData).slice(), + iv: new Uint8Array(0), + keyIndex: 0, + } } - async handleEncryptedData(payload: Uint8Array, _iv: Uint8Array, _id: string, _idx: number) { - if (!this.encryptedKeyBytes) throw new Error('No key') - const r = await this.vaultClient.decryptWithKey(payload.slice().buffer, this.freshKeyBuffer()) - return { uuid: crypto.randomUUID(), payload: new Uint8Array(r.data).slice() } + async handleEncryptedData( + payload: Uint8Array, + _iv: Uint8Array, + _participantIdentity: string, + _keyIndex: number + ) { + if (!this.encryptedKeyBytes) + throw new Error('No encrypted symmetric key set') + const r = await this.vaultClient.decryptWithKey( + payload.slice().buffer, + this.freshKeyBuffer() + ) + return { + uuid: crypto.randomUUID(), + payload: new Uint8Array(r.data).slice(), + } } - // ── Events (same as built-in E2EEManager) ─────────────────────────── + // ── Event listeners ───────────────────────────────────────────────── private setupEventListeners(room: Room): void { room.on(RoomEvent.TrackPublished, (pub, participant) => { this.setParticipantCryptorEnabled( - pub.trackInfo!.encryption !== Encryption_Type.NONE, participant.identity) + pub.trackInfo!.encryption !== Encryption_Type.NONE, + participant.identity + ) }) room.on(RoomEvent.ConnectionStateChanged, (state) => { @@ -106,7 +161,9 @@ export class VaultE2EEManager extends EventEmitter { room.remoteParticipants.forEach((p) => { p.trackPublications.forEach((pub) => { this.setParticipantCryptorEnabled( - pub.trackInfo!.encryption !== Encryption_Type.NONE, p.identity) + pub.trackInfo!.encryption !== Encryption_Type.NONE, + p.identity + ) }) }) } @@ -118,53 +175,57 @@ export class VaultE2EEManager extends EventEmitter { room.on(RoomEvent.SignalConnected, () => { this.setParticipantCryptorEnabled( - room.localParticipant.isE2EEEnabled, room.localParticipant.identity) + room.localParticipant.isE2EEEnabled, + room.localParticipant.identity + ) }) room.localParticipant.on( ParticipantEvent.LocalSenderCreated, (sender: RTCRtpSender, track: Track) => { this.setupSender(sender, track.mediaStreamID) - }, + } ) } - // ── Sender ────────────────────────────────────────────────────────── + // ── Sender (encrypt outgoing frames) ──────────────────────────────── private setupSender(sender: RTCRtpSender, _trackId: string): void { if (E2EE_FLAG in sender) return if (!this.room?.localParticipant.identity) return - // @ts-expect-error + // @ts-expect-error — createEncodedStreams not in TS types const streams = sender.createEncodedStreams() const transformStream = new TransformStream({ - transform: async (frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, controller: TransformStreamDefaultController) => { + transform: async ( + frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, + controller: TransformStreamDefaultController + ) => { try { - if (!this.encryptedKeyBytes || !frame.data || frame.data.byteLength === 0) { + if (!this.encryptedKeyBytes) return // drop — never send unencrypted + if (!frame.data || frame.data.byteLength === 0) return controller.enqueue(frame) - } const unencryptedBytes = getUnencryptedBytes(frame) const header = new Uint8Array(frame.data, 0, unencryptedBytes) const payload = new Uint8Array(frame.data, unencryptedBytes) - // Vault encrypts payload → returns [nonce][ciphertext+MAC] const { encryptedData } = await this.vaultClient.encryptWithKey( payload.slice().buffer, - this.freshKeyBuffer(), + this.freshKeyBuffer() ) - const encrypted = new Uint8Array(encryptedData) - // Frame: [header][encrypted payload] - const newData = new Uint8Array(header.byteLength + encrypted.byteLength) + const encrypted = new Uint8Array(encryptedData) + const newData = new Uint8Array( + header.byteLength + encrypted.byteLength + ) newData.set(header) newData.set(encrypted, header.byteLength) frame.data = newData.buffer - controller.enqueue(frame) } catch { - // Drop — never send unencrypted + // Drop frame on error — never send unencrypted } }, }) @@ -174,7 +235,7 @@ export class VaultE2EEManager extends EventEmitter { sender[E2EE_FLAG] = true } - // ── Receiver ──────────────────────────────────────────────────────── + // ── Receiver (decrypt incoming frames) ────────────────────────────── private setupReceiver(track: RemoteTrack, participantIdentity: string): void { if (!track.receiver) return @@ -200,43 +261,53 @@ export class VaultE2EEManager extends EventEmitter { let successEmitted = false const transformStream = new TransformStream({ - transform: async (frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, controller: TransformStreamDefaultController) => { + transform: async ( + frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, + controller: TransformStreamDefaultController + ) => { try { - if (!this.encryptedKeyBytes || !frame.data || frame.data.byteLength === 0) { + if (!this.encryptedKeyBytes) return // drop — can't decrypt without key + if (!frame.data || frame.data.byteLength === 0) return controller.enqueue(frame) - } const unencryptedBytes = getUnencryptedBytes(frame) const header = new Uint8Array(frame.data, 0, unencryptedBytes) const encryptedPayload = new Uint8Array(frame.data, unencryptedBytes) - // Vault decrypts [nonce][ciphertext+MAC] → plaintext const { data } = await this.vaultClient.decryptWithKey( encryptedPayload.slice().buffer, - this.freshKeyBuffer(), + this.freshKeyBuffer() ) - const plaintext = new Uint8Array(data) - // Frame: [header][plaintext] - const newData = new Uint8Array(header.byteLength + plaintext.byteLength) + const plaintext = new Uint8Array(data) + const newData = new Uint8Array( + header.byteLength + plaintext.byteLength + ) newData.set(header) newData.set(plaintext, header.byteLength) frame.data = newData.buffer - controller.enqueue(frame) if (!successEmitted) { successEmitted = true const p = this.room?.getParticipantByIdentity(participantIdentity) - if (p) this.emit(EncryptionEvent.ParticipantEncryptionStatusChanged, true, p) + if (p) + this.emit( + EncryptionEvent.ParticipantEncryptionStatusChanged, + true, + p + ) } } catch { - // Drop frame on decrypt error — keeps pipe alive + // Drop frame — keeps pipe alive, avoids sending corrupt data to decoder } }, }) - readable.pipeThrough(transformStream).pipeTo(writable).catch(() => {}) + readable + .pipeThrough(transformStream) + .pipeTo(writable) + .catch(() => {}) // @ts-expect-error receiver[E2EE_FLAG] = true } diff --git a/src/frontend/src/features/encryption/vault-e2ee.worker.ts b/src/frontend/src/features/encryption/vault-e2ee.worker.ts deleted file mode 100644 index d85a70c0..00000000 --- a/src/frontend/src/features/encryption/vault-e2ee.worker.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * E2EE Worker — Step 3: libsodium XChaCha20-Poly1305 with preserved codec headers. - * Same frame format as LiveKit (unencrypted header bytes), but using libsodium - * instead of crypto.subtle. Hardcoded symmetric key, no VaultClient yet. - * - * Frame format: - * [unencrypted header][ciphertext + Poly1305 MAC (16B)][nonce (24B)][NONCE_LENGTH (1B)][key index (1B)] - */ -import _sodium from 'libsodium-wrappers-sumo' - -let sodium: typeof _sodium -let symmetricKey: Uint8Array | null = null - -const KEY_INDEX = 0 - -// Same as LiveKit's FrameCryptor constants -const UNENCRYPTED_BYTES = { - key: 10, // VP8 keyframe - delta: 3, // VP8 delta frame - audio: 1, // Opus TOC byte -} - -async function init() { - await _sodium.ready - sodium = _sodium -} - -const sodiumReady = init() - -function getUnencryptedBytes(frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame): number { - if (!('type' in frame)) return UNENCRYPTED_BYTES.audio - return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta -} - -onmessage = async (ev: MessageEvent) => { - const { kind, data } = ev.data - - switch (kind) { - case 'init': - await sodiumReady - postMessage({ kind: 'initAck', data: { enabled: true } }) - break - - case 'setKey': - // Store raw symmetric key for libsodium (32 bytes) - symmetricKey = new Uint8Array(data.key) - postMessage({ - kind: 'enable', - data: { enabled: true, participantIdentity: data.participantIdentity }, - }) - break - - case 'encode': - case 'decode': { - await sodiumReady - const { readableStream, writableStream, trackId, participantIdentity } = data - const operation = kind - let frameCount = 0 - - const transformStream = new TransformStream({ - transform: async (frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, controller: TransformStreamDefaultController) => { - try { - if (!symmetricKey) return // drop — key not ready - if (!frame.data || frame.data.byteLength === 0) { - return controller.enqueue(frame) - } - - const unencryptedBytes = getUnencryptedBytes(frame) - - if (operation === 'encode') { - // ── Encrypt ── - const frameHeader = new Uint8Array(frame.data, 0, unencryptedBytes) - const payload = new Uint8Array(frame.data, unencryptedBytes) - - const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) // 24 bytes - const ciphertext = sodium.crypto_secretbox_easy(payload, nonce, symmetricKey) - - // Trailer: [NONCE_LENGTH][KEY_INDEX] - const trailer = new Uint8Array(2) - trailer[0] = sodium.crypto_secretbox_NONCEBYTES // 24 - trailer[1] = KEY_INDEX - - // [header][ciphertext + MAC][nonce][trailer] - const newData = new Uint8Array( - frameHeader.byteLength + ciphertext.byteLength + nonce.byteLength + trailer.byteLength, - ) - let offset = 0 - newData.set(frameHeader, offset); offset += frameHeader.byteLength - newData.set(ciphertext, offset); offset += ciphertext.byteLength - newData.set(nonce, offset); offset += nonce.byteLength - newData.set(trailer, offset) - - frame.data = newData.buffer - controller.enqueue(frame) - } else { - // ── Decrypt ── - const frameHeader = new Uint8Array(frame.data, 0, unencryptedBytes) - - // Read trailer (last 2 bytes) - const trailer = new Uint8Array(frame.data, frame.data.byteLength - 2, 2) - const nonceLength = trailer[0] - - // Extract nonce - const nonce = new Uint8Array( - frame.data, - frame.data.byteLength - nonceLength - trailer.byteLength, - nonceLength, - ) - - // Extract ciphertext (between header and nonce) - const ciphertextStart = frameHeader.byteLength - const ciphertextLength = frame.data.byteLength - frameHeader.byteLength - nonceLength - trailer.byteLength - const ciphertext = new Uint8Array(frame.data, ciphertextStart, ciphertextLength) - - const plaintext = sodium.crypto_secretbox_open_easy(ciphertext, nonce, symmetricKey) - - // Reconstruct: [header][plaintext] - const newData = new Uint8Array(frameHeader.byteLength + plaintext.byteLength) - newData.set(frameHeader) - newData.set(plaintext, frameHeader.byteLength) - frame.data = newData.buffer - - controller.enqueue(frame) - } - - frameCount++ - if (frameCount <= 5 || frameCount % 500 === 0) { - console.log(`[Worker] ${operation} frame #${frameCount} for ${participantIdentity}, ${frame.data.byteLength}B`) - } - } catch (e) { - // Swallow to keep pipe alive - if (frameCount < 10) { - console.error(`[Worker] ${operation} error for ${participantIdentity}:`, (e as Error)?.message) - } - } - }, - }) - - readableStream - .pipeThrough(transformStream) - .pipeTo(writableStream) - .then(() => console.warn(`[Worker] pipe completed for ${participantIdentity}/${trackId}`)) - .catch((err: Error) => console.warn(`[Worker] pipe error for ${participantIdentity}/${trackId}:`, err?.message)) - - break - } - - case 'enable': - postMessage({ - kind: 'enable', - data: { enabled: data.enabled, participantIdentity: data.participantIdentity }, - }) - break - - case 'removeTransform': - case 'setRTPMap': - case 'setSifTrailer': - case 'updateCodec': - case 'ratchetRequest': - break - } -}