diff --git a/src/frontend/src/features/encryption/EncryptionBadge.tsx b/src/frontend/src/features/encryption/EncryptionBadge.tsx index 4cdb8d9c..036445a9 100644 --- a/src/frontend/src/features/encryption/EncryptionBadge.tsx +++ b/src/frontend/src/features/encryption/EncryptionBadge.tsx @@ -1,13 +1,24 @@ /** * Per-participant encryption trust badge. * - * Single icon per trust level: - * - "verified": Green shield — identity confirmed via PKI - * - "authenticated": Blue shield — ProConnect-authenticated - * - "anonymous": Orange warning — identity not verified - * - default: Lock icon — encrypted, trust level unknown + * In advanced mode: + * - "verified": Green shield — fingerprint explicitly trusted + * - "unknown": Grey shield — has public key, not yet verified + * - "refused": Red shield — fingerprint previously refused + * - "authenticated": Blue shield — ProConnect, no vault keys + * - "anonymous": Orange warning — not signed in + * + * In basic mode: + * - "authenticated": Blue shield — ProConnect + * - "anonymous": Orange warning — not signed in */ -import { RiShieldCheckFill, RiErrorWarningFill, RiLockFill } from '@remixicon/react' +import { + RiShieldCheckFill, + RiShieldFill, + RiShieldCrossFill, + RiErrorWarningFill, + RiLockFill, +} from '@remixicon/react' import type { TrustLevel } from './types' import { css } from '@/styled-system/css' import { useTranslation } from 'react-i18next' @@ -33,6 +44,14 @@ export function EncryptionBadge({ icon = label = t('verified') break + case 'unknown': + icon = + label = t('unknown') + break + case 'refused': + icon = + label = t('refused') + break case 'authenticated': icon = label = t('authenticated') diff --git a/src/frontend/src/features/encryption/FingerprintDialog.tsx b/src/frontend/src/features/encryption/EncryptionIdentityDialog.tsx similarity index 73% rename from src/frontend/src/features/encryption/FingerprintDialog.tsx rename to src/frontend/src/features/encryption/EncryptionIdentityDialog.tsx index ddc1560c..788701f3 100644 --- a/src/frontend/src/features/encryption/FingerprintDialog.tsx +++ b/src/frontend/src/features/encryption/EncryptionIdentityDialog.tsx @@ -9,6 +9,7 @@ import { css } from '@/styled-system/css' import { VStack, HStack } from '@/styled-system/jsx' import { Dialog, Text, Button } from '@/primitives' import { Avatar } from '@/components/Avatar' +import { useUser } from '@/features/auth' import { RiShieldCheckFill, RiShieldCheckLine, @@ -18,9 +19,10 @@ import { } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { useVaultClient } from './VaultClientProvider' +import { formatFingerprint } from './useParticipantTrustLevel' import { useEffect, useState } from 'react' -interface FingerprintDialogProps { +interface EncryptionIdentityDialogProps { isOpen: boolean onOpenChange: (open: boolean) => void participantName: string @@ -28,11 +30,14 @@ interface FingerprintDialogProps { suiteUserId?: string isAuthenticated: boolean encryptionMode?: 'basic' | 'advanced' | 'none' + isSelf?: boolean + preloadedFingerprint?: string | null + preloadedFingerprintStatus?: string | null } type FingerprintStatus = 'loading' | 'no-key' | 'trusted' | 'refused' | 'unknown' | 'error' -export function FingerprintDialog({ +export function EncryptionIdentityDialog({ isOpen, onOpenChange, participantName, @@ -40,11 +45,23 @@ export function FingerprintDialog({ suiteUserId, isAuthenticated, encryptionMode, -}: FingerprintDialogProps) { + isSelf, + preloadedFingerprint, + preloadedFingerprintStatus, +}: EncryptionIdentityDialogProps) { const { t } = useTranslation('rooms', { keyPrefix: 'encryption.fingerprint' }) const { client: vaultClient } = useVaultClient() - const [status, setStatus] = useState('loading') - const [fingerprint, setFingerprint] = useState(null) + const { isLoggedIn } = useUser() + const [status, setStatus] = useState( + (preloadedFingerprintStatus as FingerprintStatus) || 'loading' + ) + const [fingerprint, setFingerprint] = useState(preloadedFingerprint || null) + + // Sync preloaded data when it becomes available (hook resolves after mount) + useEffect(() => { + if (preloadedFingerprintStatus) setStatus(preloadedFingerprintStatus as FingerprintStatus) + if (preloadedFingerprint) setFingerprint(preloadedFingerprint) + }, [preloadedFingerprint, preloadedFingerprintStatus]) const isBasicMode = encryptionMode !== 'advanced' @@ -68,7 +85,6 @@ export function FingerprintDialog({ async function checkFingerprint() { try { - // Timeout after 3 seconds — the encryption server may not be available const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000) ) @@ -85,25 +101,34 @@ export function FingerprintDialog({ return } - const { results } = await Promise.race([ - vaultClient!.checkFingerprints( - { [suiteUserId!]: '' }, - undefined - ), - timeout, - ]) + // Compute fingerprint from the public key (SHA-256, first 16 hex chars) + const hash = await crypto.subtle.digest('SHA-256', publicKey) + const fp = Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .slice(0, 16) if (cancelled) return + setFingerprint(fp) - const result = results.find((r) => r.userId === suiteUserId) - if (result) { - setFingerprint(result.providedFingerprint) - setStatus(result.status) + // Check local registry without triggering TOFU auto-trust + const { fingerprints: known } = await Promise.race([ + vaultClient!.getKnownFingerprints(), + timeout, + ]) + if (cancelled) return + + const knownEntry = known[suiteUserId!] + if (!knownEntry) { + setStatus('unknown') + } else if (knownEntry.fingerprint === fp) { + setStatus(knownEntry.status) } else { - setStatus('no-key') + // Fingerprint changed — needs re-verification + setStatus('unknown') } } catch { - if (!cancelled) setStatus('no-key') + if (!cancelled) setStatus('error') } } @@ -151,13 +176,15 @@ export function FingerprintDialog({ {participantName} - {participantEmail || t('anonymous')} + {isLoggedIn && participantEmail ? participantEmail : (!isAuthenticated ? t('anonymous') : '')} - {t('description')} + {isSelf + ? (isAuthenticated ? t('descriptionSelf') : t('descriptionSelfAnonymous')) + : t('description')} {status === 'loading' && ( @@ -182,7 +209,7 @@ export function FingerprintDialog({ )} - {status === 'no-key' && !(isBasicMode && isAuthenticated) && ( + {status === 'no-key' && !(isBasicMode && isAuthenticated) && !isSelf && ( {t('fingerprintLabel')} - {fingerprint} + {formatFingerprint(fingerprint)} {status === 'trusted' && ( @@ -236,8 +263,17 @@ export function FingerprintDialog({ - {t('trustedDescription')} + {isSelf ? t('descriptionSelf') : t('trustedDescription')} + {!isSelf && ( + setStatus('unknown')} + > + {t('changeDecision')} + + )} )} @@ -252,10 +288,17 @@ export function FingerprintDialog({ {t('refusedDescription')} + setStatus('unknown')} + > + {t('changeDecision')} + )} - {status === 'unknown' && ( + {status === 'unknown' && !isSelf && ( {t('unknownDescription')} diff --git a/src/frontend/src/features/encryption/SECURITY.md b/src/frontend/src/features/encryption/SECURITY.md index c5d238a4..58dd270f 100644 --- a/src/frontend/src/features/encryption/SECURITY.md +++ b/src/frontend/src/features/encryption/SECURITY.md @@ -72,16 +72,46 @@ Non-verified key relays should show a clear warning. | Authenticated | 🔵 Blue shield | OIDC/ProConnect login | Ephemeral DH (unsigned) | No — relies on server integrity | | Anonymous | 🟡 Orange warning | None (self-declared name) | Ephemeral DH (unsigned) | No — relies on server integrity | +#### Basic mode: unencrypted frame window on connection + +**Behavior**: LiveKit's built-in Worker passes frames through unencrypted when `!isEnabled()`. + +**Mitigation**: `setE2EEEnabled(true)` is called BEFORE the room connects (in Conference.tsx), +ensuring the 'enable' message reaches the Worker before any frames flow. This eliminates the +unencrypted window in normal operation. However, edge cases (Worker message queue delays, +race conditions during reconnection) could theoretically still allow a few unencrypted frames. + +**Advanced mode**: VaultE2EEManager drops frames when the key isn't ready — no pass-through. + +#### Basic mode: "Decryption failed" overlay may not appear with wrong passphrase + +**Behavior**: When a participant joins with a wrong passphrase, the receiver may not show the +"Decryption failed" overlay. The LiveKit Worker's error throttling (`MAX_ERRORS_PER_MINUTE = 5`) +stops emitting `EncryptionError` events after 5 failures. Additionally, when a participant +reconnects, the new `ParticipantTile` mounts fresh and may not receive errors referencing +the new participant identity. + +**Impact**: The user sees a black tile but no error message explaining why. + +**Advanced mode**: VaultE2EEManager emits `EncryptionError` for each failure and signals +`ParticipantEncryptionStatusChanged(true)` on first successful decrypt, ensuring the overlay +appears and clears correctly. + ## Implementation status -- [x] LiveKit E2EE with insertable streams -- [x] Ephemeral X25519 DH key exchange (libsodium) +- [x] Basic E2EE with LiveKit Worker + passphrase in URL hash +- [x] Advanced E2EE with VaultClient iframe (XChaCha20-Poly1305) +- [x] Preserved codec header bytes for RTP compatibility - [x] Admin as key authority - [x] Server-signed trust attributes in JWT -- [x] Trust badges (verified/authenticated/anonymous) -- [x] Fingerprint verification dialog +- [x] Trust badges (verified/unknown/refused/authenticated/anonymous) +- [x] Encryption identity dialog with fingerprint verification - [x] Encryption settings in account menu (VaultClient onboarding) +- [x] Fingerprint accept/refuse with `fingerprint-changed` event +- [x] Disable recording/transcription in encrypted rooms (backend + frontend) +- [x] Lobby bypass disabled for encrypted rooms +- [x] Backend blocks encrypted room creation when `ENCRYPTION_ENABLED=false` - [ ] Signed KEY_RESPONSE (Level 1) - [ ] SAS verification (Level 2) - [ ] Restrict key propagation to verified participants only -- [ ] Disable recording/transcription UI for encrypted rooms +- [x] Mitigate unencrypted frame window (setE2EEEnabled before connection) diff --git a/src/frontend/src/features/encryption/index.ts b/src/frontend/src/features/encryption/index.ts index 367594b3..87f5aa44 100644 --- a/src/frontend/src/features/encryption/index.ts +++ b/src/frontend/src/features/encryption/index.ts @@ -10,7 +10,7 @@ export type { ParticipantEncryptionInfo } from './HybridKeyDistributor' export { EncryptionBadge } from './EncryptionBadge' export { EncryptedMeetingBanner } from './EncryptedMeetingBanner' export { EncryptionTrustModal } from './EncryptionTrustModal' -export { FingerprintDialog } from './FingerprintDialog' +export { EncryptionIdentityDialog } from './EncryptionIdentityDialog' export { useParticipantTrustLevel } from './useParticipantTrustLevel' export { PARTICIPANT_TRUST_ATTR } from './types' diff --git a/src/frontend/src/features/encryption/types.ts b/src/frontend/src/features/encryption/types.ts index d1c4b02f..33bb17b5 100644 --- a/src/frontend/src/features/encryption/types.ts +++ b/src/frontend/src/features/encryption/types.ts @@ -8,7 +8,7 @@ * - 'anonymous': Key was distributed via ephemeral DH, participant is not authenticated. * Identity is self-declared. */ -export type TrustLevel = 'verified' | 'authenticated' | 'anonymous' +export type TrustLevel = 'verified' | 'authenticated' | 'anonymous' | 'refused' | 'unknown' /** * Metadata attached to participant attributes for encryption trust level. diff --git a/src/frontend/src/features/encryption/useParticipantTrustLevel.ts b/src/frontend/src/features/encryption/useParticipantTrustLevel.ts index ca9f73b8..b87aa8df 100644 --- a/src/frontend/src/features/encryption/useParticipantTrustLevel.ts +++ b/src/frontend/src/features/encryption/useParticipantTrustLevel.ts @@ -1,49 +1,133 @@ /** - * Hook that determines a participant's trust level by checking: - * 1. If they have a registered public key in the encryption library (via VaultClient) - * 2. If they are authenticated via OIDC + * Hook that determines a participant's trust level and fingerprint status + * by checking the vault (encryption library) via VaultClient. * - * Returns "verified" if they have a public key, "authenticated" if OIDC-authenticated, - * "anonymous" otherwise. + * In advanced mode: + * - Checks if the participant has a registered public key + * - Checks the fingerprint status (trusted/refused/unknown) + * - Returns "verified" only if they have a public key + * + * In basic mode: + * - Only uses authentication status (no vault check) */ import { useEffect, useState } from 'react' import { useVaultClient } from './VaultClientProvider' import type { TrustLevel } from './types' +/** Compute a fingerprint from a public key (same as encryption repo: SHA-256, first 16 hex chars) */ +async function computeFingerprint(publicKey: ArrayBuffer): Promise { + const hash = await crypto.subtle.digest('SHA-256', publicKey) + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .slice(0, 16) +} + +/** Format for display: "a1b2c3d4e5f67890" → "A1B2 C3D4 E5F6 7890" */ +export function formatFingerprint(fp: string): string { + return fp.replace(/(.{4})/g, '$1 ').trim().toUpperCase() +} + +export type FingerprintStatus = 'loading' | 'trusted' | 'refused' | 'unknown' | 'no-key' | 'error' + export function useParticipantTrustLevel( - attributes: Record | undefined -): TrustLevel { + attributes: Record | undefined, + encryptionMode?: string, + isSelf?: boolean, +): { trustLevel: TrustLevel; fingerprintStatus: FingerprintStatus; fingerprint: string | null } { const { client: vaultClient } = useVaultClient() - const [hasPublicKey, setHasPublicKey] = useState(false) + const [fingerprintStatus, setFingerprintStatus] = useState('loading') + const [fingerprint, setFingerprint] = useState(null) const isAuthenticated = attributes?.is_authenticated === 'true' const suiteUserId = attributes?.suite_user_id + const isAdvanced = encryptionMode === 'advanced' + + // Re-check when a fingerprint is accepted/refused via VaultClient + const [revision, setRevision] = useState(0) + useEffect(() => { + if (!vaultClient) return + const handler = () => setRevision((r) => r + 1) + vaultClient.on('fingerprint-changed', handler) + return () => { vaultClient.off('fingerprint-changed', handler) } + }, [vaultClient]) useEffect(() => { - if (!vaultClient || !suiteUserId || !isAuthenticated) { - setHasPublicKey(false) + if (!isAdvanced || !isAuthenticated) { + setFingerprintStatus('no-key') + return + } + if (!vaultClient || !suiteUserId) { + setFingerprintStatus(vaultClient ? 'no-key' : 'error') return } let cancelled = false - vaultClient - .fetchPublicKeys([suiteUserId]) - .then(({ publicKeys }) => { - if (!cancelled && publicKeys[suiteUserId]) { - setHasPublicKey(true) + async function check() { + try { + const { publicKeys } = await vaultClient!.fetchPublicKeys([suiteUserId!]) + if (cancelled) return + + const publicKey = publicKeys[suiteUserId!] + if (!publicKey) { + setFingerprintStatus('no-key') + return } - }) - .catch(() => { - // VaultClient not available or user has no public key - }) - return () => { - cancelled = true + // Compute the fingerprint from the public key (SHA-256, first 16 hex chars) + const fp = await computeFingerprint(publicKey) + if (cancelled) return + setFingerprint(fp) + + // Own fingerprint is always trusted — we hold the private key + if (isSelf) { + setFingerprintStatus('trusted') + return + } + + // Check if we have a known fingerprint in the local registry + const { fingerprints: known } = await vaultClient!.getKnownFingerprints() + if (cancelled) return + + const knownEntry = known[suiteUserId!] + if (!knownEntry) { + // Never seen — unknown, needs explicit acceptance + setFingerprintStatus('unknown') + } else if (knownEntry.fingerprint === fp) { + // Same fingerprint — use stored status + setFingerprintStatus(knownEntry.status as FingerprintStatus) + } else { + // Different fingerprint — key changed, needs re-verification + setFingerprintStatus('unknown') + } + } catch { + if (!cancelled) setFingerprintStatus('error') + } } - }, [vaultClient, suiteUserId, isAuthenticated]) - if (hasPublicKey) return 'verified' - if (isAuthenticated) return 'authenticated' - return 'anonymous' + check() + return () => { cancelled = true } + }, [vaultClient, suiteUserId, isAuthenticated, isAdvanced, isSelf, revision]) + + // Derive trust level from fingerprint status + let trustLevel: TrustLevel + if (!isAuthenticated) { + trustLevel = 'anonymous' + } else if (!isAdvanced) { + // Basic mode: only authentication matters + trustLevel = 'authenticated' + } else if (fingerprintStatus === 'trusted') { + trustLevel = 'verified' + } else if (fingerprintStatus === 'refused') { + trustLevel = 'refused' + } else if (fingerprintStatus === 'no-key' || fingerprintStatus === 'error') { + // Authenticated but no vault keys — show as authenticated (blue) + trustLevel = 'authenticated' + } else { + // 'unknown' or 'loading' — has key but not yet verified + trustLevel = 'unknown' + } + + return { trustLevel, fingerprintStatus, fingerprint } } diff --git a/src/frontend/src/features/notifications/components/WaitingParticipantNotification.tsx b/src/frontend/src/features/notifications/components/WaitingParticipantNotification.tsx index 1ce59384..9fa0d06a 100644 --- a/src/frontend/src/features/notifications/components/WaitingParticipantNotification.tsx +++ b/src/frontend/src/features/notifications/components/WaitingParticipantNotification.tsx @@ -3,7 +3,7 @@ import { HStack, VStack } from '@/styled-system/jsx' import { Avatar } from '@/components/Avatar' import { Button, Text } from '@/primitives' import { css } from '@/styled-system/css' -import { RiInfinityLine, RiShieldCheckLine, RiAlertLine } from '@remixicon/react' +import { RiInfinityLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { useEffect, useRef, useState } from 'react' import { usePrevious } from '@/hooks/usePrevious' @@ -12,10 +12,26 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useNotificationSound } from '../hooks/useSoundNotification' import { NotificationType } from '@/features/notifications' +import { EncryptionBadge } from '@/features/encryption' +import { useParticipantTrustLevel } from '@/features/encryption/useParticipantTrustLevel' +import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' +import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom' + +const WaitingBadge = ({ participant }: { participant: WaitingParticipant }) => { + const roomData = useRoomData() + const attrs = { + is_authenticated: participant.is_authenticated ? 'true' : 'false', + suite_user_id: participant.suite_user_id || '', + } + const { trustLevel } = useParticipantTrustLevel(attrs, roomData?.encryption_mode) + return +} export const NOTIFICATION_DISPLAY_DURATION = 10000 export const WaitingParticipantNotification = () => { + const roomData = useRoomData() + const encrypted = isEncryptedRoom(roomData) const { triggerNotificationSound } = useNotificationSound() const { t } = useTranslation('notifications', { @@ -107,11 +123,7 @@ export const WaitingParticipantNotification = () => { context="list" notification /> - {waitingParticipants[0].is_authenticated ? ( - - ) : ( - - )} + {encrypted && } { - const onConnected = async () => { - try { - await room.setE2EEEnabled(true) - } catch (err) { - console.error('[Encryption] E2EE enable failed:', err) - } + // Enable E2EE BEFORE connecting — sets encryptionType=GCM so tracks + // are published with encryption metadata from the start. + // Also sends 'enable' to the Worker before any frames flow, + // eliminating the unencrypted frame window. + try { + await room.setE2EEEnabled(true) + } catch (err) { + console.error('[Encryption] E2EE enable failed:', err) } - if (room.state === 'connected') onConnected() - else room.once('connected', onConnected) setEncryptionSetupComplete(true) }) @@ -293,6 +293,18 @@ export const Conference = ({ }, [room, encryptionEnabled, encryptionSetupComplete, isAdmin, useVaultE2EE]) + // In basic encrypted rooms, the passphrase is in the URL hash. + // If the user changes the hash (e.g. corrects a typo), reload the page + // so the new passphrase is picked up by the encryption setup. + useEffect(() => { + if (!encryptionEnabled || useVaultE2EE) return + const handleHashChange = () => { + window.location.reload() + } + window.addEventListener('hashchange', handleHashChange) + return () => window.removeEventListener('hashchange', handleHashChange) + }, [encryptionEnabled, useVaultE2EE]) + useEffect(() => { /** * Warm up connection to LiveKit server before joining room diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx index bcd5441b..7e1b85ce 100644 --- a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx @@ -26,9 +26,9 @@ import { useRoomContext } from '@livekit/components-react' import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand' import { EncryptionBadge, - getTrustLevelFromAttributes, - FingerprintDialog, + EncryptionIdentityDialog, } from '@/features/encryption' +import { useParticipantTrustLevel } from '@/features/encryption/useParticipantTrustLevel' import { useRoomData } from '../hooks/useRoomData' import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom' import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner' @@ -94,11 +94,11 @@ export const ParticipantTile: ( const roomData = useRoomData() const isEncryptedRoom = checkEncryptedRoom(roomData) const isAdmin = useIsAdminOrOwner() - const [isFingerprintOpen, setIsFingerprintOpen] = React.useState(false) + const [isIdentityOpen, setIsFingerprintOpen] = React.useState(false) const participantAttrs = trackReference.participant.attributes as Record | undefined - const trustLevel = getTrustLevelFromAttributes(participantAttrs, roomData?.encryption_mode) + const { trustLevel, fingerprintStatus, fingerprint: participantFingerprint } = useParticipantTrustLevel(participantAttrs, roomData?.encryption_mode, trackReference.participant.isLocal) const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' }) - const badgeTooltip = trustLevel ? tBadge(trustLevel) : undefined + const badgeTooltip = tBadge(trustLevel) // Track decryption failures via EncryptionError events from LiveKit. // useIsEncrypted returns true when E2EE is enabled, NOT when frames decrypt successfully. @@ -331,7 +331,7 @@ export const ParticipantTile: ( }} /> )} - {isEncryptedRoom && isAdmin && !isScreenShare ? ( + {isEncryptedRoom && !isScreenShare ? ( {encryptedRoom && ( - )} diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index bb6ff9df..8b03a7ed 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -698,9 +698,11 @@ "banner": "Basic encryption", "bannerStrong": "Advanced encryption", "badge": { - "verified": "Advanced encryption", - "authenticated": "Encrypted (authenticated)", - "anonymous": "Encrypted (anonymous)", + "verified": "Verified — fingerprint trusted", + "unknown": "Not yet verified — click to check fingerprint", + "refused": "Refused — fingerprint previously rejected", + "authenticated": "Authenticated via ProConnect", + "anonymous": "Unverified identity", "default": "Encrypted" }, "bannerModal": { @@ -733,6 +735,8 @@ "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.", + "descriptionSelf": "This is your encryption identity as seen by other participants. They can use it to verify that you are who you claim to be.", + "descriptionSelfAnonymous": "You joined without signing in. Other participants see your name as self-declared and unverified. Sign in via ProConnect for a verified identity.", "loading": "Checking 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.", @@ -747,6 +751,7 @@ "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", + "changeDecision": "Change my decision", "anonymous": "Unverified identity" }, "trustModal": { diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 1523a168..4726be4b 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -698,9 +698,11 @@ "banner": "Chiffrement basique", "bannerStrong": "Chiffrement avancé", "badge": { - "verified": "Chiffrement avancé", - "authenticated": "Chiffré (authentifié)", - "anonymous": "Chiffré (anonyme)", + "verified": "Vérifié — empreinte approuvée", + "unknown": "Non vérifié — cliquez pour vérifier l'empreinte", + "refused": "Refusé — empreinte précédemment rejetée", + "authenticated": "Authentifié via ProConnect", + "anonymous": "Identité non vérifiée", "default": "Chiffré" }, "bannerModal": { @@ -733,6 +735,8 @@ "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.", + "descriptionSelf": "Ceci est votre identité de chiffrement telle que vue par les autres participants. Ils peuvent l'utiliser pour vérifier que vous êtes bien qui vous prétendez être.", + "descriptionSelfAnonymous": "Vous avez rejoint sans vous connecter. Les autres participants voient votre nom comme auto-déclaré et non vérifié. Connectez-vous via ProConnect pour une identité vérifiée.", "loading": "Vérification des 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.", @@ -747,6 +751,7 @@ "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", + "changeDecision": "Modifier ma décision", "anonymous": "Identité non vérifiée" }, "trustModal": {