mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-03 14:17:59 +00:00
wip working with vaultclient
This commit is contained in:
@@ -1,10 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* STEP 2b: E2EE Manager using a custom Worker with libsodium crypto.
|
* E2EE Manager using VaultClient iframe for crypto.
|
||||||
* Same architecture as the built-in (streams transferred to Worker), but
|
* Preserves codec header bytes unencrypted (required for RTP packetization).
|
||||||
* using XChaCha20-Poly1305 via libsodium instead of AES-GCM via crypto.subtle.
|
* TransformStream runs on main thread, crypto delegated to vault iframe.
|
||||||
*
|
|
||||||
* This solves the receiver-reuse problem: transferred streams survive track
|
|
||||||
* changes, so the pipe in the Worker keeps working when a participant refreshes.
|
|
||||||
*/
|
*/
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { Encryption_Type } from '@livekit/protocol'
|
import { Encryption_Type } from '@livekit/protocol'
|
||||||
@@ -23,49 +20,41 @@ enum EncryptionEvent {
|
|||||||
EncryptionError = 'encryptionError',
|
EncryptionError = 'encryptionError',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hardcoded 32-byte key — same on all participants (Step 2)
|
// VP8 unencrypted header bytes (same as LiveKit FrameCryptor)
|
||||||
const HARDCODED_KEY = new Uint8Array([
|
const UNENCRYPTED_BYTES = { key: 10, delta: 3, audio: 1 }
|
||||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
||||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
function getUnencryptedBytes(frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame): number {
|
||||||
])
|
if (!('type' in frame)) return UNENCRYPTED_BYTES.audio
|
||||||
|
return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta
|
||||||
|
}
|
||||||
|
|
||||||
export class VaultE2EEManager extends EventEmitter {
|
export class VaultE2EEManager extends EventEmitter {
|
||||||
|
private vaultClient: VaultClient
|
||||||
private room?: Room
|
private room?: Room
|
||||||
private encryptionEnabled = false
|
private encryptionEnabled = false
|
||||||
private _isDataChannelEncryptionEnabled = false
|
private _isDataChannelEncryptionEnabled = false
|
||||||
private worker: Worker
|
private encryptedKeyBytes: Uint8Array | null = null
|
||||||
|
|
||||||
constructor(_vaultClient: VaultClient) {
|
constructor(vaultClient: VaultClient) {
|
||||||
super()
|
super()
|
||||||
this.worker = new Worker(
|
this.vaultClient = vaultClient
|
||||||
new URL('./vault-e2ee.worker.ts', import.meta.url),
|
|
||||||
{ type: 'module' },
|
|
||||||
)
|
|
||||||
this.worker.onmessage = this.onWorkerMessage
|
|
||||||
this.worker.onerror = (ev) => {
|
|
||||||
console.error('[VaultE2EE] worker error:', ev)
|
|
||||||
}
|
|
||||||
// Send init AND key immediately — key doesn't need sodium, so the Worker
|
|
||||||
// can store it before WASM loads. This avoids the race where encode/decode
|
|
||||||
// messages arrive before the key is set.
|
|
||||||
this.worker.postMessage({ kind: 'init', data: {} })
|
|
||||||
this.worker.postMessage({ kind: 'setKey', data: { key: HARDCODED_KEY, participantIdentity: '__init__' } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get isEnabled() {
|
get isEnabled() { return this.encryptionEnabled }
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
setEncryptedSymmetricKey(_key: ArrayBuffer): void {
|
private freshKeyBuffer(): ArrayBuffer {
|
||||||
// No-op for Step 2 — using hardcoded key
|
return new Uint8Array(this.encryptedKeyBytes!).buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
setEncryptedSymmetricKey(key: ArrayBuffer): void {
|
||||||
|
this.encryptedKeyBytes = new Uint8Array(new Uint8Array(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
setup(room: Room): void {
|
setup(room: Room): void {
|
||||||
@@ -78,109 +67,58 @@ 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 {
|
||||||
// Send key to worker when enabling
|
if (
|
||||||
if (enabled) {
|
participantIdentity === this.room?.localParticipant.identity &&
|
||||||
this.worker.postMessage({
|
this.encryptionEnabled !== enabled
|
||||||
kind: 'setKey',
|
) {
|
||||||
data: { key: HARDCODED_KEY, participantIdentity },
|
this.encryptionEnabled = enabled
|
||||||
})
|
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)
|
||||||
}
|
}
|
||||||
this.worker.postMessage({
|
|
||||||
kind: 'enable',
|
|
||||||
data: { enabled, participantIdentity },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSifTrailer(_trailer: Uint8Array): void {}
|
setSifTrailer(_trailer: Uint8Array): void {}
|
||||||
|
|
||||||
async encryptData(_data: Uint8Array) {
|
async encryptData(data: Uint8Array) {
|
||||||
return { uuid: crypto.randomUUID(), payload: _data, iv: new Uint8Array(0), keyIndex: 0 }
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleEncryptedData(payload: Uint8Array) {
|
async handleEncryptedData(payload: Uint8Array, _iv: Uint8Array, _id: string, _idx: number) {
|
||||||
return { uuid: crypto.randomUUID(), payload }
|
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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Worker messages ─────────────────────────────────────────────────
|
// ── Events (same as built-in E2EEManager) ───────────────────────────
|
||||||
|
|
||||||
private onWorkerMessage = (ev: MessageEvent) => {
|
|
||||||
const { kind, data } = ev.data
|
|
||||||
switch (kind) {
|
|
||||||
case 'initAck':
|
|
||||||
console.info('[VaultE2EE] STEP 2b: Worker ready (libsodium)')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'enable':
|
|
||||||
if (
|
|
||||||
this.encryptionEnabled !== data.enabled &&
|
|
||||||
data.participantIdentity === this.room?.localParticipant.identity
|
|
||||||
) {
|
|
||||||
this.encryptionEnabled = data.enabled
|
|
||||||
this.emit(EncryptionEvent.ParticipantEncryptionStatusChanged, data.enabled, this.room!.localParticipant)
|
|
||||||
} else if (data.participantIdentity && data.participantIdentity !== '__init__') {
|
|
||||||
const p = this.room?.getParticipantByIdentity(data.participantIdentity)
|
|
||||||
if (p) this.emit(EncryptionEvent.ParticipantEncryptionStatusChanged, data.enabled, p)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event listeners (same as built-in) ──────────────────────────────
|
|
||||||
|
|
||||||
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,
|
pub.trackInfo!.encryption !== Encryption_Type.NONE, participant.identity)
|
||||||
participant.identity,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
room.on(RoomEvent.ConnectionStateChanged, (state) => {
|
room.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||||
if (state === ConnectionState.Connected) {
|
if (state === ConnectionState.Connected) {
|
||||||
room.remoteParticipants.forEach((participant) => {
|
room.remoteParticipants.forEach((p) => {
|
||||||
participant.trackPublications.forEach((pub) => {
|
p.trackPublications.forEach((pub) => {
|
||||||
this.setParticipantCryptorEnabled(
|
this.setParticipantCryptorEnabled(
|
||||||
pub.trackInfo!.encryption !== Encryption_Type.NONE,
|
pub.trackInfo!.encryption !== Encryption_Type.NONE, p.identity)
|
||||||
participant.identity,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
room.on(RoomEvent.TrackUnsubscribed, (track, _, participant) => {
|
room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
|
||||||
this.worker.postMessage({
|
this.setupReceiver(track, participant.identity)
|
||||||
kind: 'removeTransform',
|
|
||||||
data: { participantIdentity: participant.identity, trackId: track.mediaStreamID },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
room.on(RoomEvent.TrackSubscribed, (track, pub, participant) => {
|
|
||||||
this.setupReceiver(track, participant.identity, pub.trackInfo)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
room.on(RoomEvent.SignalConnected, () => {
|
room.on(RoomEvent.SignalConnected, () => {
|
||||||
this.setParticipantCryptorEnabled(
|
this.setParticipantCryptorEnabled(
|
||||||
room.localParticipant.isE2EEEnabled,
|
room.localParticipant.isE2EEEnabled, room.localParticipant.identity)
|
||||||
room.localParticipant.identity,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
room.localParticipant.on(
|
room.localParticipant.on(
|
||||||
@@ -191,79 +129,114 @@ export class VaultE2EEManager extends EventEmitter {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sender/Receiver — streams transferred to Worker ─────────────────
|
// ── Sender ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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
|
||||||
const senderStreams = sender.createEncodedStreams()
|
const streams = sender.createEncodedStreams()
|
||||||
|
|
||||||
this.worker.postMessage(
|
const transformStream = new TransformStream({
|
||||||
{
|
transform: async (frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, controller: TransformStreamDefaultController) => {
|
||||||
kind: 'encode',
|
try {
|
||||||
data: {
|
if (!this.encryptedKeyBytes || !frame.data || frame.data.byteLength === 0) {
|
||||||
readableStream: senderStreams.readable,
|
return controller.enqueue(frame)
|
||||||
writableStream: senderStreams.writable,
|
}
|
||||||
trackId,
|
|
||||||
participantIdentity: this.room.localParticipant.identity,
|
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(),
|
||||||
|
)
|
||||||
|
const encrypted = new Uint8Array(encryptedData)
|
||||||
|
|
||||||
|
// Frame: [header][encrypted payload]
|
||||||
|
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
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[senderStreams.readable, senderStreams.writable],
|
})
|
||||||
)
|
|
||||||
|
|
||||||
|
streams.readable.pipeThrough(transformStream).pipeTo(streams.writable)
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
sender[E2EE_FLAG] = true
|
sender[E2EE_FLAG] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupReceiver(track: RemoteTrack, participantIdentity: string, trackInfo?: { mimeType?: string }): void {
|
// ── Receiver ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private setupReceiver(track: RemoteTrack, participantIdentity: string): void {
|
||||||
if (!track.receiver) return
|
if (!track.receiver) return
|
||||||
const receiver = track.receiver
|
const receiver = track.receiver
|
||||||
|
if (E2EE_FLAG in receiver) return
|
||||||
|
|
||||||
if (E2EE_FLAG in receiver) {
|
// @ts-expect-error
|
||||||
// Receiver reuse — Worker's existing pipe handles new track frames
|
let writable: WritableStream = receiver.writableStream
|
||||||
this.worker.postMessage({
|
// @ts-expect-error
|
||||||
kind: 'updateCodec',
|
let readable: ReadableStream = receiver.readableStream
|
||||||
data: {
|
|
||||||
trackId: track.mediaStreamID,
|
|
||||||
participantIdentity,
|
|
||||||
codec: trackInfo?.mimeType?.split('/')[1],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let writable: WritableStream
|
if (!writable || !readable) {
|
||||||
let readable: ReadableStream
|
|
||||||
|
|
||||||
try {
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const receiverStreams = receiver.createEncodedStreams()
|
const streams = receiver.createEncodedStreams()
|
||||||
writable = receiverStreams.writable
|
// @ts-expect-error
|
||||||
readable = receiverStreams.readable
|
receiver.writableStream = streams.writable
|
||||||
} catch {
|
writable = streams.writable
|
||||||
// createEncodedStreams() already called (receiver reuse after pipe death).
|
// @ts-expect-error
|
||||||
// Cannot re-create streams — this receiver is stuck.
|
receiver.readableStream = streams.readable
|
||||||
console.warn(`[VaultE2EE] Cannot create encoded streams for ${participantIdentity} (receiver reuse). Pipe unrecoverable.`)
|
readable = streams.readable
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.worker.postMessage(
|
let successEmitted = false
|
||||||
{
|
|
||||||
kind: 'decode',
|
|
||||||
data: {
|
|
||||||
readableStream: readable,
|
|
||||||
writableStream: writable,
|
|
||||||
trackId: track.mediaStreamID,
|
|
||||||
participantIdentity,
|
|
||||||
codec: trackInfo?.mimeType?.split('/')[1],
|
|
||||||
isReuse: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[readable, writable],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
const transformStream = new TransformStream({
|
||||||
|
transform: async (frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, controller: TransformStreamDefaultController) => {
|
||||||
|
try {
|
||||||
|
if (!this.encryptedKeyBytes || !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(),
|
||||||
|
)
|
||||||
|
const plaintext = new Uint8Array(data)
|
||||||
|
|
||||||
|
// Frame: [header][plaintext]
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Drop frame on decrypt error — keeps pipe alive
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
readable.pipeThrough(transformStream).pipeTo(writable).catch(() => {})
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
receiver[E2EE_FLAG] = true
|
receiver[E2EE_FLAG] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,11 +199,9 @@ export const Conference = ({
|
|||||||
if (!encryptionEnabled || encryptionSetupComplete) return
|
if (!encryptionEnabled || encryptionSetupComplete) return
|
||||||
|
|
||||||
if (useVaultE2EE) {
|
if (useVaultE2EE) {
|
||||||
// Advanced mode: VaultE2EEManager handles its own Worker+KeyProvider internally
|
// Advanced mode: VaultE2EEManager delegates crypto to VaultClient iframe
|
||||||
const vaultManager = getVaultManager()
|
const vaultManager = getVaultManager()
|
||||||
if (!vaultManager || !vaultClient) return
|
if (!vaultManager || !vaultClient) return
|
||||||
|
|
||||||
// setEncryptedSymmetricKey is a no-op for now (Step 1: hardcoded passphrase)
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const existingKey = data?.encrypted_symmetric_key
|
const existingKey = data?.encrypted_symmetric_key
|
||||||
if (existingKey) {
|
if (existingKey) {
|
||||||
|
|||||||
Reference in New Issue
Block a user