switch to exchanging through visio backend instead of livekit backend

This commit is contained in:
Thomas Ramé
2026-03-31 15:39:26 +02:00
parent d919cd8097
commit 547e93b9f1
15 changed files with 311 additions and 180 deletions
+3
View File
@@ -290,6 +290,7 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
username = serializers.CharField(required=True)
ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -297,6 +298,8 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer):
participant_id = serializers.UUIDField(required=True)
allow_entry = serializers.BooleanField(required=True)
encrypted_key = serializers.CharField(required=False, allow_blank=True, default='')
admin_ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
class CreationCallbackSerializer(BaseValidationOnlySerializer):
+5 -2
View File
@@ -451,6 +451,8 @@ class RoomViewSet(
room_id=room.id,
participant_id=str(serializer.validated_data.get("participant_id")),
allow_entry=serializer.validated_data.get("allow_entry"),
encrypted_key=serializer.validated_data.get("encrypted_key", ''),
admin_ephemeral_public_key=serializer.validated_data.get("admin_ephemeral_public_key", ''),
)
return drf_response.Response({"message": "Participant was updated."})
@@ -479,11 +481,12 @@ class RoomViewSet(
participants = lobby_service.list_waiting_participants(room.id)
# Only expose email in encrypted rooms (needed for admin identity verification).
# Strip it otherwise to avoid leaking personal data.
# Only expose email and ephemeral keys in encrypted rooms.
# Strip them otherwise to avoid leaking personal data.
if not room.encryption_enabled:
for p in participants:
p.pop("email", None)
p.pop("ephemeral_public_key", None)
return drf_response.Response({"participants": participants})
+30 -1
View File
@@ -48,6 +48,9 @@ class LobbyParticipant:
id: str
is_authenticated: bool = False
email: Optional[str] = None
ephemeral_public_key: str = ''
encrypted_key: str = ''
admin_ephemeral_public_key: str = ''
def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation."""
@@ -60,6 +63,12 @@ class LobbyParticipant:
}
if self.email:
result["email"] = self.email
if self.ephemeral_public_key:
result["ephemeral_public_key"] = self.ephemeral_public_key
if self.encrypted_key:
result["encrypted_key"] = self.encrypted_key
if self.admin_ephemeral_public_key:
result["admin_ephemeral_public_key"] = self.admin_ephemeral_public_key
return result
@classmethod
@@ -76,6 +85,9 @@ class LobbyParticipant:
color=data["color"],
is_authenticated=data.get("is_authenticated", False),
email=data.get("email"),
ephemeral_public_key=data.get("ephemeral_public_key", ''),
encrypted_key=data.get("encrypted_key", ''),
admin_ephemeral_public_key=data.get("admin_ephemeral_public_key", ''),
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
@@ -134,6 +146,7 @@ class LobbyService:
room,
request,
username: str,
ephemeral_public_key: str = '',
) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant.
@@ -182,6 +195,7 @@ class LobbyService:
room.id, participant_id, username,
is_authenticated=request.user.is_authenticated,
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
ephemeral_public_key=ephemeral_public_key,
)
elif participant.status == LobbyParticipantStatus.WAITING:
@@ -216,6 +230,7 @@ class LobbyService:
self, room_id: UUID, participant_id: str, username: str,
is_authenticated: bool = False,
email: Optional[str] = None,
ephemeral_public_key: str = '',
) -> LobbyParticipant:
"""Add participant to waiting lobby.
@@ -232,6 +247,7 @@ class LobbyService:
color=color,
is_authenticated=is_authenticated,
email=email,
ephemeral_public_key=ephemeral_public_key,
)
try:
@@ -300,6 +316,8 @@ class LobbyService:
room_id: UUID,
participant_id: str,
allow_entry: bool,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
) -> None:
"""Handle decision on participant entry.
@@ -318,7 +336,12 @@ class LobbyService:
"timeout": settings.LOBBY_DENIED_TIMEOUT,
}
self._update_participant_status(room_id, participant_id, **decision)
self._update_participant_status(
room_id, participant_id,
encrypted_key=encrypted_key,
admin_ephemeral_public_key=admin_ephemeral_public_key,
**decision,
)
def _update_participant_status(
self,
@@ -326,6 +349,8 @@ class LobbyService:
participant_id: str,
status: LobbyParticipantStatus,
timeout: int,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
) -> None:
"""Update participant status with appropriate timeout."""
@@ -346,6 +371,10 @@ class LobbyService:
raise
participant.status = status
if encrypted_key:
participant.encrypted_key = encrypted_key
if admin_ephemeral_public_key:
participant.admin_ephemeral_public_key = admin_ephemeral_public_key
cache.set(cache_key, participant.to_dict(), timeout=timeout)
def clear_room_cache(self, room_id: UUID) -> None:
@@ -1,12 +1,10 @@
import { createContext, useContext } from 'react'
interface EncryptionContextValue {
pendingParticipants: Set<string>
symmetricKey?: Uint8Array
}
const EncryptionContext = createContext<EncryptionContextValue>({
pendingParticipants: new Set(),
})
const EncryptionContext = createContext<EncryptionContextValue>({})
export const EncryptionProvider = EncryptionContext.Provider
export const useEncryptionContext = () => useContext(EncryptionContext)
@@ -236,6 +236,11 @@ export class InCallKeyExchange {
switch (message.type) {
case KeyExchangeMessageType.KEY_REQUEST:
// TEMPORARY: 10s delay to test joiner UX during key exchange
console.info(
'[Encryption] Delaying key response by 10 seconds for testing...'
)
await new Promise((resolve) => setTimeout(resolve, 10000))
await this.handleKeyRequest(message, participant)
break
case KeyExchangeMessageType.KEY_RESPONSE:
@@ -2,7 +2,6 @@ export { VaultClientProvider, useVaultClient } from './VaultClientProvider'
export type { VaultClientContextValue } from './VaultClientProvider'
export { useEncryption } from './useEncryption'
export type { EncryptionState } from './useEncryption'
export { InCallKeyExchange } from './InCallKeyExchange'
export {
determineTrustLevel,
getTrustLevelFromAttributes,
@@ -11,11 +10,10 @@ export {
} from './HybridKeyDistributor'
export type { ParticipantEncryptionInfo } from './HybridKeyDistributor'
export { EncryptionBadge } from './EncryptionBadge'
export { EncryptionSetupOverlay } from './EncryptionSetupOverlay'
export { EncryptedMeetingBanner } from './EncryptedMeetingBanner'
export { EncryptionTrustModal } from './EncryptionTrustModal'
export { FingerprintDialog } from './FingerprintDialog'
export { useParticipantTrustLevel } from './useParticipantTrustLevel'
export { EncryptionProvider, useEncryptionContext } from './EncryptionContext'
export { PARTICIPANT_TRUST_ATTR, KEY_EXCHANGE_TOPIC } from './types'
export { PARTICIPANT_TRUST_ATTR } from './types'
export type { TrustLevel } from './types'
@@ -0,0 +1,156 @@
/**
* Lobby-based key exchange using ephemeral X25519 Diffie-Hellman.
*
* The key exchange happens during the waiting room flow via the REST API,
* so the joiner already has the real symmetric key when connecting to LiveKit.
*
* Uses libsodium for algorithmic consistency:
* - X25519 for ephemeral key exchange (crypto_scalarmult)
* - XChaCha20-Poly1305 for encrypting the symmetric key (crypto_secretbox)
* - BLAKE2b for deriving a shared key from the ECDH shared secret (crypto_generichash)
*/
import _sodium from 'libsodium-wrappers-sumo'
let sodiumReady: Promise<void> | null = null
async function ensureSodium(): Promise<typeof _sodium> {
if (!sodiumReady) {
sodiumReady = _sodium.ready
}
await sodiumReady
return _sodium
}
function toBase64(bytes: Uint8Array): string {
return _sodium.to_base64(bytes, _sodium.base64_variants.URLSAFE_NO_PADDING)
}
function fromBase64(base64: string): Uint8Array {
return _sodium.from_base64(base64, _sodium.base64_variants.URLSAFE_NO_PADDING)
}
/**
* Generate an ephemeral X25519 key pair.
*/
export async function generateEphemeralKeyPair(): Promise<{
publicKey: Uint8Array
secretKey: Uint8Array
}> {
const sodium = await ensureSodium()
const secretKey = sodium.randombytes_buf(sodium.crypto_scalarmult_SCALARBYTES)
const publicKey = sodium.crypto_scalarmult_base(secretKey)
return { publicKey, secretKey }
}
/**
* Encode a public key to base64url for transmission via REST API.
*/
export function encodePublicKey(publicKey: Uint8Array): string {
return toBase64(publicKey)
}
// Module-level symmetric key — only accessible via set/get, never exposed to React components.
let _symmetricKey: Uint8Array | null = null
/**
* Store the room's symmetric key in module scope.
* Called once by the admin (after generating it) or by the joiner (after decrypting it).
*/
export function setSymmetricKey(key: Uint8Array): void {
_symmetricKey = key
}
/**
* Retrieve the stored symmetric key. Returns null if not yet set.
*/
export function getSymmetricKey(): Uint8Array | null {
return _symmetricKey
}
/**
* Clear the stored symmetric key (e.g. on disconnect).
*/
export function clearSymmetricKey(): void {
_symmetricKey = null
}
/**
* Derive a shared secret from X25519 ECDH, then derive an encryption key via BLAKE2b.
*/
async function deriveSharedKey(
mySecretKey: Uint8Array,
theirPublicKey: Uint8Array
): Promise<Uint8Array> {
const sodium = await ensureSodium()
const rawSharedSecret = sodium.crypto_scalarmult(mySecretKey, theirPublicKey)
return sodium.crypto_generichash(
32,
rawSharedSecret,
sodium.from_string('meet-key-exchange')
)
}
/**
* Admin-side: encrypt the room's symmetric key for a specific participant.
*
* Called when the admin accepts a participant from the waiting room.
* Generates an ephemeral keypair, performs DH with the participant's public key,
* and encrypts the symmetric key. Reads the key from module-level storage.
*
* @param participantPublicKeyB64 - The participant's ephemeral public key (base64url)
* @returns The encrypted key blob and admin's ephemeral public key (both base64url)
*/
export async function encryptKeyForParticipant(
participantPublicKeyB64: string
): Promise<{ encryptedKey: string; adminPublicKey: string }> {
const symmetricKey = _symmetricKey
if (!symmetricKey) {
throw new Error('Symmetric key not set — cannot encrypt for participant')
}
const sodium = await ensureSodium()
const adminKeyPair = await generateEphemeralKeyPair()
const participantPublicKey = fromBase64(participantPublicKeyB64)
const sharedKey = await deriveSharedKey(
adminKeyPair.secretKey,
participantPublicKey
)
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
const ciphertext = sodium.crypto_secretbox_easy(symmetricKey, nonce, sharedKey)
const encrypted = new Uint8Array(nonce.length + ciphertext.length)
encrypted.set(nonce, 0)
encrypted.set(ciphertext, nonce.length)
return {
encryptedKey: toBase64(encrypted),
adminPublicKey: toBase64(adminKeyPair.publicKey),
}
}
/**
* Joiner-side: decrypt the symmetric key received from the admin via the lobby.
*
* Called when the joiner's polling receives an ACCEPTED status with encryption data.
*
* @param mySecretKey - The joiner's ephemeral secret key
* @param adminPublicKeyB64 - The admin's ephemeral public key (base64url)
* @param encryptedKeyB64 - The encrypted symmetric key blob (base64url)
* @returns The decrypted symmetric key
*/
export async function decryptKeyFromAdmin(
mySecretKey: Uint8Array,
adminPublicKeyB64: string,
encryptedKeyB64: string
): Promise<Uint8Array> {
const sodium = await ensureSodium()
const adminPublicKey = fromBase64(adminPublicKeyB64)
const encryptedData = fromBase64(encryptedKeyB64)
const sharedKey = await deriveSharedKey(mySecretKey, adminPublicKey)
const nonce = encryptedData.slice(0, sodium.crypto_secretbox_NONCEBYTES)
const ciphertext = encryptedData.slice(sodium.crypto_secretbox_NONCEBYTES)
return sodium.crypto_secretbox_open_easy(ciphertext, nonce, sharedKey)
}
@@ -6,6 +6,8 @@ export interface EnterRoomParams {
roomId: string
allowEntry: boolean
participantId: string
encryptedKey?: string
adminEphemeralPublicKey?: string
}
export interface EnterRoomResponse {
@@ -16,12 +18,16 @@ export const enterRoom = async ({
roomId,
allowEntry,
participantId,
encryptedKey = '',
adminEphemeralPublicKey = '',
}: EnterRoomParams): Promise<EnterRoomResponse> => {
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
method: 'POST',
body: JSON.stringify({
participant_id: participantId,
allow_entry: allowEntry,
encrypted_key: encryptedKey,
admin_ephemeral_public_key: adminEphemeralPublicKey,
}),
})
}
@@ -10,6 +10,7 @@ export type WaitingParticipant = {
color: string
is_authenticated: boolean
email?: string
ephemeral_public_key?: string
}
export type WaitingParticipantsResponse = {
@@ -4,6 +4,7 @@ import { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
export interface RequestEntryParams {
roomId: string
username?: string
ephemeralPublicKey?: string
}
export enum ApiLobbyStatus {
@@ -17,16 +18,20 @@ export enum ApiLobbyStatus {
export interface ApiRequestEntry {
status: ApiLobbyStatus
livekit?: ApiLiveKit
encrypted_key?: string
admin_ephemeral_public_key?: string
}
export const requestEntry = async ({
roomId,
username = '',
ephemeralPublicKey = '',
}: RequestEntryParams) => {
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
method: 'POST',
body: JSON.stringify({
username,
ephemeral_public_key: ephemeralPublicKey,
}),
})
}
@@ -13,8 +13,7 @@ import {
RoomOptions,
VideoPresets,
} from 'livekit-client'
import { EncryptionSetupOverlay, EncryptionProvider } from '@/features/encryption'
import { InCallKeyExchange } from '@/features/encryption/InCallKeyExchange'
import { setSymmetricKey, getSymmetricKey } from '@/features/encryption/lobbyKeyExchange'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen'
@@ -91,13 +90,11 @@ export const Conference = ({
const encryptionEnabled = data?.encryption_enabled ?? false
// Encryption setup — PoC approach: refs for keyProvider and worker,
// Encryption setup — refs for keyProvider and worker,
// passed directly to RoomOptions.e2ee at Room construction time.
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
const workerRef = useRef<Worker | null>(null)
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled)
const [encryptionError, setEncryptionError] = useState<string | null>(null)
const [pendingParticipants, setPendingParticipants] = useState<Set<string>>(new Set())
const getKeyProvider = () => {
if (!keyProviderRef.current && encryptionEnabled) {
@@ -167,15 +164,11 @@ export const Conference = ({
return livekit_url
}, [apiConfig?.livekit])
// Encryption key exchange — no disconnect/reconnect:
// Admin: generate passphrase setKey → setE2EEEnabled → connect → distribute key
// Joiner: connect (audio/video blocked) → receive key → setKey → setE2EEEnabled → unblock audio/video
// Encryption key setup:
// Admin: generate passphrase -> setKey -> connect -> E2EE enabled
// Joiner: use pre-exchanged key from lobby -> setKey -> connect -> E2EE enabled
const isAdmin = mode === 'create' || data?.is_administrable === true
const keyExchangeRef = useRef<InCallKeyExchange | null>(null)
const keyExchangeDoneRef = useRef(false)
const adminPassphraseRef = useRef<string | null>(null)
const adminDistributingRef = useRef(false)
const [encryptionKeyReady, setEncryptionKeyReady] = useState(!encryptionEnabled)
useEffect(() => {
if (!encryptionEnabled || encryptionSetupComplete) return
@@ -190,33 +183,18 @@ export const Conference = ({
.join('')
}
const passphrase = adminPassphraseRef.current
console.info('[Encryption] Admin passphrase:', passphrase)
setSymmetricKey(new TextEncoder().encode(passphrase))
// Set key before connecting — it's ready for when E2EE activates
keyProvider
.setKey(passphrase)
.then(() => {
console.info('[Encryption] Admin: key set, allowing connection')
setEncryptionSetupComplete(true) // allow connection
setEncryptionSetupComplete(true)
// Enable E2EE and start key distribution after connecting
const onConnected = async () => {
try {
await room.setE2EEEnabled(true)
console.info('[Encryption] Admin: E2EE enabled after connection')
setEncryptionKeyReady(true)
if (!adminDistributingRef.current) {
adminDistributingRef.current = true
const kx = new InCallKeyExchange(room)
keyExchangeRef.current = kx
kx.setSymmetricKey(new TextEncoder().encode(passphrase))
kx.startListening()
console.info('[Encryption] Admin: distributing key')
}
} catch (err) {
console.error('[Encryption] Admin: E2EE enable failed:', err)
setEncryptionError((err as Error).message)
}
}
@@ -225,105 +203,40 @@ export const Conference = ({
})
.catch((err) => {
console.error('[Encryption] Admin failed:', err)
setEncryptionError(err.message)
})
} else {
// Joiner: set a temporary random passphrase BEFORE connecting.
// This ensures all frames are encrypted from the start (admin sees black, not clear).
// After key exchange, replace with the real passphrase from admin.
const tempPassphrase = Array.from(crypto.getRandomValues(new Uint8Array(16)))
.map((b) => b.toString(36).padStart(2, '0'))
.join('')
// Joiner: use the pre-exchanged key from lobby (stored in module-level lobbyKeyExchange)
const preExchangedKey = getSymmetricKey()
keyProvider
.setKey(tempPassphrase)
.then(() => {
console.info('[Encryption] Joiner: temporary key set, allowing connection')
setEncryptionSetupComplete(true)
if (preExchangedKey) {
const passphrase = new TextDecoder().decode(preExchangedKey)
const exchange = async () => {
if (keyExchangeDoneRef.current) return
keyExchangeDoneRef.current = true
keyProvider
.setKey(passphrase)
.then(() => {
setEncryptionSetupComplete(true)
try {
await room.setE2EEEnabled(true)
console.info('[Encryption] Joiner: E2EE enabled with temporary key')
const kx = new InCallKeyExchange(room)
keyExchangeRef.current = kx
kx.startListening()
console.info('[Encryption] Joiner: requesting real key from admin...')
const keyBytes = await kx.requestKey()
const passphrase = new TextDecoder().decode(keyBytes)
console.info('[Encryption] Joiner: received real passphrase')
await keyProvider.setKey(passphrase)
setEncryptionKeyReady(true)
console.info('[Encryption] Joiner: real key set, decryption active')
} catch (err) {
console.error('[Encryption] Joiner failed:', err)
setEncryptionError((err as Error).message)
const onConnected = async () => {
try {
await room.setE2EEEnabled(true)
} catch (err) {
console.error('[Encryption] Joiner: E2EE enable failed:', err)
}
}
}
if (room.state === 'connected') exchange()
else room.once('connected', exchange)
})
.catch((err) => {
console.error('[Encryption] Joiner temp key failed:', err)
setEncryptionError(err.message)
})
}
return () => {
// Don't stop the admin's key distribution listener — it needs to persist
if (keyExchangeRef.current && !isAdmin) {
keyExchangeRef.current.stopListening()
keyExchangeRef.current = null
if (room.state === 'connected') onConnected()
else room.once('connected', onConnected)
})
.catch((err) => {
console.error('[Encryption] Joiner key setup failed:', err)
})
} else {
console.error('[Encryption] Joiner: no pre-exchanged key available')
}
}
}, [room, encryptionEnabled, encryptionSetupComplete, isAdmin])
// Track participants pending key exchange.
// When a new participant joins an encrypted room, mark them as pending.
// Clear when their encryption status becomes true.
useEffect(() => {
if (!encryptionEnabled) return
const handleParticipantConnected = (participant: { identity: string }) => {
setPendingParticipants((prev) => {
const next = new Set(prev)
next.add(participant.identity)
return next
})
}
const handleEncryptionStatusChanged = (_encrypted: boolean, participant?: { identity: string }) => {
if (participant?.identity) {
setPendingParticipants((prev) => {
const next = new Set(prev)
next.delete(participant.identity)
return next
})
}
}
const handleTrackSubscribed = () => {
// If any track is successfully subscribed, clear all pending
setPendingParticipants(new Set())
}
room.on('participantConnected', handleParticipantConnected)
room.on('participantEncryptionStatusChanged', handleEncryptionStatusChanged)
room.on('trackSubscribed', handleTrackSubscribed)
return () => {
room.off('participantConnected', handleParticipantConnected)
room.off('participantEncryptionStatusChanged', handleEncryptionStatusChanged)
room.off('trackSubscribed', handleTrackSubscribed)
}
}, [room, encryptionEnabled])
useEffect(() => {
/**
* Warm up connection to LiveKit server before joining room
@@ -444,15 +357,7 @@ export const Conference = ({
}
}}
>
<EncryptionProvider value={{ pendingParticipants }}>
{encryptionEnabled && !isAdmin && (
<EncryptionSetupOverlay
isSettingUp={!encryptionKeyReady}
error={encryptionError}
/>
)}
<VideoConference />
</EncryptionProvider>
<VideoConference />
{showInviteDialog && !isMobile && (
<InviteDialog
isOpen={showInviteDialog}
@@ -341,6 +341,7 @@ export const Join = ({
roomId,
username,
onAccepted: handleAccepted,
encryptionEnabled: isEncryptedRoom,
})
const { openLoginHint } = useLoginHint()
@@ -6,6 +6,12 @@ import {
ApiLobbyStatus,
ApiRequestEntry,
} from '../api/requestEntry'
import {
generateEphemeralKeyPair,
encodePublicKey,
decryptKeyFromAdmin,
setSymmetricKey,
} from '@/features/encryption/lobbyKeyExchange'
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
export const POLL_INTERVAL_MS = 1000
@@ -14,13 +20,17 @@ export const useLobby = ({
roomId,
username,
onAccepted,
encryptionEnabled = false,
}: {
roomId: string
username: string
onAccepted: (e: ApiRequestEntry) => void
encryptionEnabled?: boolean
}) => {
const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const ephemeralKeyRef = useRef<{ publicKey: Uint8Array; secretKey: Uint8Array } | null>(null)
const ephemeralPublicKeyB64Ref = useRef<string>('')
const clearWaitingTimeout = useCallback(() => {
if (waitingTimeoutRef.current) {
@@ -43,10 +53,26 @@ export const useLobby = ({
const response = await requestEntry({
roomId,
username,
ephemeralPublicKey: ephemeralPublicKeyB64Ref.current,
})
if (response.status === ApiLobbyStatus.ACCEPTED) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED)
if (
encryptionEnabled &&
response.encrypted_key &&
response.admin_ephemeral_public_key &&
ephemeralKeyRef.current
) {
const decryptedKey = await decryptKeyFromAdmin(
ephemeralKeyRef.current.secretKey,
response.admin_ephemeral_public_key,
response.encrypted_key
)
setSymmetricKey(decryptedKey)
}
onAccepted(response)
} else if (response.status === ApiLobbyStatus.DENIED) {
clearWaitingTimeout()
@@ -60,10 +86,15 @@ export const useLobby = ({
enabled: status === ApiLobbyStatus.WAITING,
})
const startWaiting = useCallback(() => {
const startWaiting = useCallback(async () => {
if (encryptionEnabled) {
const keyPair = await generateEphemeralKeyPair()
ephemeralKeyRef.current = keyPair
ephemeralPublicKeyB64Ref.current = encodePublicKey(keyPair.publicKey)
}
setStatus(ApiLobbyStatus.WAITING)
startWaitingTimeout()
}, [startWaitingTimeout])
}, [encryptionEnabled, startWaitingTimeout])
useEffect(() => {
return () => clearWaitingTimeout()
@@ -10,6 +10,7 @@ import {
} from '../api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { encryptKeyForParticipant, getSymmetricKey } from '@/features/encryption/lobbyKeyExchange'
export const POLL_INTERVAL_MS = 1000
@@ -18,6 +19,7 @@ export const useWaitingParticipants = () => {
const roomData = useRoomData()
const roomId = roomData?.id || '' // FIXME - bad practice
const isEncryptedRoom = roomData?.encryption_enabled ?? false
const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
@@ -61,10 +63,23 @@ export const useWaitingParticipants = () => {
participant: WaitingParticipant,
allowEntry: boolean
) => {
let encryptedKey = ''
let adminEphemeralPublicKey = ''
if (allowEntry && isEncryptedRoom && getSymmetricKey() && participant.ephemeral_public_key) {
const result = await encryptKeyForParticipant(
participant.ephemeral_public_key
)
encryptedKey = result.encryptedKey
adminEphemeralPublicKey = result.adminPublicKey
}
await enterRoom({
roomId: roomId,
allowEntry,
participantId: participant.id,
encryptedKey,
adminEphemeralPublicKey,
})
await refetchWaiting()
}
@@ -76,13 +91,26 @@ export const useWaitingParticipants = () => {
setListEnabled(false)
await Promise.all(
waitingParticipants.map((participant) =>
enterRoom({
waitingParticipants.map(async (participant) => {
let encryptedKey = ''
let adminEphemeralPublicKey = ''
if (allowEntry && isEncryptedRoom && getSymmetricKey() && participant.ephemeral_public_key) {
const result = await encryptKeyForParticipant(
participant.ephemeral_public_key
)
encryptedKey = result.encryptedKey
adminEphemeralPublicKey = result.adminPublicKey
}
return enterRoom({
roomId: roomId,
allowEntry,
participantId: participant.id,
encryptedKey,
adminEphemeralPublicKey,
})
)
})
)
await refetchWaiting()
@@ -24,7 +24,6 @@ import { RiHand } from '@remixicon/react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { EncryptionBadge, getTrustLevelFromAttributes } from '@/features/encryption'
import { useRoomData } from '../hooks/useRoomData'
import { RiLockFill } from '@remixicon/react'
import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
@@ -83,9 +82,6 @@ export const ParticipantTile: (
const isEncrypted = useIsEncrypted(trackReference.participant)
const roomData = useRoomData()
const isEncryptedRoom = roomData?.encryption_enabled ?? false
// In an encrypted room, if a remote participant's encryption status is false,
// it means we can't decrypt their frames yet (key exchange in progress).
const isKeyExchangePending = isEncryptedRoom && !isEncrypted && !trackReference.participant.isLocal
const layoutContext = useMaybeLayoutContext()
const autoManageSubscription = useFeatureContext()?.autoSubscription
@@ -144,40 +140,6 @@ export const ParticipantTile: (
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
<FullScreenShareWarning trackReference={trackReference} />
{isKeyExchangePending && !isScreenShare && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 5,
backgroundColor: 'rgba(0, 0, 0, 0.85)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
}}
>
<div style={{ width: '30%', maxWidth: '80px' }}>
<ParticipantPlaceholder participant={trackReference.participant} />
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
color: '#9ca3af',
fontSize: '0.7rem',
}}
>
<RiLockFill size={11} />
<span>Key exchange in progress</span>
</div>
</div>
)}
{children ?? (
<>
{isTrackReference(trackReference) &&