From 1aa8bc822dd94d6138d10681f323041da2118767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Wed, 13 May 2026 17:59:43 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(encryption)=20remove=20advanced=20mod?= =?UTF-8?q?e=20and=20keep=20a=20distinct=20encryption=20hash=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 47 +-- src/backend/core/api/__init__.py | 9 +- src/backend/core/api/serializers.py | 101 +++-- src/backend/core/api/viewsets.py | 67 +-- .../0019_room_encryption_enabled.py | 20 - ...ption_mode_user_default_encryption_mode.py | 46 ++ .../migrations/0020_room_encryption_mode.py | 51 --- ..._resourceaccess_encrypted_symmetric_key.py | 23 - src/backend/core/models.py | 48 ++- src/backend/core/services/livekit_events.py | 11 +- src/backend/core/services/lobby.py | 101 +---- src/backend/core/utils.py | 73 ++-- src/backend/meet/settings.py | 10 +- src/frontend/src/App.tsx | 19 +- src/frontend/src/api/useConfig.ts | 3 +- src/frontend/src/features/auth/api/ApiUser.ts | 2 + .../auth/api/updateUserPreferences.ts | 11 +- .../DecryptionFailedTileOverlay.tsx | 113 +++++ .../encryption/EncryptedMeetingBanner.tsx | 181 -------- .../features/encryption/EncryptionBadge.tsx | 82 ---- .../features/encryption/EncryptionContext.tsx | 10 - .../encryption/EncryptionIdentityDialog.tsx | 326 -------------- .../encryption/EncryptionMismatchScreen.tsx | 79 ++++ .../encryption/EncryptionSetupOverlay.tsx | 118 ------ .../encryption/EncryptionTrustModal.tsx | 143 ------- .../src/features/encryption/FeaturePill.tsx | 37 ++ .../encryption/HybridKeyDistributor.ts | 122 ------ .../features/encryption/RoomStatusBanner.tsx | 141 +++++++ .../src/features/encryption/SECURITY.md | 117 ------ .../encryption/VaultClientProvider.tsx | 231 ---------- .../encryption/VaultE2EEManager.test.ts | 396 ------------------ .../features/encryption/VaultE2EEManager.ts | 328 --------------- .../src/features/encryption/global.d.ts | 104 ----- src/frontend/src/features/encryption/index.ts | 25 +- .../features/encryption/lobbyKeyExchange.ts | 49 --- .../src/features/encryption/passphrase.ts | 33 ++ src/frontend/src/features/encryption/types.ts | 43 -- .../encryption/useParticipantTrustLevel.ts | 133 ------ .../components/ConnectionDetailsDialog.tsx | 147 +++++++ .../CreateEncryptedMeetingDialog.tsx | 92 ++++ .../home/components/EncryptionModeDialog.tsx | 142 ------- .../home/components/JoinMeetingDialog.tsx | 61 ++- .../home/components/LaterMeetingDialog.tsx | 7 +- .../src/features/home/routes/Home.tsx | 167 ++++---- .../WaitingParticipantNotification.tsx | 155 ++----- .../components/RecordingProvider.tsx | 6 +- .../src/features/rooms/api/ApiRoom.ts | 11 - .../src/features/rooms/api/createRoom.ts | 9 +- .../src/features/rooms/api/enterRoom.ts | 9 - .../rooms/api/listWaitingParticipants.ts | 3 - .../src/features/rooms/api/requestEntry.ts | 6 - .../features/rooms/components/Conference.tsx | 315 +++++--------- .../rooms/components/InviteDialog.tsx | 136 +++++- .../src/features/rooms/components/Join.tsx | 233 ++--------- .../src/features/rooms/hooks/useLobby.ts | 25 +- .../rooms/hooks/useWaitingParticipants.ts | 119 +----- .../rooms/livekit/components/Admin.tsx | 157 ++++--- .../rooms/livekit/components/Info.tsx | 160 ++++++- .../livekit/components/ParticipantTile.tsx | 201 +-------- .../rooms/livekit/components/Tools.tsx | 51 +-- .../Options/ScreenRecordingMenuItem.tsx | 14 +- .../controls/Options/TranscriptMenuItem.tsx | 12 +- .../Participants/ParticipantListItem.tsx | 161 ++----- .../WaitingParticipantListItem.tsx | 155 ++----- .../livekit/hooks/useCopyRoomToClipboard.ts | 14 +- .../rooms/livekit/prefabs/VideoConference.tsx | 4 +- .../sdk/routes/CreateMeetingButton.tsx | 1 - .../src/features/sdk/routes/CreatePopup.tsx | 171 +------- .../src/features/sdk/utils/PopupManager.ts | 7 +- .../components/EncryptionDefaultField.tsx | 90 ++++ .../settings/components/SettingsDialog.tsx | 19 +- .../components/SettingsDialogExtended.tsx | 7 + .../settings/components/tabs/AccountTab.tsx | 34 +- .../settings/components/tabs/SecurityTab.tsx | 21 + 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 | 37 +- src/frontend/src/locales/en/rooms.json | 146 +++---- src/frontend/src/locales/en/sdk.json | 6 +- src/frontend/src/locales/en/settings.json | 15 +- src/frontend/src/locales/fr/global.json | 2 - src/frontend/src/locales/fr/home.json | 37 +- src/frontend/src/locales/fr/rooms.json | 148 +++---- src/frontend/src/locales/fr/sdk.json | 6 +- src/frontend/src/locales/fr/settings.json | 15 +- src/frontend/src/navigation/navigateTo.ts | 10 +- src/frontend/src/primitives/Checkbox.tsx | 5 +- src/frontend/src/primitives/Field.tsx | 9 +- 89 files changed, 2099 insertions(+), 4839 deletions(-) delete mode 100644 src/backend/core/migrations/0019_room_encryption_enabled.py create mode 100644 src/backend/core/migrations/0019_room_encryption_mode_user_default_encryption_mode.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 create mode 100644 src/frontend/src/features/encryption/DecryptionFailedTileOverlay.tsx delete mode 100644 src/frontend/src/features/encryption/EncryptedMeetingBanner.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 delete mode 100644 src/frontend/src/features/encryption/EncryptionTrustModal.tsx create mode 100644 src/frontend/src/features/encryption/FeaturePill.tsx delete mode 100644 src/frontend/src/features/encryption/HybridKeyDistributor.ts 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 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 delete mode 100644 src/frontend/src/features/encryption/useParticipantTrustLevel.ts create mode 100644 src/frontend/src/features/home/components/ConnectionDetailsDialog.tsx create mode 100644 src/frontend/src/features/home/components/CreateEncryptedMeetingDialog.tsx delete mode 100644 src/frontend/src/features/home/components/EncryptionModeDialog.tsx create mode 100644 src/frontend/src/features/settings/components/EncryptionDefaultField.tsx create mode 100644 src/frontend/src/features/settings/components/tabs/SecurityTab.tsx diff --git a/README.md b/README.md index a2a7bfd1..de4a5afd 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,31 @@ 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 hex passphrase appended to the URL hash (`#…`) — 192 bits of entropy. 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. The DB column (`Room.encryption_mode`) is only used as a sanity reference: if the URL hash and the server's claim disagree, the joining client surfaces an explicit mismatch screen instead of silently joining in clear or in a private encrypted bubble. -#### Advanced encryption +> **Threat model.** "Server doesn't see plaintext" — not "users are safe from a malicious server." A compromised server could still serve modified JavaScript to a participant, who would then leak their passphrase. The E2EE story protects the media path against a passive or compromised SFU, not against a fully compromised origin. -- 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 +#### Encryption mode is set at creation, immutable after -**Frame encryption (both modes):** +`Room.encryption_mode` is a string enum (`none` / `basic`) chosen when the room is created and never mutated afterwards — changing it would change the link's semantics, since the passphrase lives in the URL hash. There is no mid-call "pause encryption" mechanism: while a meeting is encrypted, **recording and transcription endpoints reject requests with a 400** (`Recording is unavailable in encrypted rooms.` / `Subtitles are unavailable in encrypted rooms.`), the More-tools panel renders those items disabled with an explanatory banner, and the SIP gateway never gets a dispatch rule for encrypted rooms (so dial-in numbers and PINs aren't allocated). Encrypted rooms are also force-locked to `restricted` access level (lobby admission), since basic E2EE only meaningfully protects against passive eavesdropping if the host vets joiners before they receive the in-URL key. -- 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 +#### Opt-in by user -**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. | +End-to-end encryption is a per-user preference. In **Settings**, under the **Security** section, signed-in users can flip the **End-to-end encryption** toggle — once enabled, a third "Create an encrypted meeting" entry appears in the home-page create-menu (with its own confirmation modal that lists the disabled features and a "Treat this link like a password" connection-details dialog before the meeting starts). Joining is unaffected by the toggle: any participant clicking a meeting link that carries a valid hash joins encrypted, regardless of their own setting. Authenticated joiners of encrypted rooms cannot edit their displayed name — the server enforces the OIDC name on the JWT. -**Security guarantees:** - -- 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) - -**Configuration:** +#### 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` rejects encrypted-room creation at the API level. Existing encrypted rooms stay encrypted (the mode is immutable), 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..7e7ef0e0 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_mode", + ] 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,27 +138,60 @@ 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", + "encryption_mode", + ] read_only_fields = ["id", "slug", "pin_code"] - def validate_access_level(self, value): - """Encrypted rooms must stay restricted — prevent downgrading access level.""" + def validate_encryption_mode(self, value): + """Encryption mode is part of the link's semantics (the passphrase + lives in the URL hash for `basic` rooms) so it cannot be changed once + the room exists.""" instance = self.instance - if instance and instance.encryption_enabled and value != models.RoomAccessLevel.RESTRICTED: + if instance and instance.encryption_mode != value: raise serializers.ValidationError( - "Encrypted rooms require restricted access level to enforce lobby approval." + "Encryption mode cannot be changed after room creation." ) return value - def validate_encryption_mode(self, value): - """Once encryption is enabled on a room, it cannot be disabled or downgraded.""" + def validate_access_level(self, value): + """Encrypted rooms must stay restricted — the lobby is the only way + to enforce per-participant admission, and basic encryption relies on + the host vetting each joiner before they receive the in-URL key.""" instance = self.instance - if instance and instance.encryption_enabled and value == models.EncryptionMode.NONE: + if ( + instance + and instance.encryption_mode != models.EncryptionMode.NONE + and value != models.RoomAccessLevel.RESTRICTED + ): raise serializers.ValidationError( - "Encryption cannot be disabled once enabled on a room." + "Encrypted rooms require restricted access level." ) return value + def validate(self, attrs): + """Force encrypted rooms to RESTRICTED at creation time. + + Doing this here (rather than in validate_access_level) lets the + client omit `access_level` entirely when creating an encrypted room + — we silently override whatever the default would have been. + """ + encryption_mode = attrs.get( + "encryption_mode", + self.instance.encryption_mode + if self.instance + else models.EncryptionMode.NONE, + ) + if encryption_mode != models.EncryptionMode.NONE and not self.instance: + attrs["access_level"] = models.RoomAccessLevel.RESTRICTED + return super().validate(attrs) + def to_representation(self, instance): """ Add users only for administrator users. @@ -208,9 +234,11 @@ 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: + # In encrypted rooms, authenticated users cannot pick an + # arbitrary display name — it must come from the OIDC profile. + # We enforce this server-side so a tampered client cannot + # override what other participants see. + if instance.is_encrypted and request.user.is_authenticated: username = request.user.full_name or request.user.email output["livekit"] = utils.generate_livekit_config( @@ -226,15 +254,6 @@ class RoomSerializer(serializers.ModelSerializer): 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 +336,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 +343,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..26bcfc13 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -281,32 +281,23 @@ 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) + encryption_mode = serializer.validated_data.get( + "encryption_mode", models.EncryptionMode.NONE + ) - # Block encrypted room creation if encryption is not enabled on this instance - if encryption_mode != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED: + if ( + encryption_mode != models.EncryptionMode.NONE + and not settings.ENCRYPTION_ENABLED + ): raise drf_exceptions.ValidationError( {"encryption_mode": "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"): @@ -325,20 +316,15 @@ class RoomViewSet( """Start recording a room.""" serializer = serializers.StartRecordingSerializer(data=request.data) - - if not serializer.is_valid(): - return drf_response.Response( - {"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST - ) + serializer.is_valid(raise_exception=True) mode = serializer.validated_data["mode"] 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, + if room.is_encrypted: + raise drf_exceptions.ValidationError( + {"detail": "Recording is unavailable in encrypted rooms."} ) # May raise exception if an active or initiated recording already exist for the room @@ -425,20 +411,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 +452,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 +480,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,10 +582,9 @@ 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, + if room.is_encrypted: + raise drf_exceptions.ValidationError( + {"detail": "Subtitles are unavailable in encrypted rooms."} ) try: 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_encryption_mode_user_default_encryption_mode.py b/src/backend/core/migrations/0019_room_encryption_mode_user_default_encryption_mode.py new file mode 100644 index 00000000..223e8af7 --- /dev/null +++ b/src/backend/core/migrations/0019_room_encryption_mode_user_default_encryption_mode.py @@ -0,0 +1,46 @@ +"""Add Room.encryption_mode and User.default_encryption_mode (enum-based). + +We store the mode as an enum (CharField with choices) rather than a boolean +so a future "advanced" mode (per-user vault keys, etc.) can be added without +a schema migration. +""" + +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_mode", + field=models.CharField( + choices=[ + ("none", "No encryption"), + ("basic", "Passphrase-in-URL encryption"), + ], + default="none", + help_text="End-to-end encryption mode for this room.", + max_length=20, + verbose_name="Encryption mode", + ), + ), + migrations.AddField( + model_name="user", + name="default_encryption_mode", + field=models.CharField( + choices=[ + ("none", "No encryption"), + ("basic", "Passphrase-in-URL encryption"), + ], + default="none", + help_text="Encryption mode pre-selected when this user creates a new meeting.", + max_length=20, + verbose_name="Default encryption mode", + ), + ), + ] 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..21621267 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -99,11 +99,14 @@ class RoomAccessLevel(models.TextChoices): class EncryptionMode(models.TextChoices): - """Encryption mode choices for rooms.""" + """Encryption mode for a room. + + Kept as an enum (not a boolean) so future modes — e.g. a vault-managed + per-user key flow — can be added without another schema migration. + """ NONE = "none", _("No encryption") - BASIC = "basic", _("Basic encryption") - ADVANCED = "advanced", _("Advanced encryption") + BASIC = "basic", _("Passphrase-in-URL encryption") class BaseModel(models.Model): @@ -208,6 +211,15 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): "Unselect this instead of deleting accounts." ), ) + default_encryption_mode = models.CharField( + _("Default encryption mode"), + max_length=20, + choices=EncryptionMode.choices, + default=EncryptionMode.NONE, + help_text=_( + "Encryption mode pre-selected when this user creates a new meeting." + ), + ) objects = auth_models.UserManager() @@ -332,15 +344,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,6 +408,8 @@ class Room(Resource): choices=RoomAccessLevel.choices, default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL, ) + # Set at creation, immutable after (the URL hash carries the passphrase, + # so changing the mode would break every previously-shared link). encryption_mode = models.CharField( max_length=20, choices=EncryptionMode.choices, @@ -437,8 +442,19 @@ class Room(Resource): return capfirst(self.name) def save(self, *args, **kwargs): - """Generate a unique n-digit pin code for new rooms.""" - if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code: + """Generate a unique n-digit pin code for new rooms. + + Skip PIN allocation for encrypted rooms — the SIP gateway will + always reject calls to them (no way to derive the key), and the + PIN namespace is finite (10**length): no point burning slots that + can never be dialed. + """ + if ( + settings.ROOM_TELEPHONY_ENABLED + and not self.pk + and not self.pin_code + and self.encryption_mode == EncryptionMode.NONE + ): self.pin_code = self.generate_unique_pin_code( length=settings.ROOM_TELEPHONY_PIN_LENGTH ) @@ -467,8 +483,8 @@ class Room(Resource): return self.access_level == RoomAccessLevel.PUBLIC @property - def encryption_enabled(self): - """Check if any encryption mode is active.""" + def is_encrypted(self): + """Convenience: any non-none encryption mode counts as encrypted.""" return self.encryption_mode != EncryptionMode.NONE @staticmethod diff --git a/src/backend/core/services/livekit_events.py b/src/backend/core/services/livekit_events.py index d24c241e..1461fe24 100644 --- a/src/backend/core/services/livekit_events.py +++ b/src/backend/core/services/livekit_events.py @@ -202,7 +202,16 @@ class LiveKitEventsService: except models.Room.DoesNotExist as err: raise ActionFailedError(f"Room with ID {room_id} does not exist") from err - if settings.ROOM_TELEPHONY_ENABLED: + # Note: `encryption_mode` is stamped into the LK room's metadata at + # creation time via the access token's RoomConfiguration (see + # `utils.generate_token`), so we don't need to patch it here. + + # Phone dial-in is incompatible with end-to-end encryption — a SIP + # caller has no way to derive the room key, and the SIP gateway will + # play "encryption_not_supported" and hang up on them anyway. Skip + # the dispatch rule for encrypted rooms so no metadata mentions a + # PIN that won't be reachable. + if settings.ROOM_TELEPHONY_ENABLED and not room.is_encrypted: try: self.telephony_service.create_dispatch_rule(room) except TelephonyException as e: diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index dee555a4..3b9b9ab1 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. @@ -174,6 +145,12 @@ class LobbyService: 5. If denied, do nothing. """ + # In encrypted rooms, authenticated users cannot pick an arbitrary + # display name — server enforces the OIDC name so a tampered client + # can't impersonate someone else with their account. + if room.is_encrypted and request.user.is_authenticated: + username = request.user.full_name or request.user.email or username + participant_id = self._get_or_create_participant_id(request) participant = self._get_participant(room.id, participant_id) @@ -186,6 +163,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 @@ -206,34 +184,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, @@ -259,11 +219,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 +239,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 +307,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 +325,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 +333,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 +353,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..a7a41510 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -32,6 +32,7 @@ from livekit.api import ( # pylint: disable=E0611 UpdateRoomMetadataRequest, VideoGrants, ) +from livekit.protocol.room import RoomConfiguration # pylint: disable=E0611 logger = logging.getLogger(__name__) @@ -66,7 +67,7 @@ def generate_token( sources: Optional[List[str]] = None, is_admin_or_owner: bool = False, participant_id: Optional[str] = None, - encryption_mode: str = 'none', + encryption_mode: str = "none", ) -> str: """Generate a LiveKit access token for a user in a specific room. @@ -93,15 +94,17 @@ 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' + # In encrypted rooms, no one can change their name/attributes after the + # admin accepted them — otherwise a participant authenticated under one + # identity could rewrite their JWT-presented name to spoof someone else + # mid-meeting. In plain rooms, free naming is fine. + can_update_own_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=can_update_own_metadata, can_publish=bool(sources), can_publish_sources=sources, can_subscribe=True, @@ -117,41 +120,26 @@ 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) + # Emit the email only for authenticated participants of *encrypted* + # rooms. LK signaling broadcasts attributes to every peer in the room, + # so making this conditional on the encryption gate is what prevents + # an anonymous joiner of a public/trusted room from harvesting all + # authenticated users' emails. Frontend hiding (the + # `isLoggedIn`-gated render in ParticipantListItem) is only + # defense-in-depth — anyone with devtools can read attributes + # otherwise. + if ( + not user.is_anonymous + and encryption_mode != "none" + and getattr(user, "email", None) + ): + attributes["email"] = user.email token = ( AccessToken( @@ -164,6 +152,21 @@ def generate_token( .with_attributes(attributes) ) + # Encode the encryption mode into the room's metadata at LK-creation + # time (via the access token's room_config). LiveKit creates the room + # lazily when the first participant joins; the embedded config tells + # it to stamp `{"encryption_mode": ""}` into the metadata at + # that moment — no extra round-trip, no race window where a SIP + # caller could read empty metadata before the `room_started` webhook + # has time to push it. + if encryption_mode != "none": + token = token.with_room_config( + RoomConfiguration( + name=room, + metadata=json.dumps({"encryption_mode": encryption_mode}), + ) + ) + return token.to_jwt() @@ -175,7 +178,7 @@ def generate_livekit_config( color: Optional[str] = None, configuration: Optional[dict] = None, participant_id: Optional[str] = None, - encryption_mode: str = 'none', + encryption_mode: str = "none", ) -> dict: """Generate LiveKit configuration for room access. 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..17b1cd33 100644 --- a/src/frontend/src/api/useConfig.ts +++ b/src/frontend/src/api/useConfig.ts @@ -13,6 +13,7 @@ export interface ApiConfig { help_article_transcript: string help_article_recording: string help_article_more_tools: string + help_article_encryption?: string } feedback: { url: string @@ -54,8 +55,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..556b8061 100644 --- a/src/frontend/src/features/auth/api/ApiUser.ts +++ b/src/frontend/src/features/auth/api/ApiUser.ts @@ -1,4 +1,5 @@ import { BackendLanguage } from '@/utils/languages' +import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom' export type ApiUser = { id: string @@ -8,4 +9,5 @@ export type ApiUser = { last_name: string language: BackendLanguage timezone: string + default_encryption_mode: ApiEncryptionMode } diff --git a/src/frontend/src/features/auth/api/updateUserPreferences.ts b/src/frontend/src/features/auth/api/updateUserPreferences.ts index 09d13863..006007b0 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/DecryptionFailedTileOverlay.tsx b/src/frontend/src/features/encryption/DecryptionFailedTileOverlay.tsx new file mode 100644 index 00000000..83127bfb --- /dev/null +++ b/src/frontend/src/features/encryption/DecryptionFailedTileOverlay.tsx @@ -0,0 +1,113 @@ +/** + * Tile overlay shown when LiveKit raises an EncryptionError for a remote + * participant (a passphrase/key mismatch — "you and they don't share the + * same encryption key"). Renders the participant's avatar placeholder + * over the broken video, plus a black banner at the bottom of the tile + * explaining the issue. + * + * Cleared automatically once frames decrypt again + * (ParticipantEncryptionStatusChanged with encrypted=true). + */ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Participant, RoomEvent } from 'livekit-client' +import { useRoomContext } from '@livekit/components-react' +import { RiLockFill } from '@remixicon/react' +import { css } from '@/styled-system/css' + +import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { ParticipantPlaceholder } from '@/features/rooms/livekit/components/ParticipantPlaceholder' + +interface Props { + participant: Participant +} + +export function DecryptionFailedTileOverlay({ participant }: Props) { + const { t } = useTranslation('rooms', { + keyPrefix: 'encryption.decryptionFailed', + }) + const room = useRoomContext() + const roomData = useRoomData() + const isEncrypted = roomData?.encryption_mode === 'basic' + + const [failed, setFailed] = useState(false) + + useEffect(() => { + if (!isEncrypted) return + if (participant.isLocal) return + + const identity = participant.identity + + const onError = (_err: Error, p?: Participant) => { + if (p?.identity === identity) setFailed(true) + } + const onStatus = (encrypted: boolean, p?: Participant) => { + if (p?.identity === identity && encrypted) setFailed(false) + } + + room.on(RoomEvent.EncryptionError, onError) + room.on(RoomEvent.ParticipantEncryptionStatusChanged, onStatus) + return () => { + room.off(RoomEvent.EncryptionError, onError) + room.off(RoomEvent.ParticipantEncryptionStatusChanged, onStatus) + } + }, [room, isEncrypted, participant]) + + if (!failed) return null + + return ( +
+ +
+
+ + {t('title')} +
+
+ {t('body')} +
+
+
+ ) +} 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/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..5ff8f5d3 --- /dev/null +++ b/src/frontend/src/features/encryption/EncryptionMismatchScreen.tsx @@ -0,0 +1,79 @@ +/** + * Shown when the URL hash and the room's encryption_mode 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' + +interface Props { + reason: 'missingPassphrase' | 'unexpectedPassphrase' +} + +export function EncryptionMismatchScreen({ reason }: Props) { + const { t } = useTranslation('rooms', { keyPrefix: 'encryption.mismatch' }) + + 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/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/FeaturePill.tsx b/src/frontend/src/features/encryption/FeaturePill.tsx new file mode 100644 index 00000000..cdc46bb0 --- /dev/null +++ b/src/frontend/src/features/encryption/FeaturePill.tsx @@ -0,0 +1,37 @@ +/** + * Small pill used to surface a feature name with an icon, e.g. in the + * encrypted-room create dialog, the "Meeting information" panel and the + * floating share dialog — the three places that list disabled features. + */ +import { css } from '@/styled-system/css' +import { ReactNode } from 'react' + +interface Props { + icon: ReactNode + label: string + size?: 'sm' | 'md' +} + +export const FeaturePill = ({ icon, label, size = 'md' }: Props) => { + const fontSize = size === 'sm' ? '0.8rem' : '0.85rem' + const padding = size === 'sm' ? '0.3rem 0.6rem' : '0.4rem 0.7rem' + return ( + + {icon} + {label} + + ) +} 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/RoomStatusBanner.tsx b/src/frontend/src/features/encryption/RoomStatusBanner.tsx new file mode 100644 index 00000000..50c6a6fd --- /dev/null +++ b/src/frontend/src/features/encryption/RoomStatusBanner.tsx @@ -0,0 +1,141 @@ +/** + * Top-left status banner shown during a meeting. + * + * Renders a horizontal stack of pills, one per active state: + * - "End-to-end encrypted" + * - "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, + RiRecordCircleFill, + RiShieldCheckLine, +} from '@remixicon/react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { RecordingMode, useRecordingStatuses } from '@/features/recording' + +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} + +
+ ) +} + +export function RoomStatusBanner() { + const { t } = useTranslation('rooms', { keyPrefix: 'roomStatus' }) + const roomData = useRoomData() + + // Use the metadata-driven `isStarted` for both pills — it flips to + // false the moment the user clicks stop (recording_status moves to + // Saving), so the pill disappears immediately instead of lingering + // through LK's 1-2s post-stop callback delay. + const screenRec = useRecordingStatuses(RecordingMode.ScreenRecording) + const transcript = useRecordingStatuses(RecordingMode.Transcript) + const isRecording = screenRec.isStarted + const isTranscribing = transcript.isStarted + + const isEncrypted = roomData?.encryption_mode === 'basic' + + if (!isEncrypted && !isRecording && !isTranscribing) { + return null + } + + return ( + + {isEncrypted && ( + } + label={t('encrypted')} + background="#1e3a5f" + /> + )} + {isTranscribing && ( + } + 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