mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-05 16:37:43 +00:00
switch to exchanging through visio backend instead of livekit backend
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
Reference in New Issue
Block a user