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.""" """Validate request entry data."""
username = serializers.CharField(required=True) username = serializers.CharField(required=True)
ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
class ParticipantEntrySerializer(BaseValidationOnlySerializer): class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -297,6 +298,8 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer):
participant_id = serializers.UUIDField(required=True) participant_id = serializers.UUIDField(required=True)
allow_entry = serializers.BooleanField(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): class CreationCallbackSerializer(BaseValidationOnlySerializer):
+5 -2
View File
@@ -451,6 +451,8 @@ class RoomViewSet(
room_id=room.id, room_id=room.id,
participant_id=str(serializer.validated_data.get("participant_id")), participant_id=str(serializer.validated_data.get("participant_id")),
allow_entry=serializer.validated_data.get("allow_entry"), 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."}) return drf_response.Response({"message": "Participant was updated."})
@@ -479,11 +481,12 @@ class RoomViewSet(
participants = lobby_service.list_waiting_participants(room.id) participants = lobby_service.list_waiting_participants(room.id)
# Only expose email in encrypted rooms (needed for admin identity verification). # Only expose email and ephemeral keys in encrypted rooms.
# Strip it otherwise to avoid leaking personal data. # Strip them otherwise to avoid leaking personal data.
if not room.encryption_enabled: if not room.encryption_enabled:
for p in participants: for p in participants:
p.pop("email", None) p.pop("email", None)
p.pop("ephemeral_public_key", None)
return drf_response.Response({"participants": participants}) return drf_response.Response({"participants": participants})
+30 -1
View File
@@ -48,6 +48,9 @@ class LobbyParticipant:
id: str id: str
is_authenticated: bool = False is_authenticated: bool = False
email: Optional[str] = None email: Optional[str] = None
ephemeral_public_key: str = ''
encrypted_key: str = ''
admin_ephemeral_public_key: str = ''
def to_dict(self) -> Dict[str, str]: def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation.""" """Serialize the participant object to a dict representation."""
@@ -60,6 +63,12 @@ class LobbyParticipant:
} }
if self.email: if self.email:
result["email"] = 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 return result
@classmethod @classmethod
@@ -76,6 +85,9 @@ class LobbyParticipant:
color=data["color"], color=data["color"],
is_authenticated=data.get("is_authenticated", False), is_authenticated=data.get("is_authenticated", False),
email=data.get("email"), 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: except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:") logger.exception("Error creating Participant from dict:")
@@ -134,6 +146,7 @@ class LobbyService:
room, room,
request, request,
username: str, username: str,
ephemeral_public_key: str = '',
) -> Tuple[LobbyParticipant, Optional[Dict]]: ) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant. """Request entry to a room for a participant.
@@ -182,6 +195,7 @@ class LobbyService:
room.id, participant_id, username, room.id, participant_id, username,
is_authenticated=request.user.is_authenticated, is_authenticated=request.user.is_authenticated,
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None, email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
ephemeral_public_key=ephemeral_public_key,
) )
elif participant.status == LobbyParticipantStatus.WAITING: elif participant.status == LobbyParticipantStatus.WAITING:
@@ -216,6 +230,7 @@ class LobbyService:
self, room_id: UUID, participant_id: str, username: str, self, room_id: UUID, participant_id: str, username: str,
is_authenticated: bool = False, is_authenticated: bool = False,
email: Optional[str] = None, email: Optional[str] = None,
ephemeral_public_key: str = '',
) -> LobbyParticipant: ) -> LobbyParticipant:
"""Add participant to waiting lobby. """Add participant to waiting lobby.
@@ -232,6 +247,7 @@ class LobbyService:
color=color, color=color,
is_authenticated=is_authenticated, is_authenticated=is_authenticated,
email=email, email=email,
ephemeral_public_key=ephemeral_public_key,
) )
try: try:
@@ -300,6 +316,8 @@ class LobbyService:
room_id: UUID, room_id: UUID,
participant_id: str, participant_id: str,
allow_entry: bool, allow_entry: bool,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
) -> None: ) -> None:
"""Handle decision on participant entry. """Handle decision on participant entry.
@@ -318,7 +336,12 @@ class LobbyService:
"timeout": settings.LOBBY_DENIED_TIMEOUT, "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( def _update_participant_status(
self, self,
@@ -326,6 +349,8 @@ class LobbyService:
participant_id: str, participant_id: str,
status: LobbyParticipantStatus, status: LobbyParticipantStatus,
timeout: int, timeout: int,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
) -> None: ) -> None:
"""Update participant status with appropriate timeout.""" """Update participant status with appropriate timeout."""
@@ -346,6 +371,10 @@ class LobbyService:
raise raise
participant.status = status 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) cache.set(cache_key, participant.to_dict(), timeout=timeout)
def clear_room_cache(self, room_id: UUID) -> None: def clear_room_cache(self, room_id: UUID) -> None:
@@ -1,12 +1,10 @@
import { createContext, useContext } from 'react' import { createContext, useContext } from 'react'
interface EncryptionContextValue { interface EncryptionContextValue {
pendingParticipants: Set<string> symmetricKey?: Uint8Array
} }
const EncryptionContext = createContext<EncryptionContextValue>({ const EncryptionContext = createContext<EncryptionContextValue>({})
pendingParticipants: new Set(),
})
export const EncryptionProvider = EncryptionContext.Provider export const EncryptionProvider = EncryptionContext.Provider
export const useEncryptionContext = () => useContext(EncryptionContext) export const useEncryptionContext = () => useContext(EncryptionContext)
@@ -236,6 +236,11 @@ export class InCallKeyExchange {
switch (message.type) { switch (message.type) {
case KeyExchangeMessageType.KEY_REQUEST: 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) await this.handleKeyRequest(message, participant)
break break
case KeyExchangeMessageType.KEY_RESPONSE: case KeyExchangeMessageType.KEY_RESPONSE:
@@ -2,7 +2,6 @@ export { VaultClientProvider, useVaultClient } from './VaultClientProvider'
export type { VaultClientContextValue } from './VaultClientProvider' export type { VaultClientContextValue } from './VaultClientProvider'
export { useEncryption } from './useEncryption' export { useEncryption } from './useEncryption'
export type { EncryptionState } from './useEncryption' export type { EncryptionState } from './useEncryption'
export { InCallKeyExchange } from './InCallKeyExchange'
export { export {
determineTrustLevel, determineTrustLevel,
getTrustLevelFromAttributes, getTrustLevelFromAttributes,
@@ -11,11 +10,10 @@ export {
} from './HybridKeyDistributor' } from './HybridKeyDistributor'
export type { ParticipantEncryptionInfo } from './HybridKeyDistributor' export type { ParticipantEncryptionInfo } from './HybridKeyDistributor'
export { EncryptionBadge } from './EncryptionBadge' export { EncryptionBadge } from './EncryptionBadge'
export { EncryptionSetupOverlay } from './EncryptionSetupOverlay'
export { EncryptedMeetingBanner } from './EncryptedMeetingBanner' export { EncryptedMeetingBanner } from './EncryptedMeetingBanner'
export { EncryptionTrustModal } from './EncryptionTrustModal' export { EncryptionTrustModal } from './EncryptionTrustModal'
export { FingerprintDialog } from './FingerprintDialog' export { FingerprintDialog } from './FingerprintDialog'
export { useParticipantTrustLevel } from './useParticipantTrustLevel' 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' 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 roomId: string
allowEntry: boolean allowEntry: boolean
participantId: string participantId: string
encryptedKey?: string
adminEphemeralPublicKey?: string
} }
export interface EnterRoomResponse { export interface EnterRoomResponse {
@@ -16,12 +18,16 @@ export const enterRoom = async ({
roomId, roomId,
allowEntry, allowEntry,
participantId, participantId,
encryptedKey = '',
adminEphemeralPublicKey = '',
}: EnterRoomParams): Promise<EnterRoomResponse> => { }: EnterRoomParams): Promise<EnterRoomResponse> => {
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, { return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
participant_id: participantId, participant_id: participantId,
allow_entry: allowEntry, allow_entry: allowEntry,
encrypted_key: encryptedKey,
admin_ephemeral_public_key: adminEphemeralPublicKey,
}), }),
}) })
} }
@@ -10,6 +10,7 @@ export type WaitingParticipant = {
color: string color: string
is_authenticated: boolean is_authenticated: boolean
email?: string email?: string
ephemeral_public_key?: string
} }
export type WaitingParticipantsResponse = { export type WaitingParticipantsResponse = {
@@ -4,6 +4,7 @@ import { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
export interface RequestEntryParams { export interface RequestEntryParams {
roomId: string roomId: string
username?: string username?: string
ephemeralPublicKey?: string
} }
export enum ApiLobbyStatus { export enum ApiLobbyStatus {
@@ -17,16 +18,20 @@ export enum ApiLobbyStatus {
export interface ApiRequestEntry { export interface ApiRequestEntry {
status: ApiLobbyStatus status: ApiLobbyStatus
livekit?: ApiLiveKit livekit?: ApiLiveKit
encrypted_key?: string
admin_ephemeral_public_key?: string
} }
export const requestEntry = async ({ export const requestEntry = async ({
roomId, roomId,
username = '', username = '',
ephemeralPublicKey = '',
}: RequestEntryParams) => { }: RequestEntryParams) => {
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, { return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
username, username,
ephemeral_public_key: ephemeralPublicKey,
}), }),
}) })
} }
@@ -13,8 +13,7 @@ import {
RoomOptions, RoomOptions,
VideoPresets, VideoPresets,
} from 'livekit-client' } from 'livekit-client'
import { EncryptionSetupOverlay, EncryptionProvider } from '@/features/encryption' import { setSymmetricKey, getSymmetricKey } from '@/features/encryption/lobbyKeyExchange'
import { InCallKeyExchange } from '@/features/encryption/InCallKeyExchange'
import { keys } from '@/api/queryKeys' import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
@@ -91,13 +90,11 @@ export const Conference = ({
const encryptionEnabled = data?.encryption_enabled ?? false 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. // passed directly to RoomOptions.e2ee at Room construction time.
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null) const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
const workerRef = useRef<Worker | null>(null) const workerRef = useRef<Worker | null>(null)
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled) const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled)
const [encryptionError, setEncryptionError] = useState<string | null>(null)
const [pendingParticipants, setPendingParticipants] = useState<Set<string>>(new Set())
const getKeyProvider = () => { const getKeyProvider = () => {
if (!keyProviderRef.current && encryptionEnabled) { if (!keyProviderRef.current && encryptionEnabled) {
@@ -167,15 +164,11 @@ export const Conference = ({
return livekit_url return livekit_url
}, [apiConfig?.livekit]) }, [apiConfig?.livekit])
// Encryption key exchange — no disconnect/reconnect: // Encryption key setup:
// Admin: generate passphrase setKey → setE2EEEnabled → connect → distribute key // Admin: generate passphrase -> setKey -> connect -> E2EE enabled
// Joiner: connect (audio/video blocked) → receive key → setKey → setE2EEEnabled → unblock audio/video // Joiner: use pre-exchanged key from lobby -> setKey -> connect -> E2EE enabled
const isAdmin = mode === 'create' || data?.is_administrable === true 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 adminPassphraseRef = useRef<string | null>(null)
const adminDistributingRef = useRef(false)
const [encryptionKeyReady, setEncryptionKeyReady] = useState(!encryptionEnabled)
useEffect(() => { useEffect(() => {
if (!encryptionEnabled || encryptionSetupComplete) return if (!encryptionEnabled || encryptionSetupComplete) return
@@ -190,33 +183,18 @@ export const Conference = ({
.join('') .join('')
} }
const passphrase = adminPassphraseRef.current 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 keyProvider
.setKey(passphrase) .setKey(passphrase)
.then(() => { .then(() => {
console.info('[Encryption] Admin: key set, allowing connection') setEncryptionSetupComplete(true)
setEncryptionSetupComplete(true) // allow connection
// Enable E2EE and start key distribution after connecting
const onConnected = async () => { const onConnected = async () => {
try { try {
await room.setE2EEEnabled(true) 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) { } catch (err) {
console.error('[Encryption] Admin: E2EE enable failed:', err) console.error('[Encryption] Admin: E2EE enable failed:', err)
setEncryptionError((err as Error).message)
} }
} }
@@ -225,105 +203,40 @@ export const Conference = ({
}) })
.catch((err) => { .catch((err) => {
console.error('[Encryption] Admin failed:', err) console.error('[Encryption] Admin failed:', err)
setEncryptionError(err.message)
}) })
} else { } else {
// Joiner: set a temporary random passphrase BEFORE connecting. // Joiner: use the pre-exchanged key from lobby (stored in module-level lobbyKeyExchange)
// This ensures all frames are encrypted from the start (admin sees black, not clear). const preExchangedKey = getSymmetricKey()
// 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('')
keyProvider if (preExchangedKey) {
.setKey(tempPassphrase) const passphrase = new TextDecoder().decode(preExchangedKey)
.then(() => {
console.info('[Encryption] Joiner: temporary key set, allowing connection')
setEncryptionSetupComplete(true)
const exchange = async () => { keyProvider
if (keyExchangeDoneRef.current) return .setKey(passphrase)
keyExchangeDoneRef.current = true .then(() => {
setEncryptionSetupComplete(true)
try { const onConnected = async () => {
await room.setE2EEEnabled(true) try {
console.info('[Encryption] Joiner: E2EE enabled with temporary key') await room.setE2EEEnabled(true)
} catch (err) {
const kx = new InCallKeyExchange(room) console.error('[Encryption] Joiner: E2EE enable failed:', err)
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)
} }
}
if (room.state === 'connected') exchange() if (room.state === 'connected') onConnected()
else room.once('connected', exchange) else room.once('connected', onConnected)
}) })
.catch((err) => { .catch((err) => {
console.error('[Encryption] Joiner temp key failed:', err) console.error('[Encryption] Joiner key setup failed:', err)
setEncryptionError(err.message) })
}) } else {
} console.error('[Encryption] Joiner: no pre-exchanged key available')
return () => {
// Don't stop the admin's key distribution listener — it needs to persist
if (keyExchangeRef.current && !isAdmin) {
keyExchangeRef.current.stopListening()
keyExchangeRef.current = null
} }
} }
}, [room, encryptionEnabled, encryptionSetupComplete, isAdmin]) }, [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(() => { useEffect(() => {
/** /**
* Warm up connection to LiveKit server before joining room * Warm up connection to LiveKit server before joining room
@@ -444,15 +357,7 @@ export const Conference = ({
} }
}} }}
> >
<EncryptionProvider value={{ pendingParticipants }}> <VideoConference />
{encryptionEnabled && !isAdmin && (
<EncryptionSetupOverlay
isSettingUp={!encryptionKeyReady}
error={encryptionError}
/>
)}
<VideoConference />
</EncryptionProvider>
{showInviteDialog && !isMobile && ( {showInviteDialog && !isMobile && (
<InviteDialog <InviteDialog
isOpen={showInviteDialog} isOpen={showInviteDialog}
@@ -341,6 +341,7 @@ export const Join = ({
roomId, roomId,
username, username,
onAccepted: handleAccepted, onAccepted: handleAccepted,
encryptionEnabled: isEncryptedRoom,
}) })
const { openLoginHint } = useLoginHint() const { openLoginHint } = useLoginHint()
@@ -6,6 +6,12 @@ import {
ApiLobbyStatus, ApiLobbyStatus,
ApiRequestEntry, ApiRequestEntry,
} from '../api/requestEntry' } from '../api/requestEntry'
import {
generateEphemeralKeyPair,
encodePublicKey,
decryptKeyFromAdmin,
setSymmetricKey,
} from '@/features/encryption/lobbyKeyExchange'
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
export const POLL_INTERVAL_MS = 1000 export const POLL_INTERVAL_MS = 1000
@@ -14,13 +20,17 @@ export const useLobby = ({
roomId, roomId,
username, username,
onAccepted, onAccepted,
encryptionEnabled = false,
}: { }: {
roomId: string roomId: string
username: string username: string
onAccepted: (e: ApiRequestEntry) => void onAccepted: (e: ApiRequestEntry) => void
encryptionEnabled?: boolean
}) => { }) => {
const [status, setStatus] = useState(ApiLobbyStatus.IDLE) const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null) const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const ephemeralKeyRef = useRef<{ publicKey: Uint8Array; secretKey: Uint8Array } | null>(null)
const ephemeralPublicKeyB64Ref = useRef<string>('')
const clearWaitingTimeout = useCallback(() => { const clearWaitingTimeout = useCallback(() => {
if (waitingTimeoutRef.current) { if (waitingTimeoutRef.current) {
@@ -43,10 +53,26 @@ export const useLobby = ({
const response = await requestEntry({ const response = await requestEntry({
roomId, roomId,
username, username,
ephemeralPublicKey: ephemeralPublicKeyB64Ref.current,
}) })
if (response.status === ApiLobbyStatus.ACCEPTED) { if (response.status === ApiLobbyStatus.ACCEPTED) {
clearWaitingTimeout() clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED) 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) onAccepted(response)
} else if (response.status === ApiLobbyStatus.DENIED) { } else if (response.status === ApiLobbyStatus.DENIED) {
clearWaitingTimeout() clearWaitingTimeout()
@@ -60,10 +86,15 @@ export const useLobby = ({
enabled: status === ApiLobbyStatus.WAITING, 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) setStatus(ApiLobbyStatus.WAITING)
startWaitingTimeout() startWaitingTimeout()
}, [startWaitingTimeout]) }, [encryptionEnabled, startWaitingTimeout])
useEffect(() => { useEffect(() => {
return () => clearWaitingTimeout() return () => clearWaitingTimeout()
@@ -10,6 +10,7 @@ import {
} from '../api/listWaitingParticipants' } from '../api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils' import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType' import { NotificationType } from '@/features/notifications/NotificationType'
import { encryptKeyForParticipant, getSymmetricKey } from '@/features/encryption/lobbyKeyExchange'
export const POLL_INTERVAL_MS = 1000 export const POLL_INTERVAL_MS = 1000
@@ -18,6 +19,7 @@ export const useWaitingParticipants = () => {
const roomData = useRoomData() const roomData = useRoomData()
const roomId = roomData?.id || '' // FIXME - bad practice const roomId = roomData?.id || '' // FIXME - bad practice
const isEncryptedRoom = roomData?.encryption_enabled ?? false
const room = useRoomContext() const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner() const isAdminOrOwner = useIsAdminOrOwner()
@@ -61,10 +63,23 @@ export const useWaitingParticipants = () => {
participant: WaitingParticipant, participant: WaitingParticipant,
allowEntry: boolean 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({ await enterRoom({
roomId: roomId, roomId: roomId,
allowEntry, allowEntry,
participantId: participant.id, participantId: participant.id,
encryptedKey,
adminEphemeralPublicKey,
}) })
await refetchWaiting() await refetchWaiting()
} }
@@ -76,13 +91,26 @@ export const useWaitingParticipants = () => {
setListEnabled(false) setListEnabled(false)
await Promise.all( await Promise.all(
waitingParticipants.map((participant) => waitingParticipants.map(async (participant) => {
enterRoom({ 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, roomId: roomId,
allowEntry, allowEntry,
participantId: participant.id, participantId: participant.id,
encryptedKey,
adminEphemeralPublicKey,
}) })
) })
) )
await refetchWaiting() await refetchWaiting()
@@ -24,7 +24,6 @@ import { RiHand } from '@remixicon/react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand' import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { EncryptionBadge, getTrustLevelFromAttributes } from '@/features/encryption' import { EncryptionBadge, getTrustLevelFromAttributes } from '@/features/encryption'
import { useRoomData } from '../hooks/useRoomData' import { useRoomData } from '../hooks/useRoomData'
import { RiLockFill } from '@remixicon/react'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from './MutedMicIndicator' import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder' import { ParticipantPlaceholder } from './ParticipantPlaceholder'
@@ -83,9 +82,6 @@ export const ParticipantTile: (
const isEncrypted = useIsEncrypted(trackReference.participant) const isEncrypted = useIsEncrypted(trackReference.participant)
const roomData = useRoomData() const roomData = useRoomData()
const isEncryptedRoom = roomData?.encryption_enabled ?? false 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 layoutContext = useMaybeLayoutContext()
const autoManageSubscription = useFeatureContext()?.autoSubscription const autoManageSubscription = useFeatureContext()?.autoSubscription
@@ -144,40 +140,6 @@ export const ParticipantTile: (
<TrackRefContextIfNeeded trackRef={trackReference}> <TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}> <ParticipantContextIfNeeded participant={trackReference.participant}>
<FullScreenShareWarning trackReference={trackReference} /> <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 ?? ( {children ?? (
<> <>
{isTrackReference(trackReference) && {isTrackReference(trackReference) &&