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