wip working with hash

This commit is contained in:
Thomas Ramé
2026-04-02 15:54:11 +02:00
parent 191adc0499
commit 316008016c
25 changed files with 885 additions and 125 deletions
+5 -4
View File
@@ -128,7 +128,7 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_enabled"]
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_mode"]
read_only_fields = ["id", "slug", "pin_code"]
def validate_access_level(self, value):
@@ -140,10 +140,10 @@ class RoomSerializer(serializers.ModelSerializer):
)
return value
def validate_encryption_enabled(self, value):
"""Once encryption is enabled on a room, it cannot be disabled."""
def validate_encryption_mode(self, value):
"""Once encryption is enabled on a room, it cannot be disabled or downgraded."""
instance = self.instance
if instance and instance.encryption_enabled and not value:
if instance and instance.encryption_enabled and value == models.EncryptionMode.NONE:
raise serializers.ValidationError(
"Encryption cannot be disabled once enabled on a room."
)
@@ -300,6 +300,7 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer):
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='')
encrypted_vault_key = serializers.CharField(required=False, allow_blank=True, default='')
class CreationCallbackSerializer(BaseValidationOnlySerializer):
+2 -1
View File
@@ -283,7 +283,7 @@ class RoomViewSet(
"""Set the current user as owner of the newly created room."""
# Encrypted rooms must use restricted access to enforce lobby approval
# before the encryption key is shared with participants.
if serializer.validated_data.get("encryption_enabled"):
if serializer.validated_data.get("encryption_mode", models.EncryptionMode.NONE) != models.EncryptionMode.NONE:
serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED
room = serializer.save()
@@ -453,6 +453,7 @@ class RoomViewSet(
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", ''),
encrypted_vault_key=serializer.validated_data.get("encrypted_vault_key", ''),
)
return drf_response.Response({"message": "Participant was updated."})
@@ -0,0 +1,51 @@
"""Replace encryption_enabled boolean with encryption_mode enum."""
from django.db import migrations, models
def migrate_encryption_enabled_to_mode(apps, schema_editor):
"""Convert existing encryption_enabled=True rooms to encryption_mode='basic'."""
Room = apps.get_model("core", "Room")
Room.objects.filter(encryption_enabled=True).update(encryption_mode="basic")
def migrate_mode_to_encryption_enabled(apps, schema_editor):
"""Reverse: set encryption_enabled=True for any non-'none' encryption_mode."""
Room = apps.get_model("core", "Room")
Room.objects.exclude(encryption_mode="none").update(encryption_enabled=True)
class Migration(migrations.Migration):
dependencies = [
("core", "0019_room_encryption_enabled"),
]
operations = [
# 1. Add the new encryption_mode field
migrations.AddField(
model_name="room",
name="encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Basic encryption"),
("advanced", "Advanced encryption"),
],
default="none",
help_text="End-to-end encryption mode for this room.",
max_length=20,
verbose_name="Encryption mode",
),
),
# 2. Migrate existing data
migrations.RunPython(
migrate_encryption_enabled_to_mode,
migrate_mode_to_encryption_enabled,
),
# 3. Remove the old boolean field
migrations.RemoveField(
model_name="room",
name="encryption_enabled",
),
]
+19 -4
View File
@@ -98,6 +98,14 @@ class RoomAccessLevel(models.TextChoices):
RESTRICTED = "restricted", _("Restricted Access")
class EncryptionMode(models.TextChoices):
"""Encryption mode choices for rooms."""
NONE = "none", _("No encryption")
BASIC = "basic", _("Basic encryption")
ADVANCED = "advanced", _("Advanced encryption")
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -388,10 +396,12 @@ class Room(Resource):
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
encryption_enabled = models.BooleanField(
default=False,
verbose_name=_("Encryption enabled"),
help_text=_("Whether end-to-end encryption is enabled for this room."),
encryption_mode = models.CharField(
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
verbose_name=_("Encryption mode"),
help_text=_("End-to-end encryption mode for this room."),
)
configuration = models.JSONField(
blank=True,
@@ -447,6 +457,11 @@ class Room(Resource):
"""Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC
@property
def encryption_enabled(self):
"""Check if any encryption mode is active."""
return self.encryption_mode != EncryptionMode.NONE
@staticmethod
def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code"""
+16
View File
@@ -48,9 +48,11 @@ class LobbyParticipant:
id: str
is_authenticated: bool = False
email: Optional[str] = None
suite_user_id: Optional[str] = None
ephemeral_public_key: str = ''
encrypted_key: str = ''
admin_ephemeral_public_key: str = ''
encrypted_vault_key: str = ''
def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation."""
@@ -63,12 +65,16 @@ class LobbyParticipant:
}
if self.email:
result["email"] = self.email
if self.suite_user_id:
result["suite_user_id"] = self.suite_user_id
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
if self.encrypted_vault_key:
result["encrypted_vault_key"] = self.encrypted_vault_key
return result
@classmethod
@@ -85,9 +91,11 @@ class LobbyParticipant:
color=data["color"],
is_authenticated=data.get("is_authenticated", False),
email=data.get("email"),
suite_user_id=data.get("suite_user_id"),
ephemeral_public_key=data.get("ephemeral_public_key", ''),
encrypted_key=data.get("encrypted_key", ''),
admin_ephemeral_public_key=data.get("admin_ephemeral_public_key", ''),
encrypted_vault_key=data.get("encrypted_vault_key", ''),
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
@@ -195,6 +203,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,
suite_user_id=str(request.user.id) if request.user.is_authenticated else None,
ephemeral_public_key=ephemeral_public_key,
)
@@ -245,6 +254,7 @@ class LobbyService:
self, room_id: UUID, participant_id: str, username: str,
is_authenticated: bool = False,
email: Optional[str] = None,
suite_user_id: Optional[str] = None,
ephemeral_public_key: str = '',
) -> LobbyParticipant:
"""Add participant to waiting lobby.
@@ -262,6 +272,7 @@ class LobbyService:
color=color,
is_authenticated=is_authenticated,
email=email,
suite_user_id=suite_user_id,
ephemeral_public_key=ephemeral_public_key,
)
@@ -333,6 +344,7 @@ class LobbyService:
allow_entry: bool,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
encrypted_vault_key: str = '',
) -> None:
"""Handle decision on participant entry.
@@ -355,6 +367,7 @@ class LobbyService:
room_id, participant_id,
encrypted_key=encrypted_key,
admin_ephemeral_public_key=admin_ephemeral_public_key,
encrypted_vault_key=encrypted_vault_key,
**decision,
)
@@ -366,6 +379,7 @@ class LobbyService:
timeout: int,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
encrypted_vault_key: str = '',
) -> None:
"""Update participant status with appropriate timeout."""
@@ -390,6 +404,8 @@ class LobbyService:
participant.encrypted_key = encrypted_key
if admin_ephemeral_public_key:
participant.admin_ephemeral_public_key = admin_ephemeral_public_key
if encrypted_vault_key:
participant.encrypted_vault_key = encrypted_vault_key
cache.set(cache_key, participant.to_dict(), timeout=timeout)
def clear_room_cache(self, room_id: UUID) -> None:
@@ -11,6 +11,7 @@ import { VStack } from '@/styled-system/jsx'
import { RiLockFill, RiShieldCheckFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { useVaultClient } from './VaultClientProvider'
import { useEffect, useState } from 'react'
import { Dialog, Text } from '@/primitives'
@@ -33,7 +34,7 @@ export function EncryptedMeetingBanner() {
return () => clearTimeout(timer)
}, [])
if (!roomData?.encryption_enabled) return null
if (!isEncryptedRoom(roomData)) return null
const bgColor = isStrongEncryption ? '#166534' : '#1e3a5f'
const hoverBgColor = isStrongEncryption ? '#15803d' : '#2563eb'
@@ -0,0 +1,287 @@
/**
* Custom E2EE Manager that delegates crypto operations to the VaultClient iframe.
*
* Instead of using LiveKit's built-in Worker + FrameCryptor, this manager:
* - Sets up insertable streams (createEncodedStreams) on senders/receivers
* - Pipes frames through a TransformStream on the main thread
* - Delegates encrypt/decrypt to VaultClient.encryptWithKey / decryptWithKey
* - The symmetric key never leaves the VaultClient iframe
*
* Uses transferable ArrayBuffers for zero-copy performance.
*/
import { EventEmitter } from 'events'
import type { Room, RemoteTrack, Track } from 'livekit-client'
import {
RoomEvent,
ParticipantEvent,
ConnectionState,
Encryption_Type,
} from 'livekit-client'
import type { RTCEngine } from 'livekit-client/src/room/RTCEngine'
// Re-declare the interface types we need (not exported from livekit-client)
interface EncryptDataResponse {
uuid: string
payload: Uint8Array
iv: Uint8Array
keyIndex: number
}
interface DecryptDataResponse {
uuid: string
payload: Uint8Array
}
const E2EE_FLAG = Symbol('e2ee')
enum EncryptionEvent {
ParticipantEncryptionStatusChanged = 'participantEncryptionStatusChanged',
EncryptionError = 'encryptionError',
}
function isLocalTrack(track: Track): boolean {
return (track as { sender?: RTCRtpSender }).sender !== undefined
}
export class VaultE2EEManager extends EventEmitter {
private vaultClient: VaultClient
private room?: Room
private encryptionEnabled = false
private _isDataChannelEncryptionEnabled = false
/** The symmetric key encrypted for the current user's vault public key */
private encryptedSymmetricKey: ArrayBuffer | null = null
constructor(vaultClient: VaultClient) {
super()
this.vaultClient = vaultClient
}
get isEnabled(): boolean {
return this.encryptionEnabled
}
get isDataChannelEncryptionEnabled(): boolean {
return this.isEnabled && this._isDataChannelEncryptionEnabled
}
set isDataChannelEncryptionEnabled(enabled: boolean) {
this._isDataChannelEncryptionEnabled = enabled
}
/**
* Set the encrypted symmetric key (wrapped for this user's vault public key).
* Must be called before encryption can work.
*/
setEncryptedSymmetricKey(key: ArrayBuffer): void {
this.encryptedSymmetricKey = key
}
setup(room: Room): void {
if (room !== this.room) {
this.room = room
this.setupEventListeners(room)
}
}
setupEngine(_engine: RTCEngine): void {
// No RTP map tracking needed — VaultClient handles crypto opaquely
}
setParticipantCryptorEnabled(enabled: boolean, participantIdentity: string): void {
if (
participantIdentity === this.room?.localParticipant.identity &&
this.encryptionEnabled !== enabled
) {
this.encryptionEnabled = enabled
this.emit(
EncryptionEvent.ParticipantEncryptionStatusChanged,
enabled,
this.room!.localParticipant
)
} else if (participantIdentity !== this.room?.localParticipant.identity) {
const participant = this.room?.getParticipantByIdentity(participantIdentity)
if (participant) {
this.emit(EncryptionEvent.ParticipantEncryptionStatusChanged, enabled, participant)
}
}
}
setSifTrailer(_trailer: Uint8Array): void {
// SIF (Server Injected Frames) not supported in vault mode
}
async encryptData(data: Uint8Array): Promise<EncryptDataResponse> {
if (!this.encryptedSymmetricKey) {
throw new Error('No encrypted symmetric key set')
}
const { encryptedData } = await this.vaultClient.encryptWithKey(
data.buffer as ArrayBuffer,
this.encryptedSymmetricKey
)
return {
uuid: crypto.randomUUID(),
payload: new Uint8Array(encryptedData),
iv: new Uint8Array(0), // IV is embedded by VaultClient
keyIndex: 0,
}
}
async handleEncryptedData(
payload: Uint8Array,
_iv: Uint8Array,
_participantIdentity: string,
_keyIndex: number
): Promise<DecryptDataResponse> {
if (!this.encryptedSymmetricKey) {
throw new Error('No encrypted symmetric key set')
}
const { data } = await this.vaultClient.decryptWithKey(
payload.buffer as ArrayBuffer,
this.encryptedSymmetricKey
)
return {
uuid: crypto.randomUUID(),
payload: new Uint8Array(data),
}
}
private setupEventListeners(room: Room): void {
room.on(RoomEvent.TrackPublished, (pub, participant) => {
this.setParticipantCryptorEnabled(
pub.trackInfo!.encryption !== Encryption_Type.NONE,
participant.identity
)
})
room.on(RoomEvent.ConnectionStateChanged, (state) => {
if (state === ConnectionState.Connected) {
room.remoteParticipants.forEach((participant) => {
participant.trackPublications.forEach((pub) => {
this.setParticipantCryptorEnabled(
pub.trackInfo!.encryption !== Encryption_Type.NONE,
participant.identity
)
})
})
}
})
room.on(RoomEvent.TrackSubscribed, (track, pub, participant) => {
this.setupReceiver(track, participant.identity)
})
room.on(RoomEvent.SignalConnected, () => {
this.setParticipantCryptorEnabled(
room.localParticipant.isE2EEEnabled,
room.localParticipant.identity
)
})
room.localParticipant.on(
ParticipantEvent.LocalSenderCreated,
(sender: RTCRtpSender, track: Track) => {
this.setupSender(sender, track.mediaStreamID)
}
)
}
private setupSender(sender: RTCRtpSender, trackId: string): void {
if (E2EE_FLAG in sender) return
if (!this.room?.localParticipant.identity) return
// @ts-expect-error - createEncodedStreams is not in the TS types
const senderStreams = sender.createEncodedStreams()
const readable: ReadableStream = senderStreams.readable
const writable: WritableStream = senderStreams.writable
const transformStream = new TransformStream({
transform: async (frame, controller) => {
try {
if (!this.encryptedSymmetricKey || !this.encryptionEnabled) {
controller.enqueue(frame)
return
}
const frameData = new Uint8Array(frame.data)
const { encryptedData } = await this.vaultClient.encryptWithKey(
frameData.buffer as ArrayBuffer,
this.encryptedSymmetricKey
)
frame.data = encryptedData
controller.enqueue(frame)
} catch (err) {
// On error, pass frame through unencrypted to avoid blocking the pipeline
controller.enqueue(frame)
console.error('[VaultE2EE] encrypt frame error:', err)
}
},
})
readable.pipeThrough(transformStream).pipeTo(writable)
// @ts-expect-error - custom flag
sender[E2EE_FLAG] = true
}
private setupReceiver(track: RemoteTrack, participantIdentity: string): void {
if (!track.receiver) return
const receiver = track.receiver
if (E2EE_FLAG in receiver) return
// @ts-expect-error - createEncodedStreams is not in the TS types
let writable: WritableStream = receiver.writableStream
// @ts-expect-error
let readable: ReadableStream = receiver.readableStream
if (!writable || !readable) {
// @ts-expect-error
const receiverStreams = receiver.createEncodedStreams()
// @ts-expect-error
receiver.writableStream = receiverStreams.writable
writable = receiverStreams.writable
// @ts-expect-error
receiver.readableStream = receiverStreams.readable
readable = receiverStreams.readable
}
const transformStream = new TransformStream({
transform: async (frame, controller) => {
try {
if (!this.encryptedSymmetricKey || !this.encryptionEnabled) {
controller.enqueue(frame)
return
}
const frameData = new Uint8Array(frame.data)
const { data } = await this.vaultClient.decryptWithKey(
frameData.buffer as ArrayBuffer,
this.encryptedSymmetricKey
)
frame.data = data
controller.enqueue(frame)
} catch (err) {
// Decryption failed — emit error and drop frame
this.emit(
EncryptionEvent.EncryptionError,
new Error(`Decryption failed for ${participantIdentity}`),
participantIdentity
)
}
},
})
readable.pipeThrough(transformStream).pipeTo(writable)
// @ts-expect-error
receiver[E2EE_FLAG] = true
}
}
@@ -108,6 +108,17 @@ export function clearSymmetricKey(): void {
_symmetricKey = null
}
// Module-level encrypted vault key — for advanced mode, stores the vault-wrapped key
let _encryptedVaultKey: ArrayBuffer | null = null
export function setEncryptedVaultKey(key: ArrayBuffer): void {
_encryptedVaultKey = key
}
export function getEncryptedVaultKey(): ArrayBuffer | null {
return _encryptedVaultKey
}
/**
* Derive a shared secret from X25519 ECDH, then derive an encryption key via BLAKE2b.
*/
@@ -0,0 +1,139 @@
import { Button, Dialog, type DialogProps, Text } from '@/primitives'
import { VStack, HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiLockFill, RiShieldCheckFill, RiAlertLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { useVaultClient } from '@/features/encryption'
export const EncryptionModeDialog = ({
onSelect,
isForLater = false,
...dialogProps
}: {
onSelect: (mode: ApiEncryptionMode) => void
isForLater?: boolean
} & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'encryptionModeDialog' })
const { hasKeys } = useVaultClient()
const canUseAdvanced = !!hasKeys
return (
<Dialog title={t('title')} isOpen {...dialogProps}>
<VStack gap="1rem" alignItems="stretch">
<Text variant="sm" className={css({ color: 'greyscale.700' })}>
{t('description')}
</Text>
<button
className={css({
display: 'flex',
gap: '0.75rem',
padding: '1rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.200',
backgroundColor: 'white',
cursor: 'pointer',
textAlign: 'left',
transition: 'border-color 150ms ease, background-color 150ms ease',
_hover: {
borderColor: 'primary.500',
backgroundColor: 'primary.50',
},
})}
onClick={() => onSelect(ApiEncryptionMode.BASIC)}
>
<div className={css({ flexShrink: 0, paddingTop: '0.15rem' })}>
<RiLockFill size={20} color="#2563eb" />
</div>
<VStack gap="0.25rem" alignItems="flex-start">
<Text
variant="sm"
bold
className={css({ color: 'greyscale.900' })}
>
{t('basic.title')}
</Text>
<Text variant="sm" className={css({ color: 'greyscale.600' })}>
{t('basic.description')}
</Text>
</VStack>
</button>
<div style={{ position: 'relative' }}>
<button
className={css({
display: 'flex',
gap: '0.75rem',
padding: '1rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.200',
backgroundColor: 'white',
cursor: canUseAdvanced ? 'pointer' : 'not-allowed',
textAlign: 'left',
opacity: canUseAdvanced ? 1 : 0.5,
transition:
'border-color 150ms ease, background-color 150ms ease',
_hover: canUseAdvanced
? {
borderColor: 'green.500',
backgroundColor: 'green.50',
}
: {},
})}
onClick={() => canUseAdvanced && onSelect(ApiEncryptionMode.ADVANCED)}
disabled={!canUseAdvanced}
>
<div className={css({ flexShrink: 0, paddingTop: '0.15rem' })}>
<RiShieldCheckFill
size={20}
color={canUseAdvanced ? '#166534' : '#9ca3af'}
/>
</div>
<VStack gap="0.25rem" alignItems="flex-start">
<Text
variant="sm"
bold
className={css({
color: canUseAdvanced ? 'greyscale.900' : 'greyscale.400',
})}
>
{t('advanced.title')}
</Text>
<Text
variant="sm"
className={css({
color: canUseAdvanced ? 'greyscale.600' : 'greyscale.400',
})}
>
{t('advanced.description')}
</Text>
</VStack>
</button>
{!canUseAdvanced && (
<HStack
gap="0.4rem"
className={css({
marginTop: '0.5rem',
padding: '0.5rem 0.75rem',
backgroundColor: 'orange.50',
borderRadius: '0.375rem',
})}
>
<RiAlertLine
size={14}
color="#d97706"
className={css({ flexShrink: 0 })}
/>
<Text variant="note" className={css({ color: 'orange.800' })}>
{t('advanced.onboardingRequired')}
</Text>
</HStack>
)}
</div>
</VStack>
</Dialog>
)
}
+27 -20
View File
@@ -9,6 +9,8 @@ import { useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { RiAddLine, RiLink, RiLockLine } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { EncryptionModeDialog } from '@/features/home/components/EncryptionModeDialog'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useEffect, useState } from 'react'
@@ -156,6 +158,7 @@ export const Home = () => {
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
const [encryptionDialogMode, setEncryptionDialogMode] = useState<null | 'instant' | 'later'>(null)
const [redirectFailed, setRedirectFailed] = useState(false)
const { data } = useConfig()
@@ -248,18 +251,7 @@ export const Home = () => {
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={async () => {
const slug = generateRoomId()
createRoom({
slug,
username,
encryptionEnabled: true,
}).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
onAction={() => setEncryptionDialogMode('instant')}
data-attr="create-option-encrypted-instant"
>
<RiLockLine size={18} />
@@ -269,14 +261,7 @@ export const Home = () => {
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => {
const slug = generateRoomId()
createRoom({
slug,
username,
encryptionEnabled: true,
}).then((data) => setLaterRoom(data))
}}
onAction={() => setEncryptionDialogMode('later')}
data-attr="create-option-encrypted-later"
>
<RiLockLine size={18} />
@@ -313,6 +298,28 @@ export const Home = () => {
room={laterRoom}
onOpenChange={() => setLaterRoom(null)}
/>
{encryptionDialogMode && (
<EncryptionModeDialog
onSelect={(mode) => {
setEncryptionDialogMode(null)
const slug = generateRoomId()
createRoom({
slug,
username,
encryptionMode: mode,
}).then((data) => {
if (encryptionDialogMode === 'instant') {
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
} else {
setLaterRoom(data)
}
})
}}
onOpenChange={() => setEncryptionDialogMode(null)}
/>
)}
</Screen>
</UserAware>
)
+16 -1
View File
@@ -10,6 +10,21 @@ export enum ApiAccessLevel {
RESTRICTED = 'restricted',
}
export enum ApiEncryptionMode {
NONE = 'none',
BASIC = 'basic',
ADVANCED = 'advanced',
}
export function isEncryptedRoom(room?: { encryption_mode?: ApiEncryptionMode; encryption_enabled?: boolean } | null): boolean {
if (!room) return false
// Support both new encryption_mode and legacy encryption_enabled
if (room.encryption_mode !== undefined) {
return room.encryption_mode !== ApiEncryptionMode.NONE
}
return !!room.encryption_enabled
}
export type ApiRoom = {
id: string
name: string
@@ -17,7 +32,7 @@ export type ApiRoom = {
pin_code: string
is_administrable: boolean
access_level: ApiAccessLevel
encryption_enabled: boolean
encryption_mode: ApiEncryptionMode
livekit?: ApiLiveKit
configuration?: {
[key: string]: string | number | boolean | string[]
@@ -1,27 +1,27 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiRoom } from './ApiRoom'
import { ApiRoom, ApiEncryptionMode } from './ApiRoom'
export interface CreateRoomParams {
slug: string
callbackId?: string
username?: string
encryptionEnabled?: boolean
encryptionMode?: ApiEncryptionMode
}
const createRoom = ({
slug,
callbackId,
username = '',
encryptionEnabled = false,
encryptionMode = ApiEncryptionMode.NONE,
}: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
method: 'POST',
body: JSON.stringify({
name: slug,
callback_id: callbackId,
encryption_enabled: encryptionEnabled,
encryption_mode: encryptionMode,
}),
})
}
@@ -8,6 +8,7 @@ export interface EnterRoomParams {
participantId: string
encryptedKey?: string
adminEphemeralPublicKey?: string
encryptedVaultKey?: string
}
export interface EnterRoomResponse {
@@ -20,6 +21,7 @@ export const enterRoom = async ({
participantId,
encryptedKey = '',
adminEphemeralPublicKey = '',
encryptedVaultKey = '',
}: EnterRoomParams): Promise<EnterRoomResponse> => {
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
method: 'POST',
@@ -28,6 +30,7 @@ export const enterRoom = async ({
allow_entry: allowEntry,
encrypted_key: encryptedKey,
admin_ephemeral_public_key: adminEphemeralPublicKey,
encrypted_vault_key: encryptedVaultKey,
}),
})
}
@@ -10,6 +10,7 @@ export type WaitingParticipant = {
color: string
is_authenticated: boolean
email?: string
suite_user_id?: string
ephemeral_public_key?: string
}
@@ -20,6 +20,7 @@ export interface ApiRequestEntry {
livekit?: ApiLiveKit
encrypted_key?: string
admin_ephemeral_public_key?: string
encrypted_vault_key?: string
}
export const requestEntry = async ({
@@ -13,7 +13,10 @@ import {
RoomOptions,
VideoPresets,
} from 'livekit-client'
import { setSymmetricKey, getSymmetricKey } from '@/features/encryption/lobbyKeyExchange'
import { setSymmetricKey, getSymmetricKey, getEncryptedVaultKey } from '@/features/encryption/lobbyKeyExchange'
import { isEncryptedRoom, ApiEncryptionMode } from '../api/ApiRoom'
import { VaultE2EEManager } from '@/features/encryption/VaultE2EEManager'
import { useVaultClient } from '@/features/encryption'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen'
@@ -88,23 +91,30 @@ export const Conference = ({
retry: false,
})
const encryptionEnabled = data?.encryption_enabled ?? false
const encryptionEnabled = isEncryptedRoom(data)
const { client: vaultClient, hasKeys: vaultHasKeys } = useVaultClient()
// Encryption setup — refs for keyProvider and worker,
// passed directly to RoomOptions.e2ee at Room construction time.
// Determine which E2EE backend to use:
// - Advanced mode: VaultClient (iframe-based, key never leaves iframe)
// - Basic mode: LiveKit's built-in Worker+KeyProvider with passphrase from URL hash
const isAdvancedMode = data?.encryption_mode === ApiEncryptionMode.ADVANCED
const useVaultE2EE = isAdvancedMode && !!vaultClient && !!vaultHasKeys
// Refs for both approaches (only one is used per session)
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
const workerRef = useRef<Worker | null>(null)
const vaultManagerRef = useRef<VaultE2EEManager | null>(null)
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled)
const getKeyProvider = () => {
if (!keyProviderRef.current && encryptionEnabled) {
if (!keyProviderRef.current && encryptionEnabled && !useVaultE2EE) {
keyProviderRef.current = new ExternalE2EEKeyProvider()
}
return keyProviderRef.current
}
const getWorker = () => {
if (!workerRef.current && encryptionEnabled && typeof window !== 'undefined') {
if (!workerRef.current && encryptionEnabled && !useVaultE2EE && typeof window !== 'undefined') {
workerRef.current = new Worker(
new URL('livekit-client/e2ee-worker', import.meta.url)
)
@@ -112,11 +122,15 @@ export const Conference = ({
return workerRef.current
}
const roomOptions = useMemo((): RoomOptions => {
const worker = getWorker()
const keyProvider = getKeyProvider()
const getVaultManager = () => {
if (!vaultManagerRef.current && useVaultE2EE && vaultClient) {
vaultManagerRef.current = new VaultE2EEManager(vaultClient)
}
return vaultManagerRef.current
}
return {
const roomOptions = useMemo((): RoomOptions => {
const baseOptions: RoomOptions = {
adaptiveStream: true,
dynacast: true,
publishDefaults: {
@@ -135,13 +149,26 @@ export const Conference = ({
audioOutput: {
deviceId: userConfig.audioOutputDeviceId ?? undefined,
},
encryption: encryptionEnabled && keyProvider && worker
? { keyProvider, worker }
: undefined,
}
if (useVaultE2EE) {
const vaultManager = getVaultManager()
if (vaultManager) {
baseOptions.encryption = { e2eeManager: vaultManager }
}
} else if (encryptionEnabled) {
const worker = getWorker()
const keyProvider = getKeyProvider()
if (keyProvider && worker) {
baseOptions.encryption = { keyProvider, worker }
}
}
return baseOptions
// do not rely on the userConfig object directly as its reference may change on every render
}, [
encryptionEnabled,
useVaultE2EE,
userConfig.videoDeviceId,
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
@@ -165,25 +192,110 @@ export const Conference = ({
}, [apiConfig?.livekit])
// Encryption key setup:
// Admin: generate passphrase -> setKey -> connect -> E2EE enabled
// Joiner: use pre-exchanged key from lobby -> setKey -> connect -> E2EE enabled
// VaultE2EE: admin generates key via vaultClient.encryptWithoutKey(), joiner receives wrapped key
// Fallback: admin generates passphrase, joiner receives via lobby DH exchange
const isAdmin = mode === 'create' || data?.is_administrable === true
const adminPassphraseRef = useRef<string | null>(null)
useEffect(() => {
if (!encryptionEnabled || encryptionSetupComplete) return
const keyProvider = getKeyProvider()
if (!keyProvider) return
if (isAdmin) {
// Generate passphrase once (React Strict Mode runs effects twice)
if (!adminPassphraseRef.current) {
adminPassphraseRef.current = Array.from(crypto.getRandomValues(new Uint8Array(24)))
.map((b) => b.toString(36).padStart(2, '0'))
.join('')
if (useVaultE2EE) {
// VaultClient E2EE path — key never leaves the iframe
const vaultManager = getVaultManager()
if (!vaultManager || !vaultClient) return
const setupVaultKey = async () => {
try {
if (isAdmin) {
// Admin: generate a symmetric key via VaultClient
const dummyData = new Uint8Array(32).buffer
const { publicKey } = await vaultClient.getPublicKey()
const { encryptedKeys } = await vaultClient.encryptWithoutKey(
dummyData,
{ self: publicKey }
)
const encryptedSymmetricKey = encryptedKeys['self']
vaultManager.setEncryptedSymmetricKey(encryptedSymmetricKey)
console.info('[VaultE2EE] Admin: symmetric key generated')
} else {
// Joiner: use the vault-wrapped key received from admin via lobby
const vaultKey = getEncryptedVaultKey()
if (vaultKey) {
vaultManager.setEncryptedSymmetricKey(vaultKey)
console.info('[VaultE2EE] Joiner: vault key received from lobby')
} else {
console.error('[VaultE2EE] Joiner: no vault key available')
return
}
}
setEncryptionSetupComplete(true)
const onConnected = async () => {
try {
await room.setE2EEEnabled(true)
console.info('[VaultE2EE] E2EE enabled')
} catch (err) {
console.error('[VaultE2EE] E2EE enable failed:', err)
}
}
if (room.state === 'connected') onConnected()
else room.once('connected', onConnected)
} catch (err) {
console.error('[VaultE2EE] Setup failed:', err)
}
}
setupVaultKey()
} else {
// Basic mode: LiveKit Worker+KeyProvider with passphrase in URL hash
const keyProvider = getKeyProvider()
if (!keyProvider) return
let passphrase: string | null = null
if (isAdmin) {
// Admin: generate passphrase and put it in the URL hash
if (!adminPassphraseRef.current) {
// Check if there's already a hash (e.g. admin refreshed the page)
const existingHash = window.location.hash.slice(1)
if (existingHash) {
adminPassphraseRef.current = existingHash
} else {
adminPassphraseRef.current = Array.from(crypto.getRandomValues(new Uint8Array(24)))
.map((b) => b.toString(36).padStart(2, '0'))
.join('')
// Set the hash in the URL (without triggering navigation)
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}${window.location.search}#${adminPassphraseRef.current}`
)
}
}
passphrase = adminPassphraseRef.current
setSymmetricKey(new TextEncoder().encode(passphrase))
} else {
// Joiner: read passphrase from URL hash (shared link) or from lobby exchange
const hashKey = window.location.hash.slice(1)
if (hashKey) {
passphrase = hashKey
setSymmetricKey(new TextEncoder().encode(passphrase))
} else {
// Fallback: key received via lobby DH exchange
const preExchangedKey = getSymmetricKey()
if (preExchangedKey) {
passphrase = new TextDecoder().decode(preExchangedKey)
}
}
}
if (!passphrase) {
console.error('[Encryption] No passphrase available (not in URL hash and no lobby exchange)')
return
}
const passphrase = adminPassphraseRef.current
setSymmetricKey(new TextEncoder().encode(passphrase))
keyProvider
.setKey(passphrase)
@@ -194,7 +306,7 @@ export const Conference = ({
try {
await room.setE2EEEnabled(true)
} catch (err) {
console.error('[Encryption] Admin: E2EE enable failed:', err)
console.error('[Encryption] E2EE enable failed:', err)
}
}
@@ -202,40 +314,11 @@ export const Conference = ({
else room.once('connected', onConnected)
})
.catch((err) => {
console.error('[Encryption] Admin failed:', err)
console.error('[Encryption] Key setup failed:', err)
})
} else {
// Joiner: use the pre-exchanged key from lobby (stored in module-level lobbyKeyExchange)
const preExchangedKey = getSymmetricKey()
if (preExchangedKey) {
const passphrase = new TextDecoder().decode(preExchangedKey)
keyProvider
.setKey(passphrase)
.then(() => {
setEncryptionSetupComplete(true)
const onConnected = async () => {
try {
await room.setE2EEEnabled(true)
} catch (err) {
console.error('[Encryption] Joiner: E2EE enable failed:', err)
}
}
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])
}, [room, encryptionEnabled, encryptionSetupComplete, isAdmin, useVaultE2EE])
useEffect(() => {
/**
@@ -32,7 +32,7 @@ import { useQuery } from '@tanstack/react-query'
import { queryClient } from '@/api/queryClient'
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { ApiAccessLevel, ApiEncryptionMode, isEncryptedRoom as checkEncryptedRoom } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { useUser } from '@/features/auth'
import { RiInformationLine, RiLockLine } from '@remixicon/react'
@@ -113,7 +113,13 @@ export const Join = ({
staleTime: 6 * 60 * 60 * 1000,
retry: false,
})
const isEncryptedRoom = roomInfo?.encryption_enabled ?? false
const isEncryptedRoom = checkEncryptedRoom(roomInfo)
const isBasicEncrypted = roomInfo?.encryption_mode === ApiEncryptionMode.BASIC
// Basic mode: validate the passphrase in the URL hash
const BASIC_KEY_LENGTH = 48
const hashKey = window.location.hash.slice(1)
const hasValidBasicKey = isBasicEncrypted ? (hashKey.length === BASIC_KEY_LENGTH && /^[a-z0-9]+$/.test(hashKey)) : true
// In encrypted rooms, authenticated users must use their OIDC name
const isNameLocked = isEncryptedRoom && !!isLoggedIn
@@ -443,6 +449,19 @@ export const Join = ({
)
default:
if (isBasicEncrypted && !hasValidBasicKey) {
return (
<VStack alignItems="center" textAlign="center" gap="0.75rem">
<RiLockLine size={32} color="#dc2626" />
<H lvl={1} margin={false} centered>
{t('invalidKey.title')}
</H>
<Text as="p" variant="note">
{t('invalidKey.body')}
</Text>
</VStack>
)
}
return (
<Form
onSubmit={handleSubmit}
@@ -11,6 +11,7 @@ import {
encodePublicKey,
decryptKeyFromAdmin,
setSymmetricKey,
setEncryptedVaultKey,
saveEphemeralKeyPair,
loadEphemeralKeyPair,
} from '@/features/encryption/lobbyKeyExchange'
@@ -61,6 +62,7 @@ export const useLobby = ({
clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED)
// Basic mode: DH key exchange
if (
encryptionEnabled &&
response.encrypted_key &&
@@ -75,6 +77,16 @@ export const useLobby = ({
setSymmetricKey(decryptedKey)
}
// Advanced mode: vault-wrapped key
if (encryptionEnabled && response.encrypted_vault_key) {
const binaryStr = atob(response.encrypted_vault_key)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
setEncryptedVaultKey(bytes.buffer)
}
onAccepted(response)
} else if (response.status === ApiLobbyStatus.DENIED) {
clearWaitingTimeout()
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { RoomEvent } from 'livekit-client'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useEnterRoom } from '../api/enterRoom'
import {
@@ -11,6 +12,7 @@ import {
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { encryptKeyForParticipant, getSymmetricKey } from '@/features/encryption/lobbyKeyExchange'
import { useVaultClient } from '@/features/encryption'
export const POLL_INTERVAL_MS = 1000
@@ -19,10 +21,12 @@ export const useWaitingParticipants = () => {
const roomData = useRoomData()
const roomId = roomData?.id || '' // FIXME - bad practice
const isEncryptedRoom = roomData?.encryption_enabled ?? false
const encrypted = checkEncryptedRoom(roomData)
const isAdvancedMode = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED
const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
const { client: vaultClient } = useVaultClient()
const handleDataReceived = useCallback((payload: Uint8Array) => {
const notification = decodeNotificationDataReceived(payload)
@@ -59,19 +63,59 @@ export const useWaitingParticipants = () => {
const { mutateAsync: enterRoom } = useEnterRoom()
const encryptKeyForAccept = async (participant: WaitingParticipant) => {
let encryptedKey = ''
let adminEphemeralPublicKey = ''
let encryptedVaultKey = ''
if (isAdvancedMode && vaultClient && participant.suite_user_id) {
// Advanced mode: wrap the symmetric key for the joiner's vault public key
try {
const { publicKeys } = await vaultClient.fetchPublicKeys([participant.suite_user_id])
const joinerPubKey = publicKeys[participant.suite_user_id]
if (joinerPubKey) {
// Get admin's encrypted symmetric key from the VaultE2EEManager
// and re-wrap it for the joiner
const adminPubKey = (await vaultClient.getPublicKey()).publicKey
const { encryptedKeys } = await vaultClient.encryptWithoutKey(
new Uint8Array(32).buffer,
{ [participant.suite_user_id]: joinerPubKey, self: adminPubKey }
)
const joinerKey = encryptedKeys[participant.suite_user_id]
if (joinerKey) {
// Encode as base64 for transport via REST API
const bytes = new Uint8Array(joinerKey)
encryptedVaultKey = btoa(String.fromCharCode(...bytes))
}
}
} catch (err) {
console.error('[VaultE2EE] Failed to wrap key for participant:', err)
}
} else if (encrypted && getSymmetricKey() && participant.ephemeral_public_key) {
// Basic mode: DH key exchange
const result = await encryptKeyForParticipant(
participant.ephemeral_public_key
)
encryptedKey = result.encryptedKey
adminEphemeralPublicKey = result.adminPublicKey
}
return { encryptedKey, adminEphemeralPublicKey, encryptedVaultKey }
}
const handleParticipantEntry = async (
participant: WaitingParticipant,
allowEntry: boolean
) => {
let encryptedKey = ''
let adminEphemeralPublicKey = ''
let encryptedVaultKey = ''
if (allowEntry && isEncryptedRoom && getSymmetricKey() && participant.ephemeral_public_key) {
const result = await encryptKeyForParticipant(
participant.ephemeral_public_key
)
encryptedKey = result.encryptedKey
adminEphemeralPublicKey = result.adminPublicKey
if (allowEntry) {
const keys = await encryptKeyForAccept(participant)
encryptedKey = keys.encryptedKey
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
encryptedVaultKey = keys.encryptedVaultKey
}
await enterRoom({
@@ -80,6 +124,7 @@ export const useWaitingParticipants = () => {
participantId: participant.id,
encryptedKey,
adminEphemeralPublicKey,
encryptedVaultKey,
})
await refetchWaiting()
}
@@ -94,13 +139,13 @@ export const useWaitingParticipants = () => {
waitingParticipants.map(async (participant) => {
let encryptedKey = ''
let adminEphemeralPublicKey = ''
let encryptedVaultKey = ''
if (allowEntry && isEncryptedRoom && getSymmetricKey() && participant.ephemeral_public_key) {
const result = await encryptKeyForParticipant(
participant.ephemeral_public_key
)
encryptedKey = result.encryptedKey
adminEphemeralPublicKey = result.adminPublicKey
if (allowEntry) {
const keys = await encryptKeyForAccept(participant)
encryptedKey = keys.encryptedKey
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
encryptedVaultKey = keys.encryptedVaultKey
}
return enterRoom({
@@ -109,6 +154,7 @@ export const useWaitingParticipants = () => {
participantId: participant.id,
encryptedKey,
adminEphemeralPublicKey,
encryptedVaultKey,
})
})
)
@@ -4,7 +4,7 @@ import { Separator as RACSeparator } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { ApiAccessLevel, isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
@@ -168,7 +168,7 @@ export const Admin = () => {
>
{t('access.description')}
</Text>
{readOnlyData?.encryption_enabled && (
{isEncryptedRoom(readOnlyData) && (
<HStack
gap="0.5rem"
className={css({
@@ -213,13 +213,13 @@ export const Admin = () => {
value: ApiAccessLevel.PUBLIC,
label: t('access.levels.public.label'),
description: t('access.levels.public.description'),
isDisabled: readOnlyData?.encryption_enabled,
isDisabled: isEncryptedRoom(readOnlyData),
},
{
value: ApiAccessLevel.TRUSTED,
label: t('access.levels.trusted.label'),
description: t('access.levels.trusted.description'),
isDisabled: readOnlyData?.encryption_enabled,
isDisabled: isEncryptedRoom(readOnlyData),
},
{
value: ApiAccessLevel.RESTRICTED,
@@ -19,14 +19,17 @@ import {
isTrackReferencePinned,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { Track } from 'livekit-client'
import { Track, RoomEvent } from 'livekit-client'
import type { Participant } from 'livekit-client'
import { RiHand } from '@remixicon/react'
import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import {
EncryptionBadge,
getTrustLevelFromAttributes,
} from '@/features/encryption'
import { useRoomData } from '../hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { RiLockFill } from '@remixicon/react'
import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from './MutedMicIndicator'
@@ -85,16 +88,44 @@ 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 isEncryptedRoom = checkEncryptedRoom(roomData)
// Track decryption failures via EncryptionError events from LiveKit.
// useIsEncrypted returns true when E2EE is enabled, NOT when frames decrypt successfully.
// So we listen for actual decryption errors to know when to show the overlay.
const room = useRoomContext()
const [decryptionFailed, setDecryptionFailed] = React.useState(false)
React.useEffect(() => {
if (!isEncryptedRoom || trackReference.participant.isLocal) return
const participantIdentity = trackReference.participant.identity
const handleEncryptionError = (_error: Error, participant?: Participant) => {
if (participant?.identity === participantIdentity) {
setDecryptionFailed(true)
}
}
const handleEncryptionStatusChanged = (encrypted: boolean, participant?: Participant) => {
// Clear the error when encryption status confirms frames are decrypting
if (participant?.identity === participantIdentity && encrypted) {
setDecryptionFailed(false)
}
}
room.on(RoomEvent.EncryptionError, handleEncryptionError)
room.on(RoomEvent.ParticipantEncryptionStatusChanged, handleEncryptionStatusChanged)
return () => {
room.off(RoomEvent.EncryptionError, handleEncryptionError)
room.off(RoomEvent.ParticipantEncryptionStatusChanged, handleEncryptionStatusChanged)
}
}, [room, isEncryptedRoom, trackReference.participant])
const showDecryptionError =
!trackReference.participant.isLocal &&
isEncryptedRoom &&
(forceDecryptionOverlay || isDecryptionFailed)
decryptionFailed
const layoutContext = useMaybeLayoutContext()
const autoManageSubscription = useFeatureContext()?.autoSubscription
@@ -228,8 +259,9 @@ export const ParticipantTile: (
lineHeight: 1.4,
}}
>
Unable to decrypt this participant&apos;s stream. One of
you may need to leave and rejoin.
Check that you and this person are using the correct
meeting link. If they are the only one you can&apos;t see,
the issue is likely on their side.
</div>
</div>
</div>
@@ -23,6 +23,7 @@ import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButt
import { PinBadge } from './PinBadge'
import { EncryptionBadge, getTrustLevelFromAttributes, FingerprintDialog } from '@/features/encryption'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom as isEncryptedRoomFn } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
type MicIndicatorProps = {
@@ -101,7 +102,7 @@ export const ParticipantListItem = ({
}: ParticipantListItemProps) => {
const { t } = useTranslation('rooms')
const roomData = useRoomData()
const isEncryptedRoom = roomData?.encryption_enabled ?? false
const isEncryptedRoom = isEncryptedRoomFn(roomData)
const isAdmin = useIsAdminOrOwner()
const [isFingerprintOpen, setIsFingerprintOpen] = useState(false)
const name = participant.name || participant.identity
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { RiCloseLine, RiShieldCheckLine, RiAlertLine } from '@remixicon/react'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { EncryptionTrustModal } from '@/features/encryption'
import { useState } from 'react'
@@ -73,7 +74,7 @@ export const WaitingParticipantListItem = ({
}) => {
const { t } = useTranslation('rooms')
const roomData = useRoomData()
const isEncryptedRoom = roomData?.encryption_enabled ?? false
const encryptedRoom = isEncryptedRoom(roomData)
return (
<HStack
@@ -94,7 +95,7 @@ export const WaitingParticipantListItem = ({
})}
>
<Avatar name={participant.username} bgColor={participant.color} />
{isEncryptedRoom && (
{encryptedRoom && (
<EncryptionTrustIndicator
isAuthenticated={participant.is_authenticated}
participantName={participant.username}
@@ -128,7 +129,7 @@ export const WaitingParticipantListItem = ({
{participant.username}
</span>
</Text>
{isEncryptedRoom && participant.email && (
{encryptedRoom && participant.email && (
<Text
variant={'sm'}
className={css({
+13
View File
@@ -19,6 +19,19 @@
"encryptedInstantOption": "Start an encrypted meeting",
"encryptedLaterOption": "Create an encrypted meeting for later"
},
"encryptionModeDialog": {
"title": "Choose encryption mode",
"description": "Select the level of encryption for your meeting.",
"basic": {
"title": "Basic encryption",
"description": "Protects your meeting with a shared passphrase. Accessible to everyone — no setup required."
},
"advanced": {
"title": "Advanced encryption",
"description": "Maximum security — the encryption key never leaves your browser. Requires encryption onboarding for all participants.",
"onboardingRequired": "You must complete the encryption setup in your account settings before using advanced encryption."
}
},
"laterMeetingDialog": {
"heading": "Your connection details",
"description": "Share this information with the guests. They will be able to join the meeting without needing to sign in. This meeting is permanent and can be reused.",
+4
View File
@@ -81,6 +81,10 @@
"timeoutInvite": {
"title": "You cannot join this call",
"body": "No one responded to your request"
},
"invalidKey": {
"title": "Invalid meeting link",
"body": "This encrypted meeting requires a valid encryption key in the URL. Please ask the meeting organizer for the correct link."
}
},
"leaveRoomPrompt": "This will make you leave the meeting.",