mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-03 22:25:27 +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."""
|
"""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):
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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) &&
|
||||||
|
|||||||
Reference in New Issue
Block a user