working with libsodium (issue from RTC due to encrypted headers in packets?)

This commit is contained in:
Thomas Ramé
2026-04-07 16:18:07 +02:00
parent 7105c6bc70
commit 0c21978ca4
@@ -1,42 +1,35 @@
/** /**
* E2EE Worker — Step 2d: uses crypto.subtle AES-GCM with the SAME frame format * E2EE Worker — Step 3: libsodium XChaCha20-Poly1305 with preserved codec headers.
* as LiveKit's built-in FrameCryptor, including preserved unencrypted header bytes. * Same frame format as LiveKit (unencrypted header bytes), but using libsodium
* instead of crypto.subtle. Hardcoded symmetric key, no VaultClient yet.
* *
* Frame format (same as LiveKit): * Frame format:
* [unencrypted header][ciphertext + GCM tag][IV (12B)][IV_LENGTH (1B)][key index (1B)] * [unencrypted header][ciphertext + Poly1305 MAC (16B)][nonce (24B)][NONCE_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 KEY_INDEX = 0
// Same constants as LiveKit's FrameCryptor // Same as LiveKit's FrameCryptor constants
const UNENCRYPTED_BYTES = { const UNENCRYPTED_BYTES = {
key: 10, // VP8 keyframe key: 10, // VP8 keyframe
delta: 3, // VP8 delta frame delta: 3, // VP8 delta frame
audio: 1, // Opus TOC byte audio: 1, // Opus TOC byte
} }
function getUnencryptedBytes(frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame): number { async function init() {
// Audio frames don't have .type await _sodium.ready
if (!('type' in frame)) { sodium = _sodium
return UNENCRYPTED_BYTES.audio
}
return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta
} }
function makeIV(ssrc: number, timestamp: number): Uint8Array { const sodiumReady = init()
const iv = new ArrayBuffer(IV_LENGTH)
const view = new DataView(iv) function getUnencryptedBytes(frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame): number {
view.setUint32(0, ssrc, true) if (!('type' in frame)) return UNENCRYPTED_BYTES.audio
view.setUint32(4, timestamp, true) return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta
view.setUint32(8, ssrc ^ timestamp, true)
return new Uint8Array(iv)
} }
onmessage = async (ev: MessageEvent) => { onmessage = async (ev: MessageEvent) => {
@@ -44,22 +37,22 @@ onmessage = async (ev: MessageEvent) => {
switch (kind) { switch (kind) {
case 'init': case 'init':
await sodiumReady
postMessage({ kind: 'initAck', data: { enabled: true } }) postMessage({ kind: 'initAck', data: { enabled: true } })
break break
case 'setKey': { case 'setKey':
encryptionKey = await crypto.subtle.importKey( // Store raw symmetric key for libsodium (32 bytes)
'raw', data.key, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], symmetricKey = new Uint8Array(data.key)
)
postMessage({ postMessage({
kind: 'enable', kind: 'enable',
data: { enabled: true, participantIdentity: data.participantIdentity }, data: { enabled: true, participantIdentity: data.participantIdentity },
}) })
break break
}
case 'encode': case 'encode':
case 'decode': { case 'decode': {
await sodiumReady
const { readableStream, writableStream, trackId, participantIdentity } = data const { readableStream, writableStream, trackId, participantIdentity } = data
const operation = kind const operation = kind
let frameCount = 0 let frameCount = 0
@@ -67,81 +60,64 @@ onmessage = async (ev: MessageEvent) => {
const transformStream = new TransformStream({ const transformStream = new TransformStream({
transform: async (frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, controller: TransformStreamDefaultController) => { transform: async (frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, controller: TransformStreamDefaultController) => {
try { try {
if (!encryptionKey) return // drop — key not ready if (!symmetricKey) return // drop — key not ready
if (!frame.data || frame.data.byteLength === 0) { if (!frame.data || frame.data.byteLength === 0) {
return controller.enqueue(frame) return controller.enqueue(frame)
} }
const unencryptedBytes = getUnencryptedBytes(frame)
if (operation === 'encode') { if (operation === 'encode') {
// ── Encrypt (same as LiveKit FrameCryptor.encodeFunction) ── // ── Encrypt ──
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 frameHeader = new Uint8Array(frame.data, 0, unencryptedBytes)
const payload = new Uint8Array(frame.data, unencryptedBytes)
const ciphertext = await crypto.subtle.encrypt( const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) // 24 bytes
{ const ciphertext = sodium.crypto_secretbox_easy(payload, nonce, symmetricKey)
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] // Trailer: [NONCE_LENGTH][KEY_INDEX]
const frameTrailer = new Uint8Array(2) const trailer = new Uint8Array(2)
frameTrailer[0] = IV_LENGTH trailer[0] = sodium.crypto_secretbox_NONCEBYTES // 24
frameTrailer[1] = KEY_INDEX trailer[1] = KEY_INDEX
// [header][ciphertext + MAC][nonce][trailer]
const newData = new Uint8Array( const newData = new Uint8Array(
frameHeader.byteLength + ciphertext.byteLength + iv.byteLength + frameTrailer.byteLength, frameHeader.byteLength + ciphertext.byteLength + nonce.byteLength + trailer.byteLength,
) )
newData.set(frameHeader) let offset = 0
newData.set(new Uint8Array(ciphertext), frameHeader.byteLength) newData.set(frameHeader, offset); offset += frameHeader.byteLength
newData.set(iv, frameHeader.byteLength + ciphertext.byteLength) newData.set(ciphertext, offset); offset += ciphertext.byteLength
newData.set(frameTrailer, frameHeader.byteLength + ciphertext.byteLength + iv.byteLength) newData.set(nonce, offset); offset += nonce.byteLength
newData.set(trailer, offset)
frame.data = newData.buffer frame.data = newData.buffer
controller.enqueue(frame) controller.enqueue(frame)
} else { } else {
// ── Decrypt (same as LiveKit FrameCryptor.decodeFunction) ── // ── Decrypt ──
const frameData = new Uint8Array(frame.data)
const unencryptedBytes = getUnencryptedBytes(frame)
const frameHeader = new Uint8Array(frame.data, 0, unencryptedBytes) const frameHeader = new Uint8Array(frame.data, 0, unencryptedBytes)
// Read trailer // Read trailer (last 2 bytes)
const frameTrailer = new Uint8Array(frame.data, frame.data.byteLength - 2, 2) const trailer = new Uint8Array(frame.data, frame.data.byteLength - 2, 2)
const ivLength = frameTrailer[0] const nonceLength = trailer[0]
// Extract IV // Extract nonce
const iv = new Uint8Array( const nonce = new Uint8Array(
frame.data, frame.data,
frame.data.byteLength - ivLength - frameTrailer.byteLength, frame.data.byteLength - nonceLength - trailer.byteLength,
ivLength, nonceLength,
) )
// Extract ciphertext (between header and IV) // Extract ciphertext (between header and nonce)
const ciphertextStart = frameHeader.byteLength const ciphertextStart = frameHeader.byteLength
const ciphertextLength = frame.data.byteLength - frameHeader.byteLength - ivLength - frameTrailer.byteLength const ciphertextLength = frame.data.byteLength - frameHeader.byteLength - nonceLength - trailer.byteLength
const ciphertext = new Uint8Array(frame.data, ciphertextStart, ciphertextLength)
const plaintext = await crypto.subtle.decrypt( const plaintext = sodium.crypto_secretbox_open_easy(ciphertext, nonce, symmetricKey)
{
name: 'AES-GCM',
iv,
additionalData: new Uint8Array(frame.data, 0, frameHeader.byteLength),
},
encryptionKey,
new Uint8Array(frame.data, ciphertextStart, ciphertextLength),
)
// Reconstruct: [header][plaintext] // Reconstruct: [header][plaintext]
const newData = new Uint8Array(frameHeader.byteLength + plaintext.byteLength) const newData = new Uint8Array(frameHeader.byteLength + plaintext.byteLength)
newData.set(frameHeader) newData.set(frameHeader)
newData.set(new Uint8Array(plaintext), frameHeader.byteLength) newData.set(plaintext, frameHeader.byteLength)
frame.data = newData.buffer frame.data = newData.buffer
controller.enqueue(frame) controller.enqueue(frame)