mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 11:58:53 +00:00
wip
This commit is contained in:
@@ -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')
|
||||
|
||||
+68
-25
@@ -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 }
|
||||
}
|
||||
|
||||
+18
-6
@@ -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>
|
||||
|
||||
+14
-10
@@ -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>
|
||||
|
||||
+31
-16
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user