mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-09 02:10:34 +00:00
wip issue
This commit is contained in:
@@ -204,7 +204,7 @@ class LobbyService:
|
||||
room.id, participant_id, username,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
|
||||
suite_user_id=str(request.user.id) if request.user.is_authenticated else None,
|
||||
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
|
||||
ephemeral_public_key=ephemeral_public_key,
|
||||
)
|
||||
|
||||
@@ -224,6 +224,7 @@ class LobbyService:
|
||||
room.id, participant_id, username,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
|
||||
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
|
||||
ephemeral_public_key=ephemeral_public_key,
|
||||
)
|
||||
return participant, None
|
||||
|
||||
@@ -1,37 +1,21 @@
|
||||
/**
|
||||
* Custom E2EE Manager that delegates crypto operations to the VaultClient iframe.
|
||||
* STEP 2b: E2EE Manager using a custom Worker with libsodium crypto.
|
||||
* Same architecture as the built-in (streams transferred to Worker), but
|
||||
* using XChaCha20-Poly1305 via libsodium instead of AES-GCM via crypto.subtle.
|
||||
*
|
||||
* Instead of using LiveKit's built-in Worker + FrameCryptor, this manager:
|
||||
* - Sets up insertable streams (createEncodedStreams) on senders/receivers
|
||||
* - Pipes frames through a TransformStream on the main thread
|
||||
* - Delegates encrypt/decrypt to VaultClient.encryptWithKey / decryptWithKey
|
||||
* - The symmetric key never leaves the VaultClient iframe
|
||||
*
|
||||
* Uses transferable ArrayBuffers for zero-copy performance.
|
||||
* 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 { Encryption_Type } from '@livekit/protocol'
|
||||
import type { Room, RemoteTrack, Track } from 'livekit-client'
|
||||
import {
|
||||
RoomEvent,
|
||||
ParticipantEvent,
|
||||
ConnectionState,
|
||||
Encryption_Type,
|
||||
} from 'livekit-client'
|
||||
import type { RTCEngine } from 'livekit-client/src/room/RTCEngine'
|
||||
|
||||
// Re-declare the interface types we need (not exported from livekit-client)
|
||||
interface EncryptDataResponse {
|
||||
uuid: string
|
||||
payload: Uint8Array
|
||||
iv: Uint8Array
|
||||
keyIndex: number
|
||||
}
|
||||
|
||||
interface DecryptDataResponse {
|
||||
uuid: string
|
||||
payload: Uint8Array
|
||||
}
|
||||
|
||||
const E2EE_FLAG = Symbol('e2ee')
|
||||
|
||||
enum EncryptionEvent {
|
||||
@@ -39,29 +23,40 @@ enum EncryptionEvent {
|
||||
EncryptionError = 'encryptionError',
|
||||
}
|
||||
|
||||
function isLocalTrack(track: Track): boolean {
|
||||
return (track as { sender?: RTCRtpSender }).sender !== undefined
|
||||
}
|
||||
// Hardcoded 32-byte key — same on all participants (Step 2)
|
||||
const HARDCODED_KEY = new Uint8Array([
|
||||
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,
|
||||
])
|
||||
|
||||
export class VaultE2EEManager extends EventEmitter {
|
||||
private vaultClient: VaultClient
|
||||
private room?: Room
|
||||
private encryptionEnabled = false
|
||||
private _isDataChannelEncryptionEnabled = false
|
||||
private worker: Worker
|
||||
|
||||
/** The symmetric key encrypted for the current user's vault public key */
|
||||
private encryptedSymmetricKey: ArrayBuffer | null = null
|
||||
|
||||
constructor(vaultClient: VaultClient) {
|
||||
constructor(_vaultClient: VaultClient) {
|
||||
super()
|
||||
this.vaultClient = vaultClient
|
||||
this.worker = new Worker(
|
||||
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(): boolean {
|
||||
get isEnabled() {
|
||||
return this.encryptionEnabled
|
||||
}
|
||||
|
||||
get isDataChannelEncryptionEnabled(): boolean {
|
||||
get isDataChannelEncryptionEnabled() {
|
||||
return this.isEnabled && this._isDataChannelEncryptionEnabled
|
||||
}
|
||||
|
||||
@@ -69,12 +64,8 @@ export class VaultE2EEManager extends EventEmitter {
|
||||
this._isDataChannelEncryptionEnabled = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the encrypted symmetric key (wrapped for this user's vault public key).
|
||||
* Must be called before encryption can work.
|
||||
*/
|
||||
setEncryptedSymmetricKey(key: ArrayBuffer): void {
|
||||
this.encryptedSymmetricKey = key
|
||||
setEncryptedSymmetricKey(_key: ArrayBuffer): void {
|
||||
// No-op for Step 2 — using hardcoded key
|
||||
}
|
||||
|
||||
setup(room: Room): void {
|
||||
@@ -84,77 +75,67 @@ export class VaultE2EEManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
setupEngine(_engine: RTCEngine): void {
|
||||
// No RTP map tracking needed — VaultClient handles crypto opaquely
|
||||
}
|
||||
setupEngine(_engine: RTCEngine): void {}
|
||||
|
||||
setParticipantCryptorEnabled(enabled: boolean, participantIdentity: string): void {
|
||||
if (
|
||||
participantIdentity === this.room?.localParticipant.identity &&
|
||||
this.encryptionEnabled !== enabled
|
||||
) {
|
||||
this.encryptionEnabled = enabled
|
||||
this.emit(
|
||||
EncryptionEvent.ParticipantEncryptionStatusChanged,
|
||||
enabled,
|
||||
this.room!.localParticipant
|
||||
)
|
||||
} else if (participantIdentity !== this.room?.localParticipant.identity) {
|
||||
const participant = this.room?.getParticipantByIdentity(participantIdentity)
|
||||
if (participant) {
|
||||
this.emit(EncryptionEvent.ParticipantEncryptionStatusChanged, enabled, participant)
|
||||
}
|
||||
// Send key to worker when enabling
|
||||
if (enabled) {
|
||||
this.worker.postMessage({
|
||||
kind: 'setKey',
|
||||
data: { key: HARDCODED_KEY, participantIdentity },
|
||||
})
|
||||
}
|
||||
this.worker.postMessage({
|
||||
kind: 'enable',
|
||||
data: { enabled, participantIdentity },
|
||||
})
|
||||
}
|
||||
|
||||
setSifTrailer(_trailer: Uint8Array): void {}
|
||||
|
||||
async encryptData(_data: Uint8Array) {
|
||||
return { uuid: crypto.randomUUID(), payload: _data, iv: new Uint8Array(0), keyIndex: 0 }
|
||||
}
|
||||
|
||||
async handleEncryptedData(payload: Uint8Array) {
|
||||
return { uuid: crypto.randomUUID(), payload }
|
||||
}
|
||||
|
||||
// ── Worker messages ─────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
setSifTrailer(_trailer: Uint8Array): void {
|
||||
// SIF (Server Injected Frames) not supported in vault mode
|
||||
}
|
||||
|
||||
async encryptData(data: Uint8Array): Promise<EncryptDataResponse> {
|
||||
if (!this.encryptedSymmetricKey) {
|
||||
throw new Error('No encrypted symmetric key set')
|
||||
}
|
||||
|
||||
const { encryptedData } = await this.vaultClient.encryptWithKey(
|
||||
data.buffer as ArrayBuffer,
|
||||
this.encryptedSymmetricKey
|
||||
)
|
||||
|
||||
return {
|
||||
uuid: crypto.randomUUID(),
|
||||
payload: new Uint8Array(encryptedData),
|
||||
iv: new Uint8Array(0), // IV is embedded by VaultClient
|
||||
keyIndex: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async handleEncryptedData(
|
||||
payload: Uint8Array,
|
||||
_iv: Uint8Array,
|
||||
_participantIdentity: string,
|
||||
_keyIndex: number
|
||||
): Promise<DecryptDataResponse> {
|
||||
if (!this.encryptedSymmetricKey) {
|
||||
throw new Error('No encrypted symmetric key set')
|
||||
}
|
||||
|
||||
const { data } = await this.vaultClient.decryptWithKey(
|
||||
payload.buffer as ArrayBuffer,
|
||||
this.encryptedSymmetricKey
|
||||
)
|
||||
|
||||
return {
|
||||
uuid: crypto.randomUUID(),
|
||||
payload: new Uint8Array(data),
|
||||
}
|
||||
}
|
||||
// ── Event listeners (same as built-in) ──────────────────────────────
|
||||
|
||||
private setupEventListeners(room: Room): void {
|
||||
room.on(RoomEvent.TrackPublished, (pub, participant) => {
|
||||
this.setParticipantCryptorEnabled(
|
||||
pub.trackInfo!.encryption !== Encryption_Type.NONE,
|
||||
participant.identity
|
||||
participant.identity,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -164,21 +145,28 @@ export class VaultE2EEManager extends EventEmitter {
|
||||
participant.trackPublications.forEach((pub) => {
|
||||
this.setParticipantCryptorEnabled(
|
||||
pub.trackInfo!.encryption !== Encryption_Type.NONE,
|
||||
participant.identity
|
||||
participant.identity,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
room.on(RoomEvent.TrackUnsubscribed, (track, _, participant) => {
|
||||
this.worker.postMessage({
|
||||
kind: 'removeTransform',
|
||||
data: { participantIdentity: participant.identity, trackId: track.mediaStreamID },
|
||||
})
|
||||
})
|
||||
|
||||
room.on(RoomEvent.TrackSubscribed, (track, pub, participant) => {
|
||||
this.setupReceiver(track, participant.identity)
|
||||
this.setupReceiver(track, participant.identity, pub.trackInfo)
|
||||
})
|
||||
|
||||
room.on(RoomEvent.SignalConnected, () => {
|
||||
this.setParticipantCryptorEnabled(
|
||||
room.localParticipant.isE2EEEnabled,
|
||||
room.localParticipant.identity
|
||||
room.localParticipant.identity,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -186,57 +174,54 @@ export class VaultE2EEManager extends EventEmitter {
|
||||
ParticipantEvent.LocalSenderCreated,
|
||||
(sender: RTCRtpSender, track: Track) => {
|
||||
this.setupSender(sender, track.mediaStreamID)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sender/Receiver — streams transferred to Worker ─────────────────
|
||||
|
||||
private setupSender(sender: RTCRtpSender, trackId: string): void {
|
||||
if (E2EE_FLAG in sender) return
|
||||
if (!this.room?.localParticipant.identity) return
|
||||
|
||||
// @ts-expect-error - createEncodedStreams is not in the TS types
|
||||
// @ts-expect-error
|
||||
const senderStreams = sender.createEncodedStreams()
|
||||
const readable: ReadableStream = senderStreams.readable
|
||||
const writable: WritableStream = senderStreams.writable
|
||||
|
||||
const transformStream = new TransformStream({
|
||||
transform: async (frame, controller) => {
|
||||
try {
|
||||
if (!this.encryptedSymmetricKey || !this.encryptionEnabled) {
|
||||
controller.enqueue(frame)
|
||||
return
|
||||
}
|
||||
|
||||
const frameData = new Uint8Array(frame.data)
|
||||
const { encryptedData } = await this.vaultClient.encryptWithKey(
|
||||
frameData.buffer as ArrayBuffer,
|
||||
this.encryptedSymmetricKey
|
||||
)
|
||||
|
||||
frame.data = encryptedData
|
||||
controller.enqueue(frame)
|
||||
} catch (err) {
|
||||
// On error, pass frame through unencrypted to avoid blocking the pipeline
|
||||
controller.enqueue(frame)
|
||||
console.error('[VaultE2EE] encrypt frame error:', err)
|
||||
}
|
||||
this.worker.postMessage(
|
||||
{
|
||||
kind: 'encode',
|
||||
data: {
|
||||
readableStream: senderStreams.readable,
|
||||
writableStream: senderStreams.writable,
|
||||
trackId,
|
||||
participantIdentity: this.room.localParticipant.identity,
|
||||
},
|
||||
},
|
||||
})
|
||||
[senderStreams.readable, senderStreams.writable],
|
||||
)
|
||||
|
||||
readable.pipeThrough(transformStream).pipeTo(writable)
|
||||
|
||||
// @ts-expect-error - custom flag
|
||||
// @ts-expect-error
|
||||
sender[E2EE_FLAG] = true
|
||||
}
|
||||
|
||||
private setupReceiver(track: RemoteTrack, participantIdentity: string): void {
|
||||
private setupReceiver(track: RemoteTrack, participantIdentity: string, trackInfo?: { mimeType?: string }): void {
|
||||
if (!track.receiver) return
|
||||
|
||||
const receiver = track.receiver
|
||||
|
||||
if (E2EE_FLAG in receiver) return
|
||||
if (E2EE_FLAG in receiver) {
|
||||
// Receiver reuse — Worker's existing pipe handles new track frames
|
||||
this.worker.postMessage({
|
||||
kind: 'updateCodec',
|
||||
data: {
|
||||
trackId: track.mediaStreamID,
|
||||
participantIdentity,
|
||||
codec: trackInfo?.mimeType?.split('/')[1],
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// @ts-expect-error - createEncodedStreams is not in the TS types
|
||||
// @ts-expect-error
|
||||
let writable: WritableStream = receiver.writableStream
|
||||
// @ts-expect-error
|
||||
let readable: ReadableStream = receiver.readableStream
|
||||
@@ -252,34 +237,20 @@ export class VaultE2EEManager extends EventEmitter {
|
||||
readable = receiverStreams.readable
|
||||
}
|
||||
|
||||
const transformStream = new TransformStream({
|
||||
transform: async (frame, controller) => {
|
||||
try {
|
||||
if (!this.encryptedSymmetricKey || !this.encryptionEnabled) {
|
||||
controller.enqueue(frame)
|
||||
return
|
||||
}
|
||||
|
||||
const frameData = new Uint8Array(frame.data)
|
||||
const { data } = await this.vaultClient.decryptWithKey(
|
||||
frameData.buffer as ArrayBuffer,
|
||||
this.encryptedSymmetricKey
|
||||
)
|
||||
|
||||
frame.data = data
|
||||
controller.enqueue(frame)
|
||||
} catch (err) {
|
||||
// Decryption failed — emit error and drop frame
|
||||
this.emit(
|
||||
EncryptionEvent.EncryptionError,
|
||||
new Error(`Decryption failed for ${participantIdentity}`),
|
||||
participantIdentity
|
||||
)
|
||||
}
|
||||
this.worker.postMessage(
|
||||
{
|
||||
kind: 'decode',
|
||||
data: {
|
||||
readableStream: readable,
|
||||
writableStream: writable,
|
||||
trackId: track.mediaStreamID,
|
||||
participantIdentity,
|
||||
codec: trackInfo?.mimeType?.split('/')[1],
|
||||
isReuse: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
readable.pipeThrough(transformStream).pipeTo(writable)
|
||||
[readable, writable],
|
||||
)
|
||||
|
||||
// @ts-expect-error
|
||||
receiver[E2EE_FLAG] = true
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import _sodium from 'libsodium-wrappers-sumo'
|
||||
|
||||
let sodium: typeof _sodium
|
||||
let symmetricKey: Uint8Array | null = null
|
||||
|
||||
const transforms = new Map<string, { cancel: () => void }>()
|
||||
|
||||
async function init() {
|
||||
await _sodium.ready
|
||||
sodium = _sodium
|
||||
}
|
||||
|
||||
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 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!)
|
||||
}
|
||||
|
||||
onmessage = async (ev: MessageEvent) => {
|
||||
const { kind, data } = ev.data
|
||||
|
||||
switch (kind) {
|
||||
case 'init':
|
||||
await sodiumReady
|
||||
postMessage({ kind: 'initAck', data: { enabled: true } })
|
||||
break
|
||||
|
||||
case 'setKey':
|
||||
symmetricKey = data.key
|
||||
// Echo back enable for the participant
|
||||
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 transformStream = new TransformStream({
|
||||
transform: async (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => {
|
||||
if (!symmetricKey || frame.data.byteLength === 0) {
|
||||
return controller.enqueue(frame)
|
||||
}
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
readableStream
|
||||
.pipeThrough(transformStream, { signal: abortController.signal })
|
||||
.pipeTo(writableStream)
|
||||
.catch(() => {})
|
||||
|
||||
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 'setRTPMap':
|
||||
case 'setSifTrailer':
|
||||
case 'updateCodec':
|
||||
case 'ratchetRequest':
|
||||
// Not needed for libsodium — ignore
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -94,11 +94,9 @@ export const Conference = ({
|
||||
const encryptionEnabled = isEncryptedRoom(data)
|
||||
const { client: vaultClient, hasKeys: vaultHasKeys } = useVaultClient()
|
||||
|
||||
// Determine which E2EE backend to use:
|
||||
// - Advanced mode: VaultClient (iframe-based, key never leaves iframe)
|
||||
// - Basic mode: LiveKit's built-in Worker+KeyProvider with passphrase from URL hash
|
||||
const isAdvancedMode = data?.encryption_mode === ApiEncryptionMode.ADVANCED
|
||||
const useVaultE2EE = isAdvancedMode && !!vaultClient && !!vaultHasKeys
|
||||
// Determine which E2EE backend to use based solely on the room's encryption_mode.
|
||||
// Advanced mode always uses VaultClient, basic mode always uses LiveKit Worker+KeyProvider.
|
||||
const useVaultE2EE = data?.encryption_mode === ApiEncryptionMode.ADVANCED
|
||||
|
||||
// Refs for both approaches (only one is used per session)
|
||||
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
|
||||
@@ -201,124 +199,96 @@ export const Conference = ({
|
||||
if (!encryptionEnabled || encryptionSetupComplete) return
|
||||
|
||||
if (useVaultE2EE) {
|
||||
// VaultClient E2EE path — key never leaves the iframe
|
||||
// Advanced mode: VaultE2EEManager handles its own Worker+KeyProvider internally
|
||||
const vaultManager = getVaultManager()
|
||||
if (!vaultManager || !vaultClient) return
|
||||
|
||||
const setupVaultKey = async () => {
|
||||
try {
|
||||
if (isAdmin) {
|
||||
// Admin: check if we already have a key (refresh/rejoin case)
|
||||
const existingKey = data?.encrypted_symmetric_key
|
||||
if (existingKey) {
|
||||
// Decode base64 to ArrayBuffer
|
||||
const binaryStr = atob(existingKey)
|
||||
const bytes = new Uint8Array(binaryStr.length)
|
||||
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
|
||||
vaultManager.setEncryptedSymmetricKey(bytes.buffer)
|
||||
console.info('[VaultE2EE] Admin: restored key from backend')
|
||||
} else {
|
||||
console.error('[VaultE2EE] Admin: no encrypted symmetric key found — was the room created with advanced mode?')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Joiner: use the vault-wrapped key received from admin via lobby
|
||||
const vaultKey = getEncryptedVaultKey()
|
||||
if (vaultKey) {
|
||||
vaultManager.setEncryptedSymmetricKey(vaultKey)
|
||||
console.info('[VaultE2EE] Joiner: vault key received from lobby')
|
||||
} else {
|
||||
console.error('[VaultE2EE] Joiner: no vault key available')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setEncryptionSetupComplete(true)
|
||||
|
||||
const onConnected = async () => {
|
||||
try {
|
||||
await room.setE2EEEnabled(true)
|
||||
console.info('[VaultE2EE] E2EE enabled')
|
||||
} catch (err) {
|
||||
console.error('[VaultE2EE] E2EE enable failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (room.state === 'connected') onConnected()
|
||||
else room.once('connected', onConnected)
|
||||
} catch (err) {
|
||||
console.error('[VaultE2EE] Setup failed:', err)
|
||||
// setEncryptedSymmetricKey is a no-op for now (Step 1: hardcoded passphrase)
|
||||
if (isAdmin) {
|
||||
const existingKey = data?.encrypted_symmetric_key
|
||||
if (existingKey) {
|
||||
const binaryStr = atob(existingKey)
|
||||
const bytes = new Uint8Array(binaryStr.length)
|
||||
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
|
||||
vaultManager.setEncryptedSymmetricKey(bytes.buffer)
|
||||
}
|
||||
} else {
|
||||
const vaultKey = getEncryptedVaultKey()
|
||||
if (vaultKey) {
|
||||
vaultManager.setEncryptedSymmetricKey(vaultKey)
|
||||
}
|
||||
}
|
||||
|
||||
setupVaultKey()
|
||||
} else {
|
||||
// Basic mode: LiveKit Worker+KeyProvider with passphrase in URL hash
|
||||
const keyProvider = getKeyProvider()
|
||||
if (!keyProvider) return
|
||||
// Enable E2EE BEFORE connecting — no tracks exist yet so
|
||||
// republishAllTracks() is a no-op. Calling after connection
|
||||
// triggers republish which times out.
|
||||
room.setE2EEEnabled(true).catch((err) => {
|
||||
console.error('[VaultE2EE] E2EE enable failed:', err)
|
||||
})
|
||||
|
||||
let passphrase: string | null = null
|
||||
setEncryptionSetupComplete(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
// Admin: generate passphrase and put it in the URL hash
|
||||
if (!adminPassphraseRef.current) {
|
||||
// Check if there's already a hash (e.g. admin refreshed the page)
|
||||
const existingHash = window.location.hash.slice(1)
|
||||
if (existingHash) {
|
||||
adminPassphraseRef.current = existingHash
|
||||
} else {
|
||||
adminPassphraseRef.current = generatePassphrase()
|
||||
// Set the hash in the URL (without triggering navigation)
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}#${adminPassphraseRef.current}`
|
||||
)
|
||||
}
|
||||
// Basic mode: LiveKit Worker+KeyProvider with passphrase
|
||||
const keyProvider = getKeyProvider()
|
||||
if (!keyProvider) return
|
||||
|
||||
let passphrase: string | null = null
|
||||
|
||||
if (isAdmin) {
|
||||
if (!adminPassphraseRef.current) {
|
||||
const existingHash = window.location.hash.slice(1)
|
||||
if (existingHash) {
|
||||
adminPassphraseRef.current = existingHash
|
||||
} else {
|
||||
adminPassphraseRef.current = generatePassphrase()
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}#${adminPassphraseRef.current}`
|
||||
)
|
||||
}
|
||||
passphrase = adminPassphraseRef.current
|
||||
}
|
||||
passphrase = adminPassphraseRef.current
|
||||
setSymmetricKey(new TextEncoder().encode(passphrase))
|
||||
} else {
|
||||
const hashKey = window.location.hash.slice(1)
|
||||
if (hashKey) {
|
||||
passphrase = hashKey
|
||||
setSymmetricKey(new TextEncoder().encode(passphrase))
|
||||
} else {
|
||||
// Joiner: read passphrase from URL hash (shared link) or from lobby exchange
|
||||
const hashKey = window.location.hash.slice(1)
|
||||
if (hashKey) {
|
||||
passphrase = hashKey
|
||||
setSymmetricKey(new TextEncoder().encode(passphrase))
|
||||
} else {
|
||||
// Fallback: key received via lobby DH exchange
|
||||
const preExchangedKey = getSymmetricKey()
|
||||
if (preExchangedKey) {
|
||||
passphrase = new TextDecoder().decode(preExchangedKey)
|
||||
}
|
||||
const preExchangedKey = getSymmetricKey()
|
||||
if (preExchangedKey) {
|
||||
passphrase = new TextDecoder().decode(preExchangedKey)
|
||||
}
|
||||
}
|
||||
|
||||
if (!passphrase) {
|
||||
console.error('[Encryption] No passphrase available (not in URL hash and no lobby exchange)')
|
||||
return
|
||||
}
|
||||
|
||||
keyProvider
|
||||
.setKey(passphrase)
|
||||
.then(() => {
|
||||
setEncryptionSetupComplete(true)
|
||||
|
||||
const onConnected = async () => {
|
||||
try {
|
||||
await room.setE2EEEnabled(true)
|
||||
} catch (err) {
|
||||
console.error('[Encryption] E2EE enable failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (room.state === 'connected') onConnected()
|
||||
else room.once('connected', onConnected)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Encryption] Key setup failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
if (!passphrase) {
|
||||
console.error('[Encryption] No passphrase available')
|
||||
return
|
||||
}
|
||||
|
||||
keyProvider
|
||||
.setKey(passphrase)
|
||||
.then(async () => {
|
||||
const onConnected = async () => {
|
||||
try {
|
||||
await room.setE2EEEnabled(true)
|
||||
} catch (err) {
|
||||
console.error('[Encryption] E2EE enable failed:', err)
|
||||
}
|
||||
}
|
||||
if (room.state === 'connected') onConnected()
|
||||
else room.once('connected', onConnected)
|
||||
|
||||
setEncryptionSetupComplete(true)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Encryption] Key setup failed:', err)
|
||||
})
|
||||
|
||||
}, [room, encryptionEnabled, encryptionSetupComplete, isAdmin, useVaultE2EE])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -79,12 +79,15 @@ export const useLobby = ({
|
||||
|
||||
// Advanced mode: vault-wrapped key
|
||||
if (encryptionEnabled && response.encrypted_vault_key) {
|
||||
console.info('[VaultE2EE] Joiner: received encrypted_vault_key from lobby, length:', response.encrypted_vault_key.length)
|
||||
const binaryStr = atob(response.encrypted_vault_key)
|
||||
const bytes = new Uint8Array(binaryStr.length)
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
bytes[i] = binaryStr.charCodeAt(i)
|
||||
}
|
||||
setEncryptedVaultKey(bytes.buffer)
|
||||
} else if (encryptionEnabled) {
|
||||
console.warn('[VaultE2EE] Joiner: ACCEPTED but no encrypted_vault_key in response', response)
|
||||
}
|
||||
|
||||
onAccepted(response)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { encryptKeyForParticipant, getSymmetricKey } from '@/features/encryption/lobbyKeyExchange'
|
||||
import { useVaultClient } from '@/features/encryption'
|
||||
import { toastQueue } from '@/features/notifications/components/ToastProvider'
|
||||
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
|
||||
@@ -68,37 +69,47 @@ export const useWaitingParticipants = () => {
|
||||
let adminEphemeralPublicKey = ''
|
||||
let encryptedVaultKey = ''
|
||||
|
||||
if (isAdvancedMode && vaultClient && participant.suite_user_id) {
|
||||
// Advanced mode: re-wrap the existing symmetric key for the joiner
|
||||
try {
|
||||
// Get the admin's own encrypted symmetric key from the room data
|
||||
const adminKeyBase64 = roomData?.encrypted_symmetric_key
|
||||
if (!adminKeyBase64) {
|
||||
console.error('[VaultE2EE] Admin has no encrypted symmetric key')
|
||||
return { encryptedKey, adminEphemeralPublicKey, encryptedVaultKey }
|
||||
}
|
||||
const adminKeyBinary = atob(adminKeyBase64)
|
||||
const adminKeyBytes = new Uint8Array(adminKeyBinary.length)
|
||||
for (let i = 0; i < adminKeyBinary.length; i++) adminKeyBytes[i] = adminKeyBinary.charCodeAt(i)
|
||||
|
||||
// Fetch joiner's vault public key
|
||||
const { publicKeys } = await vaultClient.fetchPublicKeys([participant.suite_user_id])
|
||||
const joinerPubKey = publicKeys[participant.suite_user_id]
|
||||
if (joinerPubKey) {
|
||||
// Re-wrap the symmetric key for the joiner using shareKeys
|
||||
const { encryptedKeys } = await vaultClient.shareKeys(
|
||||
adminKeyBytes.buffer,
|
||||
{ [participant.suite_user_id]: joinerPubKey }
|
||||
)
|
||||
const joinerKey = encryptedKeys[participant.suite_user_id]
|
||||
if (joinerKey) {
|
||||
const bytes = new Uint8Array(joinerKey)
|
||||
encryptedVaultKey = btoa(String.fromCharCode(...bytes))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VaultE2EE] Failed to wrap key for participant:', err)
|
||||
if (isAdvancedMode) {
|
||||
// Advanced mode: re-wrap the existing symmetric key for the joiner.
|
||||
// All steps are mandatory — if any fails, the participant must NOT be accepted
|
||||
// (they would join without a key and see nothing).
|
||||
if (!vaultClient) {
|
||||
throw new Error('Encryption service is not available')
|
||||
}
|
||||
if (!participant.suite_user_id) {
|
||||
throw new Error('Participant has no vault identity — they may not be authenticated')
|
||||
}
|
||||
|
||||
const adminKeyBase64 = roomData?.encrypted_symmetric_key
|
||||
if (!adminKeyBase64) {
|
||||
throw new Error('Admin has no encrypted symmetric key for this room')
|
||||
}
|
||||
|
||||
console.info('[VaultE2EE] Admin: wrapping key for joiner', participant.suite_user_id)
|
||||
const adminKeyBinary = atob(adminKeyBase64)
|
||||
const adminKeyBytes = new Uint8Array(adminKeyBinary.length)
|
||||
for (let i = 0; i < adminKeyBinary.length; i++) adminKeyBytes[i] = adminKeyBinary.charCodeAt(i)
|
||||
|
||||
// Fetch joiner's vault public key
|
||||
const { publicKeys } = await vaultClient.fetchPublicKeys([participant.suite_user_id])
|
||||
const joinerPubKey = publicKeys[participant.suite_user_id]
|
||||
if (!joinerPubKey) {
|
||||
throw new Error(`Could not find encryption public key for participant "${participant.username}"`)
|
||||
}
|
||||
|
||||
// Re-wrap the symmetric key for the joiner using shareKeys
|
||||
const { encryptedKeys } = await vaultClient.shareKeys(
|
||||
adminKeyBytes.buffer,
|
||||
{ [participant.suite_user_id]: joinerPubKey }
|
||||
)
|
||||
const joinerKey = encryptedKeys[participant.suite_user_id]
|
||||
if (!joinerKey) {
|
||||
throw new Error('Key wrapping returned no result — shareKeys failed')
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(joinerKey)
|
||||
encryptedVaultKey = btoa(String.fromCharCode(...bytes))
|
||||
console.info('[VaultE2EE] Admin: key wrapped successfully, length:', encryptedVaultKey.length)
|
||||
} else if (encrypted && getSymmetricKey() && participant.ephemeral_public_key) {
|
||||
// Basic mode: DH key exchange
|
||||
const result = await encryptKeyForParticipant(
|
||||
@@ -120,10 +131,22 @@ export const useWaitingParticipants = () => {
|
||||
let encryptedVaultKey = ''
|
||||
|
||||
if (allowEntry) {
|
||||
const keys = await encryptKeyForAccept(participant)
|
||||
encryptedKey = keys.encryptedKey
|
||||
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
|
||||
encryptedVaultKey = keys.encryptedVaultKey
|
||||
try {
|
||||
const keys = await encryptKeyForAccept(participant)
|
||||
encryptedKey = keys.encryptedKey
|
||||
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
|
||||
encryptedVaultKey = keys.encryptedVaultKey
|
||||
} catch (err) {
|
||||
console.error('[VaultE2EE] Cannot accept participant:', err)
|
||||
toastQueue.add(
|
||||
{
|
||||
type: 'encryptionError' as NotificationType,
|
||||
message: `Cannot accept ${participant.username}: ${(err as Error).message}`,
|
||||
},
|
||||
{ timeout: 8000 }
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await enterRoom({
|
||||
@@ -150,10 +173,22 @@ export const useWaitingParticipants = () => {
|
||||
let encryptedVaultKey = ''
|
||||
|
||||
if (allowEntry) {
|
||||
const keys = await encryptKeyForAccept(participant)
|
||||
encryptedKey = keys.encryptedKey
|
||||
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
|
||||
encryptedVaultKey = keys.encryptedVaultKey
|
||||
try {
|
||||
const keys = await encryptKeyForAccept(participant)
|
||||
encryptedKey = keys.encryptedKey
|
||||
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
|
||||
encryptedVaultKey = keys.encryptedVaultKey
|
||||
} catch (err) {
|
||||
console.error('[VaultE2EE] Cannot accept participant:', err)
|
||||
toastQueue.add(
|
||||
{
|
||||
type: 'encryptionError' as NotificationType,
|
||||
message: `Cannot accept ${participant.username}: ${(err as Error).message}`,
|
||||
},
|
||||
{ timeout: 8000 }
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return enterRoom({
|
||||
|
||||
@@ -14,7 +14,7 @@ import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { useVaultClient } from '@/features/encryption'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const Logo = () => (
|
||||
<img
|
||||
@@ -97,6 +97,44 @@ export const Header = () => {
|
||||
const { client: vaultClient, hasKeys } = useVaultClient()
|
||||
const encryptionContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [showEncryptionModal, setShowEncryptionModal] = useState(false)
|
||||
// Track whether the vault interface has been injected for the current modal session.
|
||||
// This prevents re-injection when vault events (onboarding:complete, keys-destroyed)
|
||||
// trigger re-renders via hasKeys state changes — which would destroy the iframe mid-flow.
|
||||
const vaultInjectedRef = useRef(false)
|
||||
|
||||
const encryptionRefCallback = useCallback((el: HTMLDivElement | null) => {
|
||||
encryptionContainerRef.current = el
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = encryptionContainerRef.current
|
||||
if (!showEncryptionModal || !el || !vaultClient || vaultInjectedRef.current) return
|
||||
|
||||
vaultInjectedRef.current = true
|
||||
el.innerHTML = ''
|
||||
if (hasKeys) {
|
||||
vaultClient.openSettings(el)
|
||||
} else {
|
||||
vaultClient.openOnboarding(el)
|
||||
}
|
||||
|
||||
const handleClosed = () => {
|
||||
setShowEncryptionModal(false)
|
||||
vaultClient.off('interface:closed', handleClosed)
|
||||
}
|
||||
vaultClient.on('interface:closed', handleClosed)
|
||||
|
||||
return () => {
|
||||
vaultClient.off('interface:closed', handleClosed)
|
||||
}
|
||||
}, [showEncryptionModal, vaultClient])
|
||||
|
||||
// Reset injection flag when modal closes
|
||||
useEffect(() => {
|
||||
if (!showEncryptionModal) {
|
||||
vaultInjectedRef.current = false
|
||||
}
|
||||
}, [showEncryptionModal])
|
||||
const isEncryptionAvailable = !!config?.encryption?.enabled && !!vaultClient
|
||||
const userLabel = user?.full_name || user?.email
|
||||
const loggedInTooltip = t('loggedInUserTooltip')
|
||||
@@ -268,24 +306,7 @@ export const Header = () => {
|
||||
✕
|
||||
</button>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (!el) return
|
||||
// Clear previous content and re-inject
|
||||
el.innerHTML = ''
|
||||
encryptionContainerRef.current = el
|
||||
if (hasKeys) {
|
||||
vaultClient.openSettings(el)
|
||||
} else {
|
||||
vaultClient.openOnboarding(el)
|
||||
}
|
||||
|
||||
const handleClosed = () => {
|
||||
setShowEncryptionModal(false)
|
||||
encryptionContainerRef.current = null
|
||||
vaultClient.off('interface:closed', handleClosed)
|
||||
}
|
||||
vaultClient.on('interface:closed', handleClosed)
|
||||
}}
|
||||
ref={encryptionRefCallback}
|
||||
className={css({ minHeight: '300px' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user