diff --git a/src/frontend/src/features/encryption/EncryptionContext.tsx b/src/frontend/src/features/encryption/EncryptionContext.tsx new file mode 100644 index 00000000..3a87db65 --- /dev/null +++ b/src/frontend/src/features/encryption/EncryptionContext.tsx @@ -0,0 +1,12 @@ +import { createContext, useContext } from 'react' + +interface EncryptionContextValue { + pendingParticipants: Set +} + +const EncryptionContext = createContext({ + pendingParticipants: new Set(), +}) + +export const EncryptionProvider = EncryptionContext.Provider +export const useEncryptionContext = () => useContext(EncryptionContext) diff --git a/src/frontend/src/features/encryption/InCallKeyExchange.ts b/src/frontend/src/features/encryption/InCallKeyExchange.ts index 4c1817c9..d4135def 100644 --- a/src/frontend/src/features/encryption/InCallKeyExchange.ts +++ b/src/frontend/src/features/encryption/InCallKeyExchange.ts @@ -95,7 +95,11 @@ async function encryptWithSharedKey( ): Promise { const sodium = await ensureSodium() const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) - const ciphertext = sodium.crypto_secretbox_easy(symmetricKey, nonce, sharedKey) + const ciphertext = sodium.crypto_secretbox_easy( + symmetricKey, + nonce, + sharedKey + ) // Prepend nonce to ciphertext (same format as encryption library) const result = new Uint8Array(nonce.length + ciphertext.length) result.set(nonce, 0) @@ -258,7 +262,9 @@ export class InCallKeyExchange { if (!this.symmetricKey) return // Only admins distribute the symmetric key - if (!isParticipantAdmin({ attributes: this.room.localParticipant.attributes })) { + if ( + !isParticipantAdmin({ attributes: this.room.localParticipant.attributes }) + ) { return } @@ -284,7 +290,10 @@ export class InCallKeyExchange { ) // Encrypt the symmetric key with XChaCha20-Poly1305 - const encryptedKey = await encryptWithSharedKey(sharedKey, this.symmetricKey) + const encryptedKey = await encryptWithSharedKey( + sharedKey, + this.symmetricKey + ) // Send response with our public key + encrypted symmetric key const responsePayload = JSON.stringify({ @@ -329,7 +338,9 @@ export class InCallKeyExchange { } if (!this.ephemeralKeyPair) { - console.warn('[Encryption] Received key response but no ephemeral key pair') + console.warn( + '[Encryption] Received key response but no ephemeral key pair' + ) return } diff --git a/src/frontend/src/features/encryption/index.ts b/src/frontend/src/features/encryption/index.ts index 0907b864..f315302b 100644 --- a/src/frontend/src/features/encryption/index.ts +++ b/src/frontend/src/features/encryption/index.ts @@ -16,5 +16,6 @@ export { EncryptedMeetingBanner } from './EncryptedMeetingBanner' export { EncryptionTrustModal } from './EncryptionTrustModal' export { FingerprintDialog } from './FingerprintDialog' export { useParticipantTrustLevel } from './useParticipantTrustLevel' +export { EncryptionProvider, useEncryptionContext } from './EncryptionContext' export { PARTICIPANT_TRUST_ATTR, KEY_EXCHANGE_TOPIC } from './types' export type { TrustLevel } from './types' diff --git a/src/frontend/src/features/encryption/useEncryption.ts b/src/frontend/src/features/encryption/useEncryption.ts index 64204e04..1cb3cb25 100644 --- a/src/frontend/src/features/encryption/useEncryption.ts +++ b/src/frontend/src/features/encryption/useEncryption.ts @@ -23,6 +23,8 @@ export interface EncryptionState { error: string | null /** E2EE options to pass to RoomOptions.e2ee at Room construction time */ encryptionOptions: E2EEOptions | undefined + /** Set of participant identities that currently have decryption errors (key exchange in progress) */ + pendingParticipants: Set } /** @@ -69,6 +71,7 @@ export function useEncryption( ): EncryptionState { const [isSettingUp, setIsSettingUp] = useState(false) const [error, setError] = useState(null) + const [pendingParticipants, setPendingParticipants] = useState>(new Set()) // Create keyProvider and worker as refs so they're available synchronously // for RoomOptions.e2ee. They persist across renders. @@ -131,18 +134,39 @@ export function useEncryption( } }, [room, encryptionEnabled]) - // Listen for LiveKit encryption errors (decryption failures, key mismatches) + // Listen for LiveKit encryption errors. + // InvalidKey and MissingKey are expected during key exchange (joiner has temp key, + // admin can't decrypt yet). Only surface persistent errors after key exchange. useEffect(() => { if (!room || !encryptionEnabled) return - const handleEncryptionError = (err: Error) => { + const handleEncryptionError = (err: Error, participantIdentity?: string) => { + const msg = err.message || '' + if (msg.includes('InvalidKey') || msg.includes('MissingKey') || msg.includes('missing key')) { + // Track this participant as having a pending key exchange + if (participantIdentity) { + setPendingParticipants((prev) => { + const next = new Set(prev) + next.add(participantIdentity) + return next + }) + } + return + } console.error('[Encryption] Decryption error:', err) - setError(err.message || 'Decryption failed') + setError(msg || 'Decryption failed') + } + + // When a participant's encryption status changes to encrypted, remove them from pending + const handleParticipantEncrypted = () => { + setPendingParticipants(new Set()) } room.on('encryptionError', handleEncryptionError) + room.on('participantEncryptionStatusChanged', handleParticipantEncrypted) return () => { room.off('encryptionError', handleEncryptionError) + room.off('participantEncryptionStatusChanged', handleParticipantEncrypted) } }, [room, encryptionEnabled]) @@ -174,5 +198,6 @@ export function useEncryption( isSettingUp, error, encryptionOptions, + pendingParticipants, } } diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 584c8e07..20c212fd 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -13,7 +13,7 @@ import { RoomOptions, VideoPresets, } from 'livekit-client' -import { EncryptionSetupOverlay } from '@/features/encryption' +import { EncryptionSetupOverlay, EncryptionProvider } from '@/features/encryption' import { InCallKeyExchange } from '@/features/encryption/InCallKeyExchange' import { keys } from '@/api/queryKeys' import { queryClient } from '@/api/queryClient' @@ -97,6 +97,7 @@ export const Conference = ({ const workerRef = useRef(null) const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled) const [encryptionError, setEncryptionError] = useState(null) + const [pendingParticipants, setPendingParticipants] = useState>(new Set()) const getKeyProvider = () => { if (!keyProviderRef.current && encryptionEnabled) { @@ -173,6 +174,7 @@ export const Conference = ({ const keyExchangeRef = useRef(null) const keyExchangeDoneRef = useRef(false) const adminPassphraseRef = useRef(null) + const adminDistributingRef = useRef(false) const [encryptionKeyReady, setEncryptionKeyReady] = useState(!encryptionEnabled) useEffect(() => { @@ -204,11 +206,14 @@ export const Conference = ({ 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') + if (!adminDistributingRef.current) { + adminDistributingRef.current = 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) @@ -272,13 +277,48 @@ export const Conference = ({ } return () => { - if (keyExchangeRef.current) { + // Don't stop the admin's key distribution listener — it needs to persist + if (keyExchangeRef.current && !isAdmin) { keyExchangeRef.current.stopListening() keyExchangeRef.current = null } } }, [room, encryptionEnabled, encryptionSetupComplete, isAdmin]) + // Track participants with decryption errors (key exchange in progress) + useEffect(() => { + if (!encryptionEnabled) return + + const handleEncryptionError = (_err: Error, participant?: { identity: string }) => { + console.debug('[Encryption] encryptionError for participant:', participant?.identity) + if (participant?.identity) { + setPendingParticipants((prev) => { + const next = new Set(prev) + next.add(participant.identity) + return next + }) + } + } + + // Clear a specific participant from pending when their encryption status changes + const handleEncryptionStatusChanged = (_encrypted: boolean, participant?: { identity: string }) => { + if (participant?.identity) { + setPendingParticipants((prev) => { + const next = new Set(prev) + next.delete(participant.identity) + return next + }) + } + } + + room.on('encryptionError', handleEncryptionError) + room.on('participantEncryptionStatusChanged', handleEncryptionStatusChanged) + return () => { + room.off('encryptionError', handleEncryptionError) + room.off('participantEncryptionStatusChanged', handleEncryptionStatusChanged) + } + }, [room, encryptionEnabled]) + useEffect(() => { /** * Warm up connection to LiveKit server before joining room @@ -399,13 +439,15 @@ export const Conference = ({ } }} > - {encryptionEnabled && !isAdmin && ( - - )} - + + {encryptionEnabled && !isAdmin && ( + + )} + + {showInviteDialog && !isMobile && ( + {isKeyExchangePending && !isScreenShare && ( +
+ +
+ + Key exchange in progress +
+
+ )} {children ?? ( <> {isTrackReference(trackReference) &&