This commit is contained in:
Thomas Ramé
2026-04-08 15:37:44 +02:00
parent b60193065c
commit 8fb4954c7d
13 changed files with 351 additions and 119 deletions
@@ -1,13 +1,24 @@
/** /**
* Per-participant encryption trust badge. * Per-participant encryption trust badge.
* *
* Single icon per trust level: * In advanced mode:
* - "verified": Green shield — identity confirmed via PKI * - "verified": Green shield — fingerprint explicitly trusted
* - "authenticated": Blue shield — ProConnect-authenticated * - "unknown": Grey shield — has public key, not yet verified
* - "anonymous": Orange warning — identity not verified * - "refused": Red shield — fingerprint previously refused
* - default: Lock icon — encrypted, trust level unknown * - "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 type { TrustLevel } from './types'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -33,6 +44,14 @@ export function EncryptionBadge({
icon = <RiShieldCheckFill size={14} color="#22c55e" /> icon = <RiShieldCheckFill size={14} color="#22c55e" />
label = t('verified') label = t('verified')
break break
case 'unknown':
icon = <RiShieldFill size={14} color="#9ca3af" />
label = t('unknown')
break
case 'refused':
icon = <RiShieldCrossFill size={14} color="#ef4444" />
label = t('refused')
break
case 'authenticated': case 'authenticated':
icon = <RiShieldCheckFill size={14} color="#3b82f6" /> icon = <RiShieldCheckFill size={14} color="#3b82f6" />
label = t('authenticated') label = t('authenticated')
@@ -9,6 +9,7 @@ import { css } from '@/styled-system/css'
import { VStack, HStack } from '@/styled-system/jsx' import { VStack, HStack } from '@/styled-system/jsx'
import { Dialog, Text, Button } from '@/primitives' import { Dialog, Text, Button } from '@/primitives'
import { Avatar } from '@/components/Avatar' import { Avatar } from '@/components/Avatar'
import { useUser } from '@/features/auth'
import { import {
RiShieldCheckFill, RiShieldCheckFill,
RiShieldCheckLine, RiShieldCheckLine,
@@ -18,9 +19,10 @@ import {
} from '@remixicon/react' } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useVaultClient } from './VaultClientProvider' import { useVaultClient } from './VaultClientProvider'
import { formatFingerprint } from './useParticipantTrustLevel'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
interface FingerprintDialogProps { interface EncryptionIdentityDialogProps {
isOpen: boolean isOpen: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
participantName: string participantName: string
@@ -28,11 +30,14 @@ interface FingerprintDialogProps {
suiteUserId?: string suiteUserId?: string
isAuthenticated: boolean isAuthenticated: boolean
encryptionMode?: 'basic' | 'advanced' | 'none' encryptionMode?: 'basic' | 'advanced' | 'none'
isSelf?: boolean
preloadedFingerprint?: string | null
preloadedFingerprintStatus?: string | null
} }
type FingerprintStatus = 'loading' | 'no-key' | 'trusted' | 'refused' | 'unknown' | 'error' type FingerprintStatus = 'loading' | 'no-key' | 'trusted' | 'refused' | 'unknown' | 'error'
export function FingerprintDialog({ export function EncryptionIdentityDialog({
isOpen, isOpen,
onOpenChange, onOpenChange,
participantName, participantName,
@@ -40,11 +45,23 @@ export function FingerprintDialog({
suiteUserId, suiteUserId,
isAuthenticated, isAuthenticated,
encryptionMode, encryptionMode,
}: FingerprintDialogProps) { isSelf,
preloadedFingerprint,
preloadedFingerprintStatus,
}: EncryptionIdentityDialogProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.fingerprint' }) const { t } = useTranslation('rooms', { keyPrefix: 'encryption.fingerprint' })
const { client: vaultClient } = useVaultClient() const { client: vaultClient } = useVaultClient()
const [status, setStatus] = useState<FingerprintStatus>('loading') const { isLoggedIn } = useUser()
const [fingerprint, setFingerprint] = useState<string | null>(null) const [status, setStatus] = useState<FingerprintStatus>(
(preloadedFingerprintStatus as FingerprintStatus) || 'loading'
)
const [fingerprint, setFingerprint] = useState<string | null>(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' const isBasicMode = encryptionMode !== 'advanced'
@@ -68,7 +85,6 @@ export function FingerprintDialog({
async function checkFingerprint() { async function checkFingerprint() {
try { try {
// Timeout after 3 seconds — the encryption server may not be available
const timeout = new Promise<never>((_, reject) => const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 3000) setTimeout(() => reject(new Error('timeout')), 3000)
) )
@@ -85,25 +101,34 @@ export function FingerprintDialog({
return return
} }
const { results } = await Promise.race([ // Compute fingerprint from the public key (SHA-256, first 16 hex chars)
vaultClient!.checkFingerprints( const hash = await crypto.subtle.digest('SHA-256', publicKey)
{ [suiteUserId!]: '' }, const fp = Array.from(new Uint8Array(hash))
undefined .map((b) => b.toString(16).padStart(2, '0'))
), .join('')
timeout, .slice(0, 16)
])
if (cancelled) return if (cancelled) return
setFingerprint(fp)
const result = results.find((r) => r.userId === suiteUserId) // Check local registry without triggering TOFU auto-trust
if (result) { const { fingerprints: known } = await Promise.race([
setFingerprint(result.providedFingerprint) vaultClient!.getKnownFingerprints(),
setStatus(result.status) timeout,
])
if (cancelled) return
const knownEntry = known[suiteUserId!]
if (!knownEntry) {
setStatus('unknown')
} else if (knownEntry.fingerprint === fp) {
setStatus(knownEntry.status)
} else { } else {
setStatus('no-key') // Fingerprint changed — needs re-verification
setStatus('unknown')
} }
} catch { } catch {
if (!cancelled) setStatus('no-key') if (!cancelled) setStatus('error')
} }
} }
@@ -151,13 +176,15 @@ export function FingerprintDialog({
<VStack gap="0" alignItems="start"> <VStack gap="0" alignItems="start">
<Text className={css({ fontWeight: 600, fontSize: '0.9rem' })}>{participantName}</Text> <Text className={css({ fontWeight: 600, fontSize: '0.9rem' })}>{participantName}</Text>
<Text variant="note" className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}> <Text variant="note" className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}>
{participantEmail || t('anonymous')} {isLoggedIn && participantEmail ? participantEmail : (!isAuthenticated ? t('anonymous') : '')}
</Text> </Text>
</VStack> </VStack>
</HStack> </HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}> <Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('description')} {isSelf
? (isAuthenticated ? t('descriptionSelf') : t('descriptionSelfAnonymous'))
: t('description')}
</Text> </Text>
{status === 'loading' && ( {status === 'loading' && (
@@ -182,7 +209,7 @@ export function FingerprintDialog({
</HStack> </HStack>
)} )}
{status === 'no-key' && !(isBasicMode && isAuthenticated) && ( {status === 'no-key' && !(isBasicMode && isAuthenticated) && !isSelf && (
<HStack <HStack
gap="0.5rem" gap="0.5rem"
className={css({ className={css({
@@ -224,7 +251,7 @@ export function FingerprintDialog({
<Text variant="note" className={css({ fontSize: '0.7rem', fontFamily: 'inherit' })}> <Text variant="note" className={css({ fontSize: '0.7rem', fontFamily: 'inherit' })}>
{t('fingerprintLabel')} {t('fingerprintLabel')}
</Text> </Text>
{fingerprint} {formatFingerprint(fingerprint)}
</VStack> </VStack>
{status === 'trusted' && ( {status === 'trusted' && (
@@ -236,8 +263,17 @@ export function FingerprintDialog({
</Text> </Text>
</HStack> </HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}> <Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('trustedDescription')} {isSelf ? t('descriptionSelf') : t('trustedDescription')}
</Text> </Text>
{!isSelf && (
<Text
variant="note"
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
onClick={() => setStatus('unknown')}
>
{t('changeDecision')}
</Text>
)}
</VStack> </VStack>
)} )}
@@ -252,10 +288,17 @@ export function FingerprintDialog({
<Text variant="note" className={css({ fontSize: '0.8rem' })}> <Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('refusedDescription')} {t('refusedDescription')}
</Text> </Text>
<Text
variant="note"
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
onClick={() => setStatus('unknown')}
>
{t('changeDecision')}
</Text>
</VStack> </VStack>
)} )}
{status === 'unknown' && ( {status === 'unknown' && !isSelf && (
<VStack gap="0.5rem" className={css({ width: '100%' })}> <VStack gap="0.5rem" className={css({ width: '100%' })}>
<Text variant="note" className={css({ fontSize: '0.8rem' })}> <Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('unknownDescription')} {t('unknownDescription')}
@@ -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 | | 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 | | 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 ## Implementation status
- [x] LiveKit E2EE with insertable streams - [x] Basic E2EE with LiveKit Worker + passphrase in URL hash
- [x] Ephemeral X25519 DH key exchange (libsodium) - [x] Advanced E2EE with VaultClient iframe (XChaCha20-Poly1305)
- [x] Preserved codec header bytes for RTP compatibility
- [x] Admin as key authority - [x] Admin as key authority
- [x] Server-signed trust attributes in JWT - [x] Server-signed trust attributes in JWT
- [x] Trust badges (verified/authenticated/anonymous) - [x] Trust badges (verified/unknown/refused/authenticated/anonymous)
- [x] Fingerprint verification dialog - [x] Encryption identity dialog with fingerprint verification
- [x] Encryption settings in account menu (VaultClient onboarding) - [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) - [ ] Signed KEY_RESPONSE (Level 1)
- [ ] SAS verification (Level 2) - [ ] SAS verification (Level 2)
- [ ] Restrict key propagation to verified participants only - [ ] Restrict key propagation to verified participants only
- [ ] Disable recording/transcription UI for encrypted rooms - [x] Mitigate unencrypted frame window (setE2EEEnabled before connection)
@@ -10,7 +10,7 @@ export type { ParticipantEncryptionInfo } from './HybridKeyDistributor'
export { EncryptionBadge } from './EncryptionBadge' export { EncryptionBadge } from './EncryptionBadge'
export { EncryptedMeetingBanner } from './EncryptedMeetingBanner' export { EncryptedMeetingBanner } from './EncryptedMeetingBanner'
export { EncryptionTrustModal } from './EncryptionTrustModal' export { EncryptionTrustModal } from './EncryptionTrustModal'
export { FingerprintDialog } from './FingerprintDialog' export { EncryptionIdentityDialog } from './EncryptionIdentityDialog'
export { useParticipantTrustLevel } from './useParticipantTrustLevel' export { useParticipantTrustLevel } from './useParticipantTrustLevel'
export { PARTICIPANT_TRUST_ATTR } from './types' export { PARTICIPANT_TRUST_ATTR } from './types'
@@ -8,7 +8,7 @@
* - 'anonymous': Key was distributed via ephemeral DH, participant is not authenticated. * - 'anonymous': Key was distributed via ephemeral DH, participant is not authenticated.
* Identity is self-declared. * 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. * Metadata attached to participant attributes for encryption trust level.
@@ -1,49 +1,133 @@
/** /**
* Hook that determines a participant's trust level by checking: * Hook that determines a participant's trust level and fingerprint status
* 1. If they have a registered public key in the encryption library (via VaultClient) * by checking the vault (encryption library) via VaultClient.
* 2. If they are authenticated via OIDC
* *
* Returns "verified" if they have a public key, "authenticated" if OIDC-authenticated, * In advanced mode:
* "anonymous" otherwise. * - 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 { useEffect, useState } from 'react'
import { useVaultClient } from './VaultClientProvider' import { useVaultClient } from './VaultClientProvider'
import type { TrustLevel } from './types' 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<string> {
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( export function useParticipantTrustLevel(
attributes: Record<string, string> | undefined attributes: Record<string, string> | undefined,
): TrustLevel { encryptionMode?: string,
isSelf?: boolean,
): { trustLevel: TrustLevel; fingerprintStatus: FingerprintStatus; fingerprint: string | null } {
const { client: vaultClient } = useVaultClient() const { client: vaultClient } = useVaultClient()
const [hasPublicKey, setHasPublicKey] = useState(false) const [fingerprintStatus, setFingerprintStatus] = useState<FingerprintStatus>('loading')
const [fingerprint, setFingerprint] = useState<string | null>(null)
const isAuthenticated = attributes?.is_authenticated === 'true' const isAuthenticated = attributes?.is_authenticated === 'true'
const suiteUserId = attributes?.suite_user_id 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(() => { useEffect(() => {
if (!vaultClient || !suiteUserId || !isAuthenticated) { if (!isAdvanced || !isAuthenticated) {
setHasPublicKey(false) setFingerprintStatus('no-key')
return
}
if (!vaultClient || !suiteUserId) {
setFingerprintStatus(vaultClient ? 'no-key' : 'error')
return return
} }
let cancelled = false let cancelled = false
vaultClient async function check() {
.fetchPublicKeys([suiteUserId]) try {
.then(({ publicKeys }) => { const { publicKeys } = await vaultClient!.fetchPublicKeys([suiteUserId!])
if (!cancelled && publicKeys[suiteUserId]) { if (cancelled) return
setHasPublicKey(true)
const publicKey = publicKeys[suiteUserId!]
if (!publicKey) {
setFingerprintStatus('no-key')
return
} }
})
.catch(() => {
// VaultClient not available or user has no public key
})
return () => { // Compute the fingerprint from the public key (SHA-256, first 16 hex chars)
cancelled = true 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' check()
if (isAuthenticated) return 'authenticated' return () => { cancelled = true }
return 'anonymous' }, [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 }
} }
@@ -3,7 +3,7 @@ import { HStack, VStack } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar' import { Avatar } from '@/components/Avatar'
import { Button, Text } from '@/primitives' import { Button, Text } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { RiInfinityLine, RiShieldCheckLine, RiAlertLine } from '@remixicon/react' import { RiInfinityLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { usePrevious } from '@/hooks/usePrevious' 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 { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useNotificationSound } from '../hooks/useSoundNotification' import { useNotificationSound } from '../hooks/useSoundNotification'
import { NotificationType } from '@/features/notifications' 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 <EncryptionBadge isEncrypted={true} trustLevel={trustLevel} />
}
export const NOTIFICATION_DISPLAY_DURATION = 10000 export const NOTIFICATION_DISPLAY_DURATION = 10000
export const WaitingParticipantNotification = () => { export const WaitingParticipantNotification = () => {
const roomData = useRoomData()
const encrypted = isEncryptedRoom(roomData)
const { triggerNotificationSound } = useNotificationSound() const { triggerNotificationSound } = useNotificationSound()
const { t } = useTranslation('notifications', { const { t } = useTranslation('notifications', {
@@ -107,11 +123,7 @@ export const WaitingParticipantNotification = () => {
context="list" context="list"
notification notification
/> />
{waitingParticipants[0].is_authenticated ? ( {encrypted && <WaitingBadge participant={waitingParticipants[0]} />}
<RiShieldCheckLine size={16} color="#3b82f6" />
) : (
<RiAlertLine size={16} color="#f59e0b" />
)}
<Text <Text
variant="sm" variant="sm"
margin={false} margin={false}
@@ -275,15 +275,15 @@ export const Conference = ({
keyProvider keyProvider
.setKey(passphrase) .setKey(passphrase)
.then(async () => { .then(async () => {
const onConnected = async () => { // Enable E2EE BEFORE connecting — sets encryptionType=GCM so tracks
try { // are published with encryption metadata from the start.
await room.setE2EEEnabled(true) // Also sends 'enable' to the Worker before any frames flow,
} catch (err) { // eliminating the unencrypted frame window.
console.error('[Encryption] E2EE enable failed:', err) 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) setEncryptionSetupComplete(true)
}) })
@@ -293,6 +293,18 @@ export const Conference = ({
}, [room, encryptionEnabled, encryptionSetupComplete, isAdmin, useVaultE2EE]) }, [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(() => { useEffect(() => {
/** /**
* Warm up connection to LiveKit server before joining room * Warm up connection to LiveKit server before joining room
@@ -26,9 +26,9 @@ import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand' import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { import {
EncryptionBadge, EncryptionBadge,
getTrustLevelFromAttributes, EncryptionIdentityDialog,
FingerprintDialog,
} from '@/features/encryption' } from '@/features/encryption'
import { useParticipantTrustLevel } from '@/features/encryption/useParticipantTrustLevel'
import { useRoomData } from '../hooks/useRoomData' import { useRoomData } from '../hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom' import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
@@ -94,11 +94,11 @@ export const ParticipantTile: (
const roomData = useRoomData() const roomData = useRoomData()
const isEncryptedRoom = checkEncryptedRoom(roomData) const isEncryptedRoom = checkEncryptedRoom(roomData)
const isAdmin = useIsAdminOrOwner() const isAdmin = useIsAdminOrOwner()
const [isFingerprintOpen, setIsFingerprintOpen] = React.useState(false) const [isIdentityOpen, setIsFingerprintOpen] = React.useState(false)
const participantAttrs = trackReference.participant.attributes as Record<string, string> | undefined const participantAttrs = trackReference.participant.attributes as Record<string, string> | 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 { 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. // Track decryption failures via EncryptionError events from LiveKit.
// useIsEncrypted returns true when E2EE is enabled, NOT when frames decrypt successfully. // useIsEncrypted returns true when E2EE is enabled, NOT when frames decrypt successfully.
@@ -331,7 +331,7 @@ export const ParticipantTile: (
}} }}
/> />
)} )}
{isEncryptedRoom && isAdmin && !isScreenShare ? ( {isEncryptedRoom && !isScreenShare ? (
<Button <Button
variant="greyscale" variant="greyscale"
size="sm" size="sm"
@@ -373,7 +373,7 @@ export const ParticipantTile: (
{(isEncrypted || isEncryptedRoom) && !isScreenShare && ( {(isEncrypted || isEncryptedRoom) && !isScreenShare && (
<EncryptionBadge <EncryptionBadge
isEncrypted={true} isEncrypted={true}
trustLevel={getTrustLevelFromAttributes(participantAttrs, roomData?.encryption_mode)} trustLevel={trustLevel}
/> />
)} )}
<div className="lk-participant-name-wrapper"> <div className="lk-participant-name-wrapper">
@@ -406,15 +406,18 @@ export const ParticipantTile: (
), ),
})} })}
</KeyboardShortcutHint> </KeyboardShortcutHint>
{isEncryptedRoom && isAdmin && ( {isEncryptedRoom && (
<FingerprintDialog <EncryptionIdentityDialog
isOpen={isFingerprintOpen} isOpen={isIdentityOpen}
onOpenChange={setIsFingerprintOpen} onOpenChange={setIsFingerprintOpen}
participantName={trackReference.participant.name || trackReference.participant.identity} participantName={trackReference.participant.name || trackReference.participant.identity}
participantEmail={participantAttrs?.email} participantEmail={participantAttrs?.email}
suiteUserId={participantAttrs?.suite_user_id} suiteUserId={participantAttrs?.suite_user_id}
isAuthenticated={participantAttrs?.is_authenticated === 'true'} isAuthenticated={participantAttrs?.is_authenticated === 'true'}
encryptionMode={roomData?.encryption_mode} encryptionMode={roomData?.encryption_mode}
isSelf={trackReference.participant.isLocal}
preloadedFingerprint={participantFingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/> />
)} )}
</div> </div>
@@ -21,7 +21,8 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton' import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
import { PinBadge } from './PinBadge' import { PinBadge } from './PinBadge'
import { EncryptionBadge, getTrustLevelFromAttributes, FingerprintDialog } from '@/features/encryption' import { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
import { useParticipantTrustLevel } from '@/features/encryption/useParticipantTrustLevel'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom as isEncryptedRoomFn } from '@/features/rooms/api/ApiRoom' import { isEncryptedRoom as isEncryptedRoomFn } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
@@ -108,11 +109,11 @@ export const ParticipantListItem = ({
const isAdmin = useIsAdminOrOwner() const isAdmin = useIsAdminOrOwner()
const { isLoggedIn } = useUser() const { isLoggedIn } = useUser()
const { t: tEncBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' }) const { t: tEncBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
const [isFingerprintOpen, setIsFingerprintOpen] = useState(false) const [isIdentityOpen, setIsFingerprintOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
const attrs = participant.attributes as Record<string, string> | undefined const attrs = participant.attributes as Record<string, string> | undefined
const trustLevel = getTrustLevelFromAttributes(attrs, roomData?.encryption_mode) const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(attrs, roomData?.encryption_mode, isLocal(participant))
const badgeTooltip = trustLevel ? tEncBadge(trustLevel) : undefined const badgeTooltip = tEncBadge(trustLevel)
return ( return (
<HStack <HStack
role="listitem" role="listitem"
@@ -139,7 +140,7 @@ export const ParticipantListItem = ({
size="sm" size="sm"
tooltip={badgeTooltip} tooltip={badgeTooltip}
aria-label={badgeTooltip} aria-label={badgeTooltip}
onPress={isAdmin ? () => setIsFingerprintOpen(true) : undefined} onPress={() => setIsFingerprintOpen(true)}
className={css({ className={css({
padding: '0.1rem 0.25rem !important', padding: '0.1rem 0.25rem !important',
minWidth: 'auto !important', minWidth: 'auto !important',
@@ -148,9 +149,9 @@ export const ParticipantListItem = ({
borderRadius: '0.25rem !important', borderRadius: '0.25rem !important',
backgroundColor: 'transparent !important', backgroundColor: 'transparent !important',
color: 'greyscale.900 !important', color: 'greyscale.900 !important',
cursor: isAdmin ? 'pointer' : 'default', cursor: isEncryptedRoom ? 'pointer' : 'default',
'&[data-hovered]': { '&[data-hovered]': {
backgroundColor: isAdmin ? 'greyscale.100 !important' : 'transparent !important', backgroundColor: 'greyscale.100 !important',
}, },
})} })}
> >
@@ -244,15 +245,18 @@ export const ParticipantListItem = ({
<MicIndicator participant={participant} /> <MicIndicator participant={participant} />
<ParticipantMenuButton participant={participant} /> <ParticipantMenuButton participant={participant} />
</HStack> </HStack>
{isEncryptedRoom && isAdmin && ( {isEncryptedRoom && (
<FingerprintDialog <EncryptionIdentityDialog
isOpen={isFingerprintOpen} isOpen={isIdentityOpen}
onOpenChange={setIsFingerprintOpen} onOpenChange={setIsFingerprintOpen}
participantName={name} participantName={name}
participantEmail={attrs?.email} participantEmail={attrs?.email}
suiteUserId={attrs?.suite_user_id} suiteUserId={attrs?.suite_user_id}
isAuthenticated={attrs?.is_authenticated === 'true'} isAuthenticated={attrs?.is_authenticated === 'true'}
encryptionMode={roomData?.encryption_mode} encryptionMode={roomData?.encryption_mode}
isSelf={isLocal(participant)}
preloadedFingerprint={fingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/> />
)} )}
</HStack> </HStack>
@@ -7,20 +7,10 @@ import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom' import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { EncryptionBadge, getTrustLevelFromAttributes, FingerprintDialog } from '@/features/encryption' import { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
import type { TrustLevel } from '@/features/encryption/types' import { useParticipantTrustLevel, formatFingerprint } from '@/features/encryption/useParticipantTrustLevel'
import { useState } from 'react' import { useState } from 'react'
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 = ({ export const WaitingParticipantListItem = ({
participant, participant,
onAction, onAction,
@@ -32,8 +22,13 @@ export const WaitingParticipantListItem = ({
const roomData = useRoomData() const roomData = useRoomData()
const encryptedRoom = isEncryptedRoom(roomData) const encryptedRoom = isEncryptedRoom(roomData)
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' }) const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isIdentityOpen, setIsDialogOpen] = useState(false)
const trustLevel = waitingParticipantTrustLevel(participant, roomData?.encryption_mode) // Build attributes-like object for the hook (waiting participants aren't in LiveKit yet)
const waitingAttrs = {
is_authenticated: participant.is_authenticated ? 'true' : 'false',
suite_user_id: participant.suite_user_id || '',
}
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(waitingAttrs, roomData?.encryption_mode)
const badgeTooltip = encryptedRoom ? tBadge(trustLevel) : undefined const badgeTooltip = encryptedRoom ? tBadge(trustLevel) : undefined
return ( return (
@@ -107,6 +102,24 @@ export const WaitingParticipantListItem = ({
{participant.username} {participant.username}
</Text> </Text>
)} )}
{encryptedRoom && fingerprint && (
<Text
variant="sm"
className={css({
fontSize: '0.6rem',
fontFamily: 'monospace',
color: 'greyscale.400',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
paddingLeft: '0.25rem',
width: '100%',
minWidth: 0,
})}
>
{formatFingerprint(fingerprint)}
</Text>
)}
{encryptedRoom && (() => { {encryptedRoom && (() => {
const email = participant.is_authenticated && participant.email const email = participant.is_authenticated && participant.email
? participant.email ? participant.email
@@ -173,14 +186,16 @@ export const WaitingParticipantListItem = ({
</Button> </Button>
</HStack> </HStack>
{encryptedRoom && ( {encryptedRoom && (
<FingerprintDialog <EncryptionIdentityDialog
isOpen={isDialogOpen} isOpen={isIdentityOpen}
onOpenChange={setIsDialogOpen} onOpenChange={setIsDialogOpen}
participantName={participant.username} participantName={participant.username}
participantEmail={participant.email} participantEmail={participant.email}
suiteUserId={participant.suite_user_id} suiteUserId={participant.suite_user_id}
isAuthenticated={participant.is_authenticated} isAuthenticated={participant.is_authenticated}
encryptionMode={roomData?.encryption_mode} encryptionMode={roomData?.encryption_mode}
preloadedFingerprint={fingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/> />
)} )}
</HStack> </HStack>
+8 -3
View File
@@ -698,9 +698,11 @@
"banner": "Basic encryption", "banner": "Basic encryption",
"bannerStrong": "Advanced encryption", "bannerStrong": "Advanced encryption",
"badge": { "badge": {
"verified": "Advanced encryption", "verified": "Verified — fingerprint trusted",
"authenticated": "Encrypted (authenticated)", "unknown": "Not yet verified — click to check fingerprint",
"anonymous": "Encrypted (anonymous)", "refused": "Refused — fingerprint previously rejected",
"authenticated": "Authenticated via ProConnect",
"anonymous": "Unverified identity",
"default": "Encrypted" "default": "Encrypted"
}, },
"bannerModal": { "bannerModal": {
@@ -733,6 +735,8 @@
"fingerprint": { "fingerprint": {
"title": "Encryption identity", "title": "Encryption identity",
"description": "This shows the encryption identity of this participant. It allows you to verify they are who they claim to be.", "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...", "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.", "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.", "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.", "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", "accept": "Trust",
"refuse": "Refuse", "refuse": "Refuse",
"changeDecision": "Change my decision",
"anonymous": "Unverified identity" "anonymous": "Unverified identity"
}, },
"trustModal": { "trustModal": {
+8 -3
View File
@@ -698,9 +698,11 @@
"banner": "Chiffrement basique", "banner": "Chiffrement basique",
"bannerStrong": "Chiffrement avancé", "bannerStrong": "Chiffrement avancé",
"badge": { "badge": {
"verified": "Chiffrement avancé", "verified": "Vérifié — empreinte approuvée",
"authenticated": "Chiffré (authentifié)", "unknown": "Non vérifié — cliquez pour vérifier l'empreinte",
"anonymous": "Chiffré (anonyme)", "refused": "Refusé — empreinte précédemment rejetée",
"authenticated": "Authentifié via ProConnect",
"anonymous": "Identité non vérifiée",
"default": "Chiffré" "default": "Chiffré"
}, },
"bannerModal": { "bannerModal": {
@@ -733,6 +735,8 @@
"fingerprint": { "fingerprint": {
"title": "Identité chiffrée", "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.", "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...", "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.", "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.", "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é.", "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", "accept": "Approuver",
"refuse": "Refuser", "refuse": "Refuser",
"changeDecision": "Modifier ma décision",
"anonymous": "Identité non vérifiée" "anonymous": "Identité non vérifiée"
}, },
"trustModal": { "trustModal": {