From 316008016c284ac346801ed7a88ad5070f9b809d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Thu, 2 Apr 2026 15:54:11 +0200 Subject: [PATCH] wip working with hash --- src/backend/core/api/serializers.py | 9 +- src/backend/core/api/viewsets.py | 3 +- .../migrations/0020_room_encryption_mode.py | 51 ++++ src/backend/core/models.py | 23 +- src/backend/core/services/lobby.py | 16 + .../encryption/EncryptedMeetingBanner.tsx | 3 +- .../features/encryption/VaultE2EEManager.ts | 287 ++++++++++++++++++ .../features/encryption/lobbyKeyExchange.ts | 11 + .../home/components/EncryptionModeDialog.tsx | 139 +++++++++ .../src/features/home/routes/Home.tsx | 47 +-- .../src/features/rooms/api/ApiRoom.ts | 17 +- .../src/features/rooms/api/createRoom.ts | 8 +- .../src/features/rooms/api/enterRoom.ts | 3 + .../rooms/api/listWaitingParticipants.ts | 1 + .../src/features/rooms/api/requestEntry.ts | 1 + .../features/rooms/components/Conference.tsx | 197 ++++++++---- .../src/features/rooms/components/Join.tsx | 23 +- .../src/features/rooms/hooks/useLobby.ts | 12 + .../rooms/hooks/useWaitingParticipants.ts | 72 ++++- .../rooms/livekit/components/Admin.tsx | 8 +- .../livekit/components/ParticipantTile.tsx | 52 +++- .../Participants/ParticipantListItem.tsx | 3 +- .../WaitingParticipantListItem.tsx | 7 +- src/frontend/src/locales/en/home.json | 13 + src/frontend/src/locales/en/rooms.json | 4 + 25 files changed, 885 insertions(+), 125 deletions(-) create mode 100644 src/backend/core/migrations/0020_room_encryption_mode.py create mode 100644 src/frontend/src/features/encryption/VaultE2EEManager.ts create mode 100644 src/frontend/src/features/home/components/EncryptionModeDialog.tsx diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index a151a66a..7adf252e 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -128,7 +128,7 @@ class RoomSerializer(serializers.ModelSerializer): class Meta: model = models.Room - fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_enabled"] + fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_mode"] read_only_fields = ["id", "slug", "pin_code"] def validate_access_level(self, value): @@ -140,10 +140,10 @@ class RoomSerializer(serializers.ModelSerializer): ) return value - def validate_encryption_enabled(self, value): - """Once encryption is enabled on a room, it cannot be disabled.""" + def validate_encryption_mode(self, value): + """Once encryption is enabled on a room, it cannot be disabled or downgraded.""" instance = self.instance - if instance and instance.encryption_enabled and not value: + if instance and instance.encryption_enabled and value == models.EncryptionMode.NONE: raise serializers.ValidationError( "Encryption cannot be disabled once enabled on a room." ) @@ -300,6 +300,7 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer): 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='') + encrypted_vault_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 ec6e8870..271281f6 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -283,7 +283,7 @@ class RoomViewSet( """Set the current user as owner of the newly created room.""" # Encrypted rooms must use restricted access to enforce lobby approval # before the encryption key is shared with participants. - if serializer.validated_data.get("encryption_enabled"): + if serializer.validated_data.get("encryption_mode", models.EncryptionMode.NONE) != models.EncryptionMode.NONE: serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED room = serializer.save() @@ -453,6 +453,7 @@ class RoomViewSet( 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", ''), + encrypted_vault_key=serializer.validated_data.get("encrypted_vault_key", ''), ) return drf_response.Response({"message": "Participant was updated."}) diff --git a/src/backend/core/migrations/0020_room_encryption_mode.py b/src/backend/core/migrations/0020_room_encryption_mode.py new file mode 100644 index 00000000..02a295bb --- /dev/null +++ b/src/backend/core/migrations/0020_room_encryption_mode.py @@ -0,0 +1,51 @@ +"""Replace encryption_enabled boolean with encryption_mode enum.""" + +from django.db import migrations, models + + +def migrate_encryption_enabled_to_mode(apps, schema_editor): + """Convert existing encryption_enabled=True rooms to encryption_mode='basic'.""" + Room = apps.get_model("core", "Room") + Room.objects.filter(encryption_enabled=True).update(encryption_mode="basic") + + +def migrate_mode_to_encryption_enabled(apps, schema_editor): + """Reverse: set encryption_enabled=True for any non-'none' encryption_mode.""" + Room = apps.get_model("core", "Room") + Room.objects.exclude(encryption_mode="none").update(encryption_enabled=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0019_room_encryption_enabled"), + ] + + operations = [ + # 1. Add the new encryption_mode field + migrations.AddField( + model_name="room", + name="encryption_mode", + field=models.CharField( + choices=[ + ("none", "No encryption"), + ("basic", "Basic encryption"), + ("advanced", "Advanced encryption"), + ], + default="none", + help_text="End-to-end encryption mode for this room.", + max_length=20, + verbose_name="Encryption mode", + ), + ), + # 2. Migrate existing data + migrations.RunPython( + migrate_encryption_enabled_to_mode, + migrate_mode_to_encryption_enabled, + ), + # 3. Remove the old boolean field + migrations.RemoveField( + model_name="room", + name="encryption_enabled", + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 771f7a30..f2279d2d 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -98,6 +98,14 @@ class RoomAccessLevel(models.TextChoices): RESTRICTED = "restricted", _("Restricted Access") +class EncryptionMode(models.TextChoices): + """Encryption mode choices for rooms.""" + + NONE = "none", _("No encryption") + BASIC = "basic", _("Basic encryption") + ADVANCED = "advanced", _("Advanced encryption") + + class BaseModel(models.Model): """ Serves as an abstract base model for other models, ensuring that records are validated @@ -388,10 +396,12 @@ class Room(Resource): choices=RoomAccessLevel.choices, default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL, ) - encryption_enabled = models.BooleanField( - default=False, - verbose_name=_("Encryption enabled"), - help_text=_("Whether end-to-end encryption is enabled for this room."), + encryption_mode = models.CharField( + max_length=20, + choices=EncryptionMode.choices, + default=EncryptionMode.NONE, + verbose_name=_("Encryption mode"), + help_text=_("End-to-end encryption mode for this room."), ) configuration = models.JSONField( blank=True, @@ -447,6 +457,11 @@ class Room(Resource): """Check if a room is public""" return self.access_level == RoomAccessLevel.PUBLIC + @property + def encryption_enabled(self): + """Check if any encryption mode is active.""" + return self.encryption_mode != EncryptionMode.NONE + @staticmethod def generate_unique_pin_code(length): """Generate a unique n-digit PIN code""" diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index 9fc5ceab..5546f8a9 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -48,9 +48,11 @@ class LobbyParticipant: id: str is_authenticated: bool = False email: Optional[str] = None + suite_user_id: Optional[str] = None ephemeral_public_key: str = '' encrypted_key: str = '' admin_ephemeral_public_key: str = '' + encrypted_vault_key: str = '' def to_dict(self) -> Dict[str, str]: """Serialize the participant object to a dict representation.""" @@ -63,12 +65,16 @@ class LobbyParticipant: } if self.email: result["email"] = self.email + if self.suite_user_id: + result["suite_user_id"] = self.suite_user_id 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 + if self.encrypted_vault_key: + result["encrypted_vault_key"] = self.encrypted_vault_key return result @classmethod @@ -85,9 +91,11 @@ class LobbyParticipant: color=data["color"], is_authenticated=data.get("is_authenticated", False), email=data.get("email"), + suite_user_id=data.get("suite_user_id"), ephemeral_public_key=data.get("ephemeral_public_key", ''), encrypted_key=data.get("encrypted_key", ''), admin_ephemeral_public_key=data.get("admin_ephemeral_public_key", ''), + encrypted_vault_key=data.get("encrypted_vault_key", ''), ) except (KeyError, ValueError) as e: logger.exception("Error creating Participant from dict:") @@ -195,6 +203,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, + suite_user_id=str(request.user.id) if request.user.is_authenticated else None, ephemeral_public_key=ephemeral_public_key, ) @@ -245,6 +254,7 @@ class LobbyService: self, room_id: UUID, participant_id: str, username: str, is_authenticated: bool = False, email: Optional[str] = None, + suite_user_id: Optional[str] = None, ephemeral_public_key: str = '', ) -> LobbyParticipant: """Add participant to waiting lobby. @@ -262,6 +272,7 @@ class LobbyService: color=color, is_authenticated=is_authenticated, email=email, + suite_user_id=suite_user_id, ephemeral_public_key=ephemeral_public_key, ) @@ -333,6 +344,7 @@ class LobbyService: allow_entry: bool, encrypted_key: str = '', admin_ephemeral_public_key: str = '', + encrypted_vault_key: str = '', ) -> None: """Handle decision on participant entry. @@ -355,6 +367,7 @@ class LobbyService: room_id, participant_id, encrypted_key=encrypted_key, admin_ephemeral_public_key=admin_ephemeral_public_key, + encrypted_vault_key=encrypted_vault_key, **decision, ) @@ -366,6 +379,7 @@ class LobbyService: timeout: int, encrypted_key: str = '', admin_ephemeral_public_key: str = '', + encrypted_vault_key: str = '', ) -> None: """Update participant status with appropriate timeout.""" @@ -390,6 +404,8 @@ class LobbyService: participant.encrypted_key = encrypted_key if admin_ephemeral_public_key: participant.admin_ephemeral_public_key = admin_ephemeral_public_key + if encrypted_vault_key: + participant.encrypted_vault_key = encrypted_vault_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/EncryptedMeetingBanner.tsx b/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx index 45bb7763..01043042 100644 --- a/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx +++ b/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx @@ -11,6 +11,7 @@ import { VStack } from '@/styled-system/jsx' import { RiLockFill, RiShieldCheckFill } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom' import { useVaultClient } from './VaultClientProvider' import { useEffect, useState } from 'react' import { Dialog, Text } from '@/primitives' @@ -33,7 +34,7 @@ export function EncryptedMeetingBanner() { return () => clearTimeout(timer) }, []) - if (!roomData?.encryption_enabled) return null + if (!isEncryptedRoom(roomData)) return null const bgColor = isStrongEncryption ? '#166534' : '#1e3a5f' const hoverBgColor = isStrongEncryption ? '#15803d' : '#2563eb' diff --git a/src/frontend/src/features/encryption/VaultE2EEManager.ts b/src/frontend/src/features/encryption/VaultE2EEManager.ts new file mode 100644 index 00000000..45c97891 --- /dev/null +++ b/src/frontend/src/features/encryption/VaultE2EEManager.ts @@ -0,0 +1,287 @@ +/** + * Custom E2EE Manager that delegates crypto operations to the VaultClient iframe. + * + * Instead of using LiveKit's built-in Worker + FrameCryptor, this manager: + * - Sets up insertable streams (createEncodedStreams) on senders/receivers + * - Pipes frames through a TransformStream on the main thread + * - Delegates encrypt/decrypt to VaultClient.encryptWithKey / decryptWithKey + * - The symmetric key never leaves the VaultClient iframe + * + * Uses transferable ArrayBuffers for zero-copy performance. + */ +import { EventEmitter } from 'events' +import type { Room, RemoteTrack, Track } from 'livekit-client' +import { + RoomEvent, + ParticipantEvent, + ConnectionState, + Encryption_Type, +} from 'livekit-client' +import type { RTCEngine } from 'livekit-client/src/room/RTCEngine' + +// Re-declare the interface types we need (not exported from livekit-client) +interface EncryptDataResponse { + uuid: string + payload: Uint8Array + iv: Uint8Array + keyIndex: number +} + +interface DecryptDataResponse { + uuid: string + payload: Uint8Array +} + +const E2EE_FLAG = Symbol('e2ee') + +enum EncryptionEvent { + ParticipantEncryptionStatusChanged = 'participantEncryptionStatusChanged', + EncryptionError = 'encryptionError', +} + +function isLocalTrack(track: Track): boolean { + return (track as { sender?: RTCRtpSender }).sender !== undefined +} + +export class VaultE2EEManager extends EventEmitter { + private vaultClient: VaultClient + private room?: Room + private encryptionEnabled = false + private _isDataChannelEncryptionEnabled = false + + /** The symmetric key encrypted for the current user's vault public key */ + private encryptedSymmetricKey: ArrayBuffer | null = null + + constructor(vaultClient: VaultClient) { + super() + this.vaultClient = vaultClient + } + + get isEnabled(): boolean { + return this.encryptionEnabled + } + + get isDataChannelEncryptionEnabled(): boolean { + return this.isEnabled && this._isDataChannelEncryptionEnabled + } + + set isDataChannelEncryptionEnabled(enabled: boolean) { + this._isDataChannelEncryptionEnabled = enabled + } + + /** + * Set the encrypted symmetric key (wrapped for this user's vault public key). + * Must be called before encryption can work. + */ + setEncryptedSymmetricKey(key: ArrayBuffer): void { + this.encryptedSymmetricKey = key + } + + setup(room: Room): void { + if (room !== this.room) { + this.room = room + this.setupEventListeners(room) + } + } + + setupEngine(_engine: RTCEngine): void { + // No RTP map tracking needed — VaultClient handles crypto opaquely + } + + setParticipantCryptorEnabled(enabled: boolean, participantIdentity: string): void { + if ( + participantIdentity === this.room?.localParticipant.identity && + this.encryptionEnabled !== enabled + ) { + this.encryptionEnabled = enabled + this.emit( + EncryptionEvent.ParticipantEncryptionStatusChanged, + enabled, + this.room!.localParticipant + ) + } else if (participantIdentity !== this.room?.localParticipant.identity) { + const participant = this.room?.getParticipantByIdentity(participantIdentity) + if (participant) { + this.emit(EncryptionEvent.ParticipantEncryptionStatusChanged, enabled, participant) + } + } + } + + setSifTrailer(_trailer: Uint8Array): void { + // SIF (Server Injected Frames) not supported in vault mode + } + + async encryptData(data: Uint8Array): Promise { + if (!this.encryptedSymmetricKey) { + throw new Error('No encrypted symmetric key set') + } + + const { encryptedData } = await this.vaultClient.encryptWithKey( + data.buffer as ArrayBuffer, + this.encryptedSymmetricKey + ) + + return { + uuid: crypto.randomUUID(), + payload: new Uint8Array(encryptedData), + iv: new Uint8Array(0), // IV is embedded by VaultClient + keyIndex: 0, + } + } + + async handleEncryptedData( + payload: Uint8Array, + _iv: Uint8Array, + _participantIdentity: string, + _keyIndex: number + ): Promise { + if (!this.encryptedSymmetricKey) { + throw new Error('No encrypted symmetric key set') + } + + const { data } = await this.vaultClient.decryptWithKey( + payload.buffer as ArrayBuffer, + this.encryptedSymmetricKey + ) + + return { + uuid: crypto.randomUUID(), + payload: new Uint8Array(data), + } + } + + private setupEventListeners(room: Room): void { + room.on(RoomEvent.TrackPublished, (pub, participant) => { + this.setParticipantCryptorEnabled( + pub.trackInfo!.encryption !== Encryption_Type.NONE, + participant.identity + ) + }) + + room.on(RoomEvent.ConnectionStateChanged, (state) => { + if (state === ConnectionState.Connected) { + room.remoteParticipants.forEach((participant) => { + participant.trackPublications.forEach((pub) => { + this.setParticipantCryptorEnabled( + pub.trackInfo!.encryption !== Encryption_Type.NONE, + participant.identity + ) + }) + }) + } + }) + + room.on(RoomEvent.TrackSubscribed, (track, pub, participant) => { + this.setupReceiver(track, participant.identity) + }) + + room.on(RoomEvent.SignalConnected, () => { + this.setParticipantCryptorEnabled( + room.localParticipant.isE2EEEnabled, + room.localParticipant.identity + ) + }) + + room.localParticipant.on( + ParticipantEvent.LocalSenderCreated, + (sender: RTCRtpSender, track: Track) => { + this.setupSender(sender, track.mediaStreamID) + } + ) + } + + private setupSender(sender: RTCRtpSender, trackId: string): void { + if (E2EE_FLAG in sender) return + if (!this.room?.localParticipant.identity) return + + // @ts-expect-error - createEncodedStreams is not in the TS types + const senderStreams = sender.createEncodedStreams() + const readable: ReadableStream = senderStreams.readable + const writable: WritableStream = senderStreams.writable + + const transformStream = new TransformStream({ + transform: async (frame, controller) => { + try { + if (!this.encryptedSymmetricKey || !this.encryptionEnabled) { + controller.enqueue(frame) + return + } + + const frameData = new Uint8Array(frame.data) + const { encryptedData } = await this.vaultClient.encryptWithKey( + frameData.buffer as ArrayBuffer, + this.encryptedSymmetricKey + ) + + frame.data = encryptedData + controller.enqueue(frame) + } catch (err) { + // On error, pass frame through unencrypted to avoid blocking the pipeline + controller.enqueue(frame) + console.error('[VaultE2EE] encrypt frame error:', err) + } + }, + }) + + readable.pipeThrough(transformStream).pipeTo(writable) + + // @ts-expect-error - custom flag + sender[E2EE_FLAG] = true + } + + private setupReceiver(track: RemoteTrack, participantIdentity: string): void { + if (!track.receiver) return + + const receiver = track.receiver + + if (E2EE_FLAG in receiver) return + + // @ts-expect-error - createEncodedStreams is not in the TS types + let writable: WritableStream = receiver.writableStream + // @ts-expect-error + let readable: ReadableStream = receiver.readableStream + + if (!writable || !readable) { + // @ts-expect-error + const receiverStreams = receiver.createEncodedStreams() + // @ts-expect-error + receiver.writableStream = receiverStreams.writable + writable = receiverStreams.writable + // @ts-expect-error + receiver.readableStream = receiverStreams.readable + readable = receiverStreams.readable + } + + const transformStream = new TransformStream({ + transform: async (frame, controller) => { + try { + if (!this.encryptedSymmetricKey || !this.encryptionEnabled) { + controller.enqueue(frame) + return + } + + const frameData = new Uint8Array(frame.data) + const { data } = await this.vaultClient.decryptWithKey( + frameData.buffer as ArrayBuffer, + this.encryptedSymmetricKey + ) + + frame.data = data + controller.enqueue(frame) + } catch (err) { + // Decryption failed — emit error and drop frame + this.emit( + EncryptionEvent.EncryptionError, + new Error(`Decryption failed for ${participantIdentity}`), + participantIdentity + ) + } + }, + }) + + readable.pipeThrough(transformStream).pipeTo(writable) + + // @ts-expect-error + receiver[E2EE_FLAG] = true + } +} diff --git a/src/frontend/src/features/encryption/lobbyKeyExchange.ts b/src/frontend/src/features/encryption/lobbyKeyExchange.ts index 58186abb..70cf9702 100644 --- a/src/frontend/src/features/encryption/lobbyKeyExchange.ts +++ b/src/frontend/src/features/encryption/lobbyKeyExchange.ts @@ -108,6 +108,17 @@ export function clearSymmetricKey(): void { _symmetricKey = null } +// Module-level encrypted vault key — for advanced mode, stores the vault-wrapped key +let _encryptedVaultKey: ArrayBuffer | null = null + +export function setEncryptedVaultKey(key: ArrayBuffer): void { + _encryptedVaultKey = key +} + +export function getEncryptedVaultKey(): ArrayBuffer | null { + return _encryptedVaultKey +} + /** * Derive a shared secret from X25519 ECDH, then derive an encryption key via BLAKE2b. */ diff --git a/src/frontend/src/features/home/components/EncryptionModeDialog.tsx b/src/frontend/src/features/home/components/EncryptionModeDialog.tsx new file mode 100644 index 00000000..94e87361 --- /dev/null +++ b/src/frontend/src/features/home/components/EncryptionModeDialog.tsx @@ -0,0 +1,139 @@ +import { Button, Dialog, type DialogProps, Text } from '@/primitives' +import { VStack, HStack } from '@/styled-system/jsx' +import { css } from '@/styled-system/css' +import { RiLockFill, RiShieldCheckFill, RiAlertLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom' +import { useVaultClient } from '@/features/encryption' + +export const EncryptionModeDialog = ({ + onSelect, + isForLater = false, + ...dialogProps +}: { + onSelect: (mode: ApiEncryptionMode) => void + isForLater?: boolean +} & Omit) => { + const { t } = useTranslation('home', { keyPrefix: 'encryptionModeDialog' }) + const { hasKeys } = useVaultClient() + const canUseAdvanced = !!hasKeys + + return ( + + + + {t('description')} + + + + +
+ + {!canUseAdvanced && ( + + + + {t('advanced.onboardingRequired')} + + + )} +
+
+
+ ) +} diff --git a/src/frontend/src/features/home/routes/Home.tsx b/src/frontend/src/features/home/routes/Home.tsx index ab2ecdb8..f1bcf4b9 100644 --- a/src/frontend/src/features/home/routes/Home.tsx +++ b/src/frontend/src/features/home/routes/Home.tsx @@ -9,6 +9,8 @@ import { useUser, UserAware } from '@/features/auth' import { JoinMeetingDialog } from '../components/JoinMeetingDialog' import { RiAddLine, RiLink, RiLockLine } from '@remixicon/react' import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog' +import { EncryptionModeDialog } from '@/features/home/components/EncryptionModeDialog' +import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom' import { IntroSlider } from '@/features/home/components/IntroSlider' import { MoreLink } from '@/features/home/components/MoreLink' import { ReactNode, useEffect, useState } from 'react' @@ -156,6 +158,7 @@ export const Home = () => { const { mutateAsync: createRoom } = useCreateRoom() const [laterRoom, setLaterRoom] = useState(null) + const [encryptionDialogMode, setEncryptionDialogMode] = useState(null) const [redirectFailed, setRedirectFailed] = useState(false) const { data } = useConfig() @@ -248,18 +251,7 @@ export const Home = () => { className={ menuRecipe({ icon: true, variant: 'light' }).item } - onAction={async () => { - const slug = generateRoomId() - createRoom({ - slug, - username, - encryptionEnabled: true, - }).then((data) => - navigateTo('room', data.slug, { - state: { create: true, initialRoomData: data }, - }) - ) - }} + onAction={() => setEncryptionDialogMode('instant')} data-attr="create-option-encrypted-instant" > @@ -269,14 +261,7 @@ export const Home = () => { className={ menuRecipe({ icon: true, variant: 'light' }).item } - onAction={() => { - const slug = generateRoomId() - createRoom({ - slug, - username, - encryptionEnabled: true, - }).then((data) => setLaterRoom(data)) - }} + onAction={() => setEncryptionDialogMode('later')} data-attr="create-option-encrypted-later" > @@ -313,6 +298,28 @@ export const Home = () => { room={laterRoom} onOpenChange={() => setLaterRoom(null)} /> + {encryptionDialogMode && ( + { + setEncryptionDialogMode(null) + const slug = generateRoomId() + createRoom({ + slug, + username, + encryptionMode: mode, + }).then((data) => { + if (encryptionDialogMode === 'instant') { + navigateTo('room', data.slug, { + state: { create: true, initialRoomData: data }, + }) + } else { + setLaterRoom(data) + } + }) + }} + onOpenChange={() => setEncryptionDialogMode(null)} + /> + )} ) diff --git a/src/frontend/src/features/rooms/api/ApiRoom.ts b/src/frontend/src/features/rooms/api/ApiRoom.ts index 22630635..22229790 100644 --- a/src/frontend/src/features/rooms/api/ApiRoom.ts +++ b/src/frontend/src/features/rooms/api/ApiRoom.ts @@ -10,6 +10,21 @@ export enum ApiAccessLevel { RESTRICTED = 'restricted', } +export enum ApiEncryptionMode { + NONE = 'none', + BASIC = 'basic', + ADVANCED = 'advanced', +} + +export function isEncryptedRoom(room?: { encryption_mode?: ApiEncryptionMode; encryption_enabled?: boolean } | null): boolean { + if (!room) return false + // Support both new encryption_mode and legacy encryption_enabled + if (room.encryption_mode !== undefined) { + return room.encryption_mode !== ApiEncryptionMode.NONE + } + return !!room.encryption_enabled +} + export type ApiRoom = { id: string name: string @@ -17,7 +32,7 @@ export type ApiRoom = { pin_code: string is_administrable: boolean access_level: ApiAccessLevel - encryption_enabled: boolean + encryption_mode: ApiEncryptionMode livekit?: ApiLiveKit configuration?: { [key: string]: string | number | boolean | string[] diff --git a/src/frontend/src/features/rooms/api/createRoom.ts b/src/frontend/src/features/rooms/api/createRoom.ts index 363f1680..727a1064 100644 --- a/src/frontend/src/features/rooms/api/createRoom.ts +++ b/src/frontend/src/features/rooms/api/createRoom.ts @@ -1,27 +1,27 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { fetchApi } from '@/api/fetchApi' import { ApiError } from '@/api/ApiError' -import { ApiRoom } from './ApiRoom' +import { ApiRoom, ApiEncryptionMode } from './ApiRoom' export interface CreateRoomParams { slug: string callbackId?: string username?: string - encryptionEnabled?: boolean + encryptionMode?: ApiEncryptionMode } const createRoom = ({ slug, callbackId, username = '', - encryptionEnabled = false, + encryptionMode = ApiEncryptionMode.NONE, }: CreateRoomParams): Promise => { return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, { method: 'POST', body: JSON.stringify({ name: slug, callback_id: callbackId, - encryption_enabled: encryptionEnabled, + encryption_mode: encryptionMode, }), }) } diff --git a/src/frontend/src/features/rooms/api/enterRoom.ts b/src/frontend/src/features/rooms/api/enterRoom.ts index 77ac3935..4f435930 100644 --- a/src/frontend/src/features/rooms/api/enterRoom.ts +++ b/src/frontend/src/features/rooms/api/enterRoom.ts @@ -8,6 +8,7 @@ export interface EnterRoomParams { participantId: string encryptedKey?: string adminEphemeralPublicKey?: string + encryptedVaultKey?: string } export interface EnterRoomResponse { @@ -20,6 +21,7 @@ export const enterRoom = async ({ participantId, encryptedKey = '', adminEphemeralPublicKey = '', + encryptedVaultKey = '', }: EnterRoomParams): Promise => { return await fetchApi(`/rooms/${roomId}/enter/`, { method: 'POST', @@ -28,6 +30,7 @@ export const enterRoom = async ({ allow_entry: allowEntry, encrypted_key: encryptedKey, admin_ephemeral_public_key: adminEphemeralPublicKey, + encrypted_vault_key: encryptedVaultKey, }), }) } diff --git a/src/frontend/src/features/rooms/api/listWaitingParticipants.ts b/src/frontend/src/features/rooms/api/listWaitingParticipants.ts index 97ffe710..4d07b633 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 + suite_user_id?: string ephemeral_public_key?: string } diff --git a/src/frontend/src/features/rooms/api/requestEntry.ts b/src/frontend/src/features/rooms/api/requestEntry.ts index e5d2e679..a3a6787f 100644 --- a/src/frontend/src/features/rooms/api/requestEntry.ts +++ b/src/frontend/src/features/rooms/api/requestEntry.ts @@ -20,6 +20,7 @@ export interface ApiRequestEntry { livekit?: ApiLiveKit encrypted_key?: string admin_ephemeral_public_key?: string + encrypted_vault_key?: string } export const requestEntry = async ({ diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index bbd72fd5..5f946beb 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -13,7 +13,10 @@ import { RoomOptions, VideoPresets, } from 'livekit-client' -import { setSymmetricKey, getSymmetricKey } from '@/features/encryption/lobbyKeyExchange' +import { setSymmetricKey, getSymmetricKey, getEncryptedVaultKey } from '@/features/encryption/lobbyKeyExchange' +import { isEncryptedRoom, ApiEncryptionMode } from '../api/ApiRoom' +import { VaultE2EEManager } from '@/features/encryption/VaultE2EEManager' +import { useVaultClient } from '@/features/encryption' import { keys } from '@/api/queryKeys' import { queryClient } from '@/api/queryClient' import { Screen } from '@/layout/Screen' @@ -88,23 +91,30 @@ export const Conference = ({ retry: false, }) - const encryptionEnabled = data?.encryption_enabled ?? false + const encryptionEnabled = isEncryptedRoom(data) + const { client: vaultClient, hasKeys: vaultHasKeys } = useVaultClient() - // Encryption setup — refs for keyProvider and worker, - // passed directly to RoomOptions.e2ee at Room construction time. + // Determine which E2EE backend to use: + // - Advanced mode: VaultClient (iframe-based, key never leaves iframe) + // - Basic mode: LiveKit's built-in Worker+KeyProvider with passphrase from URL hash + const isAdvancedMode = data?.encryption_mode === ApiEncryptionMode.ADVANCED + const useVaultE2EE = isAdvancedMode && !!vaultClient && !!vaultHasKeys + + // Refs for both approaches (only one is used per session) const keyProviderRef = useRef(null) const workerRef = useRef(null) + const vaultManagerRef = useRef(null) const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled) const getKeyProvider = () => { - if (!keyProviderRef.current && encryptionEnabled) { + if (!keyProviderRef.current && encryptionEnabled && !useVaultE2EE) { keyProviderRef.current = new ExternalE2EEKeyProvider() } return keyProviderRef.current } const getWorker = () => { - if (!workerRef.current && encryptionEnabled && typeof window !== 'undefined') { + if (!workerRef.current && encryptionEnabled && !useVaultE2EE && typeof window !== 'undefined') { workerRef.current = new Worker( new URL('livekit-client/e2ee-worker', import.meta.url) ) @@ -112,11 +122,15 @@ export const Conference = ({ return workerRef.current } - const roomOptions = useMemo((): RoomOptions => { - const worker = getWorker() - const keyProvider = getKeyProvider() + const getVaultManager = () => { + if (!vaultManagerRef.current && useVaultE2EE && vaultClient) { + vaultManagerRef.current = new VaultE2EEManager(vaultClient) + } + return vaultManagerRef.current + } - return { + const roomOptions = useMemo((): RoomOptions => { + const baseOptions: RoomOptions = { adaptiveStream: true, dynacast: true, publishDefaults: { @@ -135,13 +149,26 @@ export const Conference = ({ audioOutput: { deviceId: userConfig.audioOutputDeviceId ?? undefined, }, - encryption: encryptionEnabled && keyProvider && worker - ? { keyProvider, worker } - : undefined, } + + if (useVaultE2EE) { + const vaultManager = getVaultManager() + if (vaultManager) { + baseOptions.encryption = { e2eeManager: vaultManager } + } + } else if (encryptionEnabled) { + const worker = getWorker() + const keyProvider = getKeyProvider() + if (keyProvider && worker) { + baseOptions.encryption = { keyProvider, worker } + } + } + + return baseOptions // do not rely on the userConfig object directly as its reference may change on every render }, [ encryptionEnabled, + useVaultE2EE, userConfig.videoDeviceId, userConfig.videoPublishResolution, userConfig.audioDeviceId, @@ -165,25 +192,110 @@ export const Conference = ({ }, [apiConfig?.livekit]) // Encryption key setup: - // Admin: generate passphrase -> setKey -> connect -> E2EE enabled - // Joiner: use pre-exchanged key from lobby -> setKey -> connect -> E2EE enabled + // VaultE2EE: admin generates key via vaultClient.encryptWithoutKey(), joiner receives wrapped key + // Fallback: admin generates passphrase, joiner receives via lobby DH exchange const isAdmin = mode === 'create' || data?.is_administrable === true const adminPassphraseRef = useRef(null) useEffect(() => { 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('') + if (useVaultE2EE) { + // VaultClient E2EE path — key never leaves the iframe + const vaultManager = getVaultManager() + if (!vaultManager || !vaultClient) return + + const setupVaultKey = async () => { + try { + if (isAdmin) { + // Admin: generate a symmetric key via VaultClient + const dummyData = new Uint8Array(32).buffer + const { publicKey } = await vaultClient.getPublicKey() + const { encryptedKeys } = await vaultClient.encryptWithoutKey( + dummyData, + { self: publicKey } + ) + const encryptedSymmetricKey = encryptedKeys['self'] + vaultManager.setEncryptedSymmetricKey(encryptedSymmetricKey) + console.info('[VaultE2EE] Admin: symmetric key generated') + } else { + // Joiner: use the vault-wrapped key received from admin via lobby + const vaultKey = getEncryptedVaultKey() + if (vaultKey) { + vaultManager.setEncryptedSymmetricKey(vaultKey) + console.info('[VaultE2EE] Joiner: vault key received from lobby') + } else { + console.error('[VaultE2EE] Joiner: no vault key available') + return + } + } + + setEncryptionSetupComplete(true) + + const onConnected = async () => { + try { + await room.setE2EEEnabled(true) + console.info('[VaultE2EE] E2EE enabled') + } catch (err) { + console.error('[VaultE2EE] E2EE enable failed:', err) + } + } + + if (room.state === 'connected') onConnected() + else room.once('connected', onConnected) + } catch (err) { + console.error('[VaultE2EE] Setup failed:', err) + } + } + + setupVaultKey() + } else { + // Basic mode: LiveKit Worker+KeyProvider with passphrase in URL hash + const keyProvider = getKeyProvider() + if (!keyProvider) return + + let passphrase: string | null = null + + if (isAdmin) { + // Admin: generate passphrase and put it in the URL hash + if (!adminPassphraseRef.current) { + // Check if there's already a hash (e.g. admin refreshed the page) + const existingHash = window.location.hash.slice(1) + if (existingHash) { + adminPassphraseRef.current = existingHash + } else { + adminPassphraseRef.current = Array.from(crypto.getRandomValues(new Uint8Array(24))) + .map((b) => b.toString(36).padStart(2, '0')) + .join('') + // Set the hash in the URL (without triggering navigation) + window.history.replaceState( + window.history.state, + '', + `${window.location.pathname}${window.location.search}#${adminPassphraseRef.current}` + ) + } + } + passphrase = adminPassphraseRef.current + setSymmetricKey(new TextEncoder().encode(passphrase)) + } else { + // Joiner: read passphrase from URL hash (shared link) or from lobby exchange + const hashKey = window.location.hash.slice(1) + if (hashKey) { + passphrase = hashKey + setSymmetricKey(new TextEncoder().encode(passphrase)) + } else { + // Fallback: key received via lobby DH exchange + const preExchangedKey = getSymmetricKey() + if (preExchangedKey) { + passphrase = new TextDecoder().decode(preExchangedKey) + } + } + } + + if (!passphrase) { + console.error('[Encryption] No passphrase available (not in URL hash and no lobby exchange)') + return } - const passphrase = adminPassphraseRef.current - setSymmetricKey(new TextEncoder().encode(passphrase)) keyProvider .setKey(passphrase) @@ -194,7 +306,7 @@ export const Conference = ({ try { await room.setE2EEEnabled(true) } catch (err) { - console.error('[Encryption] Admin: E2EE enable failed:', err) + console.error('[Encryption] E2EE enable failed:', err) } } @@ -202,40 +314,11 @@ export const Conference = ({ else room.once('connected', onConnected) }) .catch((err) => { - console.error('[Encryption] Admin failed:', err) + console.error('[Encryption] Key setup failed:', err) }) - } else { - // Joiner: use the pre-exchanged key from lobby (stored in module-level lobbyKeyExchange) - const preExchangedKey = getSymmetricKey() - - if (preExchangedKey) { - const passphrase = new TextDecoder().decode(preExchangedKey) - - keyProvider - .setKey(passphrase) - .then(() => { - setEncryptionSetupComplete(true) - - const onConnected = async () => { - try { - await room.setE2EEEnabled(true) - } catch (err) { - console.error('[Encryption] Joiner: E2EE enable failed:', err) - } - } - - 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]) + }, [room, encryptionEnabled, encryptionSetupComplete, isAdmin, useVaultE2EE]) useEffect(() => { /** diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index 5a11e400..b6be379a 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -32,7 +32,7 @@ import { useQuery } from '@tanstack/react-query' import { queryClient } from '@/api/queryClient' import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry' import { Spinner } from '@/primitives/Spinner' -import { ApiAccessLevel } from '../api/ApiRoom' +import { ApiAccessLevel, ApiEncryptionMode, isEncryptedRoom as checkEncryptedRoom } from '../api/ApiRoom' import { useLoginHint } from '@/hooks/useLoginHint' import { useUser } from '@/features/auth' import { RiInformationLine, RiLockLine } from '@remixicon/react' @@ -113,7 +113,13 @@ export const Join = ({ staleTime: 6 * 60 * 60 * 1000, retry: false, }) - const isEncryptedRoom = roomInfo?.encryption_enabled ?? false + const isEncryptedRoom = checkEncryptedRoom(roomInfo) + const isBasicEncrypted = roomInfo?.encryption_mode === ApiEncryptionMode.BASIC + + // Basic mode: validate the passphrase in the URL hash + const BASIC_KEY_LENGTH = 48 + const hashKey = window.location.hash.slice(1) + const hasValidBasicKey = isBasicEncrypted ? (hashKey.length === BASIC_KEY_LENGTH && /^[a-z0-9]+$/.test(hashKey)) : true // In encrypted rooms, authenticated users must use their OIDC name const isNameLocked = isEncryptedRoom && !!isLoggedIn @@ -443,6 +449,19 @@ export const Join = ({ ) default: + if (isBasicEncrypted && !hasValidBasicKey) { + return ( + + + + {t('invalidKey.title')} + + + {t('invalidKey.body')} + + + ) + } return (
{ const roomData = useRoomData() const roomId = roomData?.id || '' // FIXME - bad practice - const isEncryptedRoom = roomData?.encryption_enabled ?? false + const encrypted = checkEncryptedRoom(roomData) + const isAdvancedMode = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED const room = useRoomContext() const isAdminOrOwner = useIsAdminOrOwner() + const { client: vaultClient } = useVaultClient() const handleDataReceived = useCallback((payload: Uint8Array) => { const notification = decodeNotificationDataReceived(payload) @@ -59,19 +63,59 @@ export const useWaitingParticipants = () => { const { mutateAsync: enterRoom } = useEnterRoom() + const encryptKeyForAccept = async (participant: WaitingParticipant) => { + let encryptedKey = '' + let adminEphemeralPublicKey = '' + let encryptedVaultKey = '' + + if (isAdvancedMode && vaultClient && participant.suite_user_id) { + // Advanced mode: wrap the symmetric key for the joiner's vault public key + try { + const { publicKeys } = await vaultClient.fetchPublicKeys([participant.suite_user_id]) + const joinerPubKey = publicKeys[participant.suite_user_id] + if (joinerPubKey) { + // Get admin's encrypted symmetric key from the VaultE2EEManager + // and re-wrap it for the joiner + const adminPubKey = (await vaultClient.getPublicKey()).publicKey + const { encryptedKeys } = await vaultClient.encryptWithoutKey( + new Uint8Array(32).buffer, + { [participant.suite_user_id]: joinerPubKey, self: adminPubKey } + ) + const joinerKey = encryptedKeys[participant.suite_user_id] + if (joinerKey) { + // Encode as base64 for transport via REST API + const bytes = new Uint8Array(joinerKey) + encryptedVaultKey = btoa(String.fromCharCode(...bytes)) + } + } + } catch (err) { + console.error('[VaultE2EE] Failed to wrap key for participant:', err) + } + } else if (encrypted && getSymmetricKey() && participant.ephemeral_public_key) { + // Basic mode: DH key exchange + const result = await encryptKeyForParticipant( + participant.ephemeral_public_key + ) + encryptedKey = result.encryptedKey + adminEphemeralPublicKey = result.adminPublicKey + } + + return { encryptedKey, adminEphemeralPublicKey, encryptedVaultKey } + } + const handleParticipantEntry = async ( participant: WaitingParticipant, allowEntry: boolean ) => { let encryptedKey = '' let adminEphemeralPublicKey = '' + let encryptedVaultKey = '' - if (allowEntry && isEncryptedRoom && getSymmetricKey() && participant.ephemeral_public_key) { - const result = await encryptKeyForParticipant( - participant.ephemeral_public_key - ) - encryptedKey = result.encryptedKey - adminEphemeralPublicKey = result.adminPublicKey + if (allowEntry) { + const keys = await encryptKeyForAccept(participant) + encryptedKey = keys.encryptedKey + adminEphemeralPublicKey = keys.adminEphemeralPublicKey + encryptedVaultKey = keys.encryptedVaultKey } await enterRoom({ @@ -80,6 +124,7 @@ export const useWaitingParticipants = () => { participantId: participant.id, encryptedKey, adminEphemeralPublicKey, + encryptedVaultKey, }) await refetchWaiting() } @@ -94,13 +139,13 @@ export const useWaitingParticipants = () => { waitingParticipants.map(async (participant) => { let encryptedKey = '' let adminEphemeralPublicKey = '' + let encryptedVaultKey = '' - if (allowEntry && isEncryptedRoom && getSymmetricKey() && participant.ephemeral_public_key) { - const result = await encryptKeyForParticipant( - participant.ephemeral_public_key - ) - encryptedKey = result.encryptedKey - adminEphemeralPublicKey = result.adminPublicKey + if (allowEntry) { + const keys = await encryptKeyForAccept(participant) + encryptedKey = keys.encryptedKey + adminEphemeralPublicKey = keys.adminEphemeralPublicKey + encryptedVaultKey = keys.encryptedVaultKey } return enterRoom({ @@ -109,6 +154,7 @@ export const useWaitingParticipants = () => { participantId: participant.id, encryptedKey, adminEphemeralPublicKey, + encryptedVaultKey, }) }) ) diff --git a/src/frontend/src/features/rooms/livekit/components/Admin.tsx b/src/frontend/src/features/rooms/livekit/components/Admin.tsx index c7180233..1291811a 100644 --- a/src/frontend/src/features/rooms/livekit/components/Admin.tsx +++ b/src/frontend/src/features/rooms/livekit/components/Admin.tsx @@ -4,7 +4,7 @@ import { Separator as RACSeparator } from 'react-aria-components' import { useTranslation } from 'react-i18next' import { usePatchRoom } from '@/features/rooms/api/patchRoom' import { fetchRoom } from '@/features/rooms/api/fetchRoom' -import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom' +import { ApiAccessLevel, isEncryptedRoom } from '@/features/rooms/api/ApiRoom' import { queryClient } from '@/api/queryClient' import { keys } from '@/api/queryKeys' import { useQuery } from '@tanstack/react-query' @@ -168,7 +168,7 @@ export const Admin = () => { > {t('access.description')} - {readOnlyData?.encryption_enabled && ( + {isEncryptedRoom(readOnlyData) && ( { value: ApiAccessLevel.PUBLIC, label: t('access.levels.public.label'), description: t('access.levels.public.description'), - isDisabled: readOnlyData?.encryption_enabled, + isDisabled: isEncryptedRoom(readOnlyData), }, { value: ApiAccessLevel.TRUSTED, label: t('access.levels.trusted.label'), description: t('access.levels.trusted.description'), - isDisabled: readOnlyData?.encryption_enabled, + isDisabled: isEncryptedRoom(readOnlyData), }, { value: ApiAccessLevel.RESTRICTED, diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx index c3f6cdf3..3ff17c3e 100644 --- a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx @@ -19,14 +19,17 @@ import { isTrackReferencePinned, TrackReferenceOrPlaceholder, } from '@livekit/components-core' -import { Track } from 'livekit-client' +import { Track, RoomEvent } from 'livekit-client' +import type { Participant } from 'livekit-client' import { RiHand } from '@remixicon/react' +import { useRoomContext } from '@livekit/components-react' import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand' import { EncryptionBadge, getTrustLevelFromAttributes, } from '@/features/encryption' import { useRoomData } from '../hooks/useRoomData' +import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom' import { RiLockFill } from '@remixicon/react' import { HStack } from '@/styled-system/jsx' import { MutedMicIndicator } from './MutedMicIndicator' @@ -85,16 +88,44 @@ export const ParticipantTile: ( }) const isEncrypted = useIsEncrypted(trackReference.participant) const roomData = useRoomData() - const isEncryptedRoom = roomData?.encryption_enabled ?? false - // Show overlay when we cannot decrypt a remote participant's frames - const isDecryptionFailed = - isEncryptedRoom && !isEncrypted && !trackReference.participant.isLocal - // TODO: remove this force flag after CSS adjustments - const forceDecryptionOverlay = true + const isEncryptedRoom = checkEncryptedRoom(roomData) + + // Track decryption failures via EncryptionError events from LiveKit. + // useIsEncrypted returns true when E2EE is enabled, NOT when frames decrypt successfully. + // So we listen for actual decryption errors to know when to show the overlay. + const room = useRoomContext() + const [decryptionFailed, setDecryptionFailed] = React.useState(false) + + React.useEffect(() => { + if (!isEncryptedRoom || trackReference.participant.isLocal) return + + const participantIdentity = trackReference.participant.identity + + const handleEncryptionError = (_error: Error, participant?: Participant) => { + if (participant?.identity === participantIdentity) { + setDecryptionFailed(true) + } + } + + const handleEncryptionStatusChanged = (encrypted: boolean, participant?: Participant) => { + // Clear the error when encryption status confirms frames are decrypting + if (participant?.identity === participantIdentity && encrypted) { + setDecryptionFailed(false) + } + } + + room.on(RoomEvent.EncryptionError, handleEncryptionError) + room.on(RoomEvent.ParticipantEncryptionStatusChanged, handleEncryptionStatusChanged) + return () => { + room.off(RoomEvent.EncryptionError, handleEncryptionError) + room.off(RoomEvent.ParticipantEncryptionStatusChanged, handleEncryptionStatusChanged) + } + }, [room, isEncryptedRoom, trackReference.participant]) + const showDecryptionError = !trackReference.participant.isLocal && isEncryptedRoom && - (forceDecryptionOverlay || isDecryptionFailed) + decryptionFailed const layoutContext = useMaybeLayoutContext() const autoManageSubscription = useFeatureContext()?.autoSubscription @@ -228,8 +259,9 @@ export const ParticipantTile: ( lineHeight: 1.4, }} > - Unable to decrypt this participant's stream. One of - you may need to leave and rejoin. + Check that you and this person are using the correct + meeting link. If they are the only one you can't see, + the issue is likely on their side. diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx index e37800b0..11ce93e4 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx @@ -23,6 +23,7 @@ import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButt import { PinBadge } from './PinBadge' import { EncryptionBadge, getTrustLevelFromAttributes, FingerprintDialog } from '@/features/encryption' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { isEncryptedRoom as isEncryptedRoomFn } from '@/features/rooms/api/ApiRoom' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' type MicIndicatorProps = { @@ -101,7 +102,7 @@ export const ParticipantListItem = ({ }: ParticipantListItemProps) => { const { t } = useTranslation('rooms') const roomData = useRoomData() - const isEncryptedRoom = roomData?.encryption_enabled ?? false + const isEncryptedRoom = isEncryptedRoomFn(roomData) const isAdmin = useIsAdminOrOwner() const [isFingerprintOpen, setIsFingerprintOpen] = useState(false) const name = participant.name || participant.identity diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx index f46bb56d..c7f6682a 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next' import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants' import { RiCloseLine, RiShieldCheckLine, RiAlertLine } from '@remixicon/react' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom' import { EncryptionTrustModal } from '@/features/encryption' import { useState } from 'react' @@ -73,7 +74,7 @@ export const WaitingParticipantListItem = ({ }) => { const { t } = useTranslation('rooms') const roomData = useRoomData() - const isEncryptedRoom = roomData?.encryption_enabled ?? false + const encryptedRoom = isEncryptedRoom(roomData) return ( - {isEncryptedRoom && ( + {encryptedRoom && ( - {isEncryptedRoom && participant.email && ( + {encryptedRoom && participant.email && (