diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 51b37fbb..9c2d867e 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -130,17 +130,18 @@ def to_representation(self, instance): del output["configuration"] if role is not None or instance.is_public: - slug = f"{instance.id!s}" + room_id = f"{instance.id!s}" username = request.query_params.get("username", None) output["livekit"] = { "url": settings.LIVEKIT_CONFIGURATION["url"], - "room": slug, + "room": room_id, "token": utils.generate_token( - room=slug, user=request.user, username=username + room=room_id, user=request.user, username=username ), + "passphrase": utils.get_cached_passphrase(room_id) } - + output["is_administrable"] = is_admin return output diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 9ee02528..4f85ba59 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -47,6 +47,8 @@ from . import permissions, serializers +from livekit import api as livekit_api + # pylint: disable=too-many-ancestors logger = getLogger(__name__) @@ -210,6 +212,10 @@ def retrieve(self, request, *args, **kwargs): Allow unregistered rooms when activated. For unregistered rooms we only return a null id and the livekit room and token. """ + + # todo - determine whether encryption is needed store a shared secret in memory or in redis + # todo - check if a secret already exists, else create one. + try: instance = self.get_object() except Http404: @@ -343,6 +349,36 @@ def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argum {"message": f"Recording stopped for room {room.slug}."} ) + @decorators.action( + detail=False, + methods=["post"], + url_path="livekit-webhook", + permission_classes=[], + authentication_classes=[], + ) + def handle_livekit_webhook(self, request, pk=None): # pylint: disable=unused-argument + """Handle LiveKit webhook events.""" + auth_token = request.headers.get("Authorization") + if not auth_token: + return drf_response.Response( + {"error": "Missing LiveKit authentication token"}, + status=drf_status.HTTP_401_UNAUTHORIZED + ) + + token_verifier = livekit_api.TokenVerifier() + webhook_receiver = livekit_api.WebhookReceiver(token_verifier) + + webhook_data = webhook_receiver.receive(request.body.decode("utf-8"), auth_token) + + # Todo - livekit triggers a webhook for all events, see if we can restrict webhook to a limited number of events. + # Todo - handle Egress stopped / aborted events. + + if webhook_data.event == "room_finished": + room_id = webhook_data.room.name + utils.clear_cache_passphrase(room_id) + + return drf_response.Response({"message": f"Event processed"}) + class ResourceAccessListModelMixin: """List mixin for resource access API.""" diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 304b5de7..53a035a4 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -14,6 +14,47 @@ from livekit.api import AccessToken, VideoGrants +import secrets +import string + +from django.core.cache import cache +from cryptography.fernet import Fernet + +import base64 + + +def generate_random_passphrase(length=26): + """Generate a random passphrase using letters and digits""" + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(length)) + + +def build_room_passphrase_key(room_id: str) -> str: + """Build cache key for room passphrase.""" + return f"room_passphrase:{room_id}" + +def get_cached_passphrase(room_id: str) -> str: + """Get or generate encrypted passphrase for a room. + + Retrieves existing passphrase from cache or generates, + encrypts and caches a new one if not found. + """ + cypher = Fernet(settings.PASSPHRASE_ENCRYPTION_KEY.encode()) + cache_key = build_room_passphrase_key(room_id) + encrypted_passphrase = cache.get(cache_key) + + if encrypted_passphrase is None: + passphrase = generate_random_passphrase() + encrypted_passphrase = cypher.encrypt(passphrase.encode()).decode() + cache.set(cache_key, encrypted_passphrase, timeout=86400) # 24 hours + return passphrase + + return cypher.decrypt(encrypted_passphrase.encode()).decode() + +def clear_room_passphrase(room_id: str) -> None: + """Remove room passphrase from cache.""" + cache.delete(build_room_passphrase_key(room_id)) + def generate_color(identity: str) -> str: """Generates a consistent HSL color based on a given identity string. diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 9b30d2de..e0b599b3 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -483,6 +483,8 @@ class Base(Configuration): ) BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True}) + PASSPHRASE_ENCRYPTION_KEY = values.Value(environ_name="PASSPHRASE_ENCRYPTION_KEY", environ_prefix=None) + # pylint: disable=invalid-name @property def ENVIRONMENT(self): diff --git a/src/frontend/src/features/rooms/api/ApiRoom.ts b/src/frontend/src/features/rooms/api/ApiRoom.ts index 7e5404d0..4c2652e2 100644 --- a/src/frontend/src/features/rooms/api/ApiRoom.ts +++ b/src/frontend/src/features/rooms/api/ApiRoom.ts @@ -8,6 +8,7 @@ export type ApiRoom = { url: string room: string token: string + passphrase: string } configuration?: { [key: string]: string | number | boolean diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 47638edb..3914c0d0 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -1,8 +1,13 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react' -import { Room, RoomOptions } from 'livekit-client' +import { + Room, + RoomOptions, + ExternalE2EEKeyProvider, + DeviceUnsupportedError, +} from 'livekit-client' import { keys } from '@/api/queryKeys' import { queryClient } from '@/api/queryClient' import { Screen } from '@/layout/Screen' @@ -17,6 +22,9 @@ import { VideoConference } from '../livekit/prefabs/VideoConference' import posthog from 'posthog-js' import { css } from '@/styled-system/css' +// todo - release worker when quitting the room, same for the key provider? +// todo - check, seems the demo app from livekit trigger the web worker twice because of re-rendering + export const Conference = ({ roomId, userConfig, @@ -63,12 +71,49 @@ export const Conference = ({ retry: false, }) + const e2eeEnabled = true + + const workerRef = useRef(null) + const keyProvider = useRef(null) + + const getKeyProvider = () => { + if (!keyProvider.current && typeof window !== 'undefined') { + keyProvider.current = new ExternalE2EEKeyProvider() + } + return keyProvider.current + } + + const getWorker = () => { + if (!e2eeEnabled) { + return + } + if (!workerRef.current && typeof window !== 'undefined') { + workerRef.current = new Worker( + new URL('livekit-client/e2ee-worker', import.meta.url) + ) + } + return workerRef.current + } + + const e2eePassphrase = data?.livekit?.passphrase + + const [e2eeSetupComplete, setE2eeSetupComplete] = useState(false) + const roomOptions = useMemo((): RoomOptions => { + const worker = getWorker() + const keyProvider = getKeyProvider() + + // todo - explain why + const videoCodec = e2eeEnabled ? undefined : 'vp9' + const e2ee = e2eeEnabled ? { keyProvider, worker } : undefined + return { adaptiveStream: true, dynacast: true, publishDefaults: { - videoCodec: 'vp9', + // todo - explain why + red: !e2eeEnabled, + videoCodec, }, videoCaptureDefaults: { deviceId: userConfig.videoDeviceId ?? undefined, @@ -76,12 +121,35 @@ export const Conference = ({ audioCaptureDefaults: { deviceId: userConfig.audioDeviceId ?? undefined, }, + e2ee, } // do not rely on the userConfig object directly as its reference may change on every render }, [userConfig.videoDeviceId, userConfig.audioDeviceId]) const room = useMemo(() => new Room(roomOptions), [roomOptions]) + useEffect(() => { + console.log('enter', e2eePassphrase) + if (e2eePassphrase) { + const keyProvider = getKeyProvider() + keyProvider + .setKey(e2eePassphrase) + .then(() => { + room.setE2EEEnabled(true).catch((e) => { + if (e instanceof DeviceUnsupportedError) { + alert( + `You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.` + ) + console.error(e) + } else { + throw e + } + }) + }) + .then(() => setE2eeSetupComplete(true)) + } + }, [room, e2eePassphrase]) + const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create') const { t } = useTranslation('rooms') @@ -102,6 +170,10 @@ export const Conference = ({ peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation } + const handleEncryptionError = () => { + console.log('error') + } + return ( @@ -109,13 +181,14 @@ export const Conference = ({ room={room} serverUrl={data?.livekit?.url} token={data?.livekit?.token} - connect={true} + connect={e2eeSetupComplete} audio={userConfig.audioEnabled} video={userConfig.videoEnabled} connectOptions={connectOptions} className={css({ backgroundColor: 'primaryDark.50 !important', })} + onEncryptionError={handleEncryptionError} > {showInviteDialog && ( diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx index 076ea42f..83bc99b6 100644 --- a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx @@ -15,8 +15,9 @@ import { VideoTrack, TrackRefContext, ParticipantContextIfNeeded, + useIsSpeaking, } from '@livekit/components-react' -import React from 'react' +import React, { useEffect } from 'react' import { isTrackReference, isTrackReferencePinned, diff --git a/src/helm/env.d/dev/values.livekit.yaml.gotmpl b/src/helm/env.d/dev/values.livekit.yaml.gotmpl index 1671e2b0..1168ea3d 100644 --- a/src/helm/env.d/dev/values.livekit.yaml.gotmpl +++ b/src/helm/env.d/dev/values.livekit.yaml.gotmpl @@ -17,6 +17,10 @@ livekit: udp_port: 443 domain: livekit.127.0.0.1.nip.io loadBalancerAnnotations: {} + webhook: + api_key: devkey + urls: + - https://meet.127.0.0.1.nip.io/api/v1.0/rooms/livekit-webhook/ loadBalancer: diff --git a/src/helm/env.d/dev/values.meet.yaml.gotmpl b/src/helm/env.d/dev/values.meet.yaml.gotmpl index a73a41f3..d8957965 100644 --- a/src/helm/env.d/dev/values.meet.yaml.gotmpl +++ b/src/helm/env.d/dev/values.meet.yaml.gotmpl @@ -61,6 +61,7 @@ backend: RECORDING_STORAGE_EVENT_TOKEN: password SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/ SUMMARY_SERVICE_API_TOKEN: password + PASSPHRASE_ENCRYPTION_KEY: lT3cX5dzFhCe-9xNjXUiTCX00r2ZgHgGUJKO66x-QIo= migrate: