mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-08 01:43:17 +00:00
wip working with hash
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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"""
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'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't see,
|
||||
the issue is likely on their side.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+4
-3
@@ -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({
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user