This commit is contained in:
Thomas Ramé
2026-03-31 14:34:26 +02:00
parent 5376d39111
commit 768b5d7680
6 changed files with 145 additions and 22 deletions
@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react'
interface EncryptionContextValue {
pendingParticipants: Set<string>
}
const EncryptionContext = createContext<EncryptionContextValue>({
pendingParticipants: new Set(),
})
export const EncryptionProvider = EncryptionContext.Provider
export const useEncryptionContext = () => useContext(EncryptionContext)
@@ -95,7 +95,11 @@ async function encryptWithSharedKey(
): Promise<Uint8Array> {
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
}
@@ -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'
@@ -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<string>
}
/**
@@ -69,6 +71,7 @@ export function useEncryption(
): EncryptionState {
const [isSettingUp, setIsSettingUp] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pendingParticipants, setPendingParticipants] = useState<Set<string>>(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,
}
}
@@ -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<Worker | null>(null)
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled)
const [encryptionError, setEncryptionError] = useState<string | null>(null)
const [pendingParticipants, setPendingParticipants] = useState<Set<string>>(new Set())
const getKeyProvider = () => {
if (!keyProviderRef.current && encryptionEnabled) {
@@ -173,6 +174,7 @@ export const Conference = ({
const keyExchangeRef = useRef<InCallKeyExchange | null>(null)
const keyExchangeDoneRef = useRef(false)
const adminPassphraseRef = useRef<string | null>(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 && (
<EncryptionSetupOverlay
isSettingUp={!encryptionKeyReady}
error={encryptionError}
/>
)}
<VideoConference />
<EncryptionProvider value={{ pendingParticipants }}>
{encryptionEnabled && !isAdmin && (
<EncryptionSetupOverlay
isSettingUp={!encryptionKeyReady}
error={encryptionError}
/>
)}
<VideoConference />
</EncryptionProvider>
{showInviteDialog && !isMobile && (
<InviteDialog
isOpen={showInviteDialog}
@@ -22,8 +22,9 @@ import {
import { Track } from 'livekit-client'
import { RiHand } from '@remixicon/react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { EncryptionBadge, getTrustLevelFromAttributes } from '@/features/encryption'
import { EncryptionBadge, getTrustLevelFromAttributes, useEncryptionContext } from '@/features/encryption'
import { useRoomData } from '../hooks/useRoomData'
import { RiLockFill } from '@remixicon/react'
import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
@@ -82,6 +83,8 @@ export const ParticipantTile: (
const isEncrypted = useIsEncrypted(trackReference.participant)
const roomData = useRoomData()
const isEncryptedRoom = roomData?.encryption_enabled ?? false
const { pendingParticipants } = useEncryptionContext()
const isKeyExchangePending = pendingParticipants.has(trackReference.participant.identity)
const layoutContext = useMaybeLayoutContext()
const autoManageSubscription = useFeatureContext()?.autoSubscription
@@ -140,6 +143,35 @@ export const ParticipantTile: (
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
<FullScreenShareWarning trackReference={trackReference} />
{isKeyExchangePending && !isScreenShare && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 5,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
gap: '0.5rem',
}}
>
<ParticipantPlaceholder participant={trackReference.participant} />
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
color: '#9ca3af',
fontSize: '0.75rem',
}}
>
<RiLockFill size={12} />
<span>Key exchange in progress</span>
</div>
</div>
)}
{children ?? (
<>
{isTrackReference(trackReference) &&