From 5376d391117be465bb6d66d27dfcc94094459bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Tue, 31 Mar 2026 13:11:18 +0200 Subject: [PATCH] good but need to investigate better exchange --- .../encryption/EncryptionSetupOverlay.tsx | 48 +++-- .../src/features/encryption/useEncryption.ts | 65 ++---- .../features/rooms/components/Conference.tsx | 191 +++++++++++++++--- src/frontend/src/locales/en/rooms.json | 5 +- src/frontend/src/locales/fr/rooms.json | 5 +- 5 files changed, 214 insertions(+), 100 deletions(-) diff --git a/src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx b/src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx index ec2189e5..4cd31eca 100644 --- a/src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx +++ b/src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx @@ -4,13 +4,18 @@ * When a participant joins an encrypted room, there's a brief period * between connection and receiving the symmetric key where media * cannot be decrypted. This overlay provides feedback during that time. + * + * After 20 seconds without the key, shows an error with a refresh button. */ import { css } from '@/styled-system/css' import { VStack } from '@/styled-system/jsx' -import { Text } from '@/primitives' +import { Text, Button } from '@/primitives' import { Spinner } from '@/primitives/Spinner' -import { RiLockFill, RiAlertFill } from '@remixicon/react' +import { RiLockFill, RiAlertFill, RiRefreshLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' +import { useEffect, useState } from 'react' + +const KEY_EXCHANGE_TIMEOUT = 20000 export function EncryptionSetupOverlay({ isSettingUp, @@ -20,9 +25,22 @@ export function EncryptionSetupOverlay({ error: string | null }) { const { t } = useTranslation('rooms', { keyPrefix: 'encryption' }) + const [timedOut, setTimedOut] = useState(false) + + useEffect(() => { + if (!isSettingUp) { + setTimedOut(false) + return + } + + const timer = setTimeout(() => setTimedOut(true), KEY_EXCHANGE_TIMEOUT) + return () => clearTimeout(timer) + }, [isSettingUp]) if (!isSettingUp && !error) return null + const showError = error || timedOut + return (
- - {error ? ( + {showError ? ( <> - + - {t('error.title')} + {timedOut ? t('error.timeout') : t('error.title')} - {error} + {error || t('error.timeoutHint')} - window.location.reload()} > - {t('error.hint')} - + + {t('error.refresh')} + ) : ( <> + { if (!room || !keyProviderRef.current || !encryptionEnabled || setupDoneRef.current) { return @@ -111,52 +119,9 @@ export function useEncryption( setError(null) try { - const keyExchange = new InCallKeyExchange(room) - keyExchangeRef.current = keyExchange - keyExchange.startListening() - - const isAdmin = isLocalParticipantAdmin(room) - - let passphrase: string - - if (isAdmin) { - const existingAdmins = Array.from( - room.remoteParticipants.values() - ).filter((p) => p.attributes?.room_admin === 'true') - - if (existingAdmins.length > 0) { - console.info( - '[Encryption] Another admin is present, requesting passphrase...' - ) - const keyBytes = await keyExchange.requestKey() - keyExchange.setSymmetricKey(keyBytes) - passphrase = new TextDecoder().decode(keyBytes) - console.info('[Encryption] Received passphrase from existing admin') - } else { - passphrase = generatePassphrase() - const keyBytes = new TextEncoder().encode(passphrase) - keyExchange.setSymmetricKey(keyBytes) - console.info( - '[Encryption] Generated passphrase as room admin (key authority)' - ) - } - } else { - console.info( - '[Encryption] Requesting passphrase from room admin...' - ) - const keyBytes = await keyExchange.requestKey() - keyExchange.setSymmetricKey(keyBytes) - passphrase = new TextDecoder().decode(keyBytes) - console.info('[Encryption] Received passphrase from admin') - } - - // Feed the passphrase to LiveKit's encryption worker. - // Using a string passphrase (as in the PoC PR #296) — LiveKit derives - // the actual AES encryption key internally from this passphrase. - console.info('[Encryption] Setting passphrase, length:', passphrase.length) - await keyProviderRef.current!.setKey(passphrase) + console.info('[Encryption] Setting hardcoded passphrase (PoC mode)') + await keyProviderRef.current!.setKey(HARDCODED_PASSPHRASE) await room.setE2EEEnabled(true) - console.info('[Encryption] End-to-end encryption enabled') } catch (err) { console.error('[Encryption] Failed to set up encryption:', err) diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 5a1d61a8..584c8e07 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -7,12 +7,14 @@ import { } from '@livekit/components-react' import { DisconnectReason, + ExternalE2EEKeyProvider, MediaDeviceFailure, Room, RoomOptions, VideoPresets, } from 'livekit-client' -import { useEncryption, EncryptionSetupOverlay } from '@/features/encryption' +import { EncryptionSetupOverlay } from '@/features/encryption' +import { InCallKeyExchange } from '@/features/encryption/InCallKeyExchange' import { keys } from '@/api/queryKeys' import { queryClient } from '@/api/queryClient' import { Screen } from '@/layout/Screen' @@ -89,23 +91,38 @@ export const Conference = ({ const encryptionEnabled = data?.encryption_enabled ?? false - // Encryption: encryptionOptions (keyProvider + worker) are created synchronously via refs - // inside the hook. The room is passed via state so the hook re-runs when it's created. - const [roomInstance, setRoomInstance] = useState(undefined) - const { encryptionOptions, isSettingUp: isEncryptionSettingUp, error: encryptionError } = useEncryption(roomInstance, encryptionEnabled) + // Encryption setup — PoC approach: refs for keyProvider and worker, + // passed directly to RoomOptions.e2ee at Room construction time. + const keyProviderRef = useRef(null) + const workerRef = useRef(null) + const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled) + const [encryptionError, setEncryptionError] = useState(null) - // Stabilize encryptionOptions reference — only recalculate roomOptions when - // encryptionEnabled changes, not when encryptionOptions object reference changes. - const encryptionOptionsRef = useRef(encryptionOptions) - encryptionOptionsRef.current = encryptionOptions + const getKeyProvider = () => { + if (!keyProviderRef.current && encryptionEnabled) { + keyProviderRef.current = new ExternalE2EEKeyProvider() + } + return keyProviderRef.current + } + + const getWorker = () => { + if (!workerRef.current && encryptionEnabled && typeof window !== 'undefined') { + workerRef.current = new Worker( + new URL('livekit-client/e2ee-worker', import.meta.url) + ) + } + return workerRef.current + } const roomOptions = useMemo((): RoomOptions => { + const worker = getWorker() + const keyProvider = getKeyProvider() + return { adaptiveStream: true, dynacast: true, publishDefaults: { - // Encryption requires VP8 codec — VP9 and RED are not compatible with insertable streams - videoCodec: encryptionEnabled ? 'vp8' : 'vp9', + videoCodec: encryptionEnabled ? undefined : 'vp9', red: !encryptionEnabled, }, videoCaptureDefaults: { @@ -120,7 +137,9 @@ export const Conference = ({ audioOutput: { deviceId: userConfig.audioOutputDeviceId ?? undefined, }, - e2ee: encryptionOptionsRef.current, + e2ee: encryptionEnabled && keyProvider && worker + ? { keyProvider, worker } + : undefined, } // do not rely on the userConfig object directly as its reference may change on every render }, [ @@ -133,10 +152,132 @@ export const Conference = ({ const room = useMemo(() => new Room(roomOptions), [roomOptions]) - // Pass the room to the encryption hook via state (triggers re-render so the hook sees it) + /* + * Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility + * (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor + * may fail - the force_wss_protocol flag allows explicit WSS protocol conversion + */ + const serverUrl = useMemo(() => { + const livekit_url = apiConfig?.livekit.url + if (!livekit_url) return + if (apiConfig?.livekit.force_wss_protocol) { + return livekit_url.replace('https://', 'wss://') + } + return livekit_url + }, [apiConfig?.livekit]) + + // Encryption key exchange — no disconnect/reconnect: + // Admin: generate passphrase → setKey → setE2EEEnabled → connect → distribute key + // Joiner: connect (audio/video blocked) → receive key → setKey → setE2EEEnabled → unblock audio/video + const isAdmin = mode === 'create' || data?.is_administrable === true + const keyExchangeRef = useRef(null) + const keyExchangeDoneRef = useRef(false) + const adminPassphraseRef = useRef(null) + const [encryptionKeyReady, setEncryptionKeyReady] = useState(!encryptionEnabled) + useEffect(() => { - setRoomInstance(room) - }, [room]) + if (!encryptionEnabled || encryptionSetupComplete) return + const keyProvider = getKeyProvider() + if (!keyProvider) return + + if (isAdmin) { + // Generate passphrase once (React Strict Mode runs effects twice) + if (!adminPassphraseRef.current) { + adminPassphraseRef.current = Array.from(crypto.getRandomValues(new Uint8Array(24))) + .map((b) => b.toString(36).padStart(2, '0')) + .join('') + } + const passphrase = adminPassphraseRef.current + console.info('[Encryption] Admin passphrase:', passphrase) + + // Set key before connecting — it's ready for when E2EE activates + keyProvider + .setKey(passphrase) + .then(() => { + console.info('[Encryption] Admin: key set, allowing connection') + setEncryptionSetupComplete(true) // allow connection + + // Enable E2EE and start key distribution after connecting + const onConnected = async () => { + try { + await room.setE2EEEnabled(true) + console.info('[Encryption] Admin: E2EE enabled after connection') + setEncryptionKeyReady(true) + + const kx = new InCallKeyExchange(room) + keyExchangeRef.current = kx + kx.setSymmetricKey(new TextEncoder().encode(passphrase)) + kx.startListening() + console.info('[Encryption] Admin: distributing key') + } catch (err) { + console.error('[Encryption] Admin: E2EE enable failed:', err) + setEncryptionError((err as Error).message) + } + } + + if (room.state === 'connected') onConnected() + else room.once('connected', onConnected) + }) + .catch((err) => { + console.error('[Encryption] Admin failed:', err) + setEncryptionError(err.message) + }) + } else { + // Joiner: set a temporary random passphrase BEFORE connecting. + // This ensures all frames are encrypted from the start (admin sees black, not clear). + // After key exchange, replace with the real passphrase from admin. + const tempPassphrase = Array.from(crypto.getRandomValues(new Uint8Array(16))) + .map((b) => b.toString(36).padStart(2, '0')) + .join('') + + keyProvider + .setKey(tempPassphrase) + .then(() => { + console.info('[Encryption] Joiner: temporary key set, allowing connection') + setEncryptionSetupComplete(true) + + const exchange = async () => { + if (keyExchangeDoneRef.current) return + keyExchangeDoneRef.current = true + + try { + await room.setE2EEEnabled(true) + console.info('[Encryption] Joiner: E2EE enabled with temporary key') + + const kx = new InCallKeyExchange(room) + keyExchangeRef.current = kx + kx.startListening() + + console.info('[Encryption] Joiner: requesting real key from admin...') + const keyBytes = await kx.requestKey() + const passphrase = new TextDecoder().decode(keyBytes) + console.info('[Encryption] Joiner: received real passphrase') + + await keyProvider.setKey(passphrase) + setEncryptionKeyReady(true) + console.info('[Encryption] Joiner: real key set, decryption active') + } catch (err) { + console.error('[Encryption] Joiner failed:', err) + setEncryptionError((err as Error).message) + } + } + + if (room.state === 'connected') exchange() + else room.once('connected', exchange) + }) + .catch((err) => { + console.error('[Encryption] Joiner temp key failed:', err) + setEncryptionError(err.message) + }) + } + + return () => { + if (keyExchangeRef.current) { + keyExchangeRef.current.stopListening() + keyExchangeRef.current = null + } + } + }, [room, encryptionEnabled, encryptionSetupComplete, isAdmin]) useEffect(() => { /** @@ -194,20 +335,6 @@ export const Conference = ({ const isMobile = useIsMobile() - /* - * Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility - * (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor - * may fail - the force_wss_protocol flag allows explicit WSS protocol conversion - */ - const serverUrl = useMemo(() => { - const livekit_url = apiConfig?.livekit.url - if (!livekit_url) return - if (apiConfig?.livekit.force_wss_protocol) { - return livekit_url.replace('https://', 'wss://') - } - return livekit_url - }, [apiConfig?.livekit]) - const { t } = useTranslation('rooms') if (isCreateError) { // this error screen should be replaced by a proper waiting room for anonymous user. @@ -233,7 +360,7 @@ export const Conference = ({ room={room} serverUrl={serverUrl} token={data?.livekit?.token} - connect={isConnectionWarmedUp} + connect={isConnectionWarmedUp && encryptionSetupComplete} audio={userConfig.audioEnabled} video={ userConfig.videoEnabled && { @@ -272,9 +399,9 @@ export const Conference = ({ } }} > - {encryptionEnabled && ( + {encryptionEnabled && !isAdmin && ( )} diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index e9eee261..d2f364b0 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -698,7 +698,10 @@ }, "error": { "title": "Encryption error", - "hint": "The encrypted data could not be decoded. Try leaving and rejoining the meeting, or ask the host to restart the call." + "hint": "The encrypted data could not be decoded. Try leaving and rejoining the meeting, or ask the host to restart the call.", + "timeout": "Key exchange timed out", + "timeoutHint": "The encryption key could not be received from the host. They may not be in the meeting yet.", + "refresh": "Retry" }, "fingerprint": { "title": "Encryption identity", diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index f96fc2a9..6de265ba 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -698,7 +698,10 @@ }, "error": { "title": "Erreur de chiffrement", - "hint": "Les données chiffrées n'ont pas pu être décodées. Essayez de quitter et de rejoindre la réunion, ou demandez à l'hôte de relancer l'appel." + "hint": "Les données chiffrées n'ont pas pu être décodées. Essayez de quitter et de rejoindre la réunion, ou demandez à l'hôte de relancer l'appel.", + "timeout": "Échange de clés expiré", + "timeoutHint": "La clé de chiffrement n'a pas pu être reçue de l'hôte. Il n'est peut-être pas encore dans la réunion.", + "refresh": "Réessayer" }, "fingerprint": { "title": "Identité chiffrée",