@@ -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 (
+ <>
+