From 58bc6398ebb1706b9cd47df3c76bb5245b1bd1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Mon, 30 Mar 2026 17:37:05 +0200 Subject: [PATCH] wip base --- README.md | 73 +++- src/backend/core/api/__init__.py | 6 + src/backend/core/api/serializers.py | 26 +- src/backend/core/api/viewsets.py | 23 +- .../0019_room_encryption_enabled.py | 20 + src/backend/core/models.py | 5 + src/backend/core/services/lobby.py | 22 +- src/backend/core/utils.py | 20 +- src/backend/meet/settings.py | 11 + src/frontend/package-lock.json | 16 + src/frontend/package.json | 1 + src/frontend/src/App.tsx | 19 +- src/frontend/src/api/useConfig.ts | 5 + .../encryption/EncryptedMeetingBanner.tsx | 177 ++++++++ .../features/encryption/EncryptionBadge.tsx | 65 +++ .../encryption/EncryptionSetupOverlay.tsx | 102 +++++ .../encryption/EncryptionTrustModal.tsx | 143 +++++++ .../features/encryption/FingerprintDialog.tsx | 230 +++++++++++ .../encryption/HybridKeyDistributor.ts | 112 +++++ .../features/encryption/InCallKeyExchange.ts | 382 ++++++++++++++++++ .../src/features/encryption/SECURITY.md | 87 ++++ .../encryption/VaultClientProvider.tsx | 216 ++++++++++ .../src/features/encryption/global.d.ts | 73 ++++ src/frontend/src/features/encryption/index.ts | 20 + src/frontend/src/features/encryption/types.ts | 43 ++ .../src/features/encryption/useEncryption.ts | 213 ++++++++++ .../encryption/useParticipantTrustLevel.ts | 49 +++ .../src/features/home/routes/Home.tsx | 49 ++- .../WaitingParticipantNotification.tsx | 9 +- .../src/features/rooms/api/ApiRoom.ts | 1 + .../src/features/rooms/api/createRoom.ts | 3 + .../rooms/api/listWaitingParticipants.ts | 2 + .../features/rooms/components/Conference.tsx | 32 +- .../src/features/rooms/components/Join.tsx | 105 ++++- .../rooms/livekit/components/Admin.tsx | 23 ++ .../livekit/components/ParticipantTile.tsx | 14 +- .../Participants/ParticipantListItem.tsx | 48 +++ .../WaitingParticipantListItem.tsx | 116 +++++- .../rooms/livekit/prefabs/VideoConference.tsx | 2 + src/frontend/src/layout/Header.tsx | 47 ++- src/frontend/src/locales/en/global.json | 2 + src/frontend/src/locales/en/home.json | 4 +- src/frontend/src/locales/en/rooms.json | 60 +++ src/frontend/src/locales/fr/global.json | 2 + src/frontend/src/locales/fr/home.json | 4 +- src/frontend/src/locales/fr/rooms.json | 60 +++ src/frontend/src/primitives/Field.tsx | 3 +- src/frontend/src/primitives/Radio.tsx | 5 + src/frontend/vite.config.ts | 2 +- 49 files changed, 2672 insertions(+), 80 deletions(-) create mode 100644 src/backend/core/migrations/0019_room_encryption_enabled.py create mode 100644 src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx create mode 100644 src/frontend/src/features/encryption/EncryptionBadge.tsx create mode 100644 src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx create mode 100644 src/frontend/src/features/encryption/EncryptionTrustModal.tsx create mode 100644 src/frontend/src/features/encryption/FingerprintDialog.tsx create mode 100644 src/frontend/src/features/encryption/HybridKeyDistributor.ts create mode 100644 src/frontend/src/features/encryption/InCallKeyExchange.ts create mode 100644 src/frontend/src/features/encryption/SECURITY.md create mode 100644 src/frontend/src/features/encryption/VaultClientProvider.tsx create mode 100644 src/frontend/src/features/encryption/global.d.ts create mode 100644 src/frontend/src/features/encryption/index.ts create mode 100644 src/frontend/src/features/encryption/types.ts create mode 100644 src/frontend/src/features/encryption/useEncryption.ts create mode 100644 src/frontend/src/features/encryption/useParticipantTrustLevel.ts diff --git a/README.md b/README.md index e372bf4e..6ee73c37 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ meet logo

-

@@ -12,11 +11,11 @@ GitHub closed issues GitHub closed issues - +

- LiveKit - Chat with us - Roadmap - Changelog - Bug reports + LiveKit - Chat with us - Roadmap - Changelog - Bug reports

