diff --git a/docker/auth/realm.json b/docker/auth/realm.json index 2746c781..33447fa4 100644 --- a/docker/auth/realm.json +++ b/docker/auth/realm.json @@ -60,7 +60,7 @@ }, { "username": "user-e2e-chromium", - "email": "user@chromium.e2e", + "email": "user.test@chromium.test", "firstName": "E2E", "lastName": "Chromium", "enabled": "true", @@ -74,7 +74,7 @@ }, { "username": "user-e2e-webkit", - "email": "user@webkit.e2e", + "email": "user.test@webkit.test", "firstName": "E2E", "lastName": "Webkit", "enabled": "true", @@ -88,7 +88,7 @@ }, { "username": "user-e2e-firefox", - "email": "user@firefox.e2e", + "email": "user.test@firefox.test", "firstName": "E2E", "lastName": "Firefox", "enabled": "true", @@ -845,6 +845,23 @@ "offline_access", "microprofile-jwt" ] + }, + { + "clientId": "encryption", + "name": "Encryption Service", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "redirectUris": [ + "http://encryption.localhost:7200/auth/callback" + ], + "webOrigins": [ + "http://encryption.localhost:7200", + "http://data.encryption.localhost:7200" + ], + "protocol": "openid-connect", + "fullScopeAllowed": true } ], "clientScopes": [ diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 7adf252e..4d52d373 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -30,8 +30,8 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = models.User - fields = ["id", "email", "full_name", "short_name", "timezone", "language"] - read_only_fields = ["id", "email", "full_name", "short_name"] + fields = ["id", "sub", "email", "full_name", "short_name", "timezone", "language"] + read_only_fields = ["id", "sub", "email", "full_name", "short_name"] class UserLightSerializer(serializers.ModelSerializer): @@ -74,6 +74,23 @@ class ResourceAccessSerializerMixin: raise PermissionDenied( "Only owners of a room can assign other users as owners." ) + + # In advanced encrypted rooms, new accesses require an encrypted_symmetric_key + # so the new member can decrypt the room's streams. Without it, they'd have + # access but no key — which is useless and confusing. + # Future: a sharing UI (like Docs) could provide the key via vault shareKeys. + if not self.instance and "resource" in data: + resource = data["resource"] + if ( + hasattr(resource, 'encryption_mode') + and resource.encryption_mode == models.EncryptionMode.ADVANCED + and not data.get("encrypted_symmetric_key") + ): + raise serializers.ValidationError( + "Adding members to advanced encrypted rooms requires " + "an encrypted_symmetric_key for the new user." + ) + return data def validate_resource(self, resource): @@ -98,7 +115,7 @@ class ResourceAccessSerializer( class Meta: model = models.ResourceAccess - fields = ["id", "user", "resource", "role"] + fields = ["id", "user", "resource", "role", "encrypted_symmetric_key"] read_only_fields = ["id"] def update(self, instance, validated_data): @@ -202,12 +219,22 @@ class RoomSerializer(serializers.ModelSerializer): username=username, configuration=configuration, is_admin_or_owner=is_admin_or_owner, + encryption_mode=instance.encryption_mode, ) else: del output["pin_code"] output["is_administrable"] = is_admin_or_owner + # Include the current user's encrypted symmetric key for advanced E2EE + if request.user.is_authenticated and instance.encryption_mode == models.EncryptionMode.ADVANCED: + try: + access = instance.accesses.get(user=request.user) + if access.encrypted_symmetric_key: + output["encrypted_symmetric_key"] = access.encrypted_symmetric_key + except models.ResourceAccess.DoesNotExist: + pass + return output @@ -289,7 +316,7 @@ class StartRecordingSerializer(BaseValidationOnlySerializer): class RequestEntrySerializer(BaseValidationOnlySerializer): """Validate request entry data.""" - username = serializers.CharField(required=True) + username = serializers.CharField(required=True, allow_blank=True) ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='') diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 271281f6..2957b0a3 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -287,10 +287,12 @@ class RoomViewSet( serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED room = serializer.save() + encrypted_symmetric_key = self.request.data.get("encrypted_symmetric_key", "") models.ResourceAccess.objects.create( resource=room, user=self.request.user, role=models.RoleChoices.OWNER, + encrypted_symmetric_key=encrypted_symmetric_key, ) if callback_id := self.request.data.get("callback_id"): @@ -319,6 +321,12 @@ class RoomViewSet( options = serializer.validated_data.get("options") room = self.get_object() + if room.encryption_enabled: + return drf_response.Response( + {"detail": "Recording is not available in encrypted rooms."}, + status=drf_status.HTTP_403_FORBIDDEN, + ) + # May raise exception if an active or initiated recording already exist for the room recording = models.Recording.objects.create( room=room, @@ -403,6 +411,13 @@ class RoomViewSet( room = self.get_object() validated_data = serializer.validated_data + # Advanced encrypted rooms require authentication + if room.encryption_mode == models.EncryptionMode.ADVANCED and not request.user.is_authenticated: + return drf_response.Response( + {"detail": "This meeting requires authentication to join."}, + status=drf_status.HTTP_403_FORBIDDEN, + ) + # In encrypted rooms, authenticated users must use their real name # from the OIDC profile — they cannot choose an arbitrary name. if room.encryption_enabled and request.user.is_authenticated: diff --git a/src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py b/src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py new file mode 100644 index 00000000..d71ba9ee --- /dev/null +++ b/src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py @@ -0,0 +1,23 @@ +"""Add encrypted_symmetric_key to ResourceAccess for advanced E2EE mode.""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0020_room_encryption_mode"), + ] + + operations = [ + migrations.AddField( + model_name="resourceaccess", + name="encrypted_symmetric_key", + field=models.TextField( + blank=True, + default="", + help_text="Vault-wrapped symmetric encryption key for advanced E2EE mode. Each user's copy is encrypted for their own vault public key.", + verbose_name="Encrypted symmetric key", + ), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index f2279d2d..7fa4e881 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -332,6 +332,15 @@ class ResourceAccess(BaseModel): role = models.CharField( max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER ) + encrypted_symmetric_key = models.TextField( + blank=True, + default='', + verbose_name=_("Encrypted symmetric key"), + help_text=_( + "Vault-wrapped symmetric encryption key for advanced E2EE mode. " + "Each user's copy is encrypted for their own vault public key." + ), + ) class Meta: db_table = "meet_resource_access" diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index 5546f8a9..28f1068b 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -127,7 +127,7 @@ class LobbyService: key=settings.LOBBY_COOKIE_NAME, value=participant_id, httponly=True, - secure=True, + secure=not settings.DEBUG, samesite="Lax", ) @@ -193,6 +193,7 @@ class LobbyService: configuration=room.configuration, is_admin_or_owner=False, participant_id=participant_id, + encryption_mode=room.encryption_mode, ) return participant, livekit_config @@ -235,6 +236,7 @@ class LobbyService: configuration=room.configuration, is_admin_or_owner=False, participant_id=participant_id, + encryption_mode=room.encryption_mode, ) return participant, livekit_config diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 0297c07a..c3eb47cb 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -66,6 +66,7 @@ def generate_token( sources: Optional[List[str]] = None, is_admin_or_owner: bool = False, participant_id: Optional[str] = None, + encryption_mode: str = 'none', ) -> str: """Generate a LiveKit access token for a user in a specific room. @@ -92,11 +93,15 @@ 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 + video_grants = VideoGrants( room=room, room_join=True, room_admin=is_admin_or_owner, - can_update_own_metadata=True, + can_update_own_metadata=can_update_metadata, can_publish=bool(sources), can_publish_sources=sources, can_subscribe=True, @@ -150,6 +155,7 @@ def generate_livekit_config( color: Optional[str] = None, configuration: Optional[dict] = None, participant_id: Optional[str] = None, + encryption_mode: str = 'none', ) -> dict: """Generate LiveKit configuration for room access. @@ -182,6 +188,7 @@ def generate_livekit_config( sources=sources, is_admin_or_owner=is_admin_or_owner, participant_id=participant_id, + encryption_mode=encryption_mode, ), } diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 4e5e22a6..0a9deb39 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -561,7 +561,7 @@ class Base(Configuration): "returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None ) OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue( - default=["given_name", "usual_name"], + default=["given_name", "usual_name", "family_name"], environ_name="OIDC_USERINFO_FULLNAME_FIELDS", environ_prefix=None, ) diff --git a/src/frontend/src/features/encryption/VaultClientProvider.tsx b/src/frontend/src/features/encryption/VaultClientProvider.tsx index b793bc28..9ca9d807 100644 --- a/src/frontend/src/features/encryption/VaultClientProvider.tsx +++ b/src/frontend/src/features/encryption/VaultClientProvider.tsx @@ -18,6 +18,7 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' +import { useUser } from '@/features/auth' import { useConfig } from '@/api/useConfig' export interface VaultClientContextValue { @@ -75,6 +76,7 @@ export function VaultClientProvider({ }) { const { data: config } = useConfig() const { i18n } = useTranslation() + const { user } = useUser() const clientRef = useRef(null) const [clientInitialized, setClientInitialized] = useState(false) const [isReady, setIsReady] = useState(false) @@ -171,11 +173,24 @@ export function VaultClientProvider({ return } - // For now, mark as ready without auth context. - // Auth context will be set when we have a suite_user_id. - setIsReady(true) + const suiteUserId = (user as Record)?.sub as string | undefined + if (suiteUserId) { + client.setAuthContext({ suiteUserId }) + setIsReady(true) + // Check key state now that auth context is set + client.hasKeys() + .then(({ hasKeys: exists }) => { + setHasKeys(exists) + if (exists) { + client.getPublicKey() + .then(({ publicKey: pk }) => setPublicKey(pk)) + .catch(() => {}) + } + }) + .catch(() => {}) + } setIsLoading(false) - }, [clientInitialized]) + }, [clientInitialized, (user as Record)?.sub]) const refreshKeyState = useCallback(async () => { const client = clientRef.current @@ -198,7 +213,7 @@ export function VaultClientProvider({ return ( b.toString(36).padStart(2, '0')) + .join('') +} + +/** 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. */ diff --git a/src/frontend/src/features/home/components/LaterMeetingDialog.tsx b/src/frontend/src/features/home/components/LaterMeetingDialog.tsx index 24cddea8..09a94b90 100644 --- a/src/frontend/src/features/home/components/LaterMeetingDialog.tsx +++ b/src/frontend/src/features/home/components/LaterMeetingDialog.tsx @@ -13,11 +13,12 @@ import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRo // fixme - duplication with the InviteDialog export const LaterMeetingDialog = ({ room, + hash, ...dialogProps -}: { room: null | ApiRoom } & Omit) => { +}: { room: null | ApiRoom; hash?: string } & Omit) => { const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' }) - const roomUrl = room && getRouteUrl('room', room?.slug) + const roomUrl = room ? `${getRouteUrl('room', room.slug)}${hash ? `#${hash}` : ''}` : null const telephony = useTelephony() const [isHovered, setIsHovered] = useState(false) @@ -31,7 +32,7 @@ export const LaterMeetingDialog = ({ copyRoomToClipboard, isRoomUrlCopied, copyRoomUrlToClipboard, - } = useCopyRoomToClipboard(room || undefined) + } = useCopyRoomToClipboard(room || undefined, hash) return ( diff --git a/src/frontend/src/features/home/routes/Home.tsx b/src/frontend/src/features/home/routes/Home.tsx index f1bcf4b9..9b86a541 100644 --- a/src/frontend/src/features/home/routes/Home.tsx +++ b/src/frontend/src/features/home/routes/Home.tsx @@ -11,6 +11,8 @@ import { RiAddLine, RiLink, RiLockLine } from '@remixicon/react' import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog' import { EncryptionModeDialog } from '@/features/home/components/EncryptionModeDialog' import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom' +import { generatePassphrase } from '@/features/encryption/lobbyKeyExchange' +import { useVaultClient } from '@/features/encryption' import { IntroSlider } from '@/features/home/components/IntroSlider' import { MoreLink } from '@/features/home/components/MoreLink' import { ReactNode, useEffect, useState } from 'react' @@ -157,7 +159,8 @@ export const Home = () => { } = usePersistentUserChoices() const { mutateAsync: createRoom } = useCreateRoom() - const [laterRoom, setLaterRoom] = useState(null) + const { client: vaultClient } = useVaultClient() + const [laterRoom, setLaterRoom] = useState(null) const [encryptionDialogMode, setEncryptionDialogMode] = useState(null) const [redirectFailed, setRedirectFailed] = useState(false) @@ -232,7 +235,7 @@ export const Home = () => { onAction={() => { const slug = generateRoomId() createRoom({ slug, username }).then((data) => - setLaterRoom(data) + setLaterRoom({ room: data }) ) }} data-attr="create-option-later" @@ -295,25 +298,52 @@ export const Home = () => { setLaterRoom(null)} /> {encryptionDialogMode && ( { + onSelect={async (mode) => { + const dialogMode = encryptionDialogMode setEncryptionDialogMode(null) const slug = generateRoomId() + const hash = mode === ApiEncryptionMode.BASIC ? generatePassphrase() : undefined + + let encryptedSymmetricKey = '' + if (mode === ApiEncryptionMode.ADVANCED && vaultClient) { + // encryptWithoutKey requires data to encrypt, but we only care about + // the generated symmetric key (encryptedKeys), not the encrypted content. + // The same symmetric key will be used for all streams (video/audio/chat). + const dummyData = new Uint8Array(32).buffer + const { publicKey } = await vaultClient.getPublicKey() + const { encryptedKeys } = await vaultClient.encryptWithoutKey( + dummyData, + { self: publicKey } + ) + const keyBytes = new Uint8Array(encryptedKeys['self']) + encryptedSymmetricKey = btoa(String.fromCharCode(...keyBytes)) + } + createRoom({ slug, username, encryptionMode: mode, + encryptedSymmetricKey, }).then((data) => { - if (encryptionDialogMode === 'instant') { + if (dialogMode === 'instant') { navigateTo('room', data.slug, { state: { create: true, initialRoomData: data }, }) + if (hash) { + window.history.replaceState( + window.history.state, + '', + `${window.location.pathname}#${hash}` + ) + } } else { - setLaterRoom(data) + setLaterRoom({ room: data, hash }) } }) }} diff --git a/src/frontend/src/features/rooms/api/ApiRoom.ts b/src/frontend/src/features/rooms/api/ApiRoom.ts index 22229790..c8695111 100644 --- a/src/frontend/src/features/rooms/api/ApiRoom.ts +++ b/src/frontend/src/features/rooms/api/ApiRoom.ts @@ -33,6 +33,7 @@ export type ApiRoom = { is_administrable: boolean access_level: ApiAccessLevel encryption_mode: ApiEncryptionMode + encrypted_symmetric_key?: string livekit?: ApiLiveKit configuration?: { [key: string]: string | number | boolean | string[] diff --git a/src/frontend/src/features/rooms/api/createRoom.ts b/src/frontend/src/features/rooms/api/createRoom.ts index 727a1064..b8ef497e 100644 --- a/src/frontend/src/features/rooms/api/createRoom.ts +++ b/src/frontend/src/features/rooms/api/createRoom.ts @@ -8,6 +8,7 @@ export interface CreateRoomParams { callbackId?: string username?: string encryptionMode?: ApiEncryptionMode + encryptedSymmetricKey?: string } const createRoom = ({ @@ -15,13 +16,16 @@ const createRoom = ({ callbackId, username = '', encryptionMode = ApiEncryptionMode.NONE, + encryptedSymmetricKey = '', }: CreateRoomParams): Promise => { - return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, { + const queryParams = username ? `?username=${encodeURIComponent(username)}` : '' + return fetchApi(`rooms/${queryParams}`, { method: 'POST', body: JSON.stringify({ name: slug, callback_id: callbackId, encryption_mode: encryptionMode, + encrypted_symmetric_key: encryptedSymmetricKey, }), }) } diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 5f946beb..41acaff3 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -13,7 +13,7 @@ import { RoomOptions, VideoPresets, } from 'livekit-client' -import { setSymmetricKey, getSymmetricKey, getEncryptedVaultKey } from '@/features/encryption/lobbyKeyExchange' +import { setSymmetricKey, getSymmetricKey, getEncryptedVaultKey, generatePassphrase } from '@/features/encryption/lobbyKeyExchange' import { isEncryptedRoom, ApiEncryptionMode } from '../api/ApiRoom' import { VaultE2EEManager } from '@/features/encryption/VaultE2EEManager' import { useVaultClient } from '@/features/encryption' @@ -208,16 +208,19 @@ export const Conference = ({ const setupVaultKey = async () => { try { if (isAdmin) { - // Admin: generate a symmetric key via VaultClient - const dummyData = new Uint8Array(32).buffer - const { publicKey } = await vaultClient.getPublicKey() - const { encryptedKeys } = await vaultClient.encryptWithoutKey( - dummyData, - { self: publicKey } - ) - const encryptedSymmetricKey = encryptedKeys['self'] - vaultManager.setEncryptedSymmetricKey(encryptedSymmetricKey) - console.info('[VaultE2EE] Admin: symmetric key generated') + // Admin: check if we already have a key (refresh/rejoin case) + const existingKey = data?.encrypted_symmetric_key + if (existingKey) { + // Decode base64 to ArrayBuffer + const binaryStr = atob(existingKey) + const bytes = new Uint8Array(binaryStr.length) + for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i) + vaultManager.setEncryptedSymmetricKey(bytes.buffer) + console.info('[VaultE2EE] Admin: restored key from backend') + } else { + console.error('[VaultE2EE] Admin: no encrypted symmetric key found — was the room created with advanced mode?') + return + } } else { // Joiner: use the vault-wrapped key received from admin via lobby const vaultKey = getEncryptedVaultKey() @@ -264,9 +267,7 @@ export const Conference = ({ if (existingHash) { adminPassphraseRef.current = existingHash } else { - adminPassphraseRef.current = Array.from(crypto.getRandomValues(new Uint8Array(24))) - .map((b) => b.toString(36).padStart(2, '0')) - .join('') + adminPassphraseRef.current = generatePassphrase() // Set the hash in the URL (without triggering navigation) window.history.replaceState( window.history.state, diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index b6be379a..53b88c79 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -33,6 +33,118 @@ import { queryClient } from '@/api/queryClient' import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry' import { Spinner } from '@/primitives/Spinner' import { ApiAccessLevel, ApiEncryptionMode, isEncryptedRoom as checkEncryptedRoom } from '../api/ApiRoom' +import { useVaultClient } from '@/features/encryption' +import { LoginButton } from '@/components/LoginButton' + +const AdvancedOnboardingScreen = ({ + modalOpen, + onModalOpenChange, +}: { + modalOpen: boolean + onModalOpenChange: (open: boolean) => void +}) => { + const { t } = useTranslation('rooms', { keyPrefix: 'join' }) + const { client: vaultClient } = useVaultClient() + const containerRef = useRef(null) + + useEffect(() => { + if (!modalOpen || !vaultClient) return + + const el = containerRef.current + if (!el) return + + el.innerHTML = '' + vaultClient.openOnboarding(el) + + const handleClosed = () => { + onModalOpenChange(false) + vaultClient.off('interface:closed', handleClosed) + } + vaultClient.on('interface:closed', handleClosed) + + return () => { + vaultClient.off('interface:closed', handleClosed) + } + }, [modalOpen, vaultClient, onModalOpenChange]) + + return ( + <> + + + + {t('advancedOnboarding.title')} + + + {t('advancedOnboarding.body')} + + + + {modalOpen && ( +
{ + if (e.target === e.currentTarget) { + onModalOpenChange(false) + vaultClient?.closeInterface() + } + }} + > +
+ +
+
+
+ )} + + ) +} import { useLoginHint } from '@/hooks/useLoginHint' import { useUser } from '@/features/auth' import { RiInformationLine, RiLockLine } from '@remixicon/react' @@ -115,11 +227,16 @@ export const Join = ({ }) const isEncryptedRoom = checkEncryptedRoom(roomInfo) const isBasicEncrypted = roomInfo?.encryption_mode === ApiEncryptionMode.BASIC + const isAdvancedEncrypted = roomInfo?.encryption_mode === ApiEncryptionMode.ADVANCED // Basic mode: validate the passphrase in the URL hash - const BASIC_KEY_LENGTH = 48 const hashKey = window.location.hash.slice(1) - const hasValidBasicKey = isBasicEncrypted ? (hashKey.length === BASIC_KEY_LENGTH && /^[a-z0-9]+$/.test(hashKey)) : true + const hasValidBasicKey = isBasicEncrypted ? (hashKey.length === 48 && /^[a-z0-9]+$/.test(hashKey)) : true + + // Advanced mode: require auth + vault onboarding + const { hasKeys: vaultHasKeys, isReady: vaultReady } = useVaultClient() + const advancedRequiresLogin = isAdvancedEncrypted && !isLoggedIn + const advancedRequiresOnboarding = isAdvancedEncrypted && isLoggedIn && vaultReady && !vaultHasKeys // In encrypted rooms, authenticated users must use their OIDC name const isNameLocked = isEncryptedRoom && !!isLoggedIn @@ -350,6 +467,7 @@ export const Join = ({ encryptionEnabled: isEncryptedRoom, }) + const [advancedOnboardingOpen, setAdvancedOnboardingOpen] = useState(false) const { openLoginHint } = useLoginHint() const handleSubmit = async () => { @@ -449,6 +567,28 @@ export const Join = ({ ) default: + if (advancedRequiresLogin) { + return ( + + + + {t('advancedAuth.title')} + + + {t('advancedAuth.body')} + + + + ) + } + if (advancedRequiresOnboarding || advancedOnboardingOpen) { + return ( + + ) + } if (isBasicEncrypted && !hasValidBasicKey) { return ( diff --git a/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts b/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts index 12ff78c0..06e6d945 100644 --- a/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts +++ b/src/frontend/src/features/rooms/hooks/useWaitingParticipants.ts @@ -69,21 +69,29 @@ export const useWaitingParticipants = () => { let encryptedVaultKey = '' if (isAdvancedMode && vaultClient && participant.suite_user_id) { - // Advanced mode: wrap the symmetric key for the joiner's vault public key + // Advanced mode: re-wrap the existing symmetric key for the joiner try { + // Get the admin's own encrypted symmetric key from the room data + const adminKeyBase64 = roomData?.encrypted_symmetric_key + if (!adminKeyBase64) { + console.error('[VaultE2EE] Admin has no encrypted symmetric key') + return { encryptedKey, adminEphemeralPublicKey, encryptedVaultKey } + } + const adminKeyBinary = atob(adminKeyBase64) + const adminKeyBytes = new Uint8Array(adminKeyBinary.length) + for (let i = 0; i < adminKeyBinary.length; i++) adminKeyBytes[i] = adminKeyBinary.charCodeAt(i) + + // Fetch joiner's vault public key const { publicKeys } = await vaultClient.fetchPublicKeys([participant.suite_user_id]) const joinerPubKey = publicKeys[participant.suite_user_id] if (joinerPubKey) { - // Get admin's encrypted symmetric key from the VaultE2EEManager - // and re-wrap it for the joiner - const adminPubKey = (await vaultClient.getPublicKey()).publicKey - const { encryptedKeys } = await vaultClient.encryptWithoutKey( - new Uint8Array(32).buffer, - { [participant.suite_user_id]: joinerPubKey, self: adminPubKey } + // Re-wrap the symmetric key for the joiner using shareKeys + const { encryptedKeys } = await vaultClient.shareKeys( + adminKeyBytes.buffer, + { [participant.suite_user_id]: joinerPubKey } ) const joinerKey = encryptedKeys[participant.suite_user_id] if (joinerKey) { - // Encode as base64 for transport via REST API const bytes = new Uint8Array(joinerKey) encryptedVaultKey = btoa(String.fromCharCode(...bytes)) } diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Options/ScreenRecordingMenuItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Options/ScreenRecordingMenuItem.tsx index af9eded7..072fd276 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Options/ScreenRecordingMenuItem.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Options/ScreenRecordingMenuItem.tsx @@ -5,18 +5,22 @@ import { menuRecipe } from '@/primitives/menuRecipe' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { RecordingMode, useHasRecordingAccess } from '@/features/recording' import { FeatureFlags } from '@/features/analytics/enums' +import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom' export const ScreenRecordingMenuItem = () => { const { t } = useTranslation('rooms', { keyPrefix: 'options.items' }) const { isScreenRecordingOpen, openScreenRecording, toggleTools } = useSidePanel() + const roomData = useRoomData() const hasScreenRecordingAccess = useHasRecordingAccess( RecordingMode.ScreenRecording, FeatureFlags.ScreenRecording ) - if (!hasScreenRecordingAccess) return null + // Recording not available in encrypted rooms + if (!hasScreenRecordingAccess || checkEncryptedRoom(roomData)) return null return ( { const { t } = useTranslation('rooms', { keyPrefix: 'options.items' }) const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel() + const roomData = useRoomData() const hasTranscriptAccess = useHasRecordingAccess( RecordingMode.Transcript, FeatureFlags.Transcript ) - if (!hasTranscriptAccess) return null + // Recording/transcription not available in encrypted rooms + if (!hasTranscriptAccess || checkEncryptedRoom(roomData)) return null return ( { const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' }) - const [isModalOpen, setIsModalOpen] = useState(false) + const [isDialogOpen, setIsDialogOpen] = useState(false) + const { client: vaultClient } = useVaultClient() + const [fpStatus, setFpStatus] = useState('loading') + + useEffect(() => { + if (!vaultClient || !participant.suite_user_id) { + setFpStatus(participant.is_authenticated ? 'loading' : 'no-key') + return + } + + let cancelled = false + + async function check() { + try { + const { publicKeys } = await vaultClient!.fetchPublicKeys([participant.suite_user_id!]) + if (cancelled) return + + if (!publicKeys[participant.suite_user_id!]) { + setFpStatus('no-key') + return + } + + const { results } = await vaultClient!.checkFingerprints( + { [participant.suite_user_id!]: '' } + ) + if (cancelled) return + + const result = results.find((r) => r.userId === participant.suite_user_id) + setFpStatus(result?.status ?? 'unknown') + } catch { + if (!cancelled) setFpStatus('no-key') + } + } + + check() + return () => { cancelled = true } + }, [vaultClient, participant.suite_user_id, participant.is_authenticated]) + + const getBadge = () => { + switch (fpStatus) { + case 'trusted': + return { + icon: , + bg: '#f0fdf4', + tooltip: t('trust.verified'), + } + case 'refused': + return { + icon: , + bg: '#fef2f2', + tooltip: t('trust.refused'), + } + case 'unknown': + return { + icon: , + bg: '#eff6ff', + tooltip: participant.is_authenticated + ? t('trust.authenticated') + : t('trust.anonymous'), + } + case 'no-key': + return { + icon: , + bg: '#fffbeb', + tooltip: participant.is_authenticated + ? t('trust.authenticated') + : t('trust.anonymous'), + } + default: + return { + icon: participant.is_authenticated + ? + : , + bg: participant.is_authenticated ? '#eff6ff' : '#fffbeb', + tooltip: participant.is_authenticated + ? t('trust.authenticated') + : t('trust.anonymous'), + } + } + } + + const badge = getBadge() return ( <> @@ -26,40 +114,28 @@ const EncryptionTrustIndicator = ({ variant="tertiaryText" size="sm" square - tooltip={ - isAuthenticated - ? t('trust.authenticated') - : t('trust.anonymous') - } - aria-label={ - isAuthenticated - ? t('trust.authenticated') - : t('trust.anonymous') - } - onPress={() => setIsModalOpen(true)} + tooltip={badge.tooltip} + aria-label={badge.tooltip} + onPress={() => setIsDialogOpen(true)} className={css({ padding: '0.15rem !important', minWidth: 'auto !important', width: '1.5rem !important', height: '1.5rem !important', borderRadius: '50% !important', - backgroundColor: isAuthenticated - ? '#eff6ff !important' - : '#fffbeb !important', + backgroundColor: `${badge.bg} !important`, flexShrink: 0, })} > - {isAuthenticated ? ( - - ) : ( - - )} + {badge.icon} - ) @@ -96,10 +172,7 @@ export const WaitingParticipantListItem = ({ > {encryptedRoom && ( - + )}
{ +export const useCopyRoomToClipboard = (room: ApiRoom | undefined, hashOverride?: string) => { const telephony = useTelephony() const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' }) @@ -32,8 +32,12 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => { }, [isRoomUrlCopied]) const roomUrl = useMemo(() => { - return room?.slug ? getRouteUrl('room', room.slug) : '' - }, [room?.slug]) + if (!room?.slug) return '' + const base = getRouteUrl('room', room.slug) + // In basic encrypted mode, the passphrase is in the URL hash + const hash = hashOverride ? `#${hashOverride}` : window.location.hash + return hash ? `${base}${hash}` : base + }, [room?.slug, hashOverride]) const hasTelephonyInfo = useMemo(() => { return telephony.enabled && room?.pin_code diff --git a/src/frontend/src/features/sdk/routes/CreateMeetingButton.tsx b/src/frontend/src/features/sdk/routes/CreateMeetingButton.tsx index 882bdbf4..339024a8 100644 --- a/src/frontend/src/features/sdk/routes/CreateMeetingButton.tsx +++ b/src/frontend/src/features/sdk/routes/CreateMeetingButton.tsx @@ -15,6 +15,7 @@ import { PopupManager } from '../utils/PopupManager' import { CallbackCreationRoomData } from '../utils/types' import { useSearchParams } from 'wouter' + const popupManager = new PopupManager() export const CreateMeetingButton = () => { @@ -39,18 +40,24 @@ export const CreateMeetingButton = () => { const { data } = useRoomCreationCallback({ callbackId }) + const [basicHash, setBasicHash] = useState(undefined) + const roomUrl = useMemo(() => { - if (room?.slug) return getRouteUrl('room', room.slug) - }, [room]) + if (!room?.slug) return undefined + const base = getRouteUrl('room', room.slug) + return basicHash ? `${base}#${basicHash}` : base + }, [room, basicHash]) useEffect(() => { if (!data?.room?.slug) return setRoom(data.room) setCallbackId(undefined) setIsPending(false) + + const url = getRouteUrl('room', data.room.slug) popupManager.sendRoomData({ room: { - url: getRouteUrl('room', data.room.slug), + url, ...data.room, }, }) @@ -61,6 +68,7 @@ export const CreateMeetingButton = () => { (id) => setCallbackId(id), (data) => { setRoom(data) + if (data.hash) setBasicHash(data.hash) setIsPending(false) } ) @@ -68,6 +76,18 @@ export const CreateMeetingButton = () => { return () => popupManager.cleanup() }, []) + // Communicate iframe height to parent for proper sizing + useEffect(() => { + const observer = new ResizeObserver(() => { + window.parent.postMessage( + { type: 'RESIZE', data: { height: document.body.scrollHeight } }, + '*' + ) + }) + observer.observe(document.body) + return () => observer.disconnect() + }, []) + const resetState = () => { setRoom(undefined) setCallbackId(undefined) diff --git a/src/frontend/src/features/sdk/routes/CreatePopup.tsx b/src/frontend/src/features/sdk/routes/CreatePopup.tsx index 6cd5b3cd..2f73864d 100644 --- a/src/frontend/src/features/sdk/routes/CreatePopup.tsx +++ b/src/frontend/src/features/sdk/routes/CreatePopup.tsx @@ -1,10 +1,21 @@ -import { useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { css } from '@/styled-system/css' import { generateRoomId, useCreateRoom } from '../../rooms' import { useUser } from '@/features/auth' import { Spinner } from '@/primitives/Spinner' +import { Button, Text } from '@/primitives' +import { VStack } from '@/styled-system/jsx' import { CallbackIdHandler } from '../utils/CallbackIdHandler' import { PopupWindow } from '../utils/PopupWindow' +import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom' +import { generatePassphrase } from '@/features/encryption/lobbyKeyExchange' +import { useVaultClient } from '@/features/encryption' +import { + RiVideoOnLine, + RiLockLine, + RiShieldCheckLine, +} from '@remixicon/react' const callbackIdHandler = new CallbackIdHandler() const popupWindow = new PopupWindow() @@ -12,53 +23,147 @@ const popupWindow = new PopupWindow() export const CreatePopup = () => { const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } }) const { mutateAsync: createRoom } = useCreateRoom() + const { t } = useTranslation('sdk', { keyPrefix: 'createPopup' }) + const { client: vaultClient, hasKeys, isReady: vaultReady } = useVaultClient() const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), []) + const [isCreating, setIsCreating] = useState(false) + const [showOnboarding, setShowOnboarding] = useState(false) + const onboardingContainerRef = useRef(null) - /** - * Handle unauthenticated users by redirecting to login - * - * When redirecting to authentication, the window.location change breaks the connection - * between this popup and its parent window. We need to send the callbackId to the parent - * before redirecting so it can re-establish connection after authentication completes. - * This prevents the popup from becoming orphaned and ensures state consistency. - */ + // Handle unauthenticated users by redirecting to login. + // Don't send callbackId to parent yet — we need the user to pick + // an encryption mode first. The callbackId is sent with createRoom. useEffect(() => { if (isLoggedIn === false) { - // redirection loses the connection to the manager - // prevent it passing an async callback id - popupWindow.sendCallbackId(callbackId, () => { - popupWindow.navigateToAuthentication() + popupWindow.navigateToAuthentication() + } + }, [isLoggedIn]) + + const handleCreate = useCallback(async (mode: ApiEncryptionMode) => { + setIsCreating(true) + + try { + const slug = generateRoomId() + const hash = + mode === ApiEncryptionMode.BASIC ? generatePassphrase() : undefined + + // For advanced mode, generate the vault key at creation time + let encryptedSymmetricKey = '' + if (mode === ApiEncryptionMode.ADVANCED && vaultClient) { + // encryptWithoutKey requires data to encrypt, but we only care about + // the generated symmetric key (encryptedKeys), not the encrypted content. + // The same symmetric key will be used for all streams (video/audio/chat). + const dummyData = new Uint8Array(32).buffer + const { publicKey } = await vaultClient.getPublicKey() + const { encryptedKeys } = await vaultClient.encryptWithoutKey( + dummyData, + { self: publicKey } + ) + const keyBytes = new Uint8Array(encryptedKeys['self']) + encryptedSymmetricKey = btoa(String.fromCharCode(...keyBytes)) + } + + const roomData = await createRoom({ + slug, + encryptionMode: mode, + encryptedSymmetricKey, + }) + + popupWindow.sendRoomData({ slug: roomData.slug, hash }, () => { + callbackIdHandler.clear() + popupWindow.close() + }) + } catch (error) { + console.error('Failed to create meeting room:', error) + setIsCreating(false) + } + }, [createRoom, vaultClient]) + + // Handle vault onboarding completion + useEffect(() => { + if (!vaultClient || !showOnboarding) return + + const handleOnboardingComplete = () => { + setShowOnboarding(false) + // After onboarding, create the advanced encrypted room + handleCreate(ApiEncryptionMode.ADVANCED) + } + + const handleInterfaceClosed = () => { + setShowOnboarding(false) + } + + vaultClient.on('onboarding:complete', handleOnboardingComplete) + vaultClient.on('interface:closed', handleInterfaceClosed) + + return () => { + vaultClient.off('onboarding:complete', handleOnboardingComplete) + vaultClient.off('interface:closed', handleInterfaceClosed) + } + }, [vaultClient, showOnboarding, handleCreate]) + + // Open vault onboarding when container is ready + useEffect(() => { + if (showOnboarding && vaultClient && onboardingContainerRef.current) { + console.info('[CreatePopup] Opening vault onboarding in container', onboardingContainerRef.current) + vaultClient.openOnboarding(onboardingContainerRef.current) + } else if (showOnboarding) { + console.warn('[CreatePopup] Cannot open onboarding:', { + vaultClient: !!vaultClient, + container: !!onboardingContainerRef.current, }) } - }, [isLoggedIn, callbackId]) + }, [showOnboarding, vaultClient]) - /** - * Automatically create meeting room once user is authenticated - * This effect will trigger either immediately if the user is already logged in, - * or after successful authentication and return to this popup - */ - useEffect(() => { - const createMeetingRoom = async () => { - try { - const slug = generateRoomId() - const roomData = await createRoom({ - slug, - callbackId, - }) - // Send room data back to parent window and clean up resources - popupWindow.sendRoomData(roomData, () => { - callbackIdHandler.clear() - popupWindow.close() - }) - } catch (error) { - console.error('Failed to create meeting room:', error) - } + const handleAdvancedClick = () => { + if (hasKeys) { + // Already onboarded, create directly + handleCreate(ApiEncryptionMode.ADVANCED) + } else if (vaultClient) { + // Need onboarding first + setShowOnboarding(true) } - if (isLoggedIn && callbackId) { - createMeetingRoom() - } - }, [isLoggedIn, callbackId, createRoom]) + } + + if (!isLoggedIn || isCreating) { + return ( +
+ +
+ ) + } + + if (showOnboarding) { + return ( +
+
+ +
+ ) + } + + // Vault is available if the client was loaded (script + init succeeded). + // Auth context (isReady) may not be set yet — the onboarding handles its own auth. + const vaultAvailable = !!vaultClient return (
{ alignItems: 'center', height: '100%', width: '100%', + padding: '2rem', })} > - + + + {t('title')} + + + + + + +
+ + + + {t('advancedDescription')} + +
) } diff --git a/src/frontend/src/features/sdk/utils/PopupManager.ts b/src/frontend/src/features/sdk/utils/PopupManager.ts index e6a7afc8..8b637882 100644 --- a/src/frontend/src/features/sdk/utils/PopupManager.ts +++ b/src/frontend/src/features/sdk/utils/PopupManager.ts @@ -59,9 +59,11 @@ export class PopupManager { case PopupMessageType.ROOM_DATA: if (!data?.room) return onRoomData(data.room) + const baseUrl = getRouteUrl('room', data.room.slug) + const roomUrl = data.room.hash ? `${baseUrl}#${data.room.hash}` : baseUrl this.sendRoomData({ room: { - url: getRouteUrl('room', data.room.slug), + url: roomUrl, ...data.room, }, }) diff --git a/src/frontend/src/features/sdk/utils/PopupWindow.ts b/src/frontend/src/features/sdk/utils/PopupWindow.ts index 15856c4b..f5e6deb1 100644 --- a/src/frontend/src/features/sdk/utils/PopupWindow.ts +++ b/src/frontend/src/features/sdk/utils/PopupWindow.ts @@ -27,7 +27,7 @@ export class PopupWindow { public sendRoomData(data: CallbackCreationRoomData, callback?: () => void) { this.sendMessageToManager( PopupMessageType.ROOM_DATA, - { room: { slug: data.slug } }, + { room: { slug: data.slug, hash: data.hash } }, callback ) } diff --git a/src/frontend/src/features/sdk/utils/types.ts b/src/frontend/src/features/sdk/utils/types.ts index b3e02bc8..6232be38 100644 --- a/src/frontend/src/features/sdk/utils/types.ts +++ b/src/frontend/src/features/sdk/utils/types.ts @@ -1,10 +1,12 @@ export type CallbackCreationRoomData = { slug: string + hash?: string } export enum ClientMessageType { ROOM_CREATED = 'ROOM_CREATED', STATE_CLEAR = 'STATE_CLEAR', + RESIZE = 'RESIZE', } export interface PopupMessageData { diff --git a/src/frontend/src/features/settings/components/tabs/AccountTab.tsx b/src/frontend/src/features/settings/components/tabs/AccountTab.tsx index 0bc8bbd5..a69195a7 100644 --- a/src/frontend/src/features/settings/components/tabs/AccountTab.tsx +++ b/src/frontend/src/features/settings/components/tabs/AccountTab.tsx @@ -8,6 +8,8 @@ import { HStack } from '@/styled-system/jsx' import { useState } from 'react' import { LoginButton } from '@/components/LoginButton' import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices' +import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom' export type AccountTabProps = Pick & Pick @@ -16,7 +18,10 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => { const { t } = useTranslation('settings') const { saveUsername } = usePersistentUserChoices() const room = useRoomContext() + const roomData = useRoomData() const { user, isLoggedIn, logout } = useUser() + const isEncryptedRoom = checkEncryptedRoom(roomData) + const isNameLocked = isEncryptedRoom && !!isLoggedIn const [name, setName] = useState(room?.localParticipant.name ?? '') const userDisplay = user?.full_name && user?.email @@ -24,8 +29,10 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => { : user?.email const handleOnSubmit = () => { - if (room) room.localParticipant.setName(name) - saveUsername(name) + if (!isNameLocked) { + if (room) room.localParticipant.setName(name) + saveUsername(name) + } if (onOpenChange) onOpenChange(false) } const handleOnCancel = () => { @@ -40,6 +47,8 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => { label={t('account.nameLabel')} value={name} onChange={setName} + isDisabled={isNameLocked} + description={isNameLocked ? t('account.nameLockedEncryption') : undefined} validate={(value) => { return !value ?

{t('account.nameError')}

: null }} diff --git a/src/frontend/src/layout/Header.tsx b/src/frontend/src/layout/Header.tsx index 84ad086f..f92307c2 100644 --- a/src/frontend/src/layout/Header.tsx +++ b/src/frontend/src/layout/Header.tsx @@ -14,7 +14,7 @@ import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip' import { useLoginHint } from '@/hooks/useLoginHint' import { useVaultClient } from '@/features/encryption' import { useConfig } from '@/api/useConfig' -import { useRef } from 'react' +import { useRef, useState } from 'react' const Logo = () => ( { const { data: config } = useConfig() const { client: vaultClient, hasKeys } = useVaultClient() const encryptionContainerRef = useRef(null) + const [showEncryptionModal, setShowEncryptionModal] = useState(false) const isEncryptionAvailable = !!config?.encryption?.enabled && !!vaultClient const userLabel = user?.full_name || user?.email const loggedInTooltip = t('loggedInUserTooltip') @@ -203,30 +204,8 @@ export const Header = () => { if (value === 'logout') { logout() } - if (value === 'encryption' && vaultClient) { - // Create a container for the VaultClient interface - const container = document.createElement('div') - container.style.position = 'fixed' - container.style.inset = '0' - container.style.zIndex = '9999' - document.body.appendChild(container) - encryptionContainerRef.current = container - - if (hasKeys) { - vaultClient.openSettings(container) - } else { - vaultClient.openOnboarding(container) - } - - // Listen for close - vaultClient.on('interface:closed', () => { - if (encryptionContainerRef.current) { - document.body.removeChild( - encryptionContainerRef.current - ) - encryptionContainerRef.current = null - } - }) + if (value === 'encryption') { + setShowEncryptionModal(true) } }} /> @@ -237,6 +216,81 @@ export const Header = () => {
+ {showEncryptionModal && vaultClient && ( +
{ + if (e.target === e.currentTarget) { + setShowEncryptionModal(false) + vaultClient.closeInterface() + } + }} + > +
+ +
{ + if (!el) return + // Clear previous content and re-inject + el.innerHTML = '' + encryptionContainerRef.current = el + if (hasKeys) { + vaultClient.openSettings(el) + } else { + vaultClient.openOnboarding(el) + } + + const handleClosed = () => { + setShowEncryptionModal(false) + encryptionContainerRef.current = null + vaultClient.off('interface:closed', handleClosed) + } + vaultClient.on('interface:closed', handleClosed) + }} + className={css({ minHeight: '300px' })} + /> +
+
+ )} ) } diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 240351cb..c8dbbaa0 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -85,6 +85,15 @@ "invalidKey": { "title": "Invalid meeting link", "body": "This encrypted meeting requires a valid encryption key in the URL. Please ask the meeting organizer for the correct link." + }, + "advancedAuth": { + "title": "Authentication required", + "body": "This meeting uses advanced encryption. You must be logged in to join." + }, + "advancedOnboarding": { + "title": "Encryption setup required", + "body": "This meeting uses advanced encryption. You must complete your encryption setup before you can join.", + "button": "Set up encryption" } }, "leaveRoomPrompt": "This will make you leave the meeting.", @@ -578,7 +587,9 @@ "all": "Deny all" }, "trust": { - "authenticated": "Authenticated identity (ProConnect). Encryption key will be exchanged securely.", + "verified": "Identity verified — fingerprint is trusted.", + "refused": "WARNING — This fingerprint has been refused. This person may not be who they claim to be.", + "authenticated": "Authenticated identity (ProConnect). Click to check fingerprint.", "anonymous": "Anonymous user — identity not verified. Verify their identity before accepting." } }, diff --git a/src/frontend/src/locales/en/sdk.json b/src/frontend/src/locales/en/sdk.json index 80438d83..3c1b9b1b 100644 --- a/src/frontend/src/locales/en/sdk.json +++ b/src/frontend/src/locales/en/sdk.json @@ -6,5 +6,13 @@ "resetLabel": "Reset", "participantLimit": "Up to 150 participants.", "popupBlocked": "Popup was blocked. Please allow popups for this site." + }, + "createPopup": { + "title": "Create a meeting", + "standard": "Standard meeting", + "encrypted": "Encrypted meeting", + "advancedEncrypted": "Advanced encrypted meeting", + "advancedDescription": "Maximum security — encryption keys never leave your device. All participants must complete encryption setup before joining. Requires key backup.", + "advancedUnavailable": "Encryption service not available" } } diff --git a/src/frontend/src/locales/en/settings.json b/src/frontend/src/locales/en/settings.json index 722978b0..51e7d3b9 100644 --- a/src/frontend/src/locales/en/settings.json +++ b/src/frontend/src/locales/en/settings.json @@ -5,7 +5,8 @@ "youAreNotLoggedIn": "You are not logged in.", "nameLabel": "Your Name", "authentication": "Authentication", - "nameError": "Your name cannot be empty" + "nameError": "Your name cannot be empty", + "nameLockedEncryption": "In encrypted meetings, your name comes from your account and cannot be changed." }, "preferences": { "title": "Preferences", diff --git a/src/frontend/src/locales/fr/home.json b/src/frontend/src/locales/fr/home.json index 34c08dfa..647a25da 100644 --- a/src/frontend/src/locales/fr/home.json +++ b/src/frontend/src/locales/fr/home.json @@ -19,6 +19,19 @@ "encryptedInstantOption": "Démarrer une réunion chiffrée", "encryptedLaterOption": "Créer une réunion chiffrée pour plus tard" }, + "encryptionModeDialog": { + "title": "Choisir le mode de chiffrement", + "description": "Sélectionnez le niveau de chiffrement pour votre réunion.", + "basic": { + "title": "Chiffrement basique", + "description": "Protège votre réunion avec une phrase secrète partagée. Accessible à tous — aucune configuration requise." + }, + "advanced": { + "title": "Chiffrement avancé", + "description": "Sécurité maximale — la clé de chiffrement ne quitte jamais votre navigateur. Nécessite la configuration du chiffrement pour tous les participants.", + "onboardingRequired": "Vous devez compléter la configuration du chiffrement dans les paramètres de votre compte avant d'utiliser le chiffrement avancé." + } + }, "laterMeetingDialog": { "heading": "Vos informations de connexion", "description": "Partagez ces informations avec les invités. Ils pourront rejoindre la réunion sans avoir besoin de se connecter. Cette réunion est permanente et peut être réutilisée.", diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 6de265ba..b93001e5 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -81,6 +81,19 @@ "timeoutInvite": { "title": "Vous ne pouvez pas participer à cet appel", "body": "Personne n'a répondu à votre demande de participation à l'appel" + }, + "invalidKey": { + "title": "Lien de réunion invalide", + "body": "Cette réunion chiffrée nécessite une clé de chiffrement valide dans l'URL. Veuillez demander le lien correct à l'organisateur de la réunion." + }, + "advancedAuth": { + "title": "Authentification requise", + "body": "Cette réunion utilise le chiffrement avancé. Vous devez être connecté pour la rejoindre." + }, + "advancedOnboarding": { + "title": "Configuration du chiffrement requise", + "body": "Cette réunion utilise le chiffrement avancé. Vous devez configurer votre chiffrement avant de pouvoir rejoindre.", + "button": "Configurer le chiffrement" } }, "leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.", @@ -574,7 +587,9 @@ "all": "Tout rejeter" }, "trust": { - "authenticated": "Identité authentifiée (ProConnect). La clé de chiffrement sera échangée de manière sécurisée.", + "verified": "Identité vérifiée — l'empreinte est de confiance.", + "refused": "ATTENTION — Cette empreinte a été refusée. Cette personne n'est peut-être pas celle qu'elle prétend être.", + "authenticated": "Identité authentifiée (ProConnect). Cliquez pour vérifier l'empreinte.", "anonymous": "Utilisateur anonyme — identité non vérifiée. Vérifiez son identité avant d'accepter." } }, diff --git a/src/frontend/src/locales/fr/sdk.json b/src/frontend/src/locales/fr/sdk.json index 5f04ca3a..68ef3f34 100644 --- a/src/frontend/src/locales/fr/sdk.json +++ b/src/frontend/src/locales/fr/sdk.json @@ -6,5 +6,13 @@ "resetLabel": "Réinitialiser", "participantLimit": "Jusqu'à 150 participants.", "popupBlocked": "La fenêtre pop-up a été bloquée. Veuillez autoriser les pop-ups pour ce site." + }, + "createPopup": { + "title": "Créer une réunion", + "standard": "Réunion standard", + "encrypted": "Réunion chiffrée", + "advancedEncrypted": "Réunion chiffrée avancée", + "advancedDescription": "Sécurité maximale — les clés de chiffrement ne quittent jamais votre appareil. Tous les participants doivent configurer le chiffrement avant de rejoindre. Nécessite une sauvegarde des clés.", + "advancedUnavailable": "Service de chiffrement non disponible" } } diff --git a/src/frontend/src/locales/fr/settings.json b/src/frontend/src/locales/fr/settings.json index 99e29c82..02c1d67b 100644 --- a/src/frontend/src/locales/fr/settings.json +++ b/src/frontend/src/locales/fr/settings.json @@ -5,7 +5,8 @@ "youAreNotLoggedIn": "Vous n'êtes pas connecté.", "nameLabel": "Votre Nom", "authentication": "Authentification", - "nameError": "Votre Nom ne peut pas être vide" + "nameError": "Votre Nom ne peut pas être vide", + "nameLockedEncryption": "Dans les réunions chiffrées, votre nom provient de votre compte et ne peut pas être modifié." }, "preferences": { "title": "Préférences",