diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 1a542105..495853d1 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -620,6 +620,12 @@ class RoomViewSet( room = self.get_object() + if room.encryption_enabled: + return drf_response.Response( + {"error": "Transcription is not available in encrypted rooms."}, + status=drf_status.HTTP_403_FORBIDDEN, + ) + try: SubtitleService().start_subtitle(room) except SubtitleException: diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 6964ec68..1b15f8eb 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -125,9 +125,29 @@ def generate_token( "is_authenticated": "true" if not user.is_anonymous else "false", } - # Add identity info for authenticated users (visible to other participants - # for identity verification in encrypted rooms) - if not user.is_anonymous: + # Add identity info for authenticated users in encrypted rooms only. + # + # Email and suite_user_id are included in the JWT attributes for encrypted + # rooms because: + # - Email: allows admins to verify participant identity in the lobby and + # participant list (important for trust decisions in encrypted meetings) + # - suite_user_id: required for vault key exchange in advanced encryption + # (vaultClient.shareKeys needs the recipient's user ID) + # + # These attributes are NOT included in non-encrypted rooms because: + # - Non-encrypted rooms have no waiting room, so anonymous users can join + # freely and would see everyone's email via LiveKit signaling + # - LiveKit JWT attributes are immutable and broadcast to ALL participants + # equally — there is no way to show them only to authenticated users + # at the protocol level + # - The frontend additionally hides email from anonymous users in the UI, + # but this is defense-in-depth, not the primary protection + # + # Future improvement: serve email via a Django API endpoint that checks + # the requester's authentication, removing it from the JWT entirely. + # This would require the backend to call LiveKit's ListParticipants API + # to cross-reference identities with the user database. + if not user.is_anonymous and encryption_mode != 'none': if user.email: attributes["email"] = user.email if user.sub: diff --git a/src/frontend/src/components/Avatar.tsx b/src/frontend/src/components/Avatar.tsx index 168fa953..c4bd1073 100644 --- a/src/frontend/src/components/Avatar.tsx +++ b/src/frontend/src/components/Avatar.tsx @@ -57,7 +57,7 @@ export const Avatar = ({ style, ...props }: AvatarProps) => { - const initial = name?.trim()?.charAt(0) ?? '' + const initial = name?.trim()?.charAt(0)?.toUpperCase() ?? '' return ( {showDecryptionError && !isScreenShare && (
)} - {(isEncrypted || isEncryptedRoom) && !isScreenShare && ( - - | undefined + {isEncryptedRoom && isAdmin && !isScreenShare ? ( + + ) : ( + <> + {(isEncrypted || isEncryptedRoom) && !isScreenShare && ( + + )} +
+ +
+ )} -
- -
@@ -358,6 +406,17 @@ export const ParticipantTile: ( ), })} + {isEncryptedRoom && isAdmin && ( + + )} ) }) diff --git a/src/frontend/src/features/rooms/livekit/components/Tools.tsx b/src/frontend/src/features/rooms/livekit/components/Tools.tsx index fbd2bc4f..b951ed04 100644 --- a/src/frontend/src/features/rooms/livekit/components/Tools.tsx +++ b/src/frontend/src/features/rooms/livekit/components/Tools.tsx @@ -12,12 +12,16 @@ import { ScreenRecordingSidePanel, } from '@/features/recording' import { useConfig } from '@/api/useConfig' +import { useRoomData } from '../hooks/useRoomData' +import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom' +import { RiLockLine } from '@remixicon/react' export interface ToolsButtonProps { icon: ReactNode title: string description: string onPress: () => void + isDisabled?: boolean } const ToolButton = ({ @@ -25,9 +29,11 @@ const ToolButton = ({ title, description, onPress, + isDisabled, }: ToolsButtonProps) => { return ( @@ -132,6 +142,9 @@ export const Tools = () => { break } + const roomData = useRoomData() + const encrypted = isEncryptedRoom(roomData) + return (
{ )} + {encrypted && ( +
+ + + {t('encryptedDisabled')} + +
+ )} {isTranscriptEnabled && ( } title={t('tools.transcript.title')} description={t('tools.transcript.body')} onPress={() => openTranscript()} + isDisabled={encrypted} /> )} {isScreenRecordingEnabled && ( @@ -180,6 +214,7 @@ export const Tools = () => { title={t('tools.screenRecording.title')} description={t('tools.screenRecording.body')} onPress={() => openScreenRecording()} + isDisabled={encrypted} /> )}
diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx index 11ce93e4..4d601caa 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx @@ -25,6 +25,8 @@ import { EncryptionBadge, getTrustLevelFromAttributes, FingerprintDialog } from import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { isEncryptedRoom as isEncryptedRoomFn } from '@/features/rooms/api/ApiRoom' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' +import { useUser } from '@/features/auth' +import { TooltipWrapper } from '@/primitives/TooltipWrapper' type MicIndicatorProps = { participant: Participant @@ -104,9 +106,13 @@ export const ParticipantListItem = ({ const roomData = useRoomData() const isEncryptedRoom = isEncryptedRoomFn(roomData) const isAdmin = useIsAdminOrOwner() + const { isLoggedIn } = useUser() + const { t: tEncBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' }) const [isFingerprintOpen, setIsFingerprintOpen] = useState(false) const name = participant.name || participant.identity const attrs = participant.attributes as Record | undefined + const trustLevel = getTrustLevelFromAttributes(attrs, roomData?.encryption_mode) + const badgeTooltip = trustLevel ? tEncBadge(trustLevel) : undefined return ( - - {isEncryptedRoom && ( - setIsFingerprintOpen(true) : undefined} + {isEncryptedRoom ? ( + + ) : ( + {name} - - {isLocal(participant) && ( - - ({t('participants.you')}) - - )} - + {isLocal(participant) && ` (${t('participants.you')})`} + + )} {getParticipantIsRoomAdmin(participant) && ( {t('participants.host')} )} - {isEncryptedRoom && - participant.attributes?.is_authenticated === 'true' && - participant.attributes?.email && ( - { + const email = participant.attributes?.is_authenticated === 'true' && participant.attributes?.email + ? participant.attributes.email + : null + const label = email || t('participants.anonymous') + return ( + + ) + })()} @@ -204,6 +252,7 @@ export const ParticipantListItem = ({ participantEmail={attrs?.email} suiteUserId={attrs?.suite_user_id} isAuthenticated={attrs?.is_authenticated === 'true'} + encryptionMode={roomData?.encryption_mode} /> )} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx index 0203947b..2391d1c5 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/WaitingParticipantListItem.tsx @@ -1,144 +1,24 @@ import { Button, Text } from '@/primitives' -import { HStack } from '@/styled-system/jsx' +import { HStack, VStack } from '@/styled-system/jsx' import { css } from '@/styled-system/css' import { Avatar } from '@/components/Avatar' import { useTranslation } from 'react-i18next' import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants' -import { - RiCloseLine, - RiShieldCheckFill, - RiShieldCheckLine, - RiAlertLine, - RiErrorWarningLine, -} from '@remixicon/react' +import { RiCloseLine } from '@remixicon/react' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom' -import { FingerprintDialog } from '@/features/encryption' -import { useVaultClient } from '@/features/encryption' -import { useState, useEffect } from 'react' +import { EncryptionBadge, getTrustLevelFromAttributes, FingerprintDialog } from '@/features/encryption' +import type { TrustLevel } from '@/features/encryption/types' +import { useState } from 'react' -type FingerprintBadgeStatus = 'loading' | 'trusted' | 'refused' | 'unknown' | 'no-key' - -const EncryptionTrustIndicator = ({ - participant, -}: { - participant: WaitingParticipant -}) => { - const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' }) - 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 ( - <> - - - - ) +function waitingParticipantTrustLevel( + participant: WaitingParticipant, + encryptionMode?: string, +): TrustLevel { + // In basic mode, no PKI — only authenticated or anonymous + // In advanced mode, would check vault keys (but waiting participants haven't joined yet) + if (participant.is_authenticated) return 'authenticated' + return 'anonymous' } export const WaitingParticipantListItem = ({ @@ -151,6 +31,10 @@ export const WaitingParticipantListItem = ({ const { t } = useTranslation('rooms') const roomData = useRoomData() const encryptedRoom = isEncryptedRoom(roomData) + const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' }) + const [isDialogOpen, setIsDialogOpen] = useState(false) + const trustLevel = waitingParticipantTrustLevel(participant, roomData?.encryption_mode) + const badgeTooltip = encryptedRoom ? tBadge(trustLevel) : undefined return ( - {encryptedRoom && ( - - )} -
- - + {encryptedRoom ? ( + + ) : ( + {participant.username} - - - {encryptedRoom && participant.email && ( - - {participant.email} )} -
+ {encryptedRoom && (() => { + const email = participant.is_authenticated && participant.email + ? participant.email + : null + const label = email || t('participants.anonymous') + return ( + + ) + })()} +
+ {encryptedRoom && ( + + )} ) } diff --git a/src/frontend/src/features/settings/components/tabs/AccountTab.tsx b/src/frontend/src/features/settings/components/tabs/AccountTab.tsx index b0249238..3d22c0b2 100644 --- a/src/frontend/src/features/settings/components/tabs/AccountTab.tsx +++ b/src/frontend/src/features/settings/components/tabs/AccountTab.tsx @@ -42,17 +42,20 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => { return ( {t('account.heading')} - { - return !value ?

{t('account.nameError')}

: null - }} - /> +
+ { + return !value ?

{t('account.nameError')}

: null + }} + /> +
{t('account.authentication')} {isLoggedIn ? ( <> diff --git a/src/frontend/src/layout/Header.tsx b/src/frontend/src/layout/Header.tsx index 1bfb09f4..7254c312 100644 --- a/src/frontend/src/layout/Header.tsx +++ b/src/frontend/src/layout/Header.tsx @@ -135,7 +135,8 @@ export const Header = () => { vaultInjectedRef.current = false } }, [showEncryptionModal]) - const isEncryptionAvailable = !!config?.encryption?.enabled && !!vaultClient + const isEncryptionEnabled = !!config?.encryption?.enabled + const isEncryptionAvailable = isEncryptionEnabled && !!vaultClient const userLabel = user?.full_name || user?.short_name || user?.email const loggedInTooltip = t('loggedInUserTooltip') const loggedInAriaLabel = userLabel @@ -226,13 +227,14 @@ export const Header = () => { { if (value === 'logout') { logout() } - if (value === 'encryption') { + if (value === 'encryption' && isEncryptionAvailable) { setShowEncryptionModal(true) } }} diff --git a/src/frontend/src/locales/en/global.json b/src/frontend/src/locales/en/global.json index 132f4be2..02923b97 100644 --- a/src/frontend/src/locales/en/global.json +++ b/src/frontend/src/locales/en/global.json @@ -23,6 +23,7 @@ "logout": "Logout", "encryptionSetup": "Set up encryption", "encryptionSettings": "Encryption settings", + "encryptionUnavailable": "Encryption (unavailable)", "notFound": { "heading": "Verify your meeting code", "body": "Check that you have entered the correct meeting code in the URL. Example:" diff --git a/src/frontend/src/locales/en/home.json b/src/frontend/src/locales/en/home.json index 4cc6f360..c88dfe29 100644 --- a/src/frontend/src/locales/en/home.json +++ b/src/frontend/src/locales/en/home.json @@ -36,7 +36,8 @@ "advanced": { "title": "Advanced encryption", "description": "Maximum security — the encryption key never leaves your browser. Requires encryption onboarding for all participants.", - "onboardingRequired": "You must complete the encryption setup in your account settings before using advanced encryption." + "onboardingRequired": "You must complete the encryption setup in your account settings before using advanced encryption.", + "serviceUnavailable": "Advanced encryption is currently unavailable. Please try again later or contact your administrator." } }, "laterMeetingDialog": { diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index e60fc973..bb6ff9df 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -363,6 +363,7 @@ }, "moreTools": { "body": "Access more tools to enhance your meetings.", + "encryptedDisabled": "Transcription and recording are not available in encrypted meetings. The server cannot access the media content.", "linkAriaLabel": "Open documentation about tools - opens in new window", "moreLink": "Open documentation", "tools": { @@ -556,6 +557,7 @@ "subheading": "In room", "you": "You", "unknown": "Unknown participant", + "anonymous": "Unverified identity", "host": "Host", "contributors": "Contributors", "collapsable": { @@ -724,19 +726,28 @@ "hint": "The encrypted data could not be decoded. Try leaving and rejoining the meeting, or ask the host to restart the call.", "timeout": "Key exchange timed out", "timeoutHint": "The encryption key could not be received from the host. They may not be in the meeting yet.", - "refresh": "Retry" + "refresh": "Retry", + "vaultUnavailable": "This meeting requires advanced encryption, which is currently unavailable.", + "vaultUnavailableHint": "This may be a temporary issue. Try refreshing the page. If the problem persists, the encryption service may be undergoing maintenance — please try again later." }, "fingerprint": { "title": "Encryption identity", + "description": "This shows the encryption identity of this participant. It allows you to verify they are who they claim to be.", "loading": "Checking encryption keys...", - "noKey": "This participant has not set up encryption keys. Their identity is verified by the authentication server only, not by a public key.", - "error": "Unable to check encryption keys.", + "noKey": "This participant has not completed encryption onboarding. Their identity is verified by the authentication server (ProConnect) only — not by a cryptographic public key. They can set up encryption in their account settings.", + "noKeyBasicAuthenticated": "This participant is signed in via ProConnect. Their name and email are provided by the authentication server and cannot be falsified.", + "noKeyAnonymous": "This participant joined without signing in. Their identity is self-declared and not verified. Only accept anonymous participants if you can confirm their identity through another channel.", + "error": "Unable to check encryption keys. The encryption service may be unavailable.", "fingerprintLabel": "Public key fingerprint", + "fingerprintHint": "Ask this participant to open their encryption settings and compare the fingerprint shown there with the one above.", "trusted": "Fingerprint verified and trusted", + "trustedDescription": "You have previously verified this participant's encryption fingerprint. Their identity is cryptographically confirmed.", "refused": "Fingerprint refused", - "unknownDescription": "This fingerprint has not been verified yet. Compare it with the participant to ensure their identity.", + "refusedDescription": "You previously refused this fingerprint. This person's identity could not be verified.", + "unknownDescription": "This fingerprint has not been verified yet. Compare it with the participant (via phone, in person, or another secure channel) to confirm their identity.", "accept": "Trust", - "refuse": "Refuse" + "refuse": "Refuse", + "anonymous": "Unverified identity" }, "trustModal": { "title": "Encryption trust level", diff --git a/src/frontend/src/locales/fr/home.json b/src/frontend/src/locales/fr/home.json index 1999b24d..7fba427f 100644 --- a/src/frontend/src/locales/fr/home.json +++ b/src/frontend/src/locales/fr/home.json @@ -36,7 +36,8 @@ "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é." + "onboardingRequired": "Vous devez compléter la configuration du chiffrement dans les paramètres de votre compte avant d'utiliser le chiffrement avancé.", + "serviceUnavailable": "Le chiffrement avancé est actuellement indisponible. Veuillez réessayer plus tard ou contacter votre administrateur." } }, "laterMeetingDialog": { diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index dddcb6c8..1523a168 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -363,6 +363,7 @@ }, "moreTools": { "body": "Accéder à davantage d'outils pour améliorer vos réunions.", + "encryptedDisabled": "La transcription et l'enregistrement ne sont pas disponibles dans les réunions chiffrées. Le serveur ne peut pas accéder au contenu média.", "linkAriaLabel": "Ouvrir la documentation sur les outils - ouvre dans une nouvelle fenêtre", "moreLink": "Ouvrir la documentation", "tools": { @@ -556,6 +557,7 @@ "subheading": "Dans la réunion", "you": "Vous", "unknown": "Participant inconnu", + "anonymous": "Identité non vérifiée", "contributors": "Contributeurs", "host": "Organisateur de la réunion", "collapsable": { @@ -724,19 +726,28 @@ "hint": "Les données chiffrées n'ont pas pu être décodées. Essayez de quitter et de rejoindre la réunion, ou demandez à l'hôte de relancer l'appel.", "timeout": "Échange de clés expiré", "timeoutHint": "La clé de chiffrement n'a pas pu être reçue de l'hôte. Il n'est peut-être pas encore dans la réunion.", - "refresh": "Réessayer" + "refresh": "Réessayer", + "vaultUnavailable": "Cette réunion nécessite le chiffrement avancé, qui est actuellement indisponible.", + "vaultUnavailableHint": "Il peut s'agir d'un problème temporaire. Essayez de rafraîchir la page. Si le problème persiste, le service de chiffrement est peut-être en maintenance — veuillez réessayer plus tard." }, "fingerprint": { "title": "Identité chiffrée", + "description": "Ceci montre l'identité de chiffrement de ce participant. Cela vous permet de vérifier qu'il est bien celui qu'il prétend être.", "loading": "Vérification des clés de chiffrement...", - "noKey": "Ce participant n'a pas configuré de clés de chiffrement. Son identité est vérifiée par le serveur d'authentification uniquement, pas par une clé publique.", - "error": "Impossible de vérifier les clés de chiffrement.", + "noKey": "Ce participant n'a pas effectué l'onboarding chiffrement. Son identité est vérifiée par le serveur d'authentification (ProConnect) uniquement — pas par une clé publique cryptographique. Il peut configurer le chiffrement dans les paramètres de son compte.", + "noKeyBasicAuthenticated": "Ce participant est connecté via ProConnect. Son nom et son email proviennent du serveur d'authentification et ne peuvent pas être falsifiés.", + "noKeyAnonymous": "Ce participant a rejoint sans se connecter. Son identité est auto-déclarée et non vérifiée. N'acceptez les participants anonymes que si vous pouvez confirmer leur identité par un autre canal.", + "error": "Impossible de vérifier les clés de chiffrement. Le service de chiffrement est peut-être indisponible.", "fingerprintLabel": "Empreinte de la clé publique", + "fingerprintHint": "Demandez à ce participant d'ouvrir ses paramètres de chiffrement et de comparer l'empreinte affichée avec celle ci-dessus.", "trusted": "Empreinte vérifiée et approuvée", + "trustedDescription": "Vous avez précédemment vérifié l'empreinte de chiffrement de ce participant. Son identité est confirmée cryptographiquement.", "refused": "Empreinte refusée", - "unknownDescription": "Cette empreinte n'a pas encore été vérifiée. Comparez-la avec le participant pour confirmer son identité.", + "refusedDescription": "Vous avez précédemment refusé cette empreinte. L'identité de cette personne n'a pas pu être vérifiée.", + "unknownDescription": "Cette empreinte n'a pas encore été vérifiée. Comparez-la avec le participant (par téléphone, en personne ou par un autre canal sécurisé) pour confirmer son identité.", "accept": "Approuver", - "refuse": "Refuser" + "refuse": "Refuser", + "anonymous": "Identité non vérifiée" }, "trustModal": { "title": "Niveau de confiance du chiffrement", diff --git a/src/frontend/src/primitives/TooltipWrapper.tsx b/src/frontend/src/primitives/TooltipWrapper.tsx index 86ac105f..94393152 100644 --- a/src/frontend/src/primitives/TooltipWrapper.tsx +++ b/src/frontend/src/primitives/TooltipWrapper.tsx @@ -25,7 +25,7 @@ export const TooltipWrapper = ({ children: ReactNode } & TooltipWrapperProps) => { return tooltip ? ( - + {children} {tooltip}