@@ -28,25 +27,62 @@ ## La Suite Meet: Simple Video Conferencing Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/). + ### Features + - Optimized for stability in large meetings (+100 p.) - Support for multiple screen sharing streams - Non-persistent, secure chat -- End-to-end encryption (coming soon) +- End-to-end encryption with hybrid key distribution - Meeting recording - Meeting transcription & Summary (currently in beta) - Telephony integration - Secure participation with robust authentication and access control - Customizable frontend style - LiveKit Advances features including : - - speaker detection - - simulcast - - end-to-end optimizations + - speaker detection + - simulcast + - end-to-end optimizations - selective subscription - SVC codecs (VP9, AV1) +### End-to-end encryption -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). +La Suite Meet supports end-to-end encryption (E2EE) for meetings, ensuring that the media server (LiveKit SFU) cannot access audio/video content. + +**Architecture:** + +- Uses LiveKit's built-in Insertable Streams API for frame-level encryption +- Symmetric key (XChaCha20-Poly1305) encrypts all media frames +- Key exchange uses ephemeral X25519 Diffie-Hellman over LiveKit data channel (libsodium) +- The room admin is the key authority — generates and distributes the symmetric key + +**Trust levels:** +| Badge | Level | Description | +|-------|-------|-------------| +| 🟢 Green shield | Verified | User completed encryption onboarding (public key registered in the encryption library). Identity cryptographically verified. | +| 🔵 Blue shield | Authenticated | User signed in via ProConnect/OIDC. Identity server-verified. Ephemeral key exchange. | +| 🟡 Orange warning | Anonymous | User not signed in. Self-declared name. Admin should verify identity before accepting. | + +**Security guarantees:** + +- Encrypted rooms enforce restricted access (lobby approval required) +- Trust information (`is_authenticated`, `email`) comes from server-signed JWT tokens — cannot be spoofed +- Only admin participants can respond to key exchange requests +- Recording and transcription are not available in encrypted rooms (server cannot decrypt media) + +**Configuration:** + +```env +ENCRYPTION_ENABLED=true +ENCRYPTION_VAULT_URL=https://data.encryption.example.fr +ENCRYPTION_INTERFACE_URL=https://encryption.example.fr +``` + +**Integration with the encryption library:** +When the [encryption library](https://github.com/suitenumerique/encryption) is deployed, users who complete encryption onboarding get the "verified" green shield badge. The admin can verify participants' public key fingerprints via the participants list. + +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). We’re continuously adding new features to enhance your experience, with the latest updates coming soon! @@ -63,7 +99,6 @@ On the 25th of January 2026, David Amiel, France’s Minister for Civil Service - [Philosophy](#philosophy) - [Open source](#open-source) - ## Get started ## Docs @@ -82,15 +117,15 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/ > Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon. #### Known instances + We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏 -| Url | Org | Access | -|---------------------------------------------------------------| --- | ------- | -| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up| -| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up| -| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month | -| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | - +| Url | Org | Access | +| ------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up | +| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up | +| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month | +| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | ## Contributing @@ -100,7 +135,6 @@ We <3 contributions of any kind, big and small: - Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md)) - Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) - ## Philosophy We’re relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics. @@ -109,7 +143,6 @@ Our users come first. We’re committed to making La Suite Meet as accessible an Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority. - ## Open-source Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE). @@ -121,14 +154,13 @@ To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr). Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr). - ## Contributors 🧞 -## Credits +## Credits We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work! This project is tested with BrowserStack. @@ -137,4 +169,3 @@ This project is tested with BrowserStack. Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique). Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html). - diff --git a/src/backend/core/api/__init__.py b/src/backend/core/api/__init__.py index b212c5ad..a751ae17 100644 --- a/src/backend/core/api/__init__.py +++ b/src/backend/core/api/__init__.py @@ -73,5 +73,11 @@ 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.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 f7db2125..75790dd2 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -128,9 +128,27 @@ class RoomSerializer(serializers.ModelSerializer): class Meta: model = models.Room - fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"] + fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_enabled"] read_only_fields = ["id", "slug", "pin_code"] + def validate_access_level(self, value): + """Encrypted rooms must stay restricted — prevent downgrading access level.""" + instance = self.instance + if instance and instance.encryption_enabled and value != models.RoomAccessLevel.RESTRICTED: + raise serializers.ValidationError( + "Encrypted rooms require restricted access level to enforce lobby approval." + ) + return value + + def validate_encryption_enabled(self, value): + """Once encryption is enabled on a room, it cannot be disabled.""" + instance = self.instance + if instance and instance.encryption_enabled and not value: + raise serializers.ValidationError( + "Encryption cannot be disabled once enabled on a room." + ) + return value + def to_representation(self, instance): """ Add users only for administrator users. @@ -172,6 +190,12 @@ class RoomSerializer(serializers.ModelSerializer): if should_access_room: 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, diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 8375ccd1..421dea8a 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -281,6 +281,11 @@ class RoomViewSet( def perform_create(self, serializer): """Set the current user as owner of the newly created room.""" + # Encrypted rooms must use restricted access to enforce lobby approval + # before the encryption key is shared with participants. + if serializer.validated_data.get("encryption_enabled"): + serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED + room = serializer.save() models.ResourceAccess.objects.create( resource=room, @@ -396,12 +401,21 @@ class RoomViewSet( serializer.is_valid(raise_exception=True) room = self.get_object() + validated_data = serializer.validated_data + + # 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( room=room, request=request, - **serializer.validated_data, + **validated_data, ) response = drf_response.Response({**participant.to_dict(), "livekit": livekit}) lobby_service.prepare_response(response, participant.id) @@ -464,6 +478,13 @@ class RoomViewSet( lobby_service = LobbyService() participants = lobby_service.list_waiting_participants(room.id) + + # Only expose email in encrypted rooms (needed for admin identity verification). + # Strip it otherwise to avoid leaking personal data. + if not room.encryption_enabled: + for p in participants: + p.pop("email", None) + return drf_response.Response({"participants": participants}) @decorators.action( diff --git a/src/backend/core/migrations/0019_room_encryption_enabled.py b/src/backend/core/migrations/0019_room_encryption_enabled.py new file mode 100644 index 00000000..ea571dbc --- /dev/null +++ b/src/backend/core/migrations/0019_room_encryption_enabled.py @@ -0,0 +1,20 @@ +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/models.py b/src/backend/core/models.py index 9e921ae5..771f7a30 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -388,6 +388,11 @@ class Room(Resource): choices=RoomAccessLevel.choices, default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL, ) + encryption_enabled = models.BooleanField( + default=False, + verbose_name=_("Encryption enabled"), + help_text=_("Whether end-to-end encryption is enabled for this room."), + ) configuration = models.JSONField( blank=True, default=dict, diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index 16fa2a3d..bcb062fb 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -46,15 +46,21 @@ class LobbyParticipant: username: str color: str id: str + is_authenticated: bool = False + email: Optional[str] = None def to_dict(self) -> Dict[str, str]: """Serialize the participant object to a dict representation.""" - return { + result = { "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 + return result @classmethod def from_dict(cls, data: dict) -> "LobbyParticipant": @@ -68,6 +74,8 @@ class LobbyParticipant: username=data["username"], id=data["id"], color=data["color"], + is_authenticated=data.get("is_authenticated", False), + email=data.get("email"), ) except (KeyError, ValueError) as e: logger.exception("Error creating Participant from dict:") @@ -170,7 +178,11 @@ class LobbyService: livekit_config = None if participant is None: - participant = self.enter(room.id, participant_id, username) + 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, + ) elif participant.status == LobbyParticipantStatus.WAITING: self.refresh_waiting_status(room.id, participant_id) @@ -201,7 +213,9 @@ 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, ) -> LobbyParticipant: """Add participant to waiting lobby. @@ -216,6 +230,8 @@ class LobbyService: username=username, id=participant_id, color=color, + is_authenticated=is_authenticated, + email=email, ) try: diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 3afba6bc..0297c07a 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -112,6 +112,22 @@ 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 (visible to other participants + # for identity verification in encrypted rooms) + if not user.is_anonymous: + 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"], @@ -120,9 +136,7 @@ def generate_token( .with_grants(video_grants) .with_identity(identity) .with_name(username or default_username) - .with_attributes( - {"color": color, "room_admin": "true" if is_admin_or_owner else "false"} - ) + .with_attributes(attributes) ) return token.to_jwt() diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index fa689dfa..4e5e22a6 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -808,6 +808,17 @@ class Base(Configuration): environ_prefix=None, ) + # End-to-end encryption settings + 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( 40, diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index a2add8ac..c9387389 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -28,6 +28,7 @@ "i18next-parser": "9.3.0", "i18next-resources-to-backend": "1.2.1", "libphonenumber-js": "1.12.10", + "libsodium-wrappers-sumo": "0.8.2", "livekit-client": "2.17.1", "posthog-js": "1.342.1", "react": "18.3.1", @@ -8422,6 +8423,21 @@ "integrity": "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==", "license": "MIT" }, + "node_modules/libsodium-sumo": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz", + "integrity": "sha512-uMgnjphJ717jLN+jFG1HUgNrK/gOVVfaO1DGZ1Ig/fKLKLVhvaH/sM1I1v784JFvmkJDaczDpi7xSYC4Jvdo1Q==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers-sumo": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.2.tgz", + "integrity": "sha512-wd1xAY++Kr6VMikSaa4EPRAHJmFvNlGWiiwU3Jh3GR1zRYF3/I3vy/wYsr4k3LVsNzwb9sqfEQ4LdVQ6zEebyQ==", + "license": "ISC", + "dependencies": { + "libsodium-sumo": "^0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index 10f33a68..ec58ec1b 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -33,6 +33,7 @@ "i18next-parser": "9.3.0", "i18next-resources-to-backend": "1.2.1", "libphonenumber-js": "1.12.10", + "libsodium-wrappers-sumo": "0.8.2", "livekit-client": "2.17.1", "posthog-js": "1.342.1", "react": "18.3.1", diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 7921a2c8..4da1c8a6 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -14,6 +14,7 @@ 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() @@ -25,20 +26,22 @@ 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 e2a255e9..b621cfa7 100644 --- a/src/frontend/src/api/useConfig.ts +++ b/src/frontend/src/api/useConfig.ts @@ -52,6 +52,11 @@ export interface ApiConfig { enable_firefox_proxy_workaround: boolean default_sources: string[] } + encryption?: { + enabled: boolean + vault_url: string + interface_url: string + } transcription_destination?: string } diff --git a/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx b/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx new file mode 100644 index 00000000..45bb7763 --- /dev/null +++ b/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx @@ -0,0 +1,177 @@ +/** + * 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 { useVaultClient } from './VaultClientProvider' +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 { hasKeys } = useVaultClient() + const [isCollapsed, setIsCollapsed] = useState(false) + const [isModalOpen, setIsModalOpen] = useState(false) + + // Strong = admin has encryption onboarding (can sign key exchange) + // Standard = admin is only ProConnect (ephemeral key exchange, trusts server) + const isStrongEncryption = hasKeys === true + + useEffect(() => { + const timer = setTimeout(() => setIsCollapsed(true), COLLAPSE_DELAY) + return () => clearTimeout(timer) + }, []) + + if (!roomData?.encryption_enabled) 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} + +
+ + + + {t('bannerModal.description')} + + + + {t('bannerModal.guarantees')} + +
    +
  • {t('bannerModal.guarantee1')}
  • +
  • {t('bannerModal.guarantee2')}
  • +
  • {t('bannerModal.guarantee3')}
  • +
