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.
*
* 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 = <RiShieldCheckFill size={14} color="#22c55e" />
label = t('verified')
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':
icon = <RiShieldCheckFill size={14} color="#3b82f6" />
label = t('authenticated')
@@ -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<FingerprintStatus>('loading')
const [fingerprint, setFingerprint] = useState<string | null>(null)
const { isLoggedIn } = useUser()
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'
@@ -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<never>((_, 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({
<VStack gap="0" alignItems="start">
<Text className={css({ fontWeight: 600, fontSize: '0.9rem' })}>{participantName}</Text>
<Text variant="note" className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}>
{participantEmail || t('anonymous')}
{isLoggedIn && participantEmail ? participantEmail : (!isAuthenticated ? t('anonymous') : '')}
</Text>
</VStack>
</HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('description')}
{isSelf
? (isAuthenticated ? t('descriptionSelf') : t('descriptionSelfAnonymous'))
: t('description')}
</Text>
{status === 'loading' && (
@@ -182,7 +209,7 @@ export function FingerprintDialog({
</HStack>
)}
{status === 'no-key' && !(isBasicMode && isAuthenticated) && (
{status === 'no-key' && !(isBasicMode && isAuthenticated) && !isSelf && (
<HStack
gap="0.5rem"
className={css({
@@ -224,7 +251,7 @@ export function FingerprintDialog({
<Text variant="note" className={css({ fontSize: '0.7rem', fontFamily: 'inherit' })}>
{t('fingerprintLabel')}
</Text>
{fingerprint}
{formatFingerprint(fingerprint)}
</VStack>
{status === 'trusted' && (
@@ -236,8 +263,17 @@ export function FingerprintDialog({
</Text>
</HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('trustedDescription')}
{isSelf ? t('descriptionSelf') : t('trustedDescription')}
</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>
)}
@@ -252,10 +288,17 @@ export function FingerprintDialog({
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('refusedDescription')}
</Text>
<Text
variant="note"
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
onClick={() => setStatus('unknown')}
>
{t('changeDecision')}
</Text>
</VStack>
)}
{status === 'unknown' && (
{status === 'unknown' && !isSelf && (
<VStack gap="0.5rem" className={css({ width: '100%' })}>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{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 |
| 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)
@@ -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'
@@ -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.
@@ -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<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(
attributes: Record<string, string> | undefined
): TrustLevel {
attributes: Record<string, string> | 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<FingerprintStatus>('loading')
const [fingerprint, setFingerprint] = useState<string | null>(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 }
}
@@ -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 <EncryptionBadge isEncrypted={true} trustLevel={trustLevel} />
}
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 ? (
<RiShieldCheckLine size={16} color="#3b82f6" />
) : (
<RiAlertLine size={16} color="#f59e0b" />
)}
{encrypted && <WaitingBadge participant={waitingParticipants[0]} />}
<Text
variant="sm"
margin={false}
@@ -275,15 +275,15 @@ export const Conference = ({
keyProvider
.setKey(passphrase)
.then(async () => {
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
@@ -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<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 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 ? (
<Button
variant="greyscale"
size="sm"
@@ -373,7 +373,7 @@ export const ParticipantTile: (
{(isEncrypted || isEncryptedRoom) && !isScreenShare && (
<EncryptionBadge
isEncrypted={true}
trustLevel={getTrustLevelFromAttributes(participantAttrs, roomData?.encryption_mode)}
trustLevel={trustLevel}
/>
)}
<div className="lk-participant-name-wrapper">
@@ -406,15 +406,18 @@ export const ParticipantTile: (
),
})}
</KeyboardShortcutHint>
{isEncryptedRoom && isAdmin && (
<FingerprintDialog
isOpen={isFingerprintOpen}
{isEncryptedRoom && (
<EncryptionIdentityDialog
isOpen={isIdentityOpen}
onOpenChange={setIsFingerprintOpen}
participantName={trackReference.participant.name || trackReference.participant.identity}
participantEmail={participantAttrs?.email}
suiteUserId={participantAttrs?.suite_user_id}
isAuthenticated={participantAttrs?.is_authenticated === 'true'}
encryptionMode={roomData?.encryption_mode}
isSelf={trackReference.participant.isLocal}
preloadedFingerprint={participantFingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/>
)}
</div>
@@ -21,7 +21,8 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
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 { isEncryptedRoom as isEncryptedRoomFn } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
@@ -108,11 +109,11 @@ export const ParticipantListItem = ({
const isAdmin = useIsAdminOrOwner()
const { isLoggedIn } = useUser()
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 attrs = participant.attributes as Record<string, string> | undefined
const trustLevel = getTrustLevelFromAttributes(attrs, roomData?.encryption_mode)
const badgeTooltip = trustLevel ? tEncBadge(trustLevel) : undefined
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(attrs, roomData?.encryption_mode, isLocal(participant))
const badgeTooltip = tEncBadge(trustLevel)
return (
<HStack
role="listitem"
@@ -139,7 +140,7 @@ export const ParticipantListItem = ({
size="sm"
tooltip={badgeTooltip}
aria-label={badgeTooltip}
onPress={isAdmin ? () => setIsFingerprintOpen(true) : undefined}
onPress={() => setIsFingerprintOpen(true)}
className={css({
padding: '0.1rem 0.25rem !important',
minWidth: 'auto !important',
@@ -148,9 +149,9 @@ export const ParticipantListItem = ({
borderRadius: '0.25rem !important',
backgroundColor: 'transparent !important',
color: 'greyscale.900 !important',
cursor: isAdmin ? 'pointer' : 'default',
cursor: isEncryptedRoom ? 'pointer' : 'default',
'&[data-hovered]': {
backgroundColor: isAdmin ? 'greyscale.100 !important' : 'transparent !important',
backgroundColor: 'greyscale.100 !important',
},
})}
>
@@ -244,15 +245,18 @@ export const ParticipantListItem = ({
<MicIndicator participant={participant} />
<ParticipantMenuButton participant={participant} />
</HStack>
{isEncryptedRoom && isAdmin && (
<FingerprintDialog
isOpen={isFingerprintOpen}
{isEncryptedRoom && (
<EncryptionIdentityDialog
isOpen={isIdentityOpen}
onOpenChange={setIsFingerprintOpen}
participantName={name}
participantEmail={attrs?.email}
suiteUserId={attrs?.suite_user_id}
isAuthenticated={attrs?.is_authenticated === 'true'}
encryptionMode={roomData?.encryption_mode}
isSelf={isLocal(participant)}
preloadedFingerprint={fingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/>
)}
</HStack>
@@ -7,20 +7,10 @@ import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants
import { RiCloseLine } from '@remixicon/react'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { EncryptionBadge, getTrustLevelFromAttributes, FingerprintDialog } from '@/features/encryption'
import type { TrustLevel } from '@/features/encryption/types'
import { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
import { useParticipantTrustLevel, formatFingerprint } from '@/features/encryption/useParticipantTrustLevel'
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 = ({
participant,
onAction,
@@ -32,8 +22,13 @@ export const WaitingParticipantListItem = ({
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 [isIdentityOpen, setIsDialogOpen] = useState(false)
// 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
return (
@@ -107,6 +102,24 @@ export const WaitingParticipantListItem = ({
{participant.username}
</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 && (() => {
const email = participant.is_authenticated && participant.email
? participant.email
@@ -173,14 +186,16 @@ export const WaitingParticipantListItem = ({
</Button>
</HStack>
{encryptedRoom && (
<FingerprintDialog
isOpen={isDialogOpen}
<EncryptionIdentityDialog
isOpen={isIdentityOpen}
onOpenChange={setIsDialogOpen}
participantName={participant.username}
participantEmail={participant.email}
suiteUserId={participant.suite_user_id}
isAuthenticated={participant.is_authenticated}
encryptionMode={roomData?.encryption_mode}
preloadedFingerprint={fingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/>
)}
</HStack>
+8 -3
View File
@@ -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": {
+8 -3
View File
@@ -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": {