mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-30 12:09:08 +00:00
wip improvement
This commit is contained in:
@@ -1,16 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* E2EE Manager using VaultClient iframe for crypto.
|
* Custom E2EE Manager that delegates crypto to the VaultClient iframe.
|
||||||
* Preserves codec header bytes unencrypted (required for RTP packetization).
|
*
|
||||||
* TransformStream runs on main thread, crypto delegated to vault 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 { EventEmitter } from 'events'
|
||||||
import { Encryption_Type } from '@livekit/protocol'
|
import { Encryption_Type } from '@livekit/protocol'
|
||||||
import type { Room, RemoteTrack, Track } from 'livekit-client'
|
import type { Room, RemoteTrack, Track } from 'livekit-client'
|
||||||
import {
|
import { RoomEvent, ParticipantEvent, ConnectionState } from 'livekit-client'
|
||||||
RoomEvent,
|
|
||||||
ParticipantEvent,
|
|
||||||
ConnectionState,
|
|
||||||
} from 'livekit-client'
|
|
||||||
import type { RTCEngine } from 'livekit-client/src/room/RTCEngine'
|
import type { RTCEngine } from 'livekit-client/src/room/RTCEngine'
|
||||||
|
|
||||||
const E2EE_FLAG = Symbol('e2ee')
|
const E2EE_FLAG = Symbol('e2ee')
|
||||||
@@ -20,10 +28,11 @@ enum EncryptionEvent {
|
|||||||
EncryptionError = 'encryptionError',
|
EncryptionError = 'encryptionError',
|
||||||
}
|
}
|
||||||
|
|
||||||
// VP8 unencrypted header bytes (same as LiveKit FrameCryptor)
|
|
||||||
const UNENCRYPTED_BYTES = { key: 10, delta: 3, audio: 1 }
|
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
|
if (!('type' in frame)) return UNENCRYPTED_BYTES.audio
|
||||||
return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta
|
return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta
|
||||||
}
|
}
|
||||||
@@ -33,6 +42,11 @@ export class VaultE2EEManager extends EventEmitter {
|
|||||||
private room?: Room
|
private room?: Room
|
||||||
private encryptionEnabled = false
|
private encryptionEnabled = false
|
||||||
private _isDataChannelEncryptionEnabled = 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
|
private encryptedKeyBytes: Uint8Array | null = null
|
||||||
|
|
||||||
constructor(vaultClient: VaultClient) {
|
constructor(vaultClient: VaultClient) {
|
||||||
@@ -40,15 +54,19 @@ export class VaultE2EEManager extends EventEmitter {
|
|||||||
this.vaultClient = vaultClient
|
this.vaultClient = vaultClient
|
||||||
}
|
}
|
||||||
|
|
||||||
get isEnabled() { return this.encryptionEnabled }
|
get isEnabled() {
|
||||||
|
return this.encryptionEnabled
|
||||||
|
}
|
||||||
|
|
||||||
get isDataChannelEncryptionEnabled() {
|
get isDataChannelEncryptionEnabled() {
|
||||||
return this.isEnabled && this._isDataChannelEncryptionEnabled
|
return this.isEnabled && this._isDataChannelEncryptionEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
set isDataChannelEncryptionEnabled(enabled: boolean) {
|
set isDataChannelEncryptionEnabled(enabled: boolean) {
|
||||||
this._isDataChannelEncryptionEnabled = enabled
|
this._isDataChannelEncryptionEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fresh ArrayBuffer copy of the key for each vault call (avoids postMessage detachment). */
|
||||||
private freshKeyBuffer(): ArrayBuffer {
|
private freshKeyBuffer(): ArrayBuffer {
|
||||||
return new Uint8Array(this.encryptedKeyBytes!).buffer
|
return new Uint8Array(this.encryptedKeyBytes!).buffer
|
||||||
}
|
}
|
||||||
@@ -57,6 +75,8 @@ export class VaultE2EEManager extends EventEmitter {
|
|||||||
this.encryptedKeyBytes = new Uint8Array(new Uint8Array(key))
|
this.encryptedKeyBytes = new Uint8Array(new Uint8Array(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle (mirrors built-in E2EEManager) ────────────────────────
|
||||||
|
|
||||||
setup(room: Room): void {
|
setup(room: Room): void {
|
||||||
if (room !== this.room) {
|
if (room !== this.room) {
|
||||||
this.room = room
|
this.room = room
|
||||||
@@ -66,39 +86,74 @@ export class VaultE2EEManager extends EventEmitter {
|
|||||||
|
|
||||||
setupEngine(_engine: RTCEngine): void {}
|
setupEngine(_engine: RTCEngine): void {}
|
||||||
|
|
||||||
setParticipantCryptorEnabled(enabled: boolean, participantIdentity: string): void {
|
setParticipantCryptorEnabled(
|
||||||
|
enabled: boolean,
|
||||||
|
participantIdentity: string
|
||||||
|
): void {
|
||||||
if (
|
if (
|
||||||
participantIdentity === this.room?.localParticipant.identity &&
|
participantIdentity === this.room?.localParticipant.identity &&
|
||||||
this.encryptionEnabled !== enabled
|
this.encryptionEnabled !== enabled
|
||||||
) {
|
) {
|
||||||
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) {
|
} else if (participantIdentity !== this.room?.localParticipant.identity) {
|
||||||
const p = this.room?.getParticipantByIdentity(participantIdentity)
|
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 {}
|
setSifTrailer(_trailer: Uint8Array): void {}
|
||||||
|
|
||||||
async encryptData(data: Uint8Array) {
|
async encryptData(data: Uint8Array) {
|
||||||
if (!this.encryptedKeyBytes) throw new Error('No key')
|
if (!this.encryptedKeyBytes)
|
||||||
const r = await this.vaultClient.encryptWithKey(data.slice().buffer, this.freshKeyBuffer())
|
throw new Error('No encrypted symmetric key set')
|
||||||
return { uuid: crypto.randomUUID(), payload: new Uint8Array(r.encryptedData).slice(), iv: new Uint8Array(0), keyIndex: 0 }
|
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) {
|
async handleEncryptedData(
|
||||||
if (!this.encryptedKeyBytes) throw new Error('No key')
|
payload: Uint8Array,
|
||||||
const r = await this.vaultClient.decryptWithKey(payload.slice().buffer, this.freshKeyBuffer())
|
_iv: Uint8Array,
|
||||||
return { uuid: crypto.randomUUID(), payload: new Uint8Array(r.data).slice() }
|
_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 {
|
private setupEventListeners(room: Room): void {
|
||||||
room.on(RoomEvent.TrackPublished, (pub, participant) => {
|
room.on(RoomEvent.TrackPublished, (pub, participant) => {
|
||||||
this.setParticipantCryptorEnabled(
|
this.setParticipantCryptorEnabled(
|
||||||
pub.trackInfo!.encryption !== Encryption_Type.NONE, participant.identity)
|
pub.trackInfo!.encryption !== Encryption_Type.NONE,
|
||||||
|
participant.identity
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
room.on(RoomEvent.ConnectionStateChanged, (state) => {
|
room.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||||
@@ -106,7 +161,9 @@ export class VaultE2EEManager extends EventEmitter {
|
|||||||
room.remoteParticipants.forEach((p) => {
|
room.remoteParticipants.forEach((p) => {
|
||||||
p.trackPublications.forEach((pub) => {
|
p.trackPublications.forEach((pub) => {
|
||||||
this.setParticipantCryptorEnabled(
|
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, () => {
|
room.on(RoomEvent.SignalConnected, () => {
|
||||||
this.setParticipantCryptorEnabled(
|
this.setParticipantCryptorEnabled(
|
||||||
room.localParticipant.isE2EEEnabled, room.localParticipant.identity)
|
room.localParticipant.isE2EEEnabled,
|
||||||
|
room.localParticipant.identity
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
room.localParticipant.on(
|
room.localParticipant.on(
|
||||||
ParticipantEvent.LocalSenderCreated,
|
ParticipantEvent.LocalSenderCreated,
|
||||||
(sender: RTCRtpSender, track: Track) => {
|
(sender: RTCRtpSender, track: Track) => {
|
||||||
this.setupSender(sender, track.mediaStreamID)
|
this.setupSender(sender, track.mediaStreamID)
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sender ──────────────────────────────────────────────────────────
|
// ── Sender (encrypt outgoing frames) ────────────────────────────────
|
||||||
|
|
||||||
private setupSender(sender: RTCRtpSender, _trackId: string): void {
|
private setupSender(sender: RTCRtpSender, _trackId: string): void {
|
||||||
if (E2EE_FLAG in sender) return
|
if (E2EE_FLAG in sender) return
|
||||||
if (!this.room?.localParticipant.identity) return
|
if (!this.room?.localParticipant.identity) return
|
||||||
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error — createEncodedStreams not in TS types
|
||||||
const streams = sender.createEncodedStreams()
|
const streams = sender.createEncodedStreams()
|
||||||
|
|
||||||
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 (!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)
|
return controller.enqueue(frame)
|
||||||
}
|
|
||||||
|
|
||||||
const unencryptedBytes = getUnencryptedBytes(frame)
|
const unencryptedBytes = getUnencryptedBytes(frame)
|
||||||
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
|
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
|
||||||
const payload = new Uint8Array(frame.data, unencryptedBytes)
|
const payload = new Uint8Array(frame.data, unencryptedBytes)
|
||||||
|
|
||||||
// Vault encrypts payload → returns [nonce][ciphertext+MAC]
|
|
||||||
const { encryptedData } = await this.vaultClient.encryptWithKey(
|
const { encryptedData } = await this.vaultClient.encryptWithKey(
|
||||||
payload.slice().buffer,
|
payload.slice().buffer,
|
||||||
this.freshKeyBuffer(),
|
this.freshKeyBuffer()
|
||||||
)
|
)
|
||||||
const encrypted = new Uint8Array(encryptedData)
|
|
||||||
|
|
||||||
// Frame: [header][encrypted payload]
|
const encrypted = new Uint8Array(encryptedData)
|
||||||
const newData = new Uint8Array(header.byteLength + encrypted.byteLength)
|
const newData = new Uint8Array(
|
||||||
|
header.byteLength + encrypted.byteLength
|
||||||
|
)
|
||||||
newData.set(header)
|
newData.set(header)
|
||||||
newData.set(encrypted, header.byteLength)
|
newData.set(encrypted, header.byteLength)
|
||||||
frame.data = newData.buffer
|
frame.data = newData.buffer
|
||||||
|
|
||||||
controller.enqueue(frame)
|
controller.enqueue(frame)
|
||||||
} catch {
|
} 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
|
sender[E2EE_FLAG] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Receiver ────────────────────────────────────────────────────────
|
// ── Receiver (decrypt incoming frames) ──────────────────────────────
|
||||||
|
|
||||||
private setupReceiver(track: RemoteTrack, participantIdentity: string): void {
|
private setupReceiver(track: RemoteTrack, participantIdentity: string): void {
|
||||||
if (!track.receiver) return
|
if (!track.receiver) return
|
||||||
@@ -200,43 +261,53 @@ export class VaultE2EEManager extends EventEmitter {
|
|||||||
let successEmitted = false
|
let successEmitted = false
|
||||||
|
|
||||||
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 (!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)
|
return controller.enqueue(frame)
|
||||||
}
|
|
||||||
|
|
||||||
const unencryptedBytes = getUnencryptedBytes(frame)
|
const unencryptedBytes = getUnencryptedBytes(frame)
|
||||||
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
|
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
|
||||||
const encryptedPayload = new Uint8Array(frame.data, unencryptedBytes)
|
const encryptedPayload = new Uint8Array(frame.data, unencryptedBytes)
|
||||||
|
|
||||||
// Vault decrypts [nonce][ciphertext+MAC] → plaintext
|
|
||||||
const { data } = await this.vaultClient.decryptWithKey(
|
const { data } = await this.vaultClient.decryptWithKey(
|
||||||
encryptedPayload.slice().buffer,
|
encryptedPayload.slice().buffer,
|
||||||
this.freshKeyBuffer(),
|
this.freshKeyBuffer()
|
||||||
)
|
)
|
||||||
const plaintext = new Uint8Array(data)
|
|
||||||
|
|
||||||
// Frame: [header][plaintext]
|
const plaintext = new Uint8Array(data)
|
||||||
const newData = new Uint8Array(header.byteLength + plaintext.byteLength)
|
const newData = new Uint8Array(
|
||||||
|
header.byteLength + plaintext.byteLength
|
||||||
|
)
|
||||||
newData.set(header)
|
newData.set(header)
|
||||||
newData.set(plaintext, header.byteLength)
|
newData.set(plaintext, header.byteLength)
|
||||||
frame.data = newData.buffer
|
frame.data = newData.buffer
|
||||||
|
|
||||||
controller.enqueue(frame)
|
controller.enqueue(frame)
|
||||||
|
|
||||||
if (!successEmitted) {
|
if (!successEmitted) {
|
||||||
successEmitted = true
|
successEmitted = true
|
||||||
const p = this.room?.getParticipantByIdentity(participantIdentity)
|
const p = this.room?.getParticipantByIdentity(participantIdentity)
|
||||||
if (p) this.emit(EncryptionEvent.ParticipantEncryptionStatusChanged, true, p)
|
if (p)
|
||||||
|
this.emit(
|
||||||
|
EncryptionEvent.ParticipantEncryptionStatusChanged,
|
||||||
|
true,
|
||||||
|
p
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} 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
|
// @ts-expect-error
|
||||||
receiver[E2EE_FLAG] = true
|
receiver[E2EE_FLAG] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user