+
+ + + + {t('bannerModal.limitations')} + +
    +
  • {t('bannerModal.limitation1')}
  • +
  • {t('bannerModal.limitation2')}
  • +
+
+ + + {t('bannerModal.note')} + +
+
+ + ) +} diff --git a/src/frontend/src/features/encryption/EncryptionBadge.tsx b/src/frontend/src/features/encryption/EncryptionBadge.tsx new file mode 100644 index 00000000..e36120d2 --- /dev/null +++ b/src/frontend/src/features/encryption/EncryptionBadge.tsx @@ -0,0 +1,65 @@ +/** + * Per-participant encryption trust badge. + * + * Single icon per trust level, consistent with lobby badges: + * - "verified": Green shield — identity confirmed via PKI + * - "authenticated": Blue shield — ProConnect-authenticated, ephemeral key + * - "anonymous": Orange warning — identity not verified, ephemeral key + * - default (no trust level): Lock icon — encrypted, trust level unknown + */ +import { RiShieldCheckFill, RiAlertFill, RiLockFill } from '@remixicon/react' +import type { TrustLevel } from './types' +import { css } from '@/styled-system/css' +import { TooltipWrapper } from '@/primitives/TooltipWrapper' + +interface EncryptionBadgeProps { + trustLevel: TrustLevel | null + isEncrypted: boolean +} + +export function EncryptionBadge({ + trustLevel, + isEncrypted, +}: EncryptionBadgeProps) { + if (!isEncrypted) return null + + let icon: React.ReactNode + let tooltip: string + + switch (trustLevel) { + case 'verified': + icon = + tooltip = 'Verified encryption' + break + case 'authenticated': + icon = + tooltip = 'Encrypted (authenticated)' + break + case 'anonymous': + icon = + tooltip = 'Encrypted (anonymous)' + break + default: + icon = + tooltip = 'Encrypted' + break + } + + return ( + + + {icon} + + + ) +} diff --git a/src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx b/src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx new file mode 100644 index 00000000..ec2189e5 --- /dev/null +++ b/src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx @@ -0,0 +1,102 @@ +/** + * 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. + */ +import { css } from '@/styled-system/css' +import { VStack } from '@/styled-system/jsx' +import { Text } from '@/primitives' +import { Spinner } from '@/primitives/Spinner' +import { RiLockFill, RiAlertFill } from '@remixicon/react' +import { useTranslation } from 'react-i18next' + +export function EncryptionSetupOverlay({ + isSettingUp, + error, +}: { + isSettingUp: boolean + error: string | null +}) { + const { t } = useTranslation('rooms', { keyPrefix: 'encryption' }) + + if (!isSettingUp && !error) return null + + return ( +
+ + + {error ? ( + <> + + + {t('error.title')} + + + {error} + + + {t('error.hint')} + + + ) : ( + <> + + {t('settingUp.title')} + + + {t('settingUp.description')} + + + + )} + +
+ ) +} diff --git a/src/frontend/src/features/encryption/EncryptionTrustModal.tsx b/src/frontend/src/features/encryption/EncryptionTrustModal.tsx new file mode 100644 index 00000000..f0ab6344 --- /dev/null +++ b/src/frontend/src/features/encryption/EncryptionTrustModal.tsx @@ -0,0 +1,143 @@ +/** + * 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/FingerprintDialog.tsx b/src/frontend/src/features/encryption/FingerprintDialog.tsx new file mode 100644 index 00000000..92d40869 --- /dev/null +++ b/src/frontend/src/features/encryption/FingerprintDialog.tsx @@ -0,0 +1,230 @@ +/** + * 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 { + RiShieldCheckFill, + RiShieldCheckLine, + RiAlertLine, + RiCheckLine, + RiCloseLine, +} from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useVaultClient } from './VaultClientProvider' +import { useEffect, useState } from 'react' + +interface FingerprintDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + participantName: string + participantEmail?: string + suiteUserId?: string + isAuthenticated: boolean +} + +type FingerprintStatus = 'loading' | 'no-key' | 'trusted' | 'refused' | 'unknown' | 'error' + +export function FingerprintDialog({ + isOpen, + onOpenChange, + participantName, + participantEmail, + suiteUserId, + isAuthenticated, +}: FingerprintDialogProps) { + const { t } = useTranslation('rooms', { keyPrefix: 'encryption.fingerprint' }) + const { client: vaultClient } = useVaultClient() + const [status, setStatus] = useState('loading') + const [fingerprint, setFingerprint] = useState(null) + + useEffect(() => { + if (!isOpen || !vaultClient || !suiteUserId) { + setStatus(isAuthenticated ? 'loading' : 'no-key') + return + } + + let cancelled = false + + async function checkFingerprint() { + try { + // Timeout after 3 seconds — the encryption server may not be available + 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 + } + + const { results } = await Promise.race([ + vaultClient!.checkFingerprints( + { [suiteUserId!]: '' }, + undefined + ), + timeout, + ]) + + if (cancelled) return + + const result = results.find((r) => r.userId === suiteUserId) + if (result) { + setFingerprint(result.providedFingerprint) + setStatus(result.status) + } else { + setStatus('no-key') + } + } catch { + if (!cancelled) setStatus('no-key') + } + } + + 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} + {participantEmail && ( + + {participantEmail} + + )} + + + {status === 'loading' && ( + {t('loading')} + )} + + {status === 'no-key' && ( + + + + {t('noKey')} + + + )} + + {status === 'error' && ( + + {t('error')} + + )} + + {(status === 'trusted' || status === 'refused' || status === 'unknown') && fingerprint && ( + <> + + + {t('fingerprintLabel')} + + {fingerprint} + + + {status === 'trusted' && ( + + + + {t('trusted')} + + + )} + + {status === 'refused' && ( + + + + {t('refused')} + + + )} + + {status === 'unknown' && ( + + + {t('unknownDescription')} + + + + + + + )} + + )} + + + ) +} diff --git a/src/frontend/src/features/encryption/HybridKeyDistributor.ts b/src/frontend/src/features/encryption/HybridKeyDistributor.ts new file mode 100644 index 00000000..bd709b38 --- /dev/null +++ b/src/frontend/src/features/encryption/HybridKeyDistributor.ts @@ -0,0 +1,112 @@ +/** + * 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). + * + * TODO: Once VaultClient integration is complete, also check for registered + * public keys to distinguish "verified" (PKI) from "authenticated" (ephemeral). + */ +export function getTrustLevelFromAttributes( + attributes: Record | undefined +): TrustLevel | null { + if (!attributes) return null + + // Check for explicit trust level (set by future PKI integration) + const explicitLevel = attributes[PARTICIPANT_TRUST_ATTR] + if (explicitLevel === 'verified' || 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/InCallKeyExchange.ts b/src/frontend/src/features/encryption/InCallKeyExchange.ts new file mode 100644 index 00000000..4c1817c9 --- /dev/null +++ b/src/frontend/src/features/encryption/InCallKeyExchange.ts @@ -0,0 +1,382 @@ +/** + * In-call key exchange protocol using ephemeral X25519 Diffie-Hellman + * over LiveKit's reliable data channel. + * + * Uses libsodium (same as the encryption library) for algorithmic consistency: + * - X25519 for ephemeral key exchange (crypto_scalarmult) + * - XChaCha20-Poly1305 for encrypting the symmetric key (crypto_secretbox) + * - BLAKE2b for deriving a shared key from the ECDH shared secret (crypto_generichash) + * + * Protocol: + * 1. New participant generates ephemeral X25519 key pair + * 2. Sends KEY_REQUEST with their ephemeral public key to the room + * 3. An admin (room_admin=true) who has the symmetric key responds with KEY_RESPONSE: + * - Generates their own ephemeral X25519 key pair + * - Derives shared secret via X25519(their ephemeral secret, requester's public key) + * - Derives encryption key via BLAKE2b(shared secret) + * - Encrypts the symmetric key with XChaCha20-Poly1305 using the derived key + * 4. Requester derives the same shared secret and decrypts the symmetric key + * 5. Requester sends KEY_ACK to confirm receipt + * + * Security: + * - Only KEY_RESPONSE from participants with room_admin=true are accepted + * - LiveKit's participant identity is server-verified (JWT-signed), so admin status cannot be spoofed + * - Each exchange uses unique ephemeral key pairs (forward secrecy per exchange) + * - Waiting room participants cannot send data channel messages (they're not in the LiveKit room) + */ +import _sodium from 'libsodium-wrappers-sumo' +import { + KeyExchangeMessage, + KeyExchangeMessageType, + KEY_EXCHANGE_TOPIC, +} from './types' +import type { Room, RemoteParticipant } from 'livekit-client' + +// Ensure libsodium is initialized +let sodiumReady: Promise | null = null +async function ensureSodium(): Promise { + if (!sodiumReady) { + sodiumReady = _sodium.ready + } + await sodiumReady + return _sodium +} + +// Helpers for base64 encoding/decoding +function toBase64(bytes: Uint8Array): string { + const sodium = _sodium + return sodium.to_base64(bytes, sodium.base64_variants.URLSAFE_NO_PADDING) +} + +function fromBase64(base64: string): Uint8Array { + const sodium = _sodium + return sodium.from_base64(base64, sodium.base64_variants.URLSAFE_NO_PADDING) +} + +/** + * Generate an ephemeral X25519 key pair using libsodium. + */ +async function generateEphemeralKeyPair(): Promise<{ + publicKey: Uint8Array + secretKey: Uint8Array +}> { + const sodium = await ensureSodium() + const secretKey = sodium.randombytes_buf(sodium.crypto_scalarmult_SCALARBYTES) + const publicKey = sodium.crypto_scalarmult_base(secretKey) + return { publicKey, secretKey } +} + +/** + * Derive a shared secret from X25519 ECDH, then derive an encryption key via BLAKE2b. + * Uses the same pattern as the encryption library's hybridEncapsulate/hybridDecapsulate. + */ +async function deriveSharedKey( + mySecretKey: Uint8Array, + theirPublicKey: Uint8Array +): Promise { + const sodium = await ensureSodium() + // X25519 scalar multiplication to get raw shared secret + const rawSharedSecret = sodium.crypto_scalarmult(mySecretKey, theirPublicKey) + // Derive a 32-byte key using BLAKE2b with a domain-separation tag + return sodium.crypto_generichash( + 32, + rawSharedSecret, + sodium.from_string('meet-key-exchange') + ) +} + +/** + * Encrypt the symmetric key using XChaCha20-Poly1305 (same as encryption library). + * Returns nonce + ciphertext. + */ +async function encryptWithSharedKey( + sharedKey: Uint8Array, + symmetricKey: Uint8Array +): Promise { + const sodium = await ensureSodium() + const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) + const ciphertext = sodium.crypto_secretbox_easy(symmetricKey, nonce, sharedKey) + // Prepend nonce to ciphertext (same format as encryption library) + const result = new Uint8Array(nonce.length + ciphertext.length) + result.set(nonce, 0) + result.set(ciphertext, nonce.length) + return result +} + +/** + * Decrypt the symmetric key using XChaCha20-Poly1305. + */ +async function decryptWithSharedKey( + sharedKey: Uint8Array, + encryptedData: Uint8Array +): Promise { + const sodium = await ensureSodium() + const nonce = encryptedData.slice(0, sodium.crypto_secretbox_NONCEBYTES) + const ciphertext = encryptedData.slice(sodium.crypto_secretbox_NONCEBYTES) + return sodium.crypto_secretbox_open_easy(ciphertext, nonce, sharedKey) +} + +/** + * Check if a participant is a room admin (server-verified via JWT). + */ +function isParticipantAdmin( + participant: { attributes?: Record } | undefined +): boolean { + return participant?.attributes?.room_admin === 'true' +} + +/** + * Manages the in-call key exchange protocol for a single participant. + */ +export class InCallKeyExchange { + private room: Room + private symmetricKey: Uint8Array | null = null + private ephemeralKeyPair: { + publicKey: Uint8Array + secretKey: Uint8Array + } | null = null + private onKeyReceived: ((key: Uint8Array) => void) | null = null + private boundHandleMessage: ( + payload: Uint8Array, + participant: RemoteParticipant | undefined, + kind: unknown, + topic: string | undefined + ) => void + + constructor(room: Room) { + this.room = room + this.boundHandleMessage = this.handleDataMessage.bind(this) + } + + /** + * Start listening for key exchange messages on the data channel. + */ + startListening(): void { + this.room.on('dataReceived', this.boundHandleMessage) + } + + /** + * Stop listening and clean up. + */ + stopListening(): void { + this.room.off('dataReceived', this.boundHandleMessage) + this.ephemeralKeyPair = null + } + + /** + * Set the symmetric key (for admins who generate or receive it). + */ + setSymmetricKey(key: Uint8Array): void { + this.symmetricKey = key + } + + /** + * Check if we already have the symmetric key. + */ + hasKey(): boolean { + return this.symmetricKey !== null + } + + /** + * Request the symmetric key from admin participants in the room. + * Returns a promise that resolves when the key is received from a verified admin. + */ + async requestKey(timeoutMs = 15000): Promise { + await ensureSodium() + + // Generate ephemeral key pair for this exchange + this.ephemeralKeyPair = await generateEphemeralKeyPair() + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.onKeyReceived = null + reject(new Error('Key exchange timeout: no admin responded')) + }, timeoutMs) + + this.onKeyReceived = (key: Uint8Array) => { + clearTimeout(timeout) + this.symmetricKey = key + resolve(key) + } + + // Send KEY_REQUEST with our ephemeral public key + const message: KeyExchangeMessage = { + type: KeyExchangeMessageType.KEY_REQUEST, + senderIdentity: this.room.localParticipant.identity, + payload: toBase64(this.ephemeralKeyPair!.publicKey), + } + + this.sendMessage(message) + }) + } + + /** + * Handle incoming data channel messages. + * The `participant` parameter is provided by LiveKit and contains + * the server-verified identity and attributes (from JWT). + */ + private async handleDataMessage( + payload: Uint8Array, + participant: RemoteParticipant | undefined, + _kind: unknown, + topic: string | undefined + ): Promise { + if (topic !== KEY_EXCHANGE_TOPIC) return + + try { + const text = new TextDecoder().decode(payload) + const message: KeyExchangeMessage = JSON.parse(text) + + // Ignore our own messages + if (message.senderIdentity === this.room.localParticipant.identity) return + + switch (message.type) { + case KeyExchangeMessageType.KEY_REQUEST: + await this.handleKeyRequest(message, participant) + break + case KeyExchangeMessageType.KEY_RESPONSE: + await this.handleKeyResponse(message, participant) + break + case KeyExchangeMessageType.KEY_ACK: + break + } + } catch (err) { + console.error('[Encryption] Error handling key exchange message:', err) + } + } + + /** + * Handle a KEY_REQUEST: if we're an admin with the symmetric key, + * respond with it encrypted using the requester's ephemeral public key. + * + * Only admins respond to key requests, ensuring the key authority model. + */ + private async handleKeyRequest( + message: KeyExchangeMessage, + _participant: RemoteParticipant | undefined + ): Promise { + if (!this.symmetricKey) return + + // Only admins distribute the symmetric key + if (!isParticipantAdmin({ attributes: this.room.localParticipant.attributes })) { + return + } + + // Only respond if the message is for us or broadcast + if ( + message.targetIdentity && + message.targetIdentity !== this.room.localParticipant.identity + ) { + return + } + + try { + // Generate our own ephemeral key pair for this exchange + const responderKeyPair = await generateEphemeralKeyPair() + + // Import the requester's public key + const requesterPublicKey = fromBase64(message.payload) + + // Derive shared key using X25519 + BLAKE2b + const sharedKey = await deriveSharedKey( + responderKeyPair.secretKey, + requesterPublicKey + ) + + // Encrypt the symmetric key with XChaCha20-Poly1305 + const encryptedKey = await encryptWithSharedKey(sharedKey, this.symmetricKey) + + // Send response with our public key + encrypted symmetric key + const responsePayload = JSON.stringify({ + responderPublicKey: toBase64(responderKeyPair.publicKey), + encryptedKey: toBase64(encryptedKey), + }) + + const response: KeyExchangeMessage = { + type: KeyExchangeMessageType.KEY_RESPONSE, + senderIdentity: this.room.localParticipant.identity, + targetIdentity: message.senderIdentity, + payload: btoa(responsePayload), + } + + this.sendMessage(response) + } catch (err) { + console.error('[Encryption] Error responding to key request:', err) + } + } + + /** + * Handle a KEY_RESPONSE: decrypt the symmetric key using our ephemeral private key. + * + * SECURITY: Only accept KEY_RESPONSE from participants with room_admin=true. + * The admin attribute is set by the server (via JWT) and cannot be spoofed by clients. + */ + private async handleKeyResponse( + message: KeyExchangeMessage, + participant: RemoteParticipant | undefined + ): Promise { + // Only process if directed at us + if (message.targetIdentity !== this.room.localParticipant.identity) return + + // SECURITY: Verify the sender is a room admin + // The `participant` object comes from LiveKit with server-verified attributes (JWT-signed) + if (!isParticipantAdmin(participant)) { + console.warn( + '[Encryption] Rejected KEY_RESPONSE from non-admin participant:', + message.senderIdentity + ) + return + } + + if (!this.ephemeralKeyPair) { + console.warn('[Encryption] Received key response but no ephemeral key pair') + return + } + + try { + const responsePayload = JSON.parse(atob(message.payload)) + const responderPublicKey = fromBase64(responsePayload.responderPublicKey) + + // Derive shared key using our ephemeral secret + responder's public key + const sharedKey = await deriveSharedKey( + this.ephemeralKeyPair.secretKey, + responderPublicKey + ) + + // Decrypt the symmetric key with XChaCha20-Poly1305 + const encryptedKey = fromBase64(responsePayload.encryptedKey) + const symmetricKey = await decryptWithSharedKey(sharedKey, encryptedKey) + + // Send ACK + const ack: KeyExchangeMessage = { + type: KeyExchangeMessageType.KEY_ACK, + senderIdentity: this.room.localParticipant.identity, + targetIdentity: message.senderIdentity, + payload: '', + } + this.sendMessage(ack) + + // Clean up ephemeral key pair + this.ephemeralKeyPair = null + + // Notify that we received the key + if (this.onKeyReceived) { + this.onKeyReceived(symmetricKey) + this.onKeyReceived = null + } + } catch (err) { + console.error('[Encryption] Error handling key response:', err) + } + } + + /** + * Send a key exchange message via LiveKit data channel. + */ + private sendMessage(message: KeyExchangeMessage): void { + const data = new TextEncoder().encode(JSON.stringify(message)) + this.room.localParticipant.publishData(data, { + reliable: true, + topic: KEY_EXCHANGE_TOPIC, + }) + } +} diff --git a/src/frontend/src/features/encryption/SECURITY.md b/src/frontend/src/features/encryption/SECURITY.md new file mode 100644 index 00000000..c5d238a4 --- /dev/null +++ b/src/frontend/src/features/encryption/SECURITY.md @@ -0,0 +1,87 @@ +# 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 | + +## Implementation status + +- [x] LiveKit E2EE with insertable streams +- [x] Ephemeral X25519 DH key exchange (libsodium) +- [x] Admin as key authority +- [x] Server-signed trust attributes in JWT +- [x] Trust badges (verified/authenticated/anonymous) +- [x] Fingerprint verification dialog +- [x] Encryption settings in account menu (VaultClient onboarding) +- [ ] Signed KEY_RESPONSE (Level 1) +- [ ] SAS verification (Level 2) +- [ ] Restrict key propagation to verified participants only +- [ ] Disable recording/transcription UI for encrypted rooms diff --git a/src/frontend/src/features/encryption/VaultClientProvider.tsx b/src/frontend/src/features/encryption/VaultClientProvider.tsx new file mode 100644 index 00000000..b793bc28 --- /dev/null +++ b/src/frontend/src/features/encryption/VaultClientProvider.tsx @@ -0,0 +1,216 @@ +/** + * React context provider for the centralized encryption VaultClient SDK. + * + * The client SDK is loaded at runtime via a