Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Poc e2ee #296

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
9 changes: 5 additions & 4 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@

from . import permissions, serializers

from livekit import api as livekit_api

# pylint: disable=too-many-ancestors

logger = getLogger(__name__)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
41 changes: 41 additions & 0 deletions src/backend/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/backend/meet/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/features/rooms/api/ApiRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type ApiRoom = {
url: string
room: string
token: string
passphrase: string
}
configuration?: {
[key: string]: string | number | boolean
Expand Down
81 changes: 77 additions & 4 deletions src/frontend/src/features/rooms/components/Conference.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -63,25 +71,85 @@ export const Conference = ({
retry: false,
})

const e2eeEnabled = true

const workerRef = useRef<Worker | null>(null)
const keyProvider = useRef<any | null>(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,
},
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')
Expand All @@ -102,20 +170,25 @@ export const Conference = ({
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
}

const handleEncryptionError = () => {
console.log('error')
}

return (
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
<Screen header={false} footer={false}>
<LiveKitRoom
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}
>
<VideoConference />
{showInviteDialog && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/helm/env.d/dev/values.livekit.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Comment on lines +20 to +23
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I manually added the misssing authority certificate inside livekit alpine pod. I need to find a better solution !



loadBalancer:
Expand Down
1 change: 1 addition & 0 deletions src/helm/env.d/dev/values.meet.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading