diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index 28f1068b..f034e06e 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -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 diff --git a/src/frontend/src/features/encryption/VaultE2EEManager.ts b/src/frontend/src/features/encryption/VaultE2EEManager.ts index 45c97891..cc10281a 100644 --- a/src/frontend/src/features/encryption/VaultE2EEManager.ts +++ b/src/frontend/src/features/encryption/VaultE2EEManager.ts @@ -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 { - 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 { - 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 diff --git a/src/frontend/src/features/encryption/vault-e2ee.worker.ts b/src/frontend/src/features/encryption/vault-e2ee.worker.ts new file mode 100644 index 00000000..b50b8cae --- /dev/null +++ b/src/frontend/src/features/encryption/vault-e2ee.worker.ts @@ -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 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 + } +} diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 41acaff3..560cb588 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -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(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(() => { diff --git a/src/frontend/src/features/rooms/hooks/useLobby.ts b/src/frontend/src/features/rooms/hooks/useLobby.ts index 77b26baf..22b0896c 100644 --- a/src/frontend/src/features/rooms/hooks/useLobby.ts +++ b/src/frontend/src/features/rooms/hooks/useLobby.ts @@ -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) diff --git a/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts b/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts index 06e6d945..22dc47b3 100644 --- a/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts +++ b/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts @@ -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({ diff --git a/src/frontend/src/layout/Header.tsx b/src/frontend/src/layout/Header.tsx index f92307c2..ddf78d83 100644 --- a/src/frontend/src/layout/Header.tsx +++ b/src/frontend/src/layout/Header.tsx @@ -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 = () => ( { const { client: vaultClient, hasKeys } = useVaultClient() const encryptionContainerRef = useRef(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 = () => { ✕
{ - 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' })} />