From 7105c6bc70bd77850d899cc06204ef1a911047bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Tue, 7 Apr 2026 16:09:27 +0200 Subject: [PATCH] working with livekit settings --- .../features/encryption/VaultE2EEManager.ts | 30 ++- .../features/encryption/vault-e2ee.worker.ts | 196 ++++++++++++------ 2 files changed, 151 insertions(+), 75 deletions(-) diff --git a/src/frontend/src/features/encryption/VaultE2EEManager.ts b/src/frontend/src/features/encryption/VaultE2EEManager.ts index cc10281a..785071d1 100644 --- a/src/frontend/src/features/encryption/VaultE2EEManager.ts +++ b/src/frontend/src/features/encryption/VaultE2EEManager.ts @@ -126,6 +126,19 @@ export class VaultE2EEManager extends EventEmitter { case 'error': this.emit(EncryptionEvent.EncryptionError, data.error, data.participantIdentity) break + + case 'pipeDead': + // Pipe ended (stream closed during disconnect). Clear E2EE_FLAG on all + // receivers so the next TrackSubscribed creates a fresh pipe. + this.room?.remoteParticipants.forEach((p) => { + p.trackPublications.forEach((pub) => { + if (pub.track?.receiver && E2EE_FLAG in pub.track.receiver) { + // @ts-expect-error + delete pub.track.receiver[E2EE_FLAG] + } + }) + }) + break } } @@ -221,20 +234,19 @@ export class VaultE2EEManager extends EventEmitter { return } - // @ts-expect-error - let writable: WritableStream = receiver.writableStream - // @ts-expect-error - let readable: ReadableStream = receiver.readableStream + let writable: WritableStream + let readable: ReadableStream - if (!writable || !readable) { + try { // @ts-expect-error const receiverStreams = receiver.createEncodedStreams() - // @ts-expect-error - receiver.writableStream = receiverStreams.writable writable = receiverStreams.writable - // @ts-expect-error - receiver.readableStream = receiverStreams.readable readable = receiverStreams.readable + } catch { + // createEncodedStreams() already called (receiver reuse after pipe death). + // Cannot re-create streams — this receiver is stuck. + console.warn(`[VaultE2EE] Cannot create encoded streams for ${participantIdentity} (receiver reuse). Pipe unrecoverable.`) + return } this.worker.postMessage( diff --git a/src/frontend/src/features/encryption/vault-e2ee.worker.ts b/src/frontend/src/features/encryption/vault-e2ee.worker.ts index b50b8cae..5a304e1e 100644 --- a/src/frontend/src/features/encryption/vault-e2ee.worker.ts +++ b/src/frontend/src/features/encryption/vault-e2ee.worker.ts @@ -1,35 +1,42 @@ /** - * E2EE Worker using libsodium XChaCha20-Poly1305. - * Mirrors the structure of LiveKit's e2ee.worker but with libsodium crypto. - * Receives encoded streams via transfer and pipes them with encrypt/decrypt transforms. + * E2EE Worker — Step 2d: uses crypto.subtle AES-GCM with the SAME frame format + * as LiveKit's built-in FrameCryptor, including preserved unencrypted header bytes. + * + * Frame format (same as LiveKit): + * [unencrypted header][ciphertext + GCM tag][IV (12B)][IV_LENGTH (1B)][key index (1B)] + * + * Unencrypted header sizes (VP8): + * - keyframe: 10 bytes + * - delta: 3 bytes + * - audio: 1 byte (Opus TOC) */ -import _sodium from 'libsodium-wrappers-sumo' -let sodium: typeof _sodium -let symmetricKey: Uint8Array | null = null +let encryptionKey: CryptoKey | null = null +const IV_LENGTH = 12 +const KEY_INDEX = 0 -const transforms = new Map void }>() - -async function init() { - await _sodium.ready - sodium = _sodium +// Same constants as LiveKit's FrameCryptor +const UNENCRYPTED_BYTES = { + key: 10, // VP8 keyframe + delta: 3, // VP8 delta frame + audio: 1, // Opus TOC byte } -const sodiumReady = init() - -function encrypt(plaintext: Uint8Array): Uint8Array { - const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) - const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, symmetricKey!) - const result = new Uint8Array(nonce.length + ciphertext.length) - result.set(nonce) - result.set(ciphertext, nonce.length) - return result +function getUnencryptedBytes(frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame): number { + // Audio frames don't have .type + if (!('type' in frame)) { + return UNENCRYPTED_BYTES.audio + } + return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta } -function decrypt(data: Uint8Array): Uint8Array { - const nonce = data.slice(0, sodium.crypto_secretbox_NONCEBYTES) - const ciphertext = data.slice(sodium.crypto_secretbox_NONCEBYTES) - return sodium.crypto_secretbox_open_easy(ciphertext, nonce, symmetricKey!) +function makeIV(ssrc: number, timestamp: number): Uint8Array { + const iv = new ArrayBuffer(IV_LENGTH) + const view = new DataView(iv) + view.setUint32(0, ssrc, true) + view.setUint32(4, timestamp, true) + view.setUint32(8, ssrc ^ timestamp, true) + return new Uint8Array(iv) } onmessage = async (ev: MessageEvent) => { @@ -37,86 +44,143 @@ onmessage = async (ev: MessageEvent) => { switch (kind) { case 'init': - await sodiumReady postMessage({ kind: 'initAck', data: { enabled: true } }) break - case 'setKey': - symmetricKey = data.key - // Echo back enable for the participant + case 'setKey': { + encryptionKey = await crypto.subtle.importKey( + 'raw', data.key, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], + ) 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 === 'encode' ? 'encrypt' : 'decrypt' - - // Cancel existing transform for this track if any - const existing = transforms.get(trackId) - if (existing) existing.cancel() - - const abortController = new AbortController() + const operation = kind + let frameCount = 0 const transformStream = new TransformStream({ - transform: async (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => { - if (!symmetricKey || frame.data.byteLength === 0) { - return controller.enqueue(frame) - } + transform: async (frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, controller: TransformStreamDefaultController) => { try { - const input = new Uint8Array(frame.data) - const output = operation === 'encrypt' ? encrypt(input) : decrypt(input) - frame.data = output.buffer - controller.enqueue(frame) - } catch (e) { - // Encrypt: drop frame (never send unencrypted) - // Decrypt: emit error - if (operation === 'decrypt') { - postMessage({ - kind: 'error', - data: { - error: new Error(`Decryption failed for ${participantIdentity}`), - participantIdentity, + if (!encryptionKey) return // drop — key not ready + if (!frame.data || frame.data.byteLength === 0) { + return controller.enqueue(frame) + } + + if (operation === 'encode') { + // ── Encrypt (same as LiveKit FrameCryptor.encodeFunction) ── + const iv = makeIV( + (frame as any).getMetadata?.().synchronizationSource ?? 0, + (frame as any).timestamp ?? 0, + ) + + const unencryptedBytes = getUnencryptedBytes(frame) + const frameHeader = new Uint8Array(frame.data, 0, unencryptedBytes) + + const ciphertext = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + additionalData: new Uint8Array(frame.data, 0, frameHeader.byteLength), }, - }) + encryptionKey, + new Uint8Array(frame.data, unencryptedBytes), + ) + + // [header][ciphertext+tag][IV][IV_LENGTH][keyIndex] + const frameTrailer = new Uint8Array(2) + frameTrailer[0] = IV_LENGTH + frameTrailer[1] = KEY_INDEX + + const newData = new Uint8Array( + frameHeader.byteLength + ciphertext.byteLength + iv.byteLength + frameTrailer.byteLength, + ) + newData.set(frameHeader) + newData.set(new Uint8Array(ciphertext), frameHeader.byteLength) + newData.set(iv, frameHeader.byteLength + ciphertext.byteLength) + newData.set(frameTrailer, frameHeader.byteLength + ciphertext.byteLength + iv.byteLength) + + frame.data = newData.buffer + controller.enqueue(frame) + } else { + // ── Decrypt (same as LiveKit FrameCryptor.decodeFunction) ── + const frameData = new Uint8Array(frame.data) + const unencryptedBytes = getUnencryptedBytes(frame) + const frameHeader = new Uint8Array(frame.data, 0, unencryptedBytes) + + // Read trailer + const frameTrailer = new Uint8Array(frame.data, frame.data.byteLength - 2, 2) + const ivLength = frameTrailer[0] + + // Extract IV + const iv = new Uint8Array( + frame.data, + frame.data.byteLength - ivLength - frameTrailer.byteLength, + ivLength, + ) + + // Extract ciphertext (between header and IV) + const ciphertextStart = frameHeader.byteLength + const ciphertextLength = frame.data.byteLength - frameHeader.byteLength - ivLength - frameTrailer.byteLength + + const plaintext = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv, + additionalData: new Uint8Array(frame.data, 0, frameHeader.byteLength), + }, + encryptionKey, + new Uint8Array(frame.data, ciphertextStart, ciphertextLength), + ) + + // Reconstruct: [header][plaintext] + const newData = new Uint8Array(frameHeader.byteLength + plaintext.byteLength) + newData.set(frameHeader) + newData.set(new Uint8Array(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, { signal: abortController.signal }) + .pipeThrough(transformStream) .pipeTo(writableStream) - .catch(() => {}) + .then(() => console.warn(`[Worker] pipe completed for ${participantIdentity}/${trackId}`)) + .catch((err: Error) => console.warn(`[Worker] pipe error for ${participantIdentity}/${trackId}:`, err?.message)) - transforms.set(trackId, { cancel: () => abortController.abort() }) break } - case 'removeTransform': - // Do NOT cancel the pipe. The transferred streams stay open across track - // changes on reused receivers. The existing pipe will process frames from - // the new track. Cancelling would kill the pipe permanently since - // createEncodedStreams() can only be called once per receiver. - break - case 'enable': - // Echo back postMessage({ kind: 'enable', data: { enabled: data.enabled, participantIdentity: data.participantIdentity }, }) break + case 'removeTransform': case 'setRTPMap': case 'setSifTrailer': case 'updateCodec': case 'ratchetRequest': - // Not needed for libsodium — ignore break } }