From 8bdca048f4c000af443e47e00b0bac36003a9d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Wed, 6 May 2026 18:03:37 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(encryption)=20temporarily=20remove=20?= =?UTF-8?q?advanced=20mode=20for=20a=20quick=20and=20simplified=20release?= =?UTF-8?q?=20[WIP]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 54 +-- src/backend/core/api/__init__.py | 9 +- src/backend/core/api/serializers.py | 67 +-- src/backend/core/api/viewsets.py | 55 +-- .../0019_room_encryption_enabled.py | 20 - ...om_is_encrypted_user_default_encryption.py | 36 ++ .../migrations/0020_room_encryption_mode.py | 51 --- ..._resourceaccess_encrypted_symmetric_key.py | 23 - src/backend/core/models.py | 44 +- src/backend/core/services/livekit_events.py | 74 ++++ src/backend/core/services/lobby.py | 97 +---- src/backend/core/utils.py | 39 +- src/backend/meet/settings.py | 10 +- src/frontend/src/App.tsx | 19 +- src/frontend/src/api/useConfig.ts | 2 - src/frontend/src/features/auth/api/ApiUser.ts | 1 + .../auth/api/updateUserPreferences.ts | 11 +- .../encryption/EncryptedMeetingBanner.tsx | 181 -------- .../EncryptionAutoResumeWatcher.tsx | 46 ++ .../features/encryption/EncryptionBadge.tsx | 82 ---- .../features/encryption/EncryptionContext.tsx | 10 - .../encryption/EncryptionIdentityDialog.tsx | 326 -------------- .../encryption/EncryptionMismatchScreen.tsx | 104 +++++ .../encryption/EncryptionSetupOverlay.tsx | 118 ------ .../encryption/EncryptionStatusContext.tsx | 356 ++++++++++++++++ .../encryption/EncryptionStatusSnackbars.tsx | 203 +++++++++ .../encryption/EncryptionTrustModal.tsx | 143 ------- .../encryption/HybridKeyDistributor.ts | 122 ------ .../src/features/encryption/IdentityBadge.tsx | 67 +++ .../PauseEncryptionConfirmDialog.tsx | 67 +++ .../features/encryption/RoomStatusBanner.tsx | 161 +++++++ .../src/features/encryption/SECURITY.md | 117 ------ .../encryption/VaultClientProvider.tsx | 231 ---------- .../encryption/VaultE2EEManager.test.ts | 396 ------------------ .../features/encryption/VaultE2EEManager.ts | 328 --------------- .../encryptionStatusContextValue.ts | 15 + .../encryption/encryptionStatusTypes.ts | 32 ++ .../src/features/encryption/global.d.ts | 104 ----- src/frontend/src/features/encryption/index.ts | 31 +- .../features/encryption/lobbyKeyExchange.ts | 49 --- .../src/features/encryption/passphrase.ts | 34 ++ src/frontend/src/features/encryption/types.ts | 43 -- .../encryption/useEncryptionStatus.ts | 4 + .../encryption/useParticipantTrustLevel.ts | 133 ------ .../home/components/EncryptionModeDialog.tsx | 142 ------- .../home/components/JoinMeetingDialog.tsx | 9 +- .../src/features/home/routes/Home.tsx | 127 ++---- .../WaitingParticipantNotification.tsx | 136 +----- .../src/features/rooms/api/ApiRoom.ts | 18 +- .../src/features/rooms/api/createRoom.ts | 11 +- .../src/features/rooms/api/enterRoom.ts | 9 - .../rooms/api/listWaitingParticipants.ts | 3 - .../src/features/rooms/api/requestEntry.ts | 6 - .../features/rooms/components/Conference.tsx | 244 ++++------- .../src/features/rooms/components/Join.tsx | 290 ++----------- .../src/features/rooms/hooks/useLobby.ts | 25 +- .../rooms/hooks/useWaitingParticipants.ts | 119 +----- .../rooms/livekit/components/Admin.tsx | 25 +- .../livekit/components/ParticipantTile.tsx | 201 +-------- .../rooms/livekit/components/Tools.tsx | 68 +-- .../Options/ScreenRecordingMenuItem.tsx | 50 ++- .../controls/Options/TranscriptMenuItem.tsx | 48 ++- .../Participants/ParticipantListItem.tsx | 151 +------ .../WaitingParticipantListItem.tsx | 149 +------ .../rooms/livekit/prefabs/VideoConference.tsx | 10 +- .../src/features/sdk/routes/CreatePopup.tsx | 171 ++------ .../src/features/sdk/utils/PopupManager.ts | 3 +- .../components/SettingsDialogExtended.tsx | 7 + .../settings/components/tabs/AccountTab.tsx | 34 +- .../settings/components/tabs/SecurityTab.tsx | 143 +++++++ src/frontend/src/features/settings/type.ts | 1 + src/frontend/src/layout/Header.tsx | 123 +----- src/frontend/src/locales/en/global.json | 3 - src/frontend/src/locales/en/home.json | 18 +- src/frontend/src/locales/en/rooms.json | 134 ++---- src/frontend/src/locales/en/sdk.json | 6 +- src/frontend/src/locales/en/settings.json | 27 +- src/frontend/src/locales/fr/global.json | 2 - src/frontend/src/locales/fr/home.json | 18 +- src/frontend/src/locales/fr/rooms.json | 134 ++---- src/frontend/src/locales/fr/sdk.json | 6 +- src/frontend/src/locales/fr/settings.json | 27 +- 82 files changed, 1974 insertions(+), 4839 deletions(-) delete mode 100644 src/backend/core/migrations/0019_room_encryption_enabled.py create mode 100644 src/backend/core/migrations/0019_room_is_encrypted_user_default_encryption.py delete mode 100644 src/backend/core/migrations/0020_room_encryption_mode.py delete mode 100644 src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py delete mode 100644 src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx create mode 100644 src/frontend/src/features/encryption/EncryptionAutoResumeWatcher.tsx delete mode 100644 src/frontend/src/features/encryption/EncryptionBadge.tsx delete mode 100644 src/frontend/src/features/encryption/EncryptionContext.tsx delete mode 100644 src/frontend/src/features/encryption/EncryptionIdentityDialog.tsx create mode 100644 src/frontend/src/features/encryption/EncryptionMismatchScreen.tsx delete mode 100644 src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx create mode 100644 src/frontend/src/features/encryption/EncryptionStatusContext.tsx create mode 100644 src/frontend/src/features/encryption/EncryptionStatusSnackbars.tsx delete mode 100644 src/frontend/src/features/encryption/EncryptionTrustModal.tsx delete mode 100644 src/frontend/src/features/encryption/HybridKeyDistributor.ts create mode 100644 src/frontend/src/features/encryption/IdentityBadge.tsx create mode 100644 src/frontend/src/features/encryption/PauseEncryptionConfirmDialog.tsx create mode 100644 src/frontend/src/features/encryption/RoomStatusBanner.tsx delete mode 100644 src/frontend/src/features/encryption/SECURITY.md delete mode 100644 src/frontend/src/features/encryption/VaultClientProvider.tsx delete mode 100644 src/frontend/src/features/encryption/VaultE2EEManager.test.ts delete mode 100644 src/frontend/src/features/encryption/VaultE2EEManager.ts create mode 100644 src/frontend/src/features/encryption/encryptionStatusContextValue.ts create mode 100644 src/frontend/src/features/encryption/encryptionStatusTypes.ts delete mode 100644 src/frontend/src/features/encryption/global.d.ts delete mode 100644 src/frontend/src/features/encryption/lobbyKeyExchange.ts create mode 100644 src/frontend/src/features/encryption/passphrase.ts delete mode 100644 src/frontend/src/features/encryption/types.ts create mode 100644 src/frontend/src/features/encryption/useEncryptionStatus.ts delete mode 100644 src/frontend/src/features/encryption/useParticipantTrustLevel.ts delete mode 100644 src/frontend/src/features/home/components/EncryptionModeDialog.tsx create mode 100644 src/frontend/src/features/settings/components/tabs/SecurityTab.tsx diff --git a/README.md b/README.md index a2a7bfd1..8f7b8cdc 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo - Optimized for stability in large meetings (+100 p.) - Support for multiple screen sharing streams - Non-persistent, secure chat -- End-to-end encryption with hybrid key distribution +- End-to-end encryption with passphrase-in-link key distribution - Meeting recording - Meeting transcription & Summary (currently in beta) - Telephony integration @@ -48,52 +48,42 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo ### End-to-end encryption -La Suite Meet supports end-to-end encryption (E2EE) for meetings, ensuring that the media server (LiveKit SFU) cannot access audio/video content. Two encryption modes are available: +La Suite Meet supports end-to-end encryption (E2EE) for meetings, so the media server (LiveKit SFU) cannot read audio, video or screen-share content. -#### Basic encryption +#### How it works -- Passphrase-based — the encryption key is embedded in the meeting URL hash (`#passphrase`) -- Uses LiveKit's built-in Worker + `crypto.subtle` (AES-GCM) for frame encryption -- Sharing the meeting link shares the encryption key -- No account or onboarding required -- Security depends on keeping the link private +- Each encrypted meeting carries a 48-character random passphrase appended to the URL hash (`#…`). The server never sees it; sharing the meeting link shares the key. +- Frames are encrypted in the browser via LiveKit's Worker + `crypto.subtle` (AES-GCM); only the media payload is encrypted, codec headers stay clear so the SFU can still packetize RTP. +- The runtime "is this call encrypted?" decision keys off the URL hash, not the database flag — a compromised server cannot fabricate a passphrase that all participants happen to share. +- The DB flag (`Room.is_encrypted`) is a hint used at room creation time only (so the Create button knows to generate a hash) and to detect link/server inconsistencies. -#### Advanced encryption +#### Opt-in by user -- Key managed by [La Suite Encryption](https://github.com/suitenumerique/encryption) — the symmetric key never leaves the vault iframe -- Uses XChaCha20-Poly1305 (libsodium) via the VaultClient iframe for frame encryption -- Key distribution uses `vaultClient.shareKeys()` (hybrid PKI with X25519 + post-quantum slot) -- All participants must complete encryption onboarding (key generation + backup) before joining -- Requires a Chromium-based browser (Chrome, Edge, Brave) — uses the Insertable Streams API +End-to-end encryption is a per-user preference. In **Settings → Security**, signed-in users can enable "End-to-end encryption" — from then on every meeting they create is encrypted by default. Joining is unaffected: if a meeting URL has a passphrase, the joining client uses it. -**Frame encryption (both modes):** +#### Pause / resume for recording and transcription -- Codec header bytes (VP8 payload descriptor) are preserved unencrypted — required for proper RTP packetization -- Only the media payload is encrypted, with a per-frame random nonce -- The server (LiveKit SFU) only forwards encrypted data it cannot read +While encryption is on, the SFU cannot record or transcribe (it has nothing to read). When an admin (or, if no admin is present, the longest-present participant — provided a pause has already been observed in the session) starts a recording or transcription: -**Trust levels (advanced mode):** -| Badge | Level | Description | -|-------|-------|-------------| -| 🟢 Green shield | Verified | User completed encryption onboarding (public key registered). Identity cryptographically verified. | -| 🔵 Blue shield | Authenticated | User signed in via ProConnect/OIDC. Identity server-verified. | -| 🟡 Orange warning | Anonymous | User not signed in. Self-declared name. Admin should verify identity before accepting. | +1. A confirmation dialog warns that encryption will be paused. +2. On confirm, an `ENCRYPTION_PAUSED` message is broadcast over a LiveKit reliable data channel. While the sender hasn't yet flipped its own state, that message is itself encrypted — which is the trust anchor: only callers holding the passphrase can produce frames everyone can decrypt. +3. Each receiver disables E2EE locally and republishes its tracks unencrypted. +4. Late joiners send an `ENCRYPTION_STATUS_PROBE` so the leader can re-emit the announcement to them. +5. When **both** recording and transcription stop, the participant who paused broadcasts `ENCRYPTION_RESUMED` and everyone re-enables E2EE with the same URL passphrase. -**Security guarantees:** +The pause state is intentionally session-only and never persisted — `Room.is_encrypted` does not flip. -- Encrypted rooms enforce restricted access (lobby approval required) -- Trust information (`is_authenticated`, `email`) comes from server-signed JWT tokens — cannot be spoofed -- Recording and transcription are not available in encrypted rooms (server cannot decrypt media) +#### Phone / SIP participants -**Configuration:** +Phone and other external devices can't decrypt our frames. When one joins an encrypted room, the backend webhook detects them, broadcasts a system notice (admins see a snackbar with an "Open settings" CTA), and removes the external participant. The admin can then disable encryption from the Security settings and the user can dial in again. + +#### Configuration ```env ENCRYPTION_ENABLED=true -ENCRYPTION_VAULT_URL=https://data.encryption.example.fr -ENCRYPTION_INTERFACE_URL=https://encryption.example.fr ``` -When the encryption service is deployed and configured, rooms can use advanced encryption. Without it, only basic (passphrase) encryption is available. +Setting `ENCRYPTION_ENABLED=false` disables the user preference toggle entirely; existing encrypted rooms stay encrypted but no new ones can be created. La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr). diff --git a/src/backend/core/api/__init__.py b/src/backend/core/api/__init__.py index a751ae17..49999d2d 100644 --- a/src/backend/core/api/__init__.py +++ b/src/backend/core/api/__init__.py @@ -73,11 +73,8 @@ def get_frontend_configuration(request): "default_sources": settings.LIVEKIT_DEFAULT_SOURCES, }, } - if settings.ENCRYPTION_ENABLED and settings.ENCRYPTION_VAULT_URL: - frontend_configuration["encryption"] = { - "enabled": True, - "vault_url": settings.ENCRYPTION_VAULT_URL, - "interface_url": settings.ENCRYPTION_INTERFACE_URL, - } + frontend_configuration["encryption"] = { + "enabled": settings.ENCRYPTION_ENABLED, + } frontend_configuration.update(settings.FRONTEND_CONFIGURATION) return Response(frontend_configuration) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 4d52d373..a4e9f4da 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -30,7 +30,16 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = models.User - fields = ["id", "sub", "email", "full_name", "short_name", "timezone", "language"] + fields = [ + "id", + "sub", + "email", + "full_name", + "short_name", + "timezone", + "language", + "default_encryption", + ] read_only_fields = ["id", "sub", "email", "full_name", "short_name"] @@ -75,22 +84,6 @@ class ResourceAccessSerializerMixin: "Only owners of a room can assign other users as owners." ) - # In advanced encrypted rooms, new accesses require an encrypted_symmetric_key - # so the new member can decrypt the room's streams. Without it, they'd have - # access but no key — which is useless and confusing. - # Future: a sharing UI (like Docs) could provide the key via vault shareKeys. - if not self.instance and "resource" in data: - resource = data["resource"] - if ( - hasattr(resource, 'encryption_mode') - and resource.encryption_mode == models.EncryptionMode.ADVANCED - and not data.get("encrypted_symmetric_key") - ): - raise serializers.ValidationError( - "Adding members to advanced encrypted rooms requires " - "an encrypted_symmetric_key for the new user." - ) - return data def validate_resource(self, resource): @@ -115,7 +108,7 @@ class ResourceAccessSerializer( class Meta: model = models.ResourceAccess - fields = ["id", "user", "resource", "role", "encrypted_symmetric_key"] + fields = ["id", "user", "resource", "role"] read_only_fields = ["id"] def update(self, instance, validated_data): @@ -145,24 +138,15 @@ class RoomSerializer(serializers.ModelSerializer): class Meta: model = models.Room - fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_mode"] + fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "is_encrypted"] read_only_fields = ["id", "slug", "pin_code"] - def validate_access_level(self, value): - """Encrypted rooms must stay restricted — prevent downgrading access level.""" + def validate_is_encrypted(self, value): + """Encryption is decided at room creation and cannot be flipped afterwards.""" instance = self.instance - if instance and instance.encryption_enabled and value != models.RoomAccessLevel.RESTRICTED: + if instance and instance.is_encrypted != value: raise serializers.ValidationError( - "Encrypted rooms require restricted access level to enforce lobby approval." - ) - return value - - 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 value == models.EncryptionMode.NONE: - raise serializers.ValidationError( - "Encryption cannot be disabled once enabled on a room." + "Encryption flag cannot be changed after room creation." ) return value @@ -208,33 +192,18 @@ class RoomSerializer(serializers.ModelSerializer): room_id = f"{instance.id!s}" username = request.query_params.get("username", None) - # In encrypted rooms, authenticated users must use their real name from - # the OIDC profile (ProConnect) — they cannot choose an arbitrary name. - if instance.encryption_enabled and request.user.is_authenticated: - username = request.user.full_name or request.user.email - output["livekit"] = utils.generate_livekit_config( room_id=room_id, user=request.user, username=username, configuration=configuration, is_admin_or_owner=is_admin_or_owner, - encryption_mode=instance.encryption_mode, ) else: del output["pin_code"] output["is_administrable"] = is_admin_or_owner - # Include the current user's encrypted symmetric key for advanced E2EE - if request.user.is_authenticated and instance.encryption_mode == models.EncryptionMode.ADVANCED: - try: - access = instance.accesses.get(user=request.user) - if access.encrypted_symmetric_key: - output["encrypted_symmetric_key"] = access.encrypted_symmetric_key - except models.ResourceAccess.DoesNotExist: - pass - return output @@ -317,7 +286,6 @@ class RequestEntrySerializer(BaseValidationOnlySerializer): """Validate request entry data.""" username = serializers.CharField(required=True, allow_blank=True) - ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='') class ParticipantEntrySerializer(BaseValidationOnlySerializer): @@ -325,9 +293,6 @@ 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='') - 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 495853d1..4dbdf7c5 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -281,32 +281,19 @@ class RoomViewSet( def perform_create(self, serializer): """Set the current user as owner of the newly created room.""" - encryption_mode = serializer.validated_data.get("encryption_mode", models.EncryptionMode.NONE) + is_encrypted = serializer.validated_data.get("is_encrypted", False) # Block encrypted room creation if encryption is not enabled on this instance - if encryption_mode != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED: + if is_encrypted and not settings.ENCRYPTION_ENABLED: raise drf_exceptions.ValidationError( - {"encryption_mode": "Encryption is not enabled on this server."} + {"is_encrypted": "Encryption is not enabled on this server."} ) - # Advanced encryption requires the vault service to be configured - if encryption_mode == models.EncryptionMode.ADVANCED and not getattr(settings, 'ENCRYPTION_VAULT_URL', ''): - raise drf_exceptions.ValidationError( - {"encryption_mode": "Advanced encryption requires the encryption service to be configured."} - ) - - # Encrypted rooms must use restricted access to enforce lobby approval - # before the encryption key is shared with participants. - if encryption_mode != models.EncryptionMode.NONE: - serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED - room = serializer.save() - encrypted_symmetric_key = self.request.data.get("encrypted_symmetric_key", "") models.ResourceAccess.objects.create( resource=room, user=self.request.user, role=models.RoleChoices.OWNER, - encrypted_symmetric_key=encrypted_symmetric_key, ) if callback_id := self.request.data.get("callback_id"): @@ -335,12 +322,6 @@ class RoomViewSet( options = serializer.validated_data.get("options") room = self.get_object() - if room.encryption_enabled: - return drf_response.Response( - {"detail": "Recording is not available in encrypted rooms."}, - status=drf_status.HTTP_403_FORBIDDEN, - ) - # May raise exception if an active or initiated recording already exist for the room recording = models.Recording.objects.create( room=room, @@ -425,20 +406,6 @@ class RoomViewSet( room = self.get_object() validated_data = serializer.validated_data - # Advanced encrypted rooms require authentication - if room.encryption_mode == models.EncryptionMode.ADVANCED and not request.user.is_authenticated: - return drf_response.Response( - {"detail": "This meeting requires authentication to join."}, - status=drf_status.HTTP_403_FORBIDDEN, - ) - - # In encrypted rooms, authenticated users must use their real name - # from the OIDC profile — they cannot choose an arbitrary name. - if room.encryption_enabled and request.user.is_authenticated: - validated_data["username"] = ( - request.user.full_name or request.user.email - ) - lobby_service = LobbyService() participant, livekit = lobby_service.request_entry( @@ -480,9 +447,6 @@ 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", ''), - encrypted_vault_key=serializer.validated_data.get("encrypted_vault_key", ''), ) return drf_response.Response({"message": "Participant was updated."}) @@ -511,13 +475,6 @@ class RoomViewSet( participants = lobby_service.list_waiting_participants(room.id) - # 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}) @decorators.action( @@ -620,12 +577,6 @@ class RoomViewSet( room = self.get_object() - if room.encryption_enabled: - return drf_response.Response( - {"error": "Transcription is not available in encrypted rooms."}, - status=drf_status.HTTP_403_FORBIDDEN, - ) - try: SubtitleService().start_subtitle(room) except SubtitleException: diff --git a/src/backend/core/migrations/0019_room_encryption_enabled.py b/src/backend/core/migrations/0019_room_encryption_enabled.py deleted file mode 100644 index ea571dbc..00000000 --- a/src/backend/core/migrations/0019_room_encryption_enabled.py +++ /dev/null @@ -1,20 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0018_rename_active_application_is_active"), - ] - - operations = [ - migrations.AddField( - model_name="room", - name="encryption_enabled", - field=models.BooleanField( - default=False, - help_text="Whether end-to-end encryption is enabled for this room.", - verbose_name="Encryption enabled", - ), - ), - ] diff --git a/src/backend/core/migrations/0019_room_is_encrypted_user_default_encryption.py b/src/backend/core/migrations/0019_room_is_encrypted_user_default_encryption.py new file mode 100644 index 00000000..a888a9ad --- /dev/null +++ b/src/backend/core/migrations/0019_room_is_encrypted_user_default_encryption.py @@ -0,0 +1,36 @@ +"""Add Room.is_encrypted and User.default_encryption. + +`is_encrypted` is a boolean for now because passphrase-in-URL is the only +supported mode. A future migration may turn it into a CharField/enum if a +local-keys (vault) mode is reintroduced. +""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0018_rename_active_application_is_active"), + ] + + operations = [ + migrations.AddField( + model_name="room", + name="is_encrypted", + field=models.BooleanField( + default=False, + help_text="Whether end-to-end encryption is enabled for this room.", + verbose_name="Encryption enabled", + ), + ), + migrations.AddField( + model_name="user", + name="default_encryption", + field=models.BooleanField( + default=False, + help_text="Whether new meetings created by this user are end-to-end encrypted by default.", + verbose_name="Default to end-to-end encryption", + ), + ), + ] diff --git a/src/backend/core/migrations/0020_room_encryption_mode.py b/src/backend/core/migrations/0020_room_encryption_mode.py deleted file mode 100644 index 02a295bb..00000000 --- a/src/backend/core/migrations/0020_room_encryption_mode.py +++ /dev/null @@ -1,51 +0,0 @@ -"""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/migrations/0021_resourceaccess_encrypted_symmetric_key.py b/src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py deleted file mode 100644 index d71ba9ee..00000000 --- a/src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Add encrypted_symmetric_key to ResourceAccess for advanced E2EE mode.""" - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0020_room_encryption_mode"), - ] - - operations = [ - migrations.AddField( - model_name="resourceaccess", - name="encrypted_symmetric_key", - field=models.TextField( - blank=True, - default="", - help_text="Vault-wrapped symmetric encryption key for advanced E2EE mode. Each user's copy is encrypted for their own vault public key.", - verbose_name="Encrypted symmetric key", - ), - ), - ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 7fa4e881..56b5e3d6 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -98,14 +98,6 @@ 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 @@ -208,6 +200,14 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): "Unselect this instead of deleting accounts." ), ) + default_encryption = models.BooleanField( + _("Default to end-to-end encryption"), + default=False, + help_text=_( + "Whether new meetings created by this user are " + "end-to-end encrypted by default." + ), + ) objects = auth_models.UserManager() @@ -332,15 +332,6 @@ class ResourceAccess(BaseModel): role = models.CharField( max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER ) - encrypted_symmetric_key = models.TextField( - blank=True, - default='', - verbose_name=_("Encrypted symmetric key"), - help_text=_( - "Vault-wrapped symmetric encryption key for advanced E2EE mode. " - "Each user's copy is encrypted for their own vault public key." - ), - ) class Meta: db_table = "meet_resource_access" @@ -405,12 +396,14 @@ class Room(Resource): choices=RoomAccessLevel.choices, default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL, ) - 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."), + # Boolean for now: today the only encryption mode is the passphrase-in-URL + # one. If a follow-up adds a stronger "local-keys" mode (private keys held + # in a vault iframe), this can grow into a CharField with choices like + # `none / passphrase / local_keys`. + is_encrypted = models.BooleanField( + default=False, + verbose_name=_("Encryption enabled"), + help_text=_("Whether end-to-end encryption is enabled for this room."), ) configuration = models.JSONField( blank=True, @@ -466,11 +459,6 @@ 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/livekit_events.py b/src/backend/core/services/livekit_events.py index d24c241e..385ed65e 100644 --- a/src/backend/core/services/livekit_events.py +++ b/src/backend/core/services/livekit_events.py @@ -18,6 +18,10 @@ from core.recording.services.recording_events import ( ) from .lobby import LobbyService +from .participants_management import ( + ParticipantsManagement, + ParticipantsManagementException, +) from .telephony import TelephonyException, TelephonyService logger = getLogger(__name__) @@ -185,6 +189,76 @@ class LiveKitEventsService: f"Failed to process limit reached event for recording {recording}" ) from e + def _handle_participant_joined(self, data): + """Handle 'participant_joined' event. + + When a SIP/phone participant joins an end-to-end encrypted room they + cannot decrypt anything. We: + 1. Send an in-band notification (chat-style) to everyone, which + surfaces an admin snackbar and a system message in the chat; + 2. Eject the SIP participant — the admin can then disable encryption + from the Security settings and the user can dial back in. + + This is the simplest reliable fallback. A future improvement is a + dedicated "audio guard" agent that joins encrypted rooms and plays a + recorded prompt to SIP participants instead of disconnecting them. + """ + + participant = getattr(data, "participant", None) + if participant is None: + return + + # LiveKit ParticipantInfo.Kind: 0 = STANDARD, 1 = INGRESS, 2 = EGRESS, + # 3 = SIP, 4 = AGENT. We treat both SIP and INGRESS as "external + # device that can't run our E2EE code". + kind = getattr(participant, "kind", 0) + is_external_device = kind in (1, 3) + if not is_external_device: + return + + try: + room_id = uuid.UUID(data.room.name) + except ValueError: + return + + try: + room = models.Room.objects.get(id=room_id) + except models.Room.DoesNotExist: + return + + if not room.is_encrypted: + return + + # 1. Broadcast a system notice — frontends decode this on the + # "encryption-state" or notifications channel and show a snackbar. + try: + utils.notify_participants( + room_name=str(room_id), + notification_data={ + "type": "external_device_blocked", + "participant_identity": participant.identity, + "participant_name": participant.name or participant.identity, + }, + ) + except utils.NotificationError: + logger.exception( + "Failed to notify room about blocked external device" + ) + + # 2. Disconnect the external participant so they don't sit in a + # silent encrypted room. The admin can disable encryption and the + # user can dial in again. + try: + ParticipantsManagement().remove( + room_name=str(room_id), identity=participant.identity + ) + except ParticipantsManagementException: + logger.exception( + "Failed to remove external device participant %s from encrypted room %s", + participant.identity, + room_id, + ) + def _handle_room_started(self, data): """Handle 'room_started' event.""" diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index dee555a4..9aa07708 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -46,36 +46,19 @@ class LobbyParticipant: username: str color: str id: str + # Whether the user signed in (e.g. via ProConnect). Surfaced to admins so + # they can decide whether to accept self-declared identities. 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]: + def to_dict(self) -> Dict[str, object]: """Serialize the participant object to a dict representation.""" - result = { + return { "status": self.status.value, "username": self.username, "id": self.id, "color": self.color, "is_authenticated": self.is_authenticated, } - 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 def from_dict(cls, data: dict) -> "LobbyParticipant": @@ -89,13 +72,7 @@ class LobbyParticipant: username=data["username"], id=data["id"], 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", ''), + is_authenticated=bool(data.get("is_authenticated", False)), ) except (KeyError, ValueError) as e: logger.exception("Error creating Participant from dict:") @@ -139,16 +116,11 @@ class LobbyService: 1. The room is public (open to everyone) 2. The room has TRUSTED access level and the user is authenticated - Encrypted rooms never bypass the lobby — participants must go through - the lobby key exchange to receive the encryption key. - Note: Room access levels can change while participants are waiting in the lobby. This function only checks the current state and should be called each time a participant requests entry to ensure consistent access control, even for participants who have already begun waiting. """ - if hasattr(room, 'encryption_mode') and room.encryption_mode != 'none': - return False return room.is_public or ( room.access_level == models.RoomAccessLevel.TRUSTED and user.is_authenticated @@ -159,7 +131,6 @@ class LobbyService: room, request, username: str, - ephemeral_public_key: str = '', ) -> Tuple[LobbyParticipant, Optional[Dict]]: """Request entry to a room for a participant. @@ -186,6 +157,7 @@ class LobbyService: username=username, id=participant_id, color=utils.generate_color(participant_id), + is_authenticated=request.user.is_authenticated, ) else: participant.status = LobbyParticipantStatus.ACCEPTED @@ -198,7 +170,6 @@ class LobbyService: configuration=room.configuration, is_admin_or_owner=False, participant_id=participant_id, - encryption_mode=room.encryption_mode, ) return participant, livekit_config @@ -206,34 +177,16 @@ class LobbyService: if participant is None: participant = self.enter( - room.id, participant_id, username, + 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.sub) if request.user.is_authenticated else None, - ephemeral_public_key=ephemeral_public_key, ) elif participant.status == LobbyParticipantStatus.WAITING: self.refresh_waiting_status(room.id, participant_id) elif participant.status == LobbyParticipantStatus.ACCEPTED: - # If the joiner comes back with a different ephemeral key (e.g. browser - # closed and reopened), they can no longer decrypt the encrypted symmetric - # key. Reset them to WAITING so the admin re-accepts with the new key. - if ( - ephemeral_public_key - and participant.ephemeral_public_key - and ephemeral_public_key != participant.ephemeral_public_key - ): - participant = self.enter( - 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.sub) if request.user.is_authenticated else None, - ephemeral_public_key=ephemeral_public_key, - ) - return participant, None - livekit_config = utils.generate_livekit_config( room_id=room_id, user=request.user, @@ -242,7 +195,6 @@ class LobbyService: configuration=room.configuration, is_admin_or_owner=False, participant_id=participant_id, - encryption_mode=room.encryption_mode, ) return participant, livekit_config @@ -259,11 +211,11 @@ class LobbyService: ) def enter( - self, room_id: UUID, participant_id: str, username: str, + 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. @@ -279,9 +231,6 @@ class LobbyService: id=participant_id, color=color, is_authenticated=is_authenticated, - email=email, - suite_user_id=suite_user_id, - ephemeral_public_key=ephemeral_public_key, ) try: @@ -350,9 +299,6 @@ class LobbyService: room_id: UUID, participant_id: str, allow_entry: bool, - encrypted_key: str = '', - admin_ephemeral_public_key: str = '', - encrypted_vault_key: str = '', ) -> None: """Handle decision on participant entry. @@ -371,13 +317,7 @@ class LobbyService: "timeout": settings.LOBBY_DENIED_TIMEOUT, } - self._update_participant_status( - room_id, participant_id, - encrypted_key=encrypted_key, - admin_ephemeral_public_key=admin_ephemeral_public_key, - encrypted_vault_key=encrypted_vault_key, - **decision, - ) + self._update_participant_status(room_id, participant_id, **decision) def _update_participant_status( self, @@ -385,9 +325,6 @@ class LobbyService: participant_id: str, status: LobbyParticipantStatus, timeout: int, - encrypted_key: str = '', - admin_ephemeral_public_key: str = '', - encrypted_vault_key: str = '', ) -> None: """Update participant status with appropriate timeout.""" @@ -408,12 +345,6 @@ 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 - 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/backend/core/utils.py b/src/backend/core/utils.py index 1b15f8eb..c1809b23 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -66,7 +66,6 @@ def generate_token( sources: Optional[List[str]] = None, is_admin_or_owner: bool = False, participant_id: Optional[str] = None, - encryption_mode: str = 'none', ) -> str: """Generate a LiveKit access token for a user in a specific room. @@ -93,15 +92,11 @@ def generate_token( if sources is None: sources = settings.LIVEKIT_DEFAULT_SOURCES - # In encrypted rooms, no one can change their name/metadata to prevent - # identity spoofing — the admin accepted them based on their declared identity. - can_update_metadata = encryption_mode == 'none' - video_grants = VideoGrants( room=room, room_join=True, room_admin=is_admin_or_owner, - can_update_own_metadata=can_update_metadata, + can_update_own_metadata=True, can_publish=bool(sources), can_publish_sources=sources, can_subscribe=True, @@ -117,42 +112,12 @@ def generate_token( if color is None: color = generate_color(identity) - # Build participant attributes — these are server-signed in the JWT - # and visible to all participants in the room. attributes = { "color": color, "room_admin": "true" if is_admin_or_owner else "false", "is_authenticated": "true" if not user.is_anonymous else "false", } - # Add identity info for authenticated users in encrypted rooms only. - # - # Email and suite_user_id are included in the JWT attributes for encrypted - # rooms because: - # - Email: allows admins to verify participant identity in the lobby and - # participant list (important for trust decisions in encrypted meetings) - # - suite_user_id: required for vault key exchange in advanced encryption - # (vaultClient.shareKeys needs the recipient's user ID) - # - # These attributes are NOT included in non-encrypted rooms because: - # - Non-encrypted rooms have no waiting room, so anonymous users can join - # freely and would see everyone's email via LiveKit signaling - # - LiveKit JWT attributes are immutable and broadcast to ALL participants - # equally — there is no way to show them only to authenticated users - # at the protocol level - # - The frontend additionally hides email from anonymous users in the UI, - # but this is defense-in-depth, not the primary protection - # - # Future improvement: serve email via a Django API endpoint that checks - # the requester's authentication, removing it from the JWT entirely. - # This would require the backend to call LiveKit's ListParticipants API - # to cross-reference identities with the user database. - if not user.is_anonymous and encryption_mode != 'none': - if user.email: - attributes["email"] = user.email - if user.sub: - attributes["suite_user_id"] = str(user.sub) - token = ( AccessToken( api_key=settings.LIVEKIT_CONFIGURATION["api_key"], @@ -175,7 +140,6 @@ def generate_livekit_config( color: Optional[str] = None, configuration: Optional[dict] = None, participant_id: Optional[str] = None, - encryption_mode: str = 'none', ) -> dict: """Generate LiveKit configuration for room access. @@ -208,7 +172,6 @@ def generate_livekit_config( sources=sources, is_admin_or_owner=is_admin_or_owner, participant_id=participant_id, - encryption_mode=encryption_mode, ), } diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 47540621..d2c25adc 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -808,16 +808,12 @@ class Base(Configuration): environ_prefix=None, ) - # End-to-end encryption settings + # End-to-end encryption (passphrase-in-URL-hash mode). + # When True, users may opt in (account preference) to have their meetings + # created as end-to-end encrypted by default. ENCRYPTION_ENABLED = values.BooleanValue( False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None ) - ENCRYPTION_VAULT_URL = values.Value( - None, environ_name="ENCRYPTION_VAULT_URL", environ_prefix=None - ) - ENCRYPTION_INTERFACE_URL = values.Value( - None, environ_name="ENCRYPTION_INTERFACE_URL", environ_prefix=None - ) # External Applications APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue( diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 4da1c8a6..7921a2c8 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -14,7 +14,6 @@ import './i18n/init' import { queryClient } from '@/api/queryClient' import { AppInitialization } from '@/components/AppInitialization' import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext' -import { VaultClientProvider } from '@/features/encryption' function App() { const { i18n } = useTranslation() @@ -26,22 +25,20 @@ function App() { {!isSDKContext && } - - - + + {Object.entries(routes).map(([, route], i) => ( ))} - - - - + + + ) diff --git a/src/frontend/src/api/useConfig.ts b/src/frontend/src/api/useConfig.ts index b621cfa7..81041489 100644 --- a/src/frontend/src/api/useConfig.ts +++ b/src/frontend/src/api/useConfig.ts @@ -54,8 +54,6 @@ export interface ApiConfig { } encryption?: { enabled: boolean - vault_url: string - interface_url: string } transcription_destination?: string } diff --git a/src/frontend/src/features/auth/api/ApiUser.ts b/src/frontend/src/features/auth/api/ApiUser.ts index f7dcc62a..65012aa2 100644 --- a/src/frontend/src/features/auth/api/ApiUser.ts +++ b/src/frontend/src/features/auth/api/ApiUser.ts @@ -8,4 +8,5 @@ export type ApiUser = { last_name: string language: BackendLanguage timezone: string + default_encryption: boolean } diff --git a/src/frontend/src/features/auth/api/updateUserPreferences.ts b/src/frontend/src/features/auth/api/updateUserPreferences.ts index 09d13863..c0d09dbc 100644 --- a/src/frontend/src/features/auth/api/updateUserPreferences.ts +++ b/src/frontend/src/features/auth/api/updateUserPreferences.ts @@ -1,15 +1,18 @@ import { type ApiUser } from './ApiUser' import { fetchApi } from '@/api/fetchApi' -export type ApiUserPreferences = Pick +export type ApiUserPreferences = Partial< + Pick +> & { id: string } export const updateUserPreferences = async ({ user, }: { user: ApiUserPreferences }): Promise => { - return await fetchApi(`/users/${user.id}/`, { - method: 'PUT', - body: JSON.stringify({ timezone: user.timezone, language: user.language }), + const { id, ...payload } = user + return await fetchApi(`/users/${id}/`, { + method: 'PATCH', + body: JSON.stringify(payload), }) } diff --git a/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx b/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx deleted file mode 100644 index d5d1048c..00000000 --- a/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Indicator shown at the top-left of an encrypted meeting. - * - * Initially shows the full label "End-to-end encrypted" with a lock icon. - * After a few seconds, collapses to just the lock icon. - * On hover, expands back with a smooth animation. - * Clicking opens a modal explaining what E2EE means and its limitations. - */ -import { css } from '@/styled-system/css' -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, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom' -import { useEffect, useState } from 'react' -import { Dialog, Text } from '@/primitives' - -const COLLAPSE_DELAY = 4000 - -export function EncryptedMeetingBanner() { - const roomData = useRoomData() - const { t } = useTranslation('rooms', { keyPrefix: 'encryption' }) - const [isCollapsed, setIsCollapsed] = useState(false) - const [isModalOpen, setIsModalOpen] = useState(false) - - const isStrongEncryption = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED - - useEffect(() => { - const timer = setTimeout(() => setIsCollapsed(true), COLLAPSE_DELAY) - return () => clearTimeout(timer) - }, []) - - if (!isEncryptedRoom(roomData)) return null - - const bgColor = isStrongEncryption ? '#166534' : '#1e3a5f' - const hoverBgColor = isStrongEncryption ? '#15803d' : '#2563eb' - const icon = isStrongEncryption - ? - : - const label = isStrongEncryption ? t('bannerStrong') : t('banner') - - return ( - <> -
setIsCollapsed(false)} - onMouseLeave={() => setIsCollapsed(true)} - onClick={() => setIsModalOpen(true)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && setIsModalOpen(true)} - aria-label={label} - className={css({ - position: 'absolute', - top: '0.5rem', - left: '0.5rem', - zIndex: 10, - display: 'flex', - alignItems: 'center', - gap: '0.35rem', - padding: '0.3rem 0.6rem', - borderRadius: '1rem', - border: '2px solid rgba(0, 0, 0, 0.3)', - cursor: 'pointer', - overflow: 'hidden', - transition: 'all 300ms ease', - maxWidth: isCollapsed ? '2.2rem' : '16rem', - whiteSpace: 'nowrap', - })} - style={{ - backgroundColor: bgColor, - paddingRight: isCollapsed ? '0.3rem' : '0.6rem', - }} - onMouseOver={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = hoverBgColor }} - onMouseOut={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = bgColor }} - > - {icon} - - {label} - -
- - - - - {isStrongEncryption - ? t('bannerModal.descriptionAdvanced') - : t('bannerModal.descriptionBasic')} - - - - - {t('bannerModal.guarantees')} - -
    -
  • {t('bannerModal.guarantee1')}
  • -
  • {t('bannerModal.guarantee2')}
  • -
  • {t('bannerModal.guarantee3')}
  • -
