mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-06 00:47:47 +00:00
wip
This commit is contained in:
@@ -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) &&
|
||||
|
||||
Reference in New Issue
Block a user