wip issue

This commit is contained in:
Thomas Ramé
2026-04-07 13:54:06 +02:00
parent d7ce25b1b5
commit 267b39a975
7 changed files with 452 additions and 329 deletions
+2 -1
View File
@@ -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({
+40 -19
View File
@@ -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>