-
- - - - {t('bannerModal.limitations')} - -
    -
  • {t('bannerModal.limitation1')}
  • -
  • {isStrongEncryption - ? t('bannerModal.limitation2Advanced') - : t('bannerModal.limitation2Basic')} -
  • -
-
- - - {t('bannerModal.note')} - -
-
- - ) -} diff --git a/src/frontend/src/features/encryption/EncryptionAutoResumeWatcher.tsx b/src/frontend/src/features/encryption/EncryptionAutoResumeWatcher.tsx new file mode 100644 index 00000000..71d07119 --- /dev/null +++ b/src/frontend/src/features/encryption/EncryptionAutoResumeWatcher.tsx @@ -0,0 +1,46 @@ +/** + * When the participant who paused encryption sees that *both* recording and + * transcription have stopped, automatically broadcast `ENCRYPTION_RESUMED`. + * + * Other participants don't run this watcher: only the pauser can resume + * (they're the one with `pausedByMe=true`). If they leave the room, the next + * leader/admin can manually resume from the Settings panel — or in v1 the + * room simply stays paused for the rest of the session. + */ +import { useEffect, useRef } from 'react' +import { useIsRecording } from '@livekit/components-react' +import { RecordingMode, useRecordingStatuses } from '@/features/recording' +import { EncryptionPhase } from './encryptionStatusTypes' +import { useEncryptionStatus } from './useEncryptionStatus' + +export function EncryptionAutoResumeWatcher() { + const { phase, pausedByMe, resumeEncryption } = useEncryptionStatus() + const isLiveKitRecording = useIsRecording() + const transcriptStatuses = useRecordingStatuses(RecordingMode.Transcript) + const screenRecStatuses = useRecordingStatuses(RecordingMode.ScreenRecording) + + // Edge guard: avoid resuming on the initial render before anything has + // actually started. We only resume after we've observed an active state. + const wasActiveRef = useRef(false) + const isAnyActive = + isLiveKitRecording || + transcriptStatuses.isActive || + screenRecStatuses.isActive + + useEffect(() => { + if (isAnyActive) { + wasActiveRef.current = true + } + }, [isAnyActive]) + + useEffect(() => { + if (phase !== EncryptionPhase.PAUSED) return + if (!pausedByMe) return + if (!wasActiveRef.current) return + if (isAnyActive) return + + void resumeEncryption() + }, [phase, pausedByMe, isAnyActive, resumeEncryption]) + + return null +} diff --git a/src/frontend/src/features/encryption/EncryptionBadge.tsx b/src/frontend/src/features/encryption/EncryptionBadge.tsx deleted file mode 100644 index 036445a9..00000000 --- a/src/frontend/src/features/encryption/EncryptionBadge.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Per-participant encryption trust badge. - * - * In advanced mode: - * - "verified": Green shield — fingerprint explicitly trusted - * - "unknown": Grey shield — has public key, not yet verified - * - "refused": Red shield — fingerprint previously refused - * - "authenticated": Blue shield — ProConnect, no vault keys - * - "anonymous": Orange warning — not signed in - * - * In basic mode: - * - "authenticated": Blue shield — ProConnect - * - "anonymous": Orange warning — not signed in - */ -import { - RiShieldCheckFill, - RiShieldFill, - RiShieldCrossFill, - RiErrorWarningFill, - RiLockFill, -} from '@remixicon/react' -import type { TrustLevel } from './types' -import { css } from '@/styled-system/css' -import { useTranslation } from 'react-i18next' - -interface EncryptionBadgeProps { - trustLevel: TrustLevel | null - isEncrypted: boolean -} - -export function EncryptionBadge({ - trustLevel, - isEncrypted, -}: EncryptionBadgeProps) { - const { t } = useTranslation('rooms', { keyPrefix: 'encryption.badge' }) - - if (!isEncrypted) return null - - let icon: React.ReactNode - let label: string - - switch (trustLevel) { - case 'verified': - icon = - label = t('verified') - break - case 'unknown': - icon = - label = t('unknown') - break - case 'refused': - icon = - label = t('refused') - break - case 'authenticated': - icon = - label = t('authenticated') - break - case 'anonymous': - icon = - label = t('anonymous') - break - default: - icon = - label = t('default') - break - } - - return ( - - {icon} - - ) -} diff --git a/src/frontend/src/features/encryption/EncryptionContext.tsx b/src/frontend/src/features/encryption/EncryptionContext.tsx deleted file mode 100644 index e956f084..00000000 --- a/src/frontend/src/features/encryption/EncryptionContext.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { createContext, useContext } from 'react' - -interface EncryptionContextValue { - symmetricKey?: Uint8Array -} - -const EncryptionContext = createContext({}) - -export const EncryptionProvider = EncryptionContext.Provider -export const useEncryptionContext = () => useContext(EncryptionContext) diff --git a/src/frontend/src/features/encryption/EncryptionIdentityDialog.tsx b/src/frontend/src/features/encryption/EncryptionIdentityDialog.tsx deleted file mode 100644 index 788701f3..00000000 --- a/src/frontend/src/features/encryption/EncryptionIdentityDialog.tsx +++ /dev/null @@ -1,326 +0,0 @@ -/** - * Dialog showing a participant's encryption fingerprint. - * Allows the admin to verify, accept, or refuse the fingerprint. - * - * This connects to the encryption library's VaultClient to check/accept/refuse - * fingerprints from the TOFU (Trust On First Use) registry. - */ -import { css } from '@/styled-system/css' -import { VStack, HStack } from '@/styled-system/jsx' -import { Dialog, Text, Button } from '@/primitives' -import { Avatar } from '@/components/Avatar' -import { useUser } from '@/features/auth' -import { - RiShieldCheckFill, - RiShieldCheckLine, - RiAlertLine, - RiCheckLine, - RiCloseLine, -} from '@remixicon/react' -import { useTranslation } from 'react-i18next' -import { useVaultClient } from './VaultClientProvider' -import { formatFingerprint } from './useParticipantTrustLevel' -import { useEffect, useState } from 'react' - -interface EncryptionIdentityDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - participantName: string - participantEmail?: string - suiteUserId?: string - isAuthenticated: boolean - encryptionMode?: 'basic' | 'advanced' | 'none' - isSelf?: boolean - preloadedFingerprint?: string | null - preloadedFingerprintStatus?: string | null -} - -type FingerprintStatus = 'loading' | 'no-key' | 'trusted' | 'refused' | 'unknown' | 'error' - -export function EncryptionIdentityDialog({ - isOpen, - onOpenChange, - participantName, - participantEmail, - suiteUserId, - isAuthenticated, - encryptionMode, - isSelf, - preloadedFingerprint, - preloadedFingerprintStatus, -}: EncryptionIdentityDialogProps) { - const { t } = useTranslation('rooms', { keyPrefix: 'encryption.fingerprint' }) - const { client: vaultClient } = useVaultClient() - const { isLoggedIn } = useUser() - const [status, setStatus] = useState( - (preloadedFingerprintStatus as FingerprintStatus) || 'loading' - ) - const [fingerprint, setFingerprint] = useState(preloadedFingerprint || null) - - // Sync preloaded data when it becomes available (hook resolves after mount) - useEffect(() => { - if (preloadedFingerprintStatus) setStatus(preloadedFingerprintStatus as FingerprintStatus) - if (preloadedFingerprint) setFingerprint(preloadedFingerprint) - }, [preloadedFingerprint, preloadedFingerprintStatus]) - - const isBasicMode = encryptionMode !== 'advanced' - - useEffect(() => { - if (!isOpen) return - // In basic mode, no fingerprint check — identity is from ProConnect only - if (isBasicMode) { - setStatus(isAuthenticated ? 'no-key' : 'no-key') - return - } - if (!vaultClient) { - setStatus('error') - return - } - if (!suiteUserId) { - setStatus(isAuthenticated ? 'no-key' : 'no-key') - return - } - - let cancelled = false - - async function checkFingerprint() { - try { - const timeout = new Promise((_, reject) => - setTimeout(() => reject(new Error('timeout')), 3000) - ) - - const fetchResult = await Promise.race([ - vaultClient!.fetchPublicKeys([suiteUserId!]), - timeout, - ]) - - const publicKey = fetchResult.publicKeys[suiteUserId!] - - if (!publicKey || cancelled) { - setStatus('no-key') - return - } - - // Compute fingerprint from the public key (SHA-256, first 16 hex chars) - const hash = await crypto.subtle.digest('SHA-256', publicKey) - const fp = Array.from(new Uint8Array(hash)) - .map((b) => b.toString(16).padStart(2, '0')) - .join('') - .slice(0, 16) - - if (cancelled) return - setFingerprint(fp) - - // Check local registry without triggering TOFU auto-trust - const { fingerprints: known } = await Promise.race([ - vaultClient!.getKnownFingerprints(), - timeout, - ]) - if (cancelled) return - - const knownEntry = known[suiteUserId!] - if (!knownEntry) { - setStatus('unknown') - } else if (knownEntry.fingerprint === fp) { - setStatus(knownEntry.status) - } else { - // Fingerprint changed — needs re-verification - setStatus('unknown') - } - } catch { - if (!cancelled) setStatus('error') - } - } - - checkFingerprint() - return () => { cancelled = true } - }, [isOpen, vaultClient, suiteUserId, isAuthenticated]) - - const handleAccept = async () => { - if (!vaultClient || !suiteUserId || !fingerprint) return - try { - await vaultClient.acceptFingerprint(suiteUserId, fingerprint) - setStatus('trusted') - } catch { - // Failed to accept - } - } - - const handleRefuse = async () => { - if (!vaultClient || !suiteUserId || !fingerprint) return - try { - await vaultClient.refuseFingerprint(suiteUserId, fingerprint) - setStatus('refused') - } catch { - // Failed to refuse - } - } - - return ( - - - -
- -
- - {participantName} - - {isLoggedIn && participantEmail ? participantEmail : (!isAuthenticated ? t('anonymous') : '')} - - -
- - - {isSelf - ? (isAuthenticated ? t('descriptionSelf') : t('descriptionSelfAnonymous')) - : t('description')} - - - {status === 'loading' && ( - {t('loading')} - )} - - {status === 'no-key' && isBasicMode && isAuthenticated && ( - - - - {t('noKeyBasicAuthenticated')} - - - )} - - {status === 'no-key' && !(isBasicMode && isAuthenticated) && !isSelf && ( - - - - {isAuthenticated ? t('noKey') : t('noKeyAnonymous')} - - - )} - - {status === 'error' && ( - - {t('error')} - - )} - - {(status === 'trusted' || status === 'refused' || status === 'unknown') && fingerprint && ( - <> - - - {t('fingerprintLabel')} - - {formatFingerprint(fingerprint)} - - - {status === 'trusted' && ( - - - - - {t('trusted')} - - - - {isSelf ? t('descriptionSelf') : t('trustedDescription')} - - {!isSelf && ( - setStatus('unknown')} - > - {t('changeDecision')} - - )} - - )} - - {status === 'refused' && ( - - - - - {t('refused')} - - - - {t('refusedDescription')} - - setStatus('unknown')} - > - {t('changeDecision')} - - - )} - - {status === 'unknown' && !isSelf && ( - - - {t('unknownDescription')} - - - {t('fingerprintHint')} - - - - - - - )} - - )} -
-
- ) -} diff --git a/src/frontend/src/features/encryption/EncryptionMismatchScreen.tsx b/src/frontend/src/features/encryption/EncryptionMismatchScreen.tsx new file mode 100644 index 00000000..fe4920ac --- /dev/null +++ b/src/frontend/src/features/encryption/EncryptionMismatchScreen.tsx @@ -0,0 +1,104 @@ +/** + * Shown when the URL hash and the room's `is_encrypted` flag disagree. + * + * - missingPassphrase: room is encrypted on the server, but the URL has no + * (or an invalid) passphrase. The user opened the wrong link. + * - unexpectedPassphrase: the URL has a passphrase, but the server says the + * room is not encrypted. Either the room was created differently or the + * link looks tampered with — either way, joining as "encrypted" would + * leave the user alone in an encrypted bubble. Better to bail. + */ +import { css } from '@/styled-system/css' +import { Center } from '@/styled-system/jsx' +import { useTranslation } from 'react-i18next' +import { RiAlertLine, RiLockUnlockLine } from '@remixicon/react' +import { Button, Text } from '@/primitives' +import { Screen } from '@/layout/Screen' +import { CenteredContent } from '@/layout/CenteredContent' +import { navigateTo } from '@/navigation/navigateTo' +import { + generateRoomId, + useCreateRoom, +} from '@/features/rooms' +import { generatePassphrase } from './passphrase' + +interface Props { + reason: 'missingPassphrase' | 'unexpectedPassphrase' +} + +export function EncryptionMismatchScreen({ reason }: Props) { + const { t } = useTranslation('rooms', { keyPrefix: 'encryption.mismatch' }) + const { mutateAsync: createRoom } = useCreateRoom() + + const handleCreateFresh = async () => { + const slug = generateRoomId() + const hash = generatePassphrase() + const room = await createRoom({ slug, isEncrypted: true }) + navigateTo('room', room.slug, { + state: { create: true, initialRoomData: room }, + }) + window.history.replaceState( + window.history.state, + '', + `${window.location.pathname}#${hash}` + ) + } + + return ( + + +
+
+
+ {reason === 'missingPassphrase' ? ( + + ) : ( + + )} +
+ + {t(`${reason}.title`)} + + + {t(`${reason}.body`)} + + +
+
+
+
+ ) +} diff --git a/src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx b/src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx deleted file mode 100644 index 4cd31eca..00000000 --- a/src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Overlay shown during encryption key exchange. - * - * When a participant joins an encrypted room, there's a brief period - * between connection and receiving the symmetric key where media - * cannot be decrypted. This overlay provides feedback during that time. - * - * After 20 seconds without the key, shows an error with a refresh button. - */ -import { css } from '@/styled-system/css' -import { VStack } from '@/styled-system/jsx' -import { Text, Button } from '@/primitives' -import { Spinner } from '@/primitives/Spinner' -import { RiLockFill, RiAlertFill, RiRefreshLine } from '@remixicon/react' -import { useTranslation } from 'react-i18next' -import { useEffect, useState } from 'react' - -const KEY_EXCHANGE_TIMEOUT = 20000 - -export function EncryptionSetupOverlay({ - isSettingUp, - error, -}: { - isSettingUp: boolean - error: string | null -}) { - const { t } = useTranslation('rooms', { keyPrefix: 'encryption' }) - const [timedOut, setTimedOut] = useState(false) - - useEffect(() => { - if (!isSettingUp) { - setTimedOut(false) - return - } - - const timer = setTimeout(() => setTimedOut(true), KEY_EXCHANGE_TIMEOUT) - return () => clearTimeout(timer) - }, [isSettingUp]) - - if (!isSettingUp && !error) return null - - const showError = error || timedOut - - return ( -
- - {showError ? ( - <> - - - {timedOut ? t('error.timeout') : t('error.title')} - - - {error || t('error.timeoutHint')} - - - - ) : ( - <> - - - {t('settingUp.title')} - - - {t('settingUp.description')} - - - - )} - -
- ) -} diff --git a/src/frontend/src/features/encryption/EncryptionStatusContext.tsx b/src/frontend/src/features/encryption/EncryptionStatusContext.tsx new file mode 100644 index 00000000..0101770f --- /dev/null +++ b/src/frontend/src/features/encryption/EncryptionStatusContext.tsx @@ -0,0 +1,356 @@ +/** + * In-call encryption state machine and pause protocol. + * + * Three phases: + * - UNENCRYPTED — the room is not end-to-end encrypted. + * - ENCRYPTED — E2EE is active; frames are encrypted with the URL passphrase. + * - PAUSED — encryption is temporarily paused for this session, typically + * so the SFU can record / transcribe. + * + * The "paused" state is intentionally ephemeral: it is never persisted in the + * database. The truth source for "this call is encrypted" is the presence of + * the passphrase in the URL hash, not a server-side flag — a hacked server + * cannot fabricate a passphrase that all participants happen to share. + * + * Pause is broadcast over a LiveKit reliable data channel. While the sender + * has not yet flipped its own state, the message itself travels encrypted, + * which is the trust anchor: only callers who hold the passphrase can produce + * frames everyone can decrypt. + * + * Pause is reversible: when both recording and transcription have stopped, + * the participant who initiated the pause broadcasts ENCRYPTION_RESUMED and + * everyone re-enables E2EE with the same URL passphrase. + * + * Initiation: admins can always pause/resume. If no admin is in the room and + * a pause has already been observed in this session (everSeenPause), the + * leader (oldest non-SIP participant) may also pause/resume — this covers + * the "the admin left mid-call" edge case without granting unsolicited + * pause power to non-admins. + */ +import { + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useRoomContext } from '@livekit/components-react' +import { + DataPacket_Kind, + Participant, + ParticipantKind, + RemoteParticipant, + RoomEvent, +} from 'livekit-client' +import { EncryptionStatusContext } from './encryptionStatusContextValue' +import { EncryptionPhase, PauseReason } from './encryptionStatusTypes' + +const ENCRYPTION_TOPIC = 'encryption-state' +const PROBE_RESPONSE_GRACE_MS = 2500 + +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder() + +interface ProtocolMessage { + type: + | 'ENCRYPTION_PAUSED' + | 'ENCRYPTION_RESUMED' + | 'ENCRYPTION_STATUS_PROBE' + reason?: PauseReason + /** Sender's `joinedAt` timestamp; used in leader election. */ + senderJoinedAt?: number + /** Whether the sender is a room admin/owner. */ + senderIsAdmin?: boolean +} + +function encodeMessage(msg: ProtocolMessage): Uint8Array { + return textEncoder.encode(JSON.stringify(msg)) +} + +function decodeMessage(payload: Uint8Array): ProtocolMessage | null { + try { + return JSON.parse(textDecoder.decode(payload)) as ProtocolMessage + } catch { + return null + } +} + +function isParticipantAdmin(participant: Participant | undefined): boolean { + return participant?.attributes?.room_admin === 'true' +} + +function isParticipantPhoneOrSip(p: Participant): boolean { + return p.kind === ParticipantKind.SIP +} + +interface EncryptionStatusProviderProps { + children: ReactNode + /** Whether this room is end-to-end encrypted. */ + isEncrypted: boolean + /** Called when the local client should toggle E2EE on/off. */ + onPhaseChange?: (phase: EncryptionPhase) => void +} + +export function EncryptionStatusProvider({ + children, + isEncrypted, + onPhaseChange, +}: EncryptionStatusProviderProps) { + const room = useRoomContext() + const initialPhase = isEncrypted + ? EncryptionPhase.ENCRYPTED + : EncryptionPhase.UNENCRYPTED + const [phase, setPhase] = useState(initialPhase) + const [pauseReason, setPauseReason] = useState() + const [pausedByMe, setPausedByMe] = useState(false) + const everSeenPauseRef = useRef(false) + const phaseRef = useRef(phase) + phaseRef.current = phase + + // When the encrypted flag changes (e.g. on initial room data load), align + // the local phase. The pause path keeps phase=PAUSED across updates. + useEffect(() => { + if (!isEncrypted && phaseRef.current !== EncryptionPhase.UNENCRYPTED) { + setPhase(EncryptionPhase.UNENCRYPTED) + setPauseReason(undefined) + setPausedByMe(false) + } else if ( + isEncrypted && + phaseRef.current === EncryptionPhase.UNENCRYPTED + ) { + setPhase(EncryptionPhase.ENCRYPTED) + } + }, [isEncrypted]) + + // Push phase transitions to LiveKit (E2EE on/off + republish). + useEffect(() => { + onPhaseChange?.(phase) + }, [phase, onPhaseChange]) + + const sendProtocolMessage = useCallback( + async (msg: ProtocolMessage, destination?: string[]) => { + try { + await room.localParticipant.publishData(encodeMessage(msg), { + reliable: true, + topic: ENCRYPTION_TOPIC, + destinationIdentities: destination, + }) + } catch (err) { + console.error('[encryption] failed to publish protocol message', err) + } + }, + [room] + ) + + /** + * Determine whether we (locally) consider `sender` legitimate to issue + * pause/resume messages. + * + * Always true for admins. For non-admins, true only if there is no admin + * currently in the room AND the sender is the oldest non-SIP participant + * we know of (deterministic across peers via joinedAt+identity). + */ + const isLegitimatePauseSender = useCallback( + (sender: RemoteParticipant | undefined): boolean => { + if (!sender) return false + if (isParticipantAdmin(sender)) return true + + const everyone: Participant[] = [ + room.localParticipant, + ...Array.from(room.remoteParticipants.values()), + ] + const adminPresent = everyone.some(isParticipantAdmin) + if (adminPresent) return false + + const eligible = everyone.filter((p) => !isParticipantPhoneOrSip(p)) + const sorted = eligible.sort((a, b) => { + const aJ = a.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER + const bJ = b.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER + if (aJ !== bJ) return aJ - bJ + return a.identity.localeCompare(b.identity) + }) + const leader = sorted[0] + return !!leader && leader.identity === sender.identity + }, + [room] + ) + + /** Same logic but applied to the local participant (am I allowed to act?). */ + const localCanInitiate = useCallback((): boolean => { + if (isParticipantAdmin(room.localParticipant)) return true + if (!everSeenPauseRef.current) return false + + const everyone: Participant[] = [ + room.localParticipant, + ...Array.from(room.remoteParticipants.values()), + ] + if (everyone.some(isParticipantAdmin)) return false + + const eligible = everyone.filter((p) => !isParticipantPhoneOrSip(p)) + const sorted = eligible.sort((a, b) => { + const aJ = a.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER + const bJ = b.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER + if (aJ !== bJ) return aJ - bJ + return a.identity.localeCompare(b.identity) + }) + return sorted[0]?.identity === room.localParticipant.identity + }, [room]) + + const handlePauseAnnouncement = useCallback( + (msg: ProtocolMessage, sender?: RemoteParticipant) => { + if (!isLegitimatePauseSender(sender)) return + everSeenPauseRef.current = true + if (phaseRef.current !== EncryptionPhase.ENCRYPTED) return + + setPhase(EncryptionPhase.PAUSED) + setPauseReason(msg.reason) + setPausedByMe(false) + }, + [isLegitimatePauseSender] + ) + + const handleResumeAnnouncement = useCallback( + (sender?: RemoteParticipant) => { + if (!isLegitimatePauseSender(sender)) return + if (phaseRef.current !== EncryptionPhase.PAUSED) return + + setPhase(EncryptionPhase.ENCRYPTED) + setPauseReason(undefined) + setPausedByMe(false) + }, + [isLegitimatePauseSender] + ) + + const handleProbe = useCallback( + (sender: RemoteParticipant) => { + if (phaseRef.current !== EncryptionPhase.PAUSED) return + // We respond if we ourselves are a legitimate sender for this room. + if (!localCanInitiate()) return + + void sendProtocolMessage( + { + type: 'ENCRYPTION_PAUSED', + reason: pauseReason, + senderIsAdmin: isParticipantAdmin(room.localParticipant), + senderJoinedAt: + room.localParticipant.joinedAt?.getTime() ?? Date.now(), + }, + [sender.identity] + ) + }, + [room, pauseReason, sendProtocolMessage, localCanInitiate] + ) + + // Subscribe to encryption-channel data messages. + useEffect(() => { + if (!isEncrypted) return + + const handler = ( + payload: Uint8Array, + participant?: RemoteParticipant, + _kind?: DataPacket_Kind, + topic?: string + ) => { + if (topic !== ENCRYPTION_TOPIC) return + const msg = decodeMessage(payload) + if (!msg) return + if (msg.type === 'ENCRYPTION_PAUSED') { + handlePauseAnnouncement(msg, participant) + } else if (msg.type === 'ENCRYPTION_RESUMED') { + handleResumeAnnouncement(participant) + } else if (msg.type === 'ENCRYPTION_STATUS_PROBE' && participant) { + handleProbe(participant) + } + } + + room.on(RoomEvent.DataReceived, handler) + return () => { + room.off(RoomEvent.DataReceived, handler) + } + }, [ + room, + isEncrypted, + handlePauseAnnouncement, + handleResumeAnnouncement, + handleProbe, + ]) + + // On join, ask the room whether encryption is currently paused. + useEffect(() => { + if (!isEncrypted) return + if (phase !== EncryptionPhase.ENCRYPTED) return + + let cancelled = false + const timer = setTimeout(() => { + if (cancelled) return + void sendProtocolMessage({ type: 'ENCRYPTION_STATUS_PROBE' }) + }, 0) + const cleanup = setTimeout(() => { + // Nothing to do — if no answer arrived, we stay encrypted. + }, PROBE_RESPONSE_GRACE_MS) + + return () => { + cancelled = true + clearTimeout(timer) + clearTimeout(cleanup) + } + // we intentionally only run this when joining the encrypted state + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEncrypted]) + + const pauseEncryption = useCallback( + async (reason: PauseReason) => { + if (phaseRef.current !== EncryptionPhase.ENCRYPTED) return false + if (!localCanInitiate()) return false + + everSeenPauseRef.current = true + setPhase(EncryptionPhase.PAUSED) + setPauseReason(reason) + setPausedByMe(true) + + await sendProtocolMessage({ + type: 'ENCRYPTION_PAUSED', + reason, + senderIsAdmin: isParticipantAdmin(room.localParticipant), + senderJoinedAt: + room.localParticipant.joinedAt?.getTime() ?? Date.now(), + }) + return true + }, + [room, sendProtocolMessage, localCanInitiate] + ) + + const resumeEncryption = useCallback(async () => { + if (phaseRef.current !== EncryptionPhase.PAUSED) return false + if (!localCanInitiate()) return false + + setPhase(EncryptionPhase.ENCRYPTED) + setPauseReason(undefined) + setPausedByMe(false) + + await sendProtocolMessage({ + type: 'ENCRYPTION_RESUMED', + senderIsAdmin: isParticipantAdmin(room.localParticipant), + senderJoinedAt: room.localParticipant.joinedAt?.getTime() ?? Date.now(), + }) + return true + }, [room, sendProtocolMessage, localCanInitiate]) + + const value = useMemo( + () => ({ + phase, + pauseReason, + pausedByMe, + pauseEncryption, + resumeEncryption, + }), + [phase, pauseReason, pausedByMe, pauseEncryption, resumeEncryption] + ) + + return ( + + {children} + + ) +} diff --git a/src/frontend/src/features/encryption/EncryptionStatusSnackbars.tsx b/src/frontend/src/features/encryption/EncryptionStatusSnackbars.tsx new file mode 100644 index 00000000..c1002637 --- /dev/null +++ b/src/frontend/src/features/encryption/EncryptionStatusSnackbars.tsx @@ -0,0 +1,203 @@ +/** + * Transient bottom snackbars announcing encryption state changes: + * - "Encryption paused while transcription is on" + * - "Encryption was turned off for this meeting" + * - "A participant can't decrypt this meeting" (admin only, with CTA) + */ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { css } from '@/styled-system/css' +import { HStack, VStack } from '@/styled-system/jsx' +import { Button, Text } from '@/primitives' +import { ParticipantKind, RemoteParticipant, RoomEvent } from 'livekit-client' +import { useRoomContext } from '@livekit/components-react' +import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' +import { useSettingsDialog, SettingsDialogExtendedKey } from '@/features/settings' +import { EncryptionPhase, PauseReason } from './encryptionStatusTypes' +import { useEncryptionStatus } from './useEncryptionStatus' + +const DISPLAY_DURATION_MS = 8000 + +const SnackbarShell = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+) + +function useTransient(value: T, displayMs: number) { + const [shown, setShown] = useState(null) + const timer = useRef | null>(null) + + useEffect(() => { + if (!value) return + setShown(value) + if (timer.current) clearTimeout(timer.current) + timer.current = setTimeout(() => setShown(null), displayMs) + return () => { + if (timer.current) clearTimeout(timer.current) + } + }, [value, displayMs]) + + return [shown, () => setShown(null)] as const +} + +export function EncryptionStatusSnackbars() { + const { t } = useTranslation('rooms', { keyPrefix: 'encryption.snackbar' }) + const { phase, pauseReason, pausedByMe } = useEncryptionStatus() + const room = useRoomContext() + const isAdmin = useIsAdminOrOwner() + const { openSettingsDialog } = useSettingsDialog() + + const [pauseSignal, setPauseSignal] = useState<{ + reason?: PauseReason + pausedByMe: boolean + } | null>(null) + + const previousPhase = useRef(phase) + useEffect(() => { + if ( + previousPhase.current !== EncryptionPhase.PAUSED && + phase === EncryptionPhase.PAUSED + ) { + setPauseSignal({ reason: pauseReason, pausedByMe }) + } + previousPhase.current = phase + }, [phase, pauseReason, pausedByMe]) + + const [pauseToast, dismissPauseToast] = useTransient( + pauseSignal, + DISPLAY_DURATION_MS + ) + + const [sipParticipant, setSipParticipant] = useState(null) + const [sipDismissed, dismissSip] = useTransient( + sipParticipant, + DISPLAY_DURATION_MS + ) + + // Detect SIP / phone participants joining an encrypted meeting. + useEffect(() => { + if (!isAdmin) return + if (phase !== EncryptionPhase.ENCRYPTED) return + + const handler = (participant: RemoteParticipant) => { + if (participant.kind === ParticipantKind.SIP) { + setSipParticipant(participant.name || participant.identity) + } + } + room.on(RoomEvent.ParticipantConnected, handler) + room.remoteParticipants.forEach((p) => { + if (p.kind === ParticipantKind.SIP) { + setSipParticipant(p.name || p.identity) + } + }) + return () => { + room.off(RoomEvent.ParticipantConnected, handler) + } + }, [room, isAdmin, phase]) + + return ( + <> + {pauseToast && ( + + + + + {pauseToast.pausedByMe + ? t('pausedByMeTitle') + : t('pausedTitle')} + + + {pauseToast.reason === 'transcript' + ? t('reasonTranscript') + : pauseToast.reason === 'recording' + ? t('reasonRecording') + : pauseToast.reason === 'sip_participant' + ? t('reasonSip') + : t('reasonManual')} + + + + + + )} + {sipDismissed && phase === EncryptionPhase.ENCRYPTED && ( + + + + + {t('sipTitle')} + + + {t('sipBody', { name: sipDismissed })} + + + + + + )} + + ) +} diff --git a/src/frontend/src/features/encryption/EncryptionTrustModal.tsx b/src/frontend/src/features/encryption/EncryptionTrustModal.tsx deleted file mode 100644 index f0ab6344..00000000 --- a/src/frontend/src/features/encryption/EncryptionTrustModal.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Modal explaining encryption trust levels. - * Shown when admin clicks the trust badge in the waiting room. - */ -import { css } from '@/styled-system/css' -import { VStack, HStack } from '@/styled-system/jsx' -import { Dialog, Text } from '@/primitives' -import { RiShieldCheckFill, RiShieldCheckLine, RiAlertLine } from '@remixicon/react' -import { useTranslation } from 'react-i18next' - -interface EncryptionTrustModalProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - participantName: string - isAuthenticated: boolean -} - -export function EncryptionTrustModal({ - isOpen, - onOpenChange, - participantName, - isAuthenticated, -}: EncryptionTrustModalProps) { - const { t } = useTranslation('rooms', { keyPrefix: 'encryption.trustModal' }) - - return ( - - - {t('intro', { name: participantName })} - - {isAuthenticated ? ( - - - - - {t('authenticated.title')} - - - {t('authenticated.description')} - - - - ) : ( - - - - - {t('anonymous.title')} - - - {t('anonymous.description')} - - - - )} - - - - {t('levels.title')} - - - - - {t('levels.verified')} - - - - - - {t('levels.authenticated')} - - - - - - {t('levels.anonymous')} - - - - - - ) -} diff --git a/src/frontend/src/features/encryption/HybridKeyDistributor.ts b/src/frontend/src/features/encryption/HybridKeyDistributor.ts deleted file mode 100644 index b12c785a..00000000 --- a/src/frontend/src/features/encryption/HybridKeyDistributor.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Hybrid key distributor: determines the best key distribution method per participant. - * - * For each participant joining an encrypted call: - * 1. Check if they have a registered public key (via VaultClient/encryption library) - * → If YES: wrap symmetric key with their public key (PKI path) → trust level "verified" - * 2. Check if they are authenticated via ProConnect - * → If YES but no public key: use ephemeral DH → trust level "authenticated" - * 3. Otherwise: use ephemeral DH → trust level "anonymous" - * - * The symmetric key is always the same for everyone — only the distribution channel varies. - */ -import type { TrustLevel } from './types' -import { PARTICIPANT_TRUST_ATTR } from './types' - -export interface ParticipantEncryptionInfo { - identity: string - trustLevel: TrustLevel - hasPublicKey: boolean - isAuthenticated: boolean -} - -/** - * Determine the trust level for a participant based on their encryption capabilities. - */ -export function determineTrustLevel( - hasPublicKey: boolean, - isAuthenticated: boolean -): TrustLevel { - if (hasPublicKey) return 'verified' - if (isAuthenticated) return 'authenticated' - return 'anonymous' -} - -/** - * Derive trust level from participant's server-signed attributes. - * - * The `is_authenticated` attribute is set by the backend in the LiveKit JWT token - * and cannot be spoofed by clients. It indicates whether the participant - * authenticated via OIDC (ProConnect/Keycloak). - * - * In basic encryption mode, the "verified" level is never returned because - * PKI keys are not used — encryption relies on a shared passphrase, not on - * per-user public keys. The green shield would be misleading. - * - * In advanced encryption mode, "verified" means the participant has completed - * encryption onboarding and their public key is used to encrypt the symmetric key. - */ -export function getTrustLevelFromAttributes( - attributes: Record | undefined, - encryptionMode?: 'basic' | 'advanced' | 'none', -): TrustLevel | null { - if (!attributes) return null - - const isAdvanced = encryptionMode === 'advanced' - - // Check for explicit trust level (set by PKI integration) - const explicitLevel = attributes[PARTICIPANT_TRUST_ATTR] - if (explicitLevel === 'verified' && isAdvanced) { - return 'verified' - } - if (explicitLevel === 'authenticated' || explicitLevel === 'anonymous') { - return explicitLevel - } - - // Derive from server-signed is_authenticated attribute - if (attributes.is_authenticated === 'true') { - return 'authenticated' - } - - return 'anonymous' -} - -/** - * Try to distribute the symmetric key via PKI (encryption library). - * Returns true if successful, false if the participant doesn't have a public key. - */ -export async function distributeKeyViaPKI( - vaultClient: VaultClient, - symmetricKey: Uint8Array, - participantUserId: string -): Promise<{ success: boolean; encryptedKey?: ArrayBuffer }> { - try { - const { publicKeys } = await vaultClient.fetchPublicKeys([ - participantUserId, - ]) - const publicKey = publicKeys[participantUserId] - - if (!publicKey) { - return { success: false } - } - - // Use encryptWithoutKey to wrap the symmetric key for this user - const { encryptedKeys } = await vaultClient.shareKeys( - symmetricKey.buffer as ArrayBuffer, - { [participantUserId]: publicKey } - ) - - const encryptedKey = encryptedKeys[participantUserId] - if (!encryptedKey) { - return { success: false } - } - - return { success: true, encryptedKey } - } catch (err) { - console.warn( - '[Encryption] PKI key distribution failed for participant:', - participantUserId, - err - ) - return { success: false } - } -} - -/** - * Encode trust level into participant attributes for badge display. - */ -export function encodeTrustLevelAttribute( - trustLevel: TrustLevel -): Record { - return { [PARTICIPANT_TRUST_ATTR]: trustLevel } -} diff --git a/src/frontend/src/features/encryption/IdentityBadge.tsx b/src/frontend/src/features/encryption/IdentityBadge.tsx new file mode 100644 index 00000000..6b25a9aa --- /dev/null +++ b/src/frontend/src/features/encryption/IdentityBadge.tsx @@ -0,0 +1,67 @@ +/** + * Tiny per-participant identity confidence pill, shown in encrypted meetings. + * + * - "ProConnect" → server-verified identity (the participant signed in). + * - "Anonymous" → self-declared name; treat with caution. + * + * Sourced from the `is_authenticated` JWT attribute set in + * `core/utils.py::generate_token` (or the equivalent flag on a lobby + * participant). No fingerprints, no email — just a one-glance signal. + */ +import { css } from '@/styled-system/css' +import { RiShieldCheckFill, RiUserLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import type { Participant } from 'livekit-client' + +interface BadgeProps { + size?: 'sm' | 'md' +} + +interface FromParticipantProps extends BadgeProps { + participant: Participant + isAuthenticated?: never +} + +interface FromFlagProps extends BadgeProps { + isAuthenticated: boolean + participant?: never +} + +export function IdentityBadge(props: FromParticipantProps | FromFlagProps) { + const { t } = useTranslation('rooms', { keyPrefix: 'identity' }) + const isAuthenticated = + props.participant !== undefined + ? props.participant.attributes?.is_authenticated === 'true' + : props.isAuthenticated + const px = props.size === 'md' ? 14 : 12 + + const label = isAuthenticated ? t('proconnect') : t('anonymous') + const color = isAuthenticated ? '#1e40af' : '#b45309' + const bg = isAuthenticated ? 'rgba(30,64,175,0.10)' : 'rgba(180,83,9,0.10)' + + return ( + + {isAuthenticated ? ( + + ) : ( + + )} + {label} + + ) +} diff --git a/src/frontend/src/features/encryption/PauseEncryptionConfirmDialog.tsx b/src/frontend/src/features/encryption/PauseEncryptionConfirmDialog.tsx new file mode 100644 index 00000000..8db25376 --- /dev/null +++ b/src/frontend/src/features/encryption/PauseEncryptionConfirmDialog.tsx @@ -0,0 +1,67 @@ +/** + * Modal asking the admin to confirm that they accept pausing encryption + * to start recording or transcription. + */ +import { Button, Dialog, Text } from '@/primitives' +import { HStack, VStack } from '@/styled-system/jsx' +import { css } from '@/styled-system/css' +import { useTranslation } from 'react-i18next' + +interface Props { + isOpen: boolean + onOpenChange: (open: boolean) => void + reason: 'recording' | 'transcript' + onConfirm: () => void | Promise +} + +export function PauseEncryptionConfirmDialog({ + isOpen, + onOpenChange, + reason, + onConfirm, +}: Props) { + const { t } = useTranslation('rooms', { + keyPrefix: 'encryption.pauseConfirm', + }) + + return ( + + + {t('description')} + + {t('learnMore')} + + + + + + + + ) +} diff --git a/src/frontend/src/features/encryption/RoomStatusBanner.tsx b/src/frontend/src/features/encryption/RoomStatusBanner.tsx new file mode 100644 index 00000000..db75548e --- /dev/null +++ b/src/frontend/src/features/encryption/RoomStatusBanner.tsx @@ -0,0 +1,161 @@ +/** + * Top-left status banner shown during a meeting. + * + * Renders a horizontal stack of pills, one per active state: + * - "End-to-end encrypted" / "Encryption paused" + * - "Recording in progress" + * - "Transcription in progress" + * + * Each pill auto-collapses to its icon a few seconds after appearing, + * and expands back on hover. + */ +import { css } from '@/styled-system/css' +import { HStack } from '@/styled-system/jsx' +import { + RiFileTextFill, + RiLockFill, + RiLockUnlockFill, + RiRecordCircleFill, +} from '@remixicon/react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRoomContext } from '@livekit/components-react' +import { RoomEvent } from 'livekit-client' +import { EncryptionPhase } from './encryptionStatusTypes' +import { useEncryptionStatus } from './useEncryptionStatus' + +const COLLAPSE_DELAY_MS = 4000 + +interface PillProps { + icon: React.ReactNode + label: string + background: string + pulse?: boolean +} + +function StatusPill({ icon, label, background, pulse }: PillProps) { + const [collapsed, setCollapsed] = useState(false) + + useEffect(() => { + const t = setTimeout(() => setCollapsed(true), COLLAPSE_DELAY_MS) + return () => clearTimeout(t) + }, []) + + return ( +
setCollapsed(false)} + onMouseLeave={() => setCollapsed(true)} + role="status" + aria-label={label} + className={css({ + display: 'inline-flex', + alignItems: 'center', + gap: '0.35rem', + padding: '0.3rem 0.6rem', + borderRadius: '1rem', + border: '2px solid rgba(0, 0, 0, 0.3)', + cursor: 'default', + overflow: 'hidden', + transition: 'max-width 300ms ease, padding-right 200ms ease', + whiteSpace: 'nowrap', + })} + style={{ + backgroundColor: background, + maxWidth: collapsed ? '2.2rem' : '20rem', + paddingRight: collapsed ? '0.3rem' : '0.6rem', + animation: pulse ? 'pulse_background 1.6s infinite' : undefined, + }} + > + + {icon} + + + {label} + +
+ ) +} + +function useRecordingStatus() { + const room = useRoomContext() + const [isRecording, setIsRecording] = useState(!!room.isRecording) + + useEffect(() => { + const handler = () => setIsRecording(!!room.isRecording) + room.on(RoomEvent.RecordingStatusChanged, handler) + return () => { + room.off(RoomEvent.RecordingStatusChanged, handler) + } + }, [room]) + + return isRecording +} + +export function RoomStatusBanner() { + const { t } = useTranslation('rooms', { keyPrefix: 'roomStatus' }) + const { phase, pauseReason } = useEncryptionStatus() + const isRecording = useRecordingStatus() + + if ( + phase === EncryptionPhase.UNENCRYPTED && + !isRecording && + pauseReason !== 'transcript' + ) { + return null + } + + return ( + + {phase === EncryptionPhase.ENCRYPTED && ( + } + label={t('encrypted')} + background="#1e3a5f" + /> + )} + {phase === EncryptionPhase.PAUSED && ( + } + label={t('paused')} + background="#b45309" + /> + )} + {pauseReason === 'transcript' && ( + } + label={t('transcribing')} + background="#7c2d12" + /> + )} + {isRecording && ( + } + label={t('recording')} + background="#b91c1c" + pulse + /> + )} + + ) +} diff --git a/src/frontend/src/features/encryption/SECURITY.md b/src/frontend/src/features/encryption/SECURITY.md deleted file mode 100644 index 58dd270f..00000000 --- a/src/frontend/src/features/encryption/SECURITY.md +++ /dev/null @@ -1,117 +0,0 @@ -# Encryption Security Architecture - -## Threat model - -### What E2EE protects against -- **Server-side data access**: The LiveKit SFU and Meet backend cannot read audio/video content -- **Network interception**: Media frames are encrypted before leaving the client -- **Unauthorized participants**: Restricted access + lobby ensures only admin-approved users join - -### Known limitations and mitigations - -#### Compromised LiveKit server (MITM on key exchange) - -**Threat**: If the LiveKit server is compromised, it could perform a Man-in-the-Middle attack on the ephemeral DH key exchange, intercepting the symmetric key. - -**Current mitigation**: KEY_RESPONSE is only accepted from participants with `room_admin: "true"` in their server-signed JWT attributes. This prevents non-admin participants from injecting fake keys, but does not protect against a compromised server that can forge JWT attributes. - -**Planned mitigations (3 levels):** - -##### Level 1 — Signed key exchange (requires encryption onboarding) - -When the admin has completed encryption onboarding via `data.encryption`: -1. Admin signs the KEY_RESPONSE with their permanent private key (stored in IndexedDB) -2. Receiving participant fetches admin's public key from `data.encryption` registry -3. Verifies the signature before accepting the symmetric key -4. If signature is invalid → **reject the key, show error, cut video** - -This protects against server compromise because the server cannot forge the admin's private key signature. - -**Requirement**: Admin must have completed encryption onboarding. If not, falls back to Level 2. - -##### Level 2 — SAS (Short Authentication String) verification - -After the ephemeral DH key exchange: -1. Both parties compute SAS = hash(DH_shared_secret) → displayed as 4 emojis or a 6-digit code -2. Each participant sees the SAS on their own screen (local rendering) -3. They read it aloud to each other during the call -4. If the SAS matches → the key exchange was not intercepted -5. If the SAS doesn't match → MITM detected → reject the key - -This works because: -- A MITM results in different DH shared secrets → different SAS codes -- The SAS is rendered locally — the server cannot change what appears on screen -- Real-time audio manipulation to fake the spoken SAS is extremely difficult - -**Requirement**: Participants must verbally compare the SAS. Optional but recommended. - -##### Level 3 — Trust the server (current default) - -Relies on the LiveKit server's integrity (JWT-signed attributes). Suitable when: -- The server infrastructure is self-hosted and trusted -- The threat model does not include server compromise -- Quick, frictionless meetings are prioritized over maximum security - -#### Key propagation without admin - -**Current behavior**: Any participant who has the symmetric key can relay it to new joiners. - -**Risk**: If the server is compromised, it could inject a fake participant who relays a compromised key. - -**Planned fix**: Only accept KEY_RESPONSE from participants whose identity can be: -- Cryptographically verified (Level 1 — signature from registered public key), or -- Manually verified (Level 2 — SAS comparison) - -Non-verified key relays should show a clear warning. - -## Trust levels - -| Level | Badge | Identity verification | Key exchange | Server compromise protection | -|-------|-------|----------------------|-------------|------------------------------| -| Verified | 🟢 Green shield | Public key registered in `data.encryption` | Signed with permanent private key | Yes — signature cannot be forged | -| Authenticated | 🔵 Blue shield | OIDC/ProConnect login | Ephemeral DH (unsigned) | No — relies on server integrity | -| Anonymous | 🟡 Orange warning | None (self-declared name) | Ephemeral DH (unsigned) | No — relies on server integrity | - -#### Basic mode: unencrypted frame window on connection - -**Behavior**: LiveKit's built-in Worker passes frames through unencrypted when `!isEnabled()`. - -**Mitigation**: `setE2EEEnabled(true)` is called BEFORE the room connects (in Conference.tsx), -ensuring the 'enable' message reaches the Worker before any frames flow. This eliminates the -unencrypted window in normal operation. However, edge cases (Worker message queue delays, -race conditions during reconnection) could theoretically still allow a few unencrypted frames. - -**Advanced mode**: VaultE2EEManager drops frames when the key isn't ready — no pass-through. - -#### Basic mode: "Decryption failed" overlay may not appear with wrong passphrase - -**Behavior**: When a participant joins with a wrong passphrase, the receiver may not show the -"Decryption failed" overlay. The LiveKit Worker's error throttling (`MAX_ERRORS_PER_MINUTE = 5`) -stops emitting `EncryptionError` events after 5 failures. Additionally, when a participant -reconnects, the new `ParticipantTile` mounts fresh and may not receive errors referencing -the new participant identity. - -**Impact**: The user sees a black tile but no error message explaining why. - -**Advanced mode**: VaultE2EEManager emits `EncryptionError` for each failure and signals -`ParticipantEncryptionStatusChanged(true)` on first successful decrypt, ensuring the overlay -appears and clears correctly. - -## Implementation status - -- [x] Basic E2EE with LiveKit Worker + passphrase in URL hash -- [x] Advanced E2EE with VaultClient iframe (XChaCha20-Poly1305) -- [x] Preserved codec header bytes for RTP compatibility -- [x] Admin as key authority -- [x] Server-signed trust attributes in JWT -- [x] Trust badges (verified/unknown/refused/authenticated/anonymous) -- [x] Encryption identity dialog with fingerprint verification -- [x] Encryption settings in account menu (VaultClient onboarding) -- [x] Fingerprint accept/refuse with `fingerprint-changed` event -- [x] Disable recording/transcription in encrypted rooms (backend + frontend) -- [x] Lobby bypass disabled for encrypted rooms -- [x] Backend blocks encrypted room creation when `ENCRYPTION_ENABLED=false` -- [ ] Signed KEY_RESPONSE (Level 1) -- [ ] SAS verification (Level 2) -- [ ] Restrict key propagation to verified participants only -- [x] Mitigate unencrypted frame window (setE2EEEnabled before connection) diff --git a/src/frontend/src/features/encryption/VaultClientProvider.tsx b/src/frontend/src/features/encryption/VaultClientProvider.tsx deleted file mode 100644 index 9ca9d807..00000000 --- a/src/frontend/src/features/encryption/VaultClientProvider.tsx +++ /dev/null @@ -1,231 +0,0 @@ -/** - * React context provider for the centralized encryption VaultClient SDK. - * - * The client SDK is loaded at runtime via a