diff --git a/README.md b/README.md
index 6ee73c37..a2a7bfd1 100644
--- a/README.md
+++ b/README.md
@@ -48,27 +48,41 @@ 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.
+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:
-**Architecture:**
+#### Basic encryption
-- 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
+- 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
-**Trust levels:**
+#### Advanced encryption
+
+- 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
+
+**Frame encryption (both modes):**
+
+- 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
+
+**Trust levels (advanced mode):**
| 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. |
+| 🟢 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. |
**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:**
@@ -79,8 +93,7 @@ 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.
+When the encryption service is deployed and configured, rooms can use advanced encryption. Without it, only basic (passphrase) encryption is available.
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/viewsets.py b/src/backend/core/api/viewsets.py
index 2957b0a3..1a542105 100644
--- a/src/backend/core/api/viewsets.py
+++ b/src/backend/core/api/viewsets.py
@@ -281,9 +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)
+
+ # Block encrypted room creation if encryption is not enabled on this instance
+ 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 serializer.validated_data.get("encryption_mode", models.EncryptionMode.NONE) != models.EncryptionMode.NONE:
+ if encryption_mode != models.EncryptionMode.NONE:
serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED
room = serializer.save()
diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py
index f034e06e..dee555a4 100644
--- a/src/backend/core/services/lobby.py
+++ b/src/backend/core/services/lobby.py
@@ -139,11 +139,16 @@ 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
diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py
index c3eb47cb..6964ec68 100644
--- a/src/backend/core/utils.py
+++ b/src/backend/core/utils.py
@@ -93,9 +93,9 @@ def generate_token(
if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES
- # In encrypted rooms, authenticated users cannot change their name/metadata
- # to prevent identity spoofing in the LiveKit room.
- can_update_metadata = encryption_mode == 'none' or user.is_anonymous
+ # In encrypted rooms, no one can change their name/metadata to prevent
+ # identity spoofing — the admin accepted them based on their declared identity.
+ can_update_metadata = encryption_mode == 'none'
video_grants = VideoGrants(
room=room,
diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py
index 0a9deb39..47540621 100755
--- a/src/backend/meet/settings.py
+++ b/src/backend/meet/settings.py
@@ -561,12 +561,12 @@ class Base(Configuration):
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
)
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
- default=["given_name", "usual_name", "family_name"],
+ default=["first_name", "last_name"],
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
environ_prefix=None,
)
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
- default="given_name",
+ default="first_name",
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None,
)
diff --git a/src/frontend/package.json b/src/frontend/package.json
index ec58ec1b..10f33a68 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -33,7 +33,6 @@
"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/features/auth/api/ApiUser.ts b/src/frontend/src/features/auth/api/ApiUser.ts
index 5d1e16a1..f7dcc62a 100644
--- a/src/frontend/src/features/auth/api/ApiUser.ts
+++ b/src/frontend/src/features/auth/api/ApiUser.ts
@@ -3,7 +3,8 @@ import { BackendLanguage } from '@/utils/languages'
export type ApiUser = {
id: string
email: string
- full_name: string
+ full_name: string | null
+ short_name: string | null
last_name: string
language: BackendLanguage
timezone: string
diff --git a/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx b/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx
index 01043042..d5d1048c 100644
--- a/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx
+++ b/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx
@@ -11,8 +11,7 @@ import { VStack } from '@/styled-system/jsx'
import { RiLockFill, RiShieldCheckFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
-import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
-import { useVaultClient } from './VaultClientProvider'
+import { isEncryptedRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { useEffect, useState } from 'react'
import { Dialog, Text } from '@/primitives'
@@ -21,13 +20,10 @@ 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
+ const isStrongEncryption = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED
useEffect(() => {
const timer = setTimeout(() => setIsCollapsed(true), COLLAPSE_DELAY)
@@ -106,7 +102,11 @@ export function EncryptedMeetingBanner() {
alignItems="start"
className={css({ maxWidth: '24rem' })}
>
- {t('bannerModal.description')}
+
+ {isStrongEncryption
+ ? t('bannerModal.descriptionAdvanced')
+ : t('bannerModal.descriptionBasic')}
+
@@ -155,7 +155,10 @@ export function EncryptedMeetingBanner() {
})}
>