From 547e93b9f15c82863f06c6c93e065da446f85d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Tue, 31 Mar 2026 15:39:26 +0200 Subject: [PATCH] switch to exchanging through visio backend instead of livekit backend --- src/backend/core/api/serializers.py | 3 + src/backend/core/api/viewsets.py | 7 +- src/backend/core/services/lobby.py | 31 +++- .../features/encryption/EncryptionContext.tsx | 6 +- .../features/encryption/InCallKeyExchange.ts | 5 + src/frontend/src/features/encryption/index.ts | 6 +- .../features/encryption/lobbyKeyExchange.ts | 156 +++++++++++++++++ .../src/features/rooms/api/enterRoom.ts | 6 + .../rooms/api/listWaitingParticipants.ts | 1 + .../src/features/rooms/api/requestEntry.ts | 5 + .../features/rooms/components/Conference.tsx | 157 ++++-------------- .../src/features/rooms/components/Join.tsx | 1 + .../src/features/rooms/hooks/useLobby.ts | 35 +++- .../rooms/hooks/useWaitingParticipants.ts | 34 +++- .../livekit/components/ParticipantTile.tsx | 38 ----- 15 files changed, 311 insertions(+), 180 deletions(-) create mode 100644 src/frontend/src/features/encryption/lobbyKeyExchange.ts diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 75790dd2..a151a66a 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -290,6 +290,7 @@ class RequestEntrySerializer(BaseValidationOnlySerializer): """Validate request entry data.""" username = serializers.CharField(required=True) + ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='') class ParticipantEntrySerializer(BaseValidationOnlySerializer): @@ -297,6 +298,8 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer): participant_id = serializers.UUIDField(required=True) allow_entry = serializers.BooleanField(required=True) + encrypted_key = serializers.CharField(required=False, allow_blank=True, default='') + admin_ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='') class CreationCallbackSerializer(BaseValidationOnlySerializer): diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 421dea8a..ec6e8870 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -451,6 +451,8 @@ class RoomViewSet( room_id=room.id, participant_id=str(serializer.validated_data.get("participant_id")), allow_entry=serializer.validated_data.get("allow_entry"), + encrypted_key=serializer.validated_data.get("encrypted_key", ''), + admin_ephemeral_public_key=serializer.validated_data.get("admin_ephemeral_public_key", ''), ) return drf_response.Response({"message": "Participant was updated."}) @@ -479,11 +481,12 @@ class RoomViewSet( participants = lobby_service.list_waiting_participants(room.id) - # Only expose email in encrypted rooms (needed for admin identity verification). - # Strip it otherwise to avoid leaking personal data. + # Only expose email and ephemeral keys in encrypted rooms. + # Strip them otherwise to avoid leaking personal data. if not room.encryption_enabled: for p in participants: p.pop("email", None) + p.pop("ephemeral_public_key", None) return drf_response.Response({"participants": participants}) diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index bcb062fb..e26a125f 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -48,6 +48,9 @@ class LobbyParticipant: id: str is_authenticated: bool = False email: Optional[str] = None + ephemeral_public_key: str = '' + encrypted_key: str = '' + admin_ephemeral_public_key: str = '' def to_dict(self) -> Dict[str, str]: """Serialize the participant object to a dict representation.""" @@ -60,6 +63,12 @@ class LobbyParticipant: } if self.email: result["email"] = self.email + if self.ephemeral_public_key: + result["ephemeral_public_key"] = self.ephemeral_public_key + if self.encrypted_key: + result["encrypted_key"] = self.encrypted_key + if self.admin_ephemeral_public_key: + result["admin_ephemeral_public_key"] = self.admin_ephemeral_public_key return result @classmethod @@ -76,6 +85,9 @@ class LobbyParticipant: color=data["color"], is_authenticated=data.get("is_authenticated", False), email=data.get("email"), + ephemeral_public_key=data.get("ephemeral_public_key", ''), + encrypted_key=data.get("encrypted_key", ''), + admin_ephemeral_public_key=data.get("admin_ephemeral_public_key", ''), ) except (KeyError, ValueError) as e: logger.exception("Error creating Participant from dict:") @@ -134,6 +146,7 @@ class LobbyService: room, request, username: str, + ephemeral_public_key: str = '', ) -> Tuple[LobbyParticipant, Optional[Dict]]: """Request entry to a room for a participant. @@ -182,6 +195,7 @@ class LobbyService: room.id, participant_id, username, is_authenticated=request.user.is_authenticated, email=getattr(request.user, 'email', None) if request.user.is_authenticated else None, + ephemeral_public_key=ephemeral_public_key, ) elif participant.status == LobbyParticipantStatus.WAITING: @@ -216,6 +230,7 @@ class LobbyService: self, room_id: UUID, participant_id: str, username: str, is_authenticated: bool = False, email: Optional[str] = None, + ephemeral_public_key: str = '', ) -> LobbyParticipant: """Add participant to waiting lobby. @@ -232,6 +247,7 @@ class LobbyService: color=color, is_authenticated=is_authenticated, email=email, + ephemeral_public_key=ephemeral_public_key, ) try: @@ -300,6 +316,8 @@ class LobbyService: room_id: UUID, participant_id: str, allow_entry: bool, + encrypted_key: str = '', + admin_ephemeral_public_key: str = '', ) -> None: """Handle decision on participant entry. @@ -318,7 +336,12 @@ class LobbyService: "timeout": settings.LOBBY_DENIED_TIMEOUT, } - self._update_participant_status(room_id, participant_id, **decision) + self._update_participant_status( + room_id, participant_id, + encrypted_key=encrypted_key, + admin_ephemeral_public_key=admin_ephemeral_public_key, + **decision, + ) def _update_participant_status( self, @@ -326,6 +349,8 @@ class LobbyService: participant_id: str, status: LobbyParticipantStatus, timeout: int, + encrypted_key: str = '', + admin_ephemeral_public_key: str = '', ) -> None: """Update participant status with appropriate timeout.""" @@ -346,6 +371,10 @@ class LobbyService: raise participant.status = status + if encrypted_key: + participant.encrypted_key = encrypted_key + if admin_ephemeral_public_key: + participant.admin_ephemeral_public_key = admin_ephemeral_public_key cache.set(cache_key, participant.to_dict(), timeout=timeout) def clear_room_cache(self, room_id: UUID) -> None: diff --git a/src/frontend/src/features/encryption/EncryptionContext.tsx b/src/frontend/src/features/encryption/EncryptionContext.tsx index 3a87db65..e956f084 100644 --- a/src/frontend/src/features/encryption/EncryptionContext.tsx +++ b/src/frontend/src/features/encryption/EncryptionContext.tsx @@ -1,12 +1,10 @@ import { createContext, useContext } from 'react' interface EncryptionContextValue { - pendingParticipants: Set + symmetricKey?: Uint8Array } -const EncryptionContext = createContext({ - pendingParticipants: new Set(), -}) +const EncryptionContext = createContext({}) 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 d4135def..9f5cce03 100644 --- a/src/frontend/src/features/encryption/InCallKeyExchange.ts +++ b/src/frontend/src/features/encryption/InCallKeyExchange.ts @@ -236,6 +236,11 @@ export class InCallKeyExchange { switch (message.type) { case KeyExchangeMessageType.KEY_REQUEST: + // TEMPORARY: 10s delay to test joiner UX during key exchange + console.info( + '[Encryption] Delaying key response by 10 seconds for testing...' + ) + await new Promise((resolve) => setTimeout(resolve, 10000)) await this.handleKeyRequest(message, participant) break case KeyExchangeMessageType.KEY_RESPONSE: diff --git a/src/frontend/src/features/encryption/index.ts b/src/frontend/src/features/encryption/index.ts index f315302b..9dec494b 100644 --- a/src/frontend/src/features/encryption/index.ts +++ b/src/frontend/src/features/encryption/index.ts @@ -2,7 +2,6 @@ export { VaultClientProvider, useVaultClient } from './VaultClientProvider' export type { VaultClientContextValue } from './VaultClientProvider' export { useEncryption } from './useEncryption' export type { EncryptionState } from './useEncryption' -export { InCallKeyExchange } from './InCallKeyExchange' export { determineTrustLevel, getTrustLevelFromAttributes, @@ -11,11 +10,10 @@ export { } from './HybridKeyDistributor' export type { ParticipantEncryptionInfo } from './HybridKeyDistributor' export { EncryptionBadge } from './EncryptionBadge' -export { EncryptionSetupOverlay } from './EncryptionSetupOverlay' 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 { PARTICIPANT_TRUST_ATTR } from './types' export type { TrustLevel } from './types' diff --git a/src/frontend/src/features/encryption/lobbyKeyExchange.ts b/src/frontend/src/features/encryption/lobbyKeyExchange.ts new file mode 100644 index 00000000..c22b2e1f --- /dev/null +++ b/src/frontend/src/features/encryption/lobbyKeyExchange.ts @@ -0,0 +1,156 @@ +/** + * Lobby-based key exchange using ephemeral X25519 Diffie-Hellman. + * + * The key exchange happens during the waiting room flow via the REST API, + * so the joiner already has the real symmetric key when connecting to LiveKit. + * + * Uses libsodium for algorithmic consistency: + * - X25519 for ephemeral key exchange (crypto_scalarmult) + * - XChaCha20-Poly1305 for encrypting the symmetric key (crypto_secretbox) + * - BLAKE2b for deriving a shared key from the ECDH shared secret (crypto_generichash) + */ +import _sodium from 'libsodium-wrappers-sumo' + +let sodiumReady: Promise | null = null +async function ensureSodium(): Promise { + if (!sodiumReady) { + sodiumReady = _sodium.ready + } + await sodiumReady + return _sodium +} + +function toBase64(bytes: Uint8Array): string { + return _sodium.to_base64(bytes, _sodium.base64_variants.URLSAFE_NO_PADDING) +} + +function fromBase64(base64: string): Uint8Array { + return _sodium.from_base64(base64, _sodium.base64_variants.URLSAFE_NO_PADDING) +} + +/** + * Generate an ephemeral X25519 key pair. + */ +export async function generateEphemeralKeyPair(): Promise<{ + publicKey: Uint8Array + secretKey: Uint8Array +}> { + const sodium = await ensureSodium() + const secretKey = sodium.randombytes_buf(sodium.crypto_scalarmult_SCALARBYTES) + const publicKey = sodium.crypto_scalarmult_base(secretKey) + return { publicKey, secretKey } +} + +/** + * Encode a public key to base64url for transmission via REST API. + */ +export function encodePublicKey(publicKey: Uint8Array): string { + return toBase64(publicKey) +} + +// Module-level symmetric key — only accessible via set/get, never exposed to React components. +let _symmetricKey: Uint8Array | null = null + +/** + * Store the room's symmetric key in module scope. + * Called once by the admin (after generating it) or by the joiner (after decrypting it). + */ +export function setSymmetricKey(key: Uint8Array): void { + _symmetricKey = key +} + +/** + * Retrieve the stored symmetric key. Returns null if not yet set. + */ +export function getSymmetricKey(): Uint8Array | null { + return _symmetricKey +} + +/** + * Clear the stored symmetric key (e.g. on disconnect). + */ +export function clearSymmetricKey(): void { + _symmetricKey = null +} + +/** + * Derive a shared secret from X25519 ECDH, then derive an encryption key via BLAKE2b. + */ +async function deriveSharedKey( + mySecretKey: Uint8Array, + theirPublicKey: Uint8Array +): Promise { + const sodium = await ensureSodium() + const rawSharedSecret = sodium.crypto_scalarmult(mySecretKey, theirPublicKey) + return sodium.crypto_generichash( + 32, + rawSharedSecret, + sodium.from_string('meet-key-exchange') + ) +} + +/** + * Admin-side: encrypt the room's symmetric key for a specific participant. + * + * Called when the admin accepts a participant from the waiting room. + * Generates an ephemeral keypair, performs DH with the participant's public key, + * and encrypts the symmetric key. Reads the key from module-level storage. + * + * @param participantPublicKeyB64 - The participant's ephemeral public key (base64url) + * @returns The encrypted key blob and admin's ephemeral public key (both base64url) + */ +export async function encryptKeyForParticipant( + participantPublicKeyB64: string +): Promise<{ encryptedKey: string; adminPublicKey: string }> { + const symmetricKey = _symmetricKey + if (!symmetricKey) { + throw new Error('Symmetric key not set — cannot encrypt for participant') + } + const sodium = await ensureSodium() + + const adminKeyPair = await generateEphemeralKeyPair() + const participantPublicKey = fromBase64(participantPublicKeyB64) + + const sharedKey = await deriveSharedKey( + adminKeyPair.secretKey, + participantPublicKey + ) + + const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) + const ciphertext = sodium.crypto_secretbox_easy(symmetricKey, nonce, sharedKey) + const encrypted = new Uint8Array(nonce.length + ciphertext.length) + encrypted.set(nonce, 0) + encrypted.set(ciphertext, nonce.length) + + return { + encryptedKey: toBase64(encrypted), + adminPublicKey: toBase64(adminKeyPair.publicKey), + } +} + +/** + * Joiner-side: decrypt the symmetric key received from the admin via the lobby. + * + * Called when the joiner's polling receives an ACCEPTED status with encryption data. + * + * @param mySecretKey - The joiner's ephemeral secret key + * @param adminPublicKeyB64 - The admin's ephemeral public key (base64url) + * @param encryptedKeyB64 - The encrypted symmetric key blob (base64url) + * @returns The decrypted symmetric key + */ +export async function decryptKeyFromAdmin( + mySecretKey: Uint8Array, + adminPublicKeyB64: string, + encryptedKeyB64: string +): Promise { + const sodium = await ensureSodium() + + const adminPublicKey = fromBase64(adminPublicKeyB64) + const encryptedData = fromBase64(encryptedKeyB64) + + const sharedKey = await deriveSharedKey(mySecretKey, adminPublicKey) + + const nonce = encryptedData.slice(0, sodium.crypto_secretbox_NONCEBYTES) + const ciphertext = encryptedData.slice(sodium.crypto_secretbox_NONCEBYTES) + return sodium.crypto_secretbox_open_easy(ciphertext, nonce, sharedKey) +} diff --git a/src/frontend/src/features/rooms/api/enterRoom.ts b/src/frontend/src/features/rooms/api/enterRoom.ts index a0564818..77ac3935 100644 --- a/src/frontend/src/features/rooms/api/enterRoom.ts +++ b/src/frontend/src/features/rooms/api/enterRoom.ts @@ -6,6 +6,8 @@ export interface EnterRoomParams { roomId: string allowEntry: boolean participantId: string + encryptedKey?: string + adminEphemeralPublicKey?: string } export interface EnterRoomResponse { @@ -16,12 +18,16 @@ export const enterRoom = async ({ roomId, allowEntry, participantId, + encryptedKey = '', + adminEphemeralPublicKey = '', }: EnterRoomParams): Promise => { return await fetchApi(`/rooms/${roomId}/enter/`, { method: 'POST', body: JSON.stringify({ participant_id: participantId, allow_entry: allowEntry, + encrypted_key: encryptedKey, + admin_ephemeral_public_key: adminEphemeralPublicKey, }), }) } diff --git a/src/frontend/src/features/rooms/api/listWaitingParticipants.ts b/src/frontend/src/features/rooms/api/listWaitingParticipants.ts index ff121f77..97ffe710 100644 --- a/src/frontend/src/features/rooms/api/listWaitingParticipants.ts +++ b/src/frontend/src/features/rooms/api/listWaitingParticipants.ts @@ -10,6 +10,7 @@ export type WaitingParticipant = { color: string is_authenticated: boolean email?: string + ephemeral_public_key?: string } export type WaitingParticipantsResponse = { diff --git a/src/frontend/src/features/rooms/api/requestEntry.ts b/src/frontend/src/features/rooms/api/requestEntry.ts index 9e3017c3..e5d2e679 100644 --- a/src/frontend/src/features/rooms/api/requestEntry.ts +++ b/src/frontend/src/features/rooms/api/requestEntry.ts @@ -4,6 +4,7 @@ import { ApiLiveKit } from '@/features/rooms/api/ApiRoom' export interface RequestEntryParams { roomId: string username?: string + ephemeralPublicKey?: string } export enum ApiLobbyStatus { @@ -17,16 +18,20 @@ export enum ApiLobbyStatus { export interface ApiRequestEntry { status: ApiLobbyStatus livekit?: ApiLiveKit + encrypted_key?: string + admin_ephemeral_public_key?: string } export const requestEntry = async ({ roomId, username = '', + ephemeralPublicKey = '', }: RequestEntryParams) => { return fetchApi(`/rooms/${roomId}/request-entry/`, { method: 'POST', body: JSON.stringify({ username, + ephemeral_public_key: ephemeralPublicKey, }), }) } diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index d0940c44..e77885c5 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -13,8 +13,7 @@ import { RoomOptions, VideoPresets, } from 'livekit-client' -import { EncryptionSetupOverlay, EncryptionProvider } from '@/features/encryption' -import { InCallKeyExchange } from '@/features/encryption/InCallKeyExchange' +import { setSymmetricKey, getSymmetricKey } from '@/features/encryption/lobbyKeyExchange' import { keys } from '@/api/queryKeys' import { queryClient } from '@/api/queryClient' import { Screen } from '@/layout/Screen' @@ -91,13 +90,11 @@ export const Conference = ({ const encryptionEnabled = data?.encryption_enabled ?? false - // Encryption setup — PoC approach: refs for keyProvider and worker, + // Encryption setup — refs for keyProvider and worker, // passed directly to RoomOptions.e2ee at Room construction time. const keyProviderRef = useRef(null) 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) { @@ -167,15 +164,11 @@ export const Conference = ({ 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 + // Encryption key setup: + // Admin: generate passphrase -> setKey -> connect -> E2EE enabled + // Joiner: use pre-exchanged key from lobby -> setKey -> connect -> E2EE enabled const isAdmin = mode === 'create' || data?.is_administrable === true - const keyExchangeRef = useRef(null) - const keyExchangeDoneRef = useRef(false) const adminPassphraseRef = useRef(null) - const adminDistributingRef = useRef(false) - const [encryptionKeyReady, setEncryptionKeyReady] = useState(!encryptionEnabled) useEffect(() => { if (!encryptionEnabled || encryptionSetupComplete) return @@ -190,33 +183,18 @@ export const Conference = ({ .join('') } const passphrase = adminPassphraseRef.current - console.info('[Encryption] Admin passphrase:', passphrase) + setSymmetricKey(new TextEncoder().encode(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 + setEncryptionSetupComplete(true) - // 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) - - 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) } } @@ -225,105 +203,40 @@ export const Conference = ({ }) .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('') + // Joiner: use the pre-exchanged key from lobby (stored in module-level lobbyKeyExchange) + const preExchangedKey = getSymmetricKey() - keyProvider - .setKey(tempPassphrase) - .then(() => { - console.info('[Encryption] Joiner: temporary key set, allowing connection') - setEncryptionSetupComplete(true) + if (preExchangedKey) { + const passphrase = new TextDecoder().decode(preExchangedKey) - const exchange = async () => { - if (keyExchangeDoneRef.current) return - keyExchangeDoneRef.current = true + keyProvider + .setKey(passphrase) + .then(() => { + setEncryptionSetupComplete(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) + const onConnected = async () => { + try { + await room.setE2EEEnabled(true) + } catch (err) { + console.error('[Encryption] Joiner: E2EE enable failed:', err) + } } - } - if (room.state === 'connected') exchange() - else room.once('connected', exchange) - }) - .catch((err) => { - console.error('[Encryption] Joiner temp key failed:', err) - setEncryptionError(err.message) - }) - } - - return () => { - // Don't stop the admin's key distribution listener — it needs to persist - if (keyExchangeRef.current && !isAdmin) { - keyExchangeRef.current.stopListening() - keyExchangeRef.current = null + if (room.state === 'connected') onConnected() + else room.once('connected', onConnected) + }) + .catch((err) => { + console.error('[Encryption] Joiner key setup failed:', err) + }) + } else { + console.error('[Encryption] Joiner: no pre-exchanged key available') } } + }, [room, encryptionEnabled, encryptionSetupComplete, isAdmin]) - // Track participants pending key exchange. - // When a new participant joins an encrypted room, mark them as pending. - // Clear when their encryption status becomes true. - useEffect(() => { - if (!encryptionEnabled) return - - const handleParticipantConnected = (participant: { identity: string }) => { - setPendingParticipants((prev) => { - const next = new Set(prev) - next.add(participant.identity) - return next - }) - } - - const handleEncryptionStatusChanged = (_encrypted: boolean, participant?: { identity: string }) => { - if (participant?.identity) { - setPendingParticipants((prev) => { - const next = new Set(prev) - next.delete(participant.identity) - return next - }) - } - } - - const handleTrackSubscribed = () => { - // If any track is successfully subscribed, clear all pending - setPendingParticipants(new Set()) - } - - room.on('participantConnected', handleParticipantConnected) - room.on('participantEncryptionStatusChanged', handleEncryptionStatusChanged) - room.on('trackSubscribed', handleTrackSubscribed) - return () => { - room.off('participantConnected', handleParticipantConnected) - room.off('participantEncryptionStatusChanged', handleEncryptionStatusChanged) - room.off('trackSubscribed', handleTrackSubscribed) - } - }, [room, encryptionEnabled]) - useEffect(() => { /** * Warm up connection to LiveKit server before joining room @@ -444,15 +357,7 @@ export const Conference = ({ } }} > - - {encryptionEnabled && !isAdmin && ( - - )} - - + {showInviteDialog && !isMobile && ( void + encryptionEnabled?: boolean }) => { const [status, setStatus] = useState(ApiLobbyStatus.IDLE) const waitingTimeoutRef = useRef(null) + const ephemeralKeyRef = useRef<{ publicKey: Uint8Array; secretKey: Uint8Array } | null>(null) + const ephemeralPublicKeyB64Ref = useRef('') const clearWaitingTimeout = useCallback(() => { if (waitingTimeoutRef.current) { @@ -43,10 +53,26 @@ export const useLobby = ({ const response = await requestEntry({ roomId, username, + ephemeralPublicKey: ephemeralPublicKeyB64Ref.current, }) if (response.status === ApiLobbyStatus.ACCEPTED) { clearWaitingTimeout() setStatus(ApiLobbyStatus.ACCEPTED) + + if ( + encryptionEnabled && + response.encrypted_key && + response.admin_ephemeral_public_key && + ephemeralKeyRef.current + ) { + const decryptedKey = await decryptKeyFromAdmin( + ephemeralKeyRef.current.secretKey, + response.admin_ephemeral_public_key, + response.encrypted_key + ) + setSymmetricKey(decryptedKey) + } + onAccepted(response) } else if (response.status === ApiLobbyStatus.DENIED) { clearWaitingTimeout() @@ -60,10 +86,15 @@ export const useLobby = ({ enabled: status === ApiLobbyStatus.WAITING, }) - const startWaiting = useCallback(() => { + const startWaiting = useCallback(async () => { + if (encryptionEnabled) { + const keyPair = await generateEphemeralKeyPair() + ephemeralKeyRef.current = keyPair + ephemeralPublicKeyB64Ref.current = encodePublicKey(keyPair.publicKey) + } setStatus(ApiLobbyStatus.WAITING) startWaitingTimeout() - }, [startWaitingTimeout]) + }, [encryptionEnabled, startWaitingTimeout]) useEffect(() => { return () => clearWaitingTimeout() diff --git a/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts b/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts index d6821384..d85a81bc 100644 --- a/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts +++ b/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts @@ -10,6 +10,7 @@ import { } from '../api/listWaitingParticipants' import { decodeNotificationDataReceived } from '@/features/notifications/utils' import { NotificationType } from '@/features/notifications/NotificationType' +import { encryptKeyForParticipant, getSymmetricKey } from '@/features/encryption/lobbyKeyExchange' export const POLL_INTERVAL_MS = 1000 @@ -18,6 +19,7 @@ export const useWaitingParticipants = () => { const roomData = useRoomData() const roomId = roomData?.id || '' // FIXME - bad practice + const isEncryptedRoom = roomData?.encryption_enabled ?? false const room = useRoomContext() const isAdminOrOwner = useIsAdminOrOwner() @@ -61,10 +63,23 @@ export const useWaitingParticipants = () => { participant: WaitingParticipant, allowEntry: boolean ) => { + let encryptedKey = '' + let adminEphemeralPublicKey = '' + + if (allowEntry && isEncryptedRoom && getSymmetricKey() && participant.ephemeral_public_key) { + const result = await encryptKeyForParticipant( + participant.ephemeral_public_key + ) + encryptedKey = result.encryptedKey + adminEphemeralPublicKey = result.adminPublicKey + } + await enterRoom({ roomId: roomId, allowEntry, participantId: participant.id, + encryptedKey, + adminEphemeralPublicKey, }) await refetchWaiting() } @@ -76,13 +91,26 @@ export const useWaitingParticipants = () => { setListEnabled(false) await Promise.all( - waitingParticipants.map((participant) => - enterRoom({ + waitingParticipants.map(async (participant) => { + let encryptedKey = '' + let adminEphemeralPublicKey = '' + + if (allowEntry && isEncryptedRoom && getSymmetricKey() && participant.ephemeral_public_key) { + const result = await encryptKeyForParticipant( + participant.ephemeral_public_key + ) + encryptedKey = result.encryptedKey + adminEphemeralPublicKey = result.adminPublicKey + } + + return enterRoom({ roomId: roomId, allowEntry, participantId: participant.id, + encryptedKey, + adminEphemeralPublicKey, }) - ) + }) ) await refetchWaiting() diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx index 7d9cdc48..39cf68fa 100644 --- a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx @@ -24,7 +24,6 @@ import { RiHand } from '@remixicon/react' import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand' import { EncryptionBadge, getTrustLevelFromAttributes } 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' @@ -83,9 +82,6 @@ export const ParticipantTile: ( const isEncrypted = useIsEncrypted(trackReference.participant) const roomData = useRoomData() const isEncryptedRoom = roomData?.encryption_enabled ?? false - // In an encrypted room, if a remote participant's encryption status is false, - // it means we can't decrypt their frames yet (key exchange in progress). - const isKeyExchangePending = isEncryptedRoom && !isEncrypted && !trackReference.participant.isLocal const layoutContext = useMaybeLayoutContext() const autoManageSubscription = useFeatureContext()?.autoSubscription @@ -144,40 +140,6 @@ export const ParticipantTile: ( - {isKeyExchangePending && !isScreenShare && ( -
-
- -
-
- - Key exchange in progress -
-
- )} {children ?? ( <> {isTrackReference(trackReference) &&