mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 21:01:55 +00:00
working with livekit settings
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<string, { cancel: () => 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user