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() { })} >
  • {t('bannerModal.limitation1')}
  • -
  • {t('bannerModal.limitation2')}
  • +
  • {isStrongEncryption + ? t('bannerModal.limitation2Advanced') + : t('bannerModal.limitation2Basic')} +
  • diff --git a/src/frontend/src/features/encryption/EncryptionBadge.tsx b/src/frontend/src/features/encryption/EncryptionBadge.tsx index e36120d2..e9d81eed 100644 --- a/src/frontend/src/features/encryption/EncryptionBadge.tsx +++ b/src/frontend/src/features/encryption/EncryptionBadge.tsx @@ -11,6 +11,7 @@ import { RiShieldCheckFill, RiAlertFill, RiLockFill } from '@remixicon/react' import type { TrustLevel } from './types' import { css } from '@/styled-system/css' import { TooltipWrapper } from '@/primitives/TooltipWrapper' +import { useTranslation } from 'react-i18next' interface EncryptionBadgeProps { trustLevel: TrustLevel | null @@ -21,6 +22,8 @@ export function EncryptionBadge({ trustLevel, isEncrypted, }: EncryptionBadgeProps) { + const { t } = useTranslation('rooms', { keyPrefix: 'encryption.badge' }) + if (!isEncrypted) return null let icon: React.ReactNode @@ -29,19 +32,19 @@ export function EncryptionBadge({ switch (trustLevel) { case 'verified': icon = - tooltip = 'Verified encryption' + tooltip = t('verified') break case 'authenticated': icon = - tooltip = 'Encrypted (authenticated)' + tooltip = t('authenticated') break case 'anonymous': icon = - tooltip = 'Encrypted (anonymous)' + tooltip = t('anonymous') break default: icon = - tooltip = 'Encrypted' + tooltip = t('default') break } diff --git a/src/frontend/src/features/encryption/InCallKeyExchange.ts b/src/frontend/src/features/encryption/InCallKeyExchange.ts deleted file mode 100644 index 9f5cce03..00000000 --- a/src/frontend/src/features/encryption/InCallKeyExchange.ts +++ /dev/null @@ -1,398 +0,0 @@ -/** - * 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: - // TEMPORARY: 10s delay to test joiner UX during key exchange - console.info( - '[Encryption] Delaying key response by 10 seconds for testing...' - ) - await new Promise((resolve) => setTimeout(resolve, 10000)) - 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/VaultE2EEManager.ts b/src/frontend/src/features/encryption/VaultE2EEManager.ts index dd73dd7b..973cc1c4 100644 --- a/src/frontend/src/features/encryption/VaultE2EEManager.ts +++ b/src/frontend/src/features/encryption/VaultE2EEManager.ts @@ -28,6 +28,14 @@ enum EncryptionEvent { EncryptionError = 'encryptionError', } +function isInsertableStreamSupported(): boolean { + return ( + typeof window.RTCRtpSender !== 'undefined' && + // @ts-expect-error — createEncodedStreams not in TS types + typeof window.RTCRtpSender.prototype.createEncodedStreams !== 'undefined' + ) +} + const UNENCRYPTED_BYTES = { key: 10, delta: 3, audio: 1 } function getUnencryptedBytes( @@ -59,7 +67,7 @@ export class VaultE2EEManager extends EventEmitter { } get isDataChannelEncryptionEnabled() { - return this.isEnabled && this._isDataChannelEncryptionEnabled + return this._isDataChannelEncryptionEnabled && !!this.encryptedKeyBytes } set isDataChannelEncryptionEnabled(enabled: boolean) { @@ -78,6 +86,12 @@ export class VaultE2EEManager extends EventEmitter { // ── Lifecycle (mirrors built-in E2EEManager) ──────────────────────── setup(room: Room): void { + if (!isInsertableStreamSupported()) { + throw new Error( + 'End-to-end encryption is not supported in this browser. ' + + 'Please use a Chromium-based browser (Chrome, Edge, Brave).' + ) + } if (room !== this.room) { this.room = room this.setupEventListeners(room) diff --git a/src/frontend/src/features/encryption/index.ts b/src/frontend/src/features/encryption/index.ts index 9dec494b..367594b3 100644 --- a/src/frontend/src/features/encryption/index.ts +++ b/src/frontend/src/features/encryption/index.ts @@ -1,7 +1,5 @@ export { VaultClientProvider, useVaultClient } from './VaultClientProvider' export type { VaultClientContextValue } from './VaultClientProvider' -export { useEncryption } from './useEncryption' -export type { EncryptionState } from './useEncryption' export { determineTrustLevel, getTrustLevelFromAttributes, diff --git a/src/frontend/src/features/encryption/lobbyKeyExchange.ts b/src/frontend/src/features/encryption/lobbyKeyExchange.ts index 89307f84..7773abf1 100644 --- a/src/frontend/src/features/encryption/lobbyKeyExchange.ts +++ b/src/frontend/src/features/encryption/lobbyKeyExchange.ts @@ -1,114 +1,28 @@ /** - * Lobby-based key exchange using ephemeral X25519 Diffie-Hellman. + * Key storage and passphrase utilities for E2EE lobby flow. * - * The key exchange happens during the waiting room flow via the REST API, - * so the joiner already has the real symmetric key when connecting to LiveKit. - * - * Uses libsodium 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) + * Basic mode: passphrase is in the URL hash — shared by sharing the link. + * Advanced mode: vault-wrapped symmetric key exchanged via lobby REST API. */ -import _sodium from 'libsodium-wrappers-sumo' -let sodiumReady: Promise | null = null -async function ensureSodium(): Promise { - if (!sodiumReady) { - sodiumReady = _sodium.ready - } - await sodiumReady - return _sodium -} +// ── Module-level symmetric key (basic mode) ─────────────────────────── -function toBase64(bytes: Uint8Array): string { - return _sodium.to_base64(bytes, _sodium.base64_variants.URLSAFE_NO_PADDING) -} - -function fromBase64(base64: string): Uint8Array { - return _sodium.from_base64(base64, _sodium.base64_variants.URLSAFE_NO_PADDING) -} - -/** - * Generate an ephemeral X25519 key pair. - */ -export 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 } -} - -/** - * Encode a public key to base64url for transmission via REST API. - */ -export function encodePublicKey(publicKey: Uint8Array): string { - return toBase64(publicKey) -} - -// Module-level symmetric key — only accessible via set/get, never exposed to React components. let _symmetricKey: Uint8Array | null = null -/** - * Store the room's symmetric key in module scope. - * Called once by the admin (after generating it) or by the joiner (after decrypting it). - */ export function setSymmetricKey(key: Uint8Array): void { _symmetricKey = key } -/** - * Retrieve the stored symmetric key. Returns null if not yet set. - */ export function getSymmetricKey(): Uint8Array | null { return _symmetricKey } -const EPHEMERAL_KEY_STORAGE_KEY = 'meet-ephemeral-keypair' - -/** - * Persist the ephemeral keypair in sessionStorage so it survives page refreshes. - * sessionStorage is cleared when the tab/browser closes — in that case the backend - * resets the participant to WAITING so the admin re-accepts with a fresh key. - */ -export function saveEphemeralKeyPair(keyPair: { publicKey: Uint8Array; secretKey: Uint8Array }): void { - try { - sessionStorage.setItem(EPHEMERAL_KEY_STORAGE_KEY, JSON.stringify({ - publicKey: toBase64(keyPair.publicKey), - secretKey: toBase64(keyPair.secretKey), - })) - } catch { - // sessionStorage may be unavailable - } -} - -/** - * Restore the ephemeral keypair from sessionStorage. - */ -export function loadEphemeralKeyPair(): { publicKey: Uint8Array; secretKey: Uint8Array } | null { - try { - const stored = sessionStorage.getItem(EPHEMERAL_KEY_STORAGE_KEY) - if (!stored) return null - const parsed = JSON.parse(stored) - return { - publicKey: fromBase64(parsed.publicKey), - secretKey: fromBase64(parsed.secretKey), - } - } catch { - return null - } -} - -/** - * Clear the stored symmetric key (e.g. on disconnect). - */ export function clearSymmetricKey(): void { _symmetricKey = null } -// Module-level encrypted vault key — for advanced mode, stores the vault-wrapped key +// ── Module-level encrypted vault key (advanced mode) ────────────────── + let _encryptedVaultKey: ArrayBuffer | null = null export function setEncryptedVaultKey(key: ArrayBuffer): void { @@ -119,6 +33,8 @@ export function getEncryptedVaultKey(): ArrayBuffer | null { return _encryptedVaultKey } +// ── Passphrase generation (basic mode) ──────────────────────────────── + /** * Generate a random passphrase for basic mode encryption. * 24 random bytes encoded in base36 = 48 alphanumeric characters. @@ -131,85 +47,3 @@ export function generatePassphrase(): string { /** Expected length of a basic mode passphrase */ export const BASIC_KEY_LENGTH = 48 - -/** - * Derive a shared secret from X25519 ECDH, then derive an encryption key via BLAKE2b. - */ -async function deriveSharedKey( - mySecretKey: Uint8Array, - theirPublicKey: Uint8Array -): Promise { - const sodium = await ensureSodium() - const rawSharedSecret = sodium.crypto_scalarmult(mySecretKey, theirPublicKey) - return sodium.crypto_generichash( - 32, - rawSharedSecret, - sodium.from_string('meet-key-exchange') - ) -} - -/** - * Admin-side: encrypt the room's symmetric key for a specific participant. - * - * Called when the admin accepts a participant from the waiting room. - * Generates an ephemeral keypair, performs DH with the participant's public key, - * and encrypts the symmetric key. Reads the key from module-level storage. - * - * @param participantPublicKeyB64 - The participant's ephemeral public key (base64url) - * @returns The encrypted key blob and admin's ephemeral public key (both base64url) - */ -export async function encryptKeyForParticipant( - participantPublicKeyB64: string -): Promise<{ encryptedKey: string; adminPublicKey: string }> { - const symmetricKey = _symmetricKey - if (!symmetricKey) { - throw new Error('Symmetric key not set — cannot encrypt for participant') - } - const sodium = await ensureSodium() - - const adminKeyPair = await generateEphemeralKeyPair() - const participantPublicKey = fromBase64(participantPublicKeyB64) - - const sharedKey = await deriveSharedKey( - adminKeyPair.secretKey, - participantPublicKey - ) - - const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) - const ciphertext = sodium.crypto_secretbox_easy(symmetricKey, nonce, sharedKey) - const encrypted = new Uint8Array(nonce.length + ciphertext.length) - encrypted.set(nonce, 0) - encrypted.set(ciphertext, nonce.length) - - return { - encryptedKey: toBase64(encrypted), - adminPublicKey: toBase64(adminKeyPair.publicKey), - } -} - -/** - * Joiner-side: decrypt the symmetric key received from the admin via the lobby. - * - * Called when the joiner's polling receives an ACCEPTED status with encryption data. - * - * @param mySecretKey - The joiner's ephemeral secret key - * @param adminPublicKeyB64 - The admin's ephemeral public key (base64url) - * @param encryptedKeyB64 - The encrypted symmetric key blob (base64url) - * @returns The decrypted symmetric key - */ -export async function decryptKeyFromAdmin( - mySecretKey: Uint8Array, - adminPublicKeyB64: string, - encryptedKeyB64: string -): Promise { - const sodium = await ensureSodium() - - const adminPublicKey = fromBase64(adminPublicKeyB64) - const encryptedData = fromBase64(encryptedKeyB64) - - const sharedKey = await deriveSharedKey(mySecretKey, adminPublicKey) - - 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) -} diff --git a/src/frontend/src/features/encryption/useEncryption.ts b/src/frontend/src/features/encryption/useEncryption.ts deleted file mode 100644 index 1cb3cb25..00000000 --- a/src/frontend/src/features/encryption/useEncryption.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Hook that orchestrates end-to-end encryption for a LiveKit room. - * - * Architecture: - * - The room admin/owner is the KEY AUTHORITY — they generate the symmetric key - * - Other participants wait in the lobby until the admin accepts them - * - When accepted and connected, non-admin participants request the key from the admin - * - Key distribution is hybrid: - * - If participant has a registered public key (encryption onboarding) → PKI → "verified" trust - * - If participant is ProConnect-authenticated → ephemeral DH → "authenticated" trust - * - If participant is anonymous → ephemeral DH → "anonymous" trust - * - * The admin can be any participant with room_admin=true (OWNER or ADMIN role). - * If multiple admins exist, any of them can distribute the key. - */ -import { useCallback, useEffect, useRef, useState } from 'react' -import { ExternalE2EEKeyProvider, Room, E2EEOptions } from 'livekit-client' -import { InCallKeyExchange } from './InCallKeyExchange' - -export interface EncryptionState { - isEnabled: boolean - isSettingUp: boolean - error: string | null - /** E2EE options to pass to RoomOptions.e2ee at Room construction time */ - encryptionOptions: E2EEOptions | undefined - /** Set of participant identities that currently have decryption errors (key exchange in progress) */ - pendingParticipants: Set -} - -/** - * Generate a random passphrase string for LiveKit E2EE. - * - * LiveKit's ExternalE2EEKeyProvider.setKey() accepts string | ArrayBuffer. - * When a string is passed, LiveKit internally derives an AES key using PBKDF2. - * Using a string passphrase is the proven approach (PoC PR #296). - * - * The passphrase is exchanged between participants via the InCallKeyExchange - * (ephemeral X25519 DH over LiveKit data channel, encrypted with XChaCha20-Poly1305). - * Both sides encode/decode via TextEncoder/TextDecoder to maintain consistency. - */ -function generatePassphrase(): string { - const bytes = crypto.getRandomValues(new Uint8Array(32)) - return btoa(String.fromCharCode(...bytes)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, '') -} - -/** - * Check if the local participant is a room admin/owner. - */ -function isLocalParticipantAdmin(room: Room): boolean { - const attributes = room.localParticipant.attributes - return attributes?.room_admin === 'true' -} - -/** - * Hook to manage encryption lifecycle for a LiveKit room. - * - * Usage in Conference.tsx: - * 1. Call useEncryption(room, encryptionEnabled) — returns encryptionOptions - * 2. Pass encryptionOptions to RoomOptions.e2ee when creating the Room - * 3. The hook handles key exchange automatically once the room is connected - * - * @param room - The LiveKit Room instance (can be undefined initially) - * @param encryptionEnabled - Whether encryption is enabled for this room - */ -export function useEncryption( - room: Room | undefined, - encryptionEnabled: boolean -): EncryptionState { - const [isSettingUp, setIsSettingUp] = useState(false) - const [error, setError] = useState(null) - const [pendingParticipants, setPendingParticipants] = useState>(new Set()) - - // Create keyProvider and worker as refs so they're available synchronously - // for RoomOptions.e2ee. They persist across renders. - const keyProviderRef = useRef(null) - const workerRef = useRef(null) - const keyExchangeRef = useRef(null) - const setupDoneRef = useRef(false) - - if (encryptionEnabled && !keyProviderRef.current) { - keyProviderRef.current = new ExternalE2EEKeyProvider() - } - if (encryptionEnabled && !workerRef.current && typeof window !== 'undefined') { - workerRef.current = new Worker( - new URL('livekit-client/e2ee-worker', import.meta.url) - ) - } - - // Cleanup worker on unmount - useEffect(() => { - return () => { - if (workerRef.current) { - workerRef.current.terminate() - workerRef.current = null - } - keyProviderRef.current = null - } - }, []) - - // Build the encryption options for RoomOptions.e2ee - const encryptionOptions: E2EEOptions | undefined = - encryptionEnabled && keyProviderRef.current && workerRef.current - ? { keyProvider: keyProviderRef.current, worker: workerRef.current } - : undefined - - // TEMPORARY: hardcoded passphrase to validate video encryption works. - // Same approach as the PoC (PR #296). Will be replaced by key exchange later. - const HARDCODED_PASSPHRASE = 'meet-encryption-test-passphrase' - - const setupKeyExchange = useCallback(async () => { - if (!room || !keyProviderRef.current || !encryptionEnabled || setupDoneRef.current) { - return - } - - if (room.state !== 'connected') return - - setupDoneRef.current = true - setIsSettingUp(true) - setError(null) - - try { - console.info('[Encryption] Setting hardcoded passphrase (PoC mode)') - await keyProviderRef.current!.setKey(HARDCODED_PASSPHRASE) - await room.setE2EEEnabled(true) - console.info('[Encryption] End-to-end encryption enabled') - } catch (err) { - console.error('[Encryption] Failed to set up encryption:', err) - setError((err as Error).message) - } finally { - setIsSettingUp(false) - } - }, [room, encryptionEnabled]) - - // Listen for LiveKit encryption errors. - // InvalidKey and MissingKey are expected during key exchange (joiner has temp key, - // admin can't decrypt yet). Only surface persistent errors after key exchange. - useEffect(() => { - if (!room || !encryptionEnabled) return - - const handleEncryptionError = (err: Error, participantIdentity?: string) => { - const msg = err.message || '' - if (msg.includes('InvalidKey') || msg.includes('MissingKey') || msg.includes('missing key')) { - // Track this participant as having a pending key exchange - if (participantIdentity) { - setPendingParticipants((prev) => { - const next = new Set(prev) - next.add(participantIdentity) - return next - }) - } - return - } - console.error('[Encryption] Decryption error:', err) - setError(msg || 'Decryption failed') - } - - // When a participant's encryption status changes to encrypted, remove them from pending - const handleParticipantEncrypted = () => { - setPendingParticipants(new Set()) - } - - room.on('encryptionError', handleEncryptionError) - room.on('participantEncryptionStatusChanged', handleParticipantEncrypted) - return () => { - room.off('encryptionError', handleEncryptionError) - room.off('participantEncryptionStatusChanged', handleParticipantEncrypted) - } - }, [room, encryptionEnabled]) - - useEffect(() => { - if (!room || !encryptionEnabled) return - - const handleConnected = () => { - setupKeyExchange() - } - - if (room.state === 'connected') { - setupKeyExchange() - } else { - room.on('connected', handleConnected) - } - - return () => { - room.off('connected', handleConnected) - if (keyExchangeRef.current) { - keyExchangeRef.current.stopListening() - keyExchangeRef.current = null - } - setupDoneRef.current = false - } - }, [room, encryptionEnabled, setupKeyExchange]) - - return { - isEnabled: encryptionEnabled, - isSettingUp, - error, - encryptionOptions, - pendingParticipants, - } -} diff --git a/src/frontend/src/features/home/components/JoinMeetingDialog.tsx b/src/frontend/src/features/home/components/JoinMeetingDialog.tsx index 78004426..d6fa7fe7 100644 --- a/src/frontend/src/features/home/components/JoinMeetingDialog.tsx +++ b/src/frontend/src/features/home/components/JoinMeetingDialog.tsx @@ -1,35 +1,147 @@ +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Field, Ul, H, P, Form, Dialog } from '@/primitives' +import { css } from '@/styled-system/css' import { navigateTo } from '@/navigation/navigateTo' import { isRoomValid } from '@/features/rooms' +import { normalizeRoomId } from '@/features/rooms/utils/isRoomValid' +import { fetchRoom } from '@/features/rooms/api/fetchRoom' +import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom' export const JoinMeetingDialog = () => { const { t } = useTranslation('home') + const [step, setStep] = useState<'room' | 'passphrase'>('room') + const [roomId, setRoomId] = useState('') + const [isLoading, setIsLoading] = useState(false) - const handleSubmit = (data: { roomId?: FormDataEntryValue }) => { - const roomId = (data.roomId as string) - .trim() - .replace(`${window.location.origin}/`, '') + const parseInput = (input: string): { roomId: string; hash: string } => { + const trimmed = input.trim() + try { + const url = new URL(trimmed) + const id = url.pathname.replace(/^\//, '') + return { roomId: id, hash: url.hash.slice(1) } + } catch { + // Not a URL — treat as room code, normalize (add hyphens if 10 chars) + const raw = trimmed.replace(`${window.location.origin}/`, '') + return { roomId: normalizeRoomId(raw), hash: '' } + } + } + + const handleRoomSubmit = async (data: { roomId?: FormDataEntryValue }) => { + const input = data.roomId as string + const parsed = parseInput(input) + + // If URL already has a hash, navigate directly with it + if (parsed.hash) { + navigateTo('room', parsed.roomId) + window.location.hash = parsed.hash + return + } + + // Check if the room uses basic encryption (needs passphrase) + setIsLoading(true) + try { + const room = await fetchRoom({ roomId: parsed.roomId }) + if (room.encryption_mode === ApiEncryptionMode.BASIC) { + setRoomId(parsed.roomId) + setStep('passphrase') + return + } + navigateTo('room', parsed.roomId) + } catch { + // Room doesn't exist yet or error — navigate anyway + navigateTo('room', parsed.roomId) + } finally { + setIsLoading(false) + } + } + + const handlePassphraseSubmit = (data: { passphrase?: FormDataEntryValue }) => { + const passphrase = (data.passphrase as string).trim() navigateTo('room', roomId) + window.location.hash = passphrase } const validateRoomId = (value: string) => { const trimmed = value.trim() if (!trimmed) return null - return !isRoomValid(trimmed) ? ( + const { roomId: id } = parseInput(trimmed) + return !isRoomValid(id) ? ( <>

    {t('joinInputError')}

    • {window.location.origin}/uio-azer-jkl
    • uio-azer-jkl
    • +
    • uioazerjkl
    ) : null } + if (step === 'passphrase') { + return ( + +
    +

    + +

    + + {/* eslint-disable jsx-a11y/no-autofocus */} + + +

    + {t('joinPassphraseWarning')} +

    + +
    + ) + } + return ( -
    + {/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */} { {t('createMenu.laterOption')} - - setEncryptionDialogMode('instant')} - data-attr="create-option-encrypted-instant" - > - - {t('createMenu.encryptedInstantOption')} - - setEncryptionDialogMode('later')} - data-attr="create-option-encrypted-later" - > - - {t('createMenu.encryptedLaterOption')} - + {data?.encryption?.enabled && ( + <> + + setEncryptionDialogMode('instant')} + data-attr="create-option-encrypted-instant" + > + + {t('createMenu.encryptedInstantOption')} + + setEncryptionDialogMode('later')} + data-attr="create-option-encrypted-later" + > + + {t('createMenu.encryptedLaterOption')} + + + )} ) : ( diff --git a/src/frontend/src/features/rooms/hooks/useLobby.ts b/src/frontend/src/features/rooms/hooks/useLobby.ts index 22b0896c..9342ad73 100644 --- a/src/frontend/src/features/rooms/hooks/useLobby.ts +++ b/src/frontend/src/features/rooms/hooks/useLobby.ts @@ -7,13 +7,8 @@ import { ApiRequestEntry, } from '../api/requestEntry' import { - generateEphemeralKeyPair, - encodePublicKey, - decryptKeyFromAdmin, setSymmetricKey, setEncryptedVaultKey, - saveEphemeralKeyPair, - loadEphemeralKeyPair, } from '@/features/encryption/lobbyKeyExchange' export const WAIT_TIMEOUT_MS = 600000 // 10 minutes @@ -32,9 +27,6 @@ export const useLobby = ({ }) => { const [status, setStatus] = useState(ApiLobbyStatus.IDLE) const waitingTimeoutRef = useRef(null) - const ephemeralKeyRef = useRef<{ publicKey: Uint8Array; secretKey: Uint8Array } | null>(null) - const ephemeralPublicKeyB64Ref = useRef('') - const clearWaitingTimeout = useCallback(() => { if (waitingTimeoutRef.current) { clearTimeout(waitingTimeoutRef.current) @@ -56,27 +48,11 @@ export const useLobby = ({ const response = await requestEntry({ roomId, username, - ephemeralPublicKey: ephemeralPublicKeyB64Ref.current, }) if (response.status === ApiLobbyStatus.ACCEPTED) { clearWaitingTimeout() setStatus(ApiLobbyStatus.ACCEPTED) - // Basic mode: DH key exchange - if ( - encryptionEnabled && - response.encrypted_key && - response.admin_ephemeral_public_key && - ephemeralKeyRef.current - ) { - const decryptedKey = await decryptKeyFromAdmin( - ephemeralKeyRef.current.secretKey, - response.admin_ephemeral_public_key, - response.encrypted_key - ) - setSymmetricKey(decryptedKey) - } - // Advanced mode: vault-wrapped key if (encryptionEnabled && response.encrypted_vault_key) { console.info('[VaultE2EE] Joiner: received encrypted_vault_key from lobby, length:', response.encrypted_vault_key.length) @@ -104,20 +80,9 @@ export const useLobby = ({ }) const startWaiting = useCallback(async () => { - if (encryptionEnabled) { - // Restore keypair from sessionStorage (survives page refresh) - // or generate a new one (backend will reset to WAITING if key changed) - const stored = loadEphemeralKeyPair() - const keyPair = stored ?? await generateEphemeralKeyPair() - ephemeralKeyRef.current = keyPair - ephemeralPublicKeyB64Ref.current = encodePublicKey(keyPair.publicKey) - if (!stored) { - saveEphemeralKeyPair(keyPair) - } - } setStatus(ApiLobbyStatus.WAITING) startWaitingTimeout() - }, [encryptionEnabled, startWaitingTimeout]) + }, [startWaitingTimeout]) useEffect(() => { return () => clearWaitingTimeout() diff --git a/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts b/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts index 22dc47b3..b3ac823e 100644 --- a/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts +++ b/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts @@ -11,7 +11,6 @@ import { } from '../api/listWaitingParticipants' import { decodeNotificationDataReceived } from '@/features/notifications/utils' import { NotificationType } from '@/features/notifications/NotificationType' -import { encryptKeyForParticipant, getSymmetricKey } from '@/features/encryption/lobbyKeyExchange' import { useVaultClient } from '@/features/encryption' import { toastQueue } from '@/features/notifications/components/ToastProvider' @@ -110,13 +109,6 @@ export const useWaitingParticipants = () => { const bytes = new Uint8Array(joinerKey) encryptedVaultKey = btoa(String.fromCharCode(...bytes)) console.info('[VaultE2EE] Admin: key wrapped successfully, length:', encryptedVaultKey.length) - } else if (encrypted && getSymmetricKey() && participant.ephemeral_public_key) { - // Basic mode: DH key exchange - const result = await encryptKeyForParticipant( - participant.ephemeral_public_key - ) - encryptedKey = result.encryptedKey - adminEphemeralPublicKey = result.adminPublicKey } return { encryptedKey, adminEphemeralPublicKey, encryptedVaultKey } diff --git a/src/frontend/src/features/rooms/utils/isRoomValid.ts b/src/frontend/src/features/rooms/utils/isRoomValid.ts index 14ac6acb..797ffef2 100644 --- a/src/frontend/src/features/rooms/utils/isRoomValid.ts +++ b/src/frontend/src/features/rooms/utils/isRoomValid.ts @@ -5,10 +5,16 @@ export const flexibleRoomIdPattern = '(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})' const roomRegex = new RegExp(`^${roomIdPattern}$`) +const roomWithoutHyphensRegex = /^[a-z]{10}$/ -export const isRoomValid = (roomIdOrUrl: string) => - roomRegex.test(roomIdOrUrl) || - new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl) +export const isRoomValid = (roomIdOrUrl: string) => { + const lower = roomIdOrUrl.toLowerCase() + return ( + roomRegex.test(lower) || + roomWithoutHyphensRegex.test(lower) || + new RegExp(`^${window.location.origin}/${roomIdPattern}`).test(roomIdOrUrl) + ) +} export const normalizeRoomId = (roomId: string) => { const cleanId = roomId.toLowerCase().replace(/-/g, '') diff --git a/src/frontend/src/features/settings/components/tabs/AccountTab.tsx b/src/frontend/src/features/settings/components/tabs/AccountTab.tsx index a69195a7..b0249238 100644 --- a/src/frontend/src/features/settings/components/tabs/AccountTab.tsx +++ b/src/frontend/src/features/settings/components/tabs/AccountTab.tsx @@ -21,7 +21,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => { const roomData = useRoomData() const { user, isLoggedIn, logout } = useUser() const isEncryptedRoom = checkEncryptedRoom(roomData) - const isNameLocked = isEncryptedRoom && !!isLoggedIn + const isNameLocked = isEncryptedRoom const [name, setName] = useState(room?.localParticipant.name ?? '') const userDisplay = user?.full_name && user?.email diff --git a/src/frontend/src/layout/Header.tsx b/src/frontend/src/layout/Header.tsx index ddf78d83..1bfb09f4 100644 --- a/src/frontend/src/layout/Header.tsx +++ b/src/frontend/src/layout/Header.tsx @@ -136,7 +136,7 @@ export const Header = () => { } }, [showEncryptionModal]) const isEncryptionAvailable = !!config?.encryption?.enabled && !!vaultClient - const userLabel = user?.full_name || user?.email + const userLabel = user?.full_name || user?.short_name || user?.email const loggedInTooltip = t('loggedInUserTooltip') const loggedInAriaLabel = userLabel ? `${loggedInTooltip} ${userLabel}` @@ -219,7 +219,7 @@ export const Header = () => { display: { base: 'none', xsm: 'block' }, })} > - {user?.full_name || user?.email} + {user?.full_name || user?.short_name || user?.email} diff --git a/src/frontend/src/locales/en/home.json b/src/frontend/src/locales/en/home.json index e56657b2..4cc6f360 100644 --- a/src/frontend/src/locales/en/home.json +++ b/src/frontend/src/locales/en/home.json @@ -7,7 +7,14 @@ "joinInputLabel": "Meeting link", "joinInputSubmit": "Join meeting", "joinMeeting": "Join a meeting", - "joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.", + "joinPassphraseLabel": "Encryption passphrase", + "joinPassphraseDescription": "This meeting uses basic encryption. Enter the passphrase — it's the part after the # symbol in the meeting link.", + "joinPassphraseExample": "{{origin}}/abc-defg-hij#the-passphrase-is-here", + "joinPassphraseWarning": "If the passphrase is incorrect, you will not be able to see, hear, or read messages from other participants.", + "joinPassphraseSubmit": "Join encrypted meeting", + "joinPassphraseBack": "Back", + "joinPassphraseError": "A passphrase is required to join this encrypted meeting", + "joinMeetingTipContent": "You can join a meeting by pasting its full link (including the # part for encrypted meetings) in the browser's address bar.", "joinMeetingTipHeading": "Did you know?", "loginToCreateMeeting": "Login to create a meeting", "moreLinkLabel": "Learn more about {{appTitle}} - new tab", diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index c8dbbaa0..e60fc973 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -693,19 +693,27 @@ "screenShare": "{{name}}'s screen" }, "encryption": { - "banner": "Encrypted", - "bannerStrong": "Verified encryption", + "banner": "Basic encryption", + "bannerStrong": "Advanced encryption", + "badge": { + "verified": "Advanced encryption", + "authenticated": "Encrypted (authenticated)", + "anonymous": "Encrypted (anonymous)", + "default": "Encrypted" + }, "bannerModal": { "title": "End-to-end encrypted meeting", - "description": "This meeting uses end-to-end encryption. Audio and video are encrypted on your device before being sent. The server only sees encrypted data it cannot read.", + "descriptionBasic": "This meeting uses basic end-to-end encryption. Audio and video are encrypted using a shared passphrase embedded in the meeting link. Anyone with the link can join and decrypt the content.", + "descriptionAdvanced": "This meeting uses advanced end-to-end encryption. The encryption key is managed by La Suite's encryption service and never leaves your device. All participants must complete encryption onboarding to join.", "guarantees": "What this protects:", "guarantee1": "The server cannot access audio, video, or screen sharing content", "guarantee2": "Only approved participants with the encryption key can view the content", "guarantee3": "Each participant's trust level is visible (verified, authenticated, or anonymous)", "limitations": "Limitations:", "limitation1": "Recording and transcription are not available (the server cannot decrypt content)", - "limitation2": "Maximum security requires all participants to complete encryption onboarding and verify fingerprints", - "note": "For the highest level of trust, ask participants to set up encryption in their account settings. This registers their public key and enables fingerprint verification." + "limitation2Basic": "Security depends on keeping the meeting link private", + "limitation2Advanced": "All participants must complete encryption onboarding in their account settings before joining", + "note": "For the highest level of trust, use advanced encryption and ask participants to verify each other's fingerprints." }, "settingUp": { "title": "Securing your connection", diff --git a/src/frontend/src/locales/fr/home.json b/src/frontend/src/locales/fr/home.json index 647a25da..1999b24d 100644 --- a/src/frontend/src/locales/fr/home.json +++ b/src/frontend/src/locales/fr/home.json @@ -7,7 +7,14 @@ "joinInputLabel": "Lien complet ou code de la réunion", "joinInputSubmit": "Rejoindre la réunion", "joinMeeting": "Rejoindre une réunion", - "joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.", + "joinPassphraseLabel": "Phrase secrète de chiffrement", + "joinPassphraseDescription": "Cette réunion utilise le chiffrement basique. Entrez la phrase secrète — c'est la partie après le symbole # dans le lien de la réunion.", + "joinPassphraseExample": "{{origin}}/abc-defg-hij#la-phrase-secrete-est-ici", + "joinPassphraseWarning": "Si la phrase secrète est incorrecte, vous ne pourrez ni voir, ni entendre, ni lire les messages des autres participants.", + "joinPassphraseSubmit": "Rejoindre la réunion chiffrée", + "joinPassphraseBack": "Retour", + "joinPassphraseError": "Une phrase secrète est nécessaire pour rejoindre cette réunion chiffrée", + "joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet (y compris la partie # pour les réunions chiffrées) dans la barre d'adresse du navigateur.", "joinMeetingTipHeading": "Astuce", "loginToCreateMeeting": "Connectez-vous pour créer une réunion", "moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre", diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index b93001e5..dddcb6c8 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -693,19 +693,27 @@ "screenShare": "Écran de {{name}}" }, "encryption": { - "banner": "Chiffré", - "bannerStrong": "Chiffrement vérifié", + "banner": "Chiffrement basique", + "bannerStrong": "Chiffrement avancé", + "badge": { + "verified": "Chiffrement avancé", + "authenticated": "Chiffré (authentifié)", + "anonymous": "Chiffré (anonyme)", + "default": "Chiffré" + }, "bannerModal": { "title": "Réunion chiffrée de bout en bout", - "description": "Cette réunion utilise le chiffrement de bout en bout. L'audio et la vidéo sont chiffrés sur votre appareil avant d'être envoyés. Le serveur ne voit que des données chiffrées qu'il ne peut pas lire.", + "descriptionBasic": "Cette réunion utilise le chiffrement de bout en bout basique. L'audio et la vidéo sont chiffrés à l'aide d'une phrase secrète intégrée au lien de la réunion. Toute personne disposant du lien peut rejoindre et déchiffrer le contenu.", + "descriptionAdvanced": "Cette réunion utilise le chiffrement de bout en bout avancé. La clé de chiffrement est gérée par le service de chiffrement de La Suite et ne quitte jamais votre appareil. Tous les participants doivent effectuer l'onboarding chiffrement pour rejoindre.", "guarantees": "Ce que cela protège :", "guarantee1": "Le serveur ne peut pas accéder au contenu audio, vidéo ou de partage d'écran", "guarantee2": "Seuls les participants approuvés disposant de la clé de chiffrement peuvent voir le contenu", "guarantee3": "Le niveau de confiance de chaque participant est visible (vérifié, authentifié ou anonyme)", "limitations": "Limitations :", "limitation1": "L'enregistrement et la transcription ne sont pas disponibles (le serveur ne peut pas déchiffrer le contenu)", - "limitation2": "La sécurité maximale nécessite que tous les participants effectuent l'onboarding chiffrement et vérifient les empreintes", - "note": "Pour le plus haut niveau de confiance, demandez aux participants de configurer le chiffrement dans les paramètres de leur compte. Cela enregistre leur clé publique et permet la vérification des empreintes." + "limitation2Basic": "La sécurité dépend de la confidentialité du lien de la réunion", + "limitation2Advanced": "Tous les participants doivent effectuer l'onboarding chiffrement dans les paramètres de leur compte avant de rejoindre", + "note": "Pour le plus haut niveau de confiance, utilisez le chiffrement avancé et demandez aux participants de vérifier mutuellement leurs empreintes." }, "settingUp": { "title": "Sécurisation de votre connexion",