From 854792e2ef8b4bd560bfa187a4a72a4788b129d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Tue, 31 Mar 2026 16:03:35 +0200 Subject: [PATCH] wip --- src/backend/core/services/lobby.py | 17 ++++- .../features/encryption/lobbyKeyExchange.ts | 35 +++++++++ .../src/features/rooms/hooks/useLobby.ts | 10 ++- .../livekit/components/ParticipantTile.tsx | 76 ++++++++++++++++++- 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index e26a125f..9fc5ceab 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -202,7 +202,22 @@ class LobbyService: self.refresh_waiting_status(room.id, participant_id) elif participant.status == LobbyParticipantStatus.ACCEPTED: - # wrongly named, contains access token to join a room + # If the joiner comes back with a different ephemeral key (e.g. browser + # closed and reopened), they can no longer decrypt the encrypted symmetric + # key. Reset them to WAITING so the admin re-accepts with the new key. + if ( + ephemeral_public_key + and participant.ephemeral_public_key + and ephemeral_public_key != participant.ephemeral_public_key + ): + participant = self.enter( + 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, + ) + return participant, None + livekit_config = utils.generate_livekit_config( room_id=room_id, user=request.user, diff --git a/src/frontend/src/features/encryption/lobbyKeyExchange.ts b/src/frontend/src/features/encryption/lobbyKeyExchange.ts index c22b2e1f..58186abb 100644 --- a/src/frontend/src/features/encryption/lobbyKeyExchange.ts +++ b/src/frontend/src/features/encryption/lobbyKeyExchange.ts @@ -66,6 +66,41 @@ export function getSymmetricKey(): Uint8Array | null { return _symmetricKey } +const EPHEMERAL_KEY_STORAGE_KEY = 'meet-ephemeral-keypair' + +/** + * Persist the ephemeral keypair in sessionStorage so it survives page refreshes. + * sessionStorage is cleared when the tab/browser closes — in that case the backend + * resets the participant to WAITING so the admin re-accepts with a fresh key. + */ +export function saveEphemeralKeyPair(keyPair: { publicKey: Uint8Array; secretKey: Uint8Array }): void { + try { + sessionStorage.setItem(EPHEMERAL_KEY_STORAGE_KEY, JSON.stringify({ + publicKey: toBase64(keyPair.publicKey), + secretKey: toBase64(keyPair.secretKey), + })) + } catch { + // sessionStorage may be unavailable + } +} + +/** + * Restore the ephemeral keypair from sessionStorage. + */ +export function loadEphemeralKeyPair(): { publicKey: Uint8Array; secretKey: Uint8Array } | null { + try { + const stored = sessionStorage.getItem(EPHEMERAL_KEY_STORAGE_KEY) + if (!stored) return null + const parsed = JSON.parse(stored) + return { + publicKey: fromBase64(parsed.publicKey), + secretKey: fromBase64(parsed.secretKey), + } + } catch { + return null + } +} + /** * Clear the stored symmetric key (e.g. on disconnect). */ diff --git a/src/frontend/src/features/rooms/hooks/useLobby.ts b/src/frontend/src/features/rooms/hooks/useLobby.ts index 59bf1906..091a6b76 100644 --- a/src/frontend/src/features/rooms/hooks/useLobby.ts +++ b/src/frontend/src/features/rooms/hooks/useLobby.ts @@ -11,6 +11,8 @@ import { encodePublicKey, decryptKeyFromAdmin, setSymmetricKey, + saveEphemeralKeyPair, + loadEphemeralKeyPair, } from '@/features/encryption/lobbyKeyExchange' export const WAIT_TIMEOUT_MS = 600000 // 10 minutes @@ -88,9 +90,15 @@ export const useLobby = ({ const startWaiting = useCallback(async () => { if (encryptionEnabled) { - const keyPair = await generateEphemeralKeyPair() + // Restore keypair from sessionStorage (survives page refresh) + // or generate a new one (backend will reset to WAITING if key changed) + const stored = loadEphemeralKeyPair() + const keyPair = stored ?? await generateEphemeralKeyPair() ephemeralKeyRef.current = keyPair ephemeralPublicKeyB64Ref.current = encodePublicKey(keyPair.publicKey) + if (!stored) { + saveEphemeralKeyPair(keyPair) + } } setStatus(ApiLobbyStatus.WAITING) startWaitingTimeout() diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx index 39cf68fa..c3f6cdf3 100644 --- a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx @@ -22,8 +22,12 @@ import { import { Track } from 'livekit-client' import { RiHand } from '@remixicon/react' import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand' -import { EncryptionBadge, getTrustLevelFromAttributes } from '@/features/encryption' +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' @@ -82,6 +86,15 @@ export const ParticipantTile: ( const isEncrypted = useIsEncrypted(trackReference.participant) const roomData = useRoomData() const isEncryptedRoom = roomData?.encryption_enabled ?? false + // Show overlay when we cannot decrypt a remote participant's frames + const isDecryptionFailed = + isEncryptedRoom && !isEncrypted && !trackReference.participant.isLocal + // TODO: remove this force flag after CSS adjustments + const forceDecryptionOverlay = true + const showDecryptionError = + !trackReference.participant.isLocal && + isEncryptedRoom && + (forceDecryptionOverlay || isDecryptionFailed) const layoutContext = useMaybeLayoutContext() const autoManageSubscription = useFeatureContext()?.autoSubscription @@ -164,6 +177,63 @@ export const ParticipantTile: ( participant={trackReference.participant} /> + {showDecryptionError && !isScreenShare && ( +
+ +
+
+ + Decryption failed +
+
+ Unable to decrypt this participant's stream. One of + you may need to leave and rejoin. +
+
+
+ )} {!disableMetadata && (
@@ -222,7 +292,9 @@ export const ParticipantTile: ( | undefined + trackReference.participant.attributes as + | Record + | undefined )} /> )}