good but need to investigate better exchange

This commit is contained in:
Thomas Ramé
2026-03-31 13:11:18 +02:00
parent 58bc6398eb
commit 5376d39111
5 changed files with 214 additions and 100 deletions
@@ -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 (
<div
className={css({
@@ -36,10 +54,9 @@ export function EncryptionSetupOverlay({
})}
>
<VStack gap="1rem" alignItems="center">
<RiLockFill size={32} color="white" />
{error ? (
{showError ? (
<>
<RiAlertFill size={32} color="#f87171" />
<RiAlertFill size={36} color="#f87171" />
<Text
className={css({
color: '#f87171',
@@ -48,7 +65,7 @@ export function EncryptionSetupOverlay({
textAlign: 'center',
})}
>
{t('error.title')}
{timedOut ? t('error.timeout') : t('error.title')}
</Text>
<Text
className={css({
@@ -58,21 +75,20 @@ export function EncryptionSetupOverlay({
maxWidth: '20rem',
})}
>
{error}
{error || t('error.timeoutHint')}
</Text>
<Text
className={css({
color: 'greyscale.400',
fontSize: '0.75rem',
textAlign: 'center',
maxWidth: '20rem',
})}
<Button
variant="primary"
size="sm"
onPress={() => window.location.reload()}
>
{t('error.hint')}
</Text>
<RiRefreshLine size={16} />
{t('error.refresh')}
</Button>
</>
) : (
<>
<RiLockFill size={32} color="white" />
<Text
className={css({
color: 'white',
@@ -26,13 +26,18 @@ export interface EncryptionState {
}
/**
* Generate a random passphrase for LiveKit E2EE.
* LiveKit's ExternalE2EEKeyProvider.setKey() works best with string passphrases
* (as proven in the PoC PR #296). The worker derives the actual AES key internally.
* Generate a random passphrase string for LiveKit E2EE.
*
* LiveKit's ExternalE2EEKeyProvider.setKey() accepts string | ArrayBuffer.
* When a string is passed, LiveKit internally derives an AES key using PBKDF2.
* Using a string passphrase is the proven approach (PoC PR #296).
*
* The passphrase is exchanged between participants via the InCallKeyExchange
* (ephemeral X25519 DH over LiveKit data channel, encrypted with XChaCha20-Poly1305).
* Both sides encode/decode via TextEncoder/TextDecoder to maintain consistency.
*/
function generatePassphrase(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
// Convert to base64url string — LiveKit derives the actual encryption key from this
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
@@ -98,7 +103,10 @@ export function useEncryption(
? { keyProvider: keyProviderRef.current, worker: workerRef.current }
: undefined
// Key exchange: once room is connected, the admin generates or distributes the key
// TEMPORARY: hardcoded passphrase to validate video encryption works.
// Same approach as the PoC (PR #296). Will be replaced by key exchange later.
const HARDCODED_PASSPHRASE = 'meet-encryption-test-passphrase'
const setupKeyExchange = useCallback(async () => {
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)
@@ -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<Room | undefined>(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<ExternalE2EEKeyProvider | null>(null)
const workerRef = useRef<Worker | null>(null)
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled)
const [encryptionError, setEncryptionError] = useState<string | null>(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<InCallKeyExchange | null>(null)
const keyExchangeDoneRef = useRef(false)
const adminPassphraseRef = useRef<string | null>(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 && (
<EncryptionSetupOverlay
isSettingUp={isEncryptionSettingUp}
isSettingUp={!encryptionKeyReady}
error={encryptionError}
/>
)}
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",