mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-07 17:33:18 +00:00
good but need to investigate better exchange
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user