This commit is contained in:
Thomas Ramé
2026-03-31 16:03:35 +02:00
parent 547e93b9f1
commit 854792e2ef
4 changed files with 134 additions and 4 deletions
+16 -1
View File
@@ -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,
@@ -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).
*/
@@ -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()
@@ -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}
/>
</div>
{showDecryptionError && !isScreenShare && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 5,
}}
>
<ParticipantPlaceholder
participant={trackReference.participant}
/>
<div
style={{
position: 'absolute',
bottom: '2.5rem',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: '0.5rem',
padding: '0.6rem 1rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.3rem',
maxWidth: '85%',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
color: '#f87171',
fontSize: '0.85rem',
fontWeight: 600,
}}
>
<RiLockFill size={14} />
<span>Decryption failed</span>
</div>
<div
style={{
color: '#d1d5db',
fontSize: '0.75rem',
textAlign: 'center',
lineHeight: 1.4,
}}
>
Unable to decrypt this participant&apos;s stream. One of
you may need to leave and rejoin.
</div>
</div>
</div>
)}
{!disableMetadata && (
<div className="lk-participant-metadata">
<HStack gap={0.25}>
@@ -222,7 +292,9 @@ export const ParticipantTile: (
<EncryptionBadge
isEncrypted={true}
trustLevel={getTrustLevelFromAttributes(
trackReference.participant.attributes as Record<string, string> | undefined
trackReference.participant.attributes as
| Record<string, string>
| undefined
)}
/>
)}