Skip to content

Commit 171d21a

Browse files
1egomanlukasIO
andauthored
Add encryption to use session (#1317)
Co-authored-by: lukasIO <mail@lukasseiler.de>
1 parent 91bb48c commit 171d21a

4 files changed

Lines changed: 179 additions & 56 deletions

File tree

.changeset/dry-nights-sing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/components-react': patch
3+
---
4+
5+
Adds new "encryption" field to useSession

examples/nextjs/pages/e2ee.tsx

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from '@livekit/components-react';
1111
import type { NextPage } from 'next';
1212
import { useMemo, useEffect, useState } from 'react';
13-
import { Room, ExternalE2EEKeyProvider, TokenSource, MediaDeviceFailure } from 'livekit-client';
13+
import { TokenSource, MediaDeviceFailure } from 'livekit-client';
1414
import { generateRandomUserId } from '../lib/helper';
1515

1616
const tokenSource = TokenSource.endpoint(process.env.NEXT_PUBLIC_LK_TOKEN_ENDPOINT!);
@@ -24,36 +24,41 @@ const E2EEExample: NextPage = () => {
2424
const [userIdentity] = useState(() => params?.get('user') ?? generateRandomUserId());
2525
setLogLevel('warn', { liveKitClientLogLevel: 'debug' });
2626

27-
const keyProvider = useMemo(() => new ExternalE2EEKeyProvider(), []);
28-
29-
keyProvider.setKey('password');
30-
31-
const room = useMemo(
32-
() =>
33-
new Room({
34-
e2ee:
35-
typeof window !== 'undefined'
36-
? {
37-
keyProvider,
38-
worker: new Worker(new URL('livekit-client/e2ee-worker', import.meta.url)),
39-
}
40-
: undefined,
41-
}),
42-
[keyProvider],
43-
);
27+
const [e2eeWebworker] = useState(() => {
28+
if (typeof window === 'undefined') {
29+
return null;
30+
}
31+
return new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
32+
});
4433

4534
const session = useSession(tokenSource, {
4635
roomName,
4736
participantIdentity: userIdentity,
4837
participantName: userIdentity,
49-
room,
38+
39+
encryption: e2eeWebworker
40+
? {
41+
worker: e2eeWebworker,
42+
key: 'test',
43+
}
44+
: undefined,
5045
});
5146

52-
useEffect(() => {
53-
if (typeof window !== 'undefined') {
54-
room.setE2EEEnabled(true);
47+
const [isEncryptionEnabled, setIsEncryptionEnabled] = useState(true);
48+
const [isTogglingEncryption, setIsTogglingEncryption] = useState(false);
49+
50+
const toggleEncryption = async () => {
51+
setIsTogglingEncryption(true);
52+
try {
53+
const next = !isEncryptionEnabled;
54+
await session.setEncryptionEnabled(next);
55+
setIsEncryptionEnabled(next);
56+
} catch (err) {
57+
console.error('Failed to toggle encryption:', err);
58+
} finally {
59+
setIsTogglingEncryption(false);
5560
}
56-
}, [room]);
61+
};
5762

5863
useEffect(() => {
5964
session
@@ -74,18 +79,30 @@ const E2EEExample: NextPage = () => {
7479
// eslint-disable-next-line react-hooks/exhaustive-deps
7580
}, [session.start, session.end]);
7681

77-
useEvents(session, SessionEvent.MediaDevicesError, (error) => {
78-
const failure = MediaDeviceFailure.getFailure(error);
79-
console.error(failure);
80-
alert(
81-
'Error acquiring camera or microphone permissions. Please make sure you grant the necessary permissions in your browser and reload the tab',
82-
);
83-
}, []);
82+
useEvents(
83+
session,
84+
SessionEvent.MediaDevicesError,
85+
(error) => {
86+
const failure = MediaDeviceFailure.getFailure(error);
87+
console.error(failure);
88+
alert(
89+
'Error acquiring camera or microphone permissions. Please make sure you grant the necessary permissions in your browser and reload the tab',
90+
);
91+
},
92+
[],
93+
);
8494

8595
return (
8696
<div data-lk-theme="default" style={{ height: '100vh' }}>
8797
{session.isConnected && (
8898
<SessionProvider session={session}>
99+
<button
100+
onClick={toggleEncryption}
101+
disabled={isTogglingEncryption}
102+
style={{ position: 'absolute', top: 10, right: 10, zIndex: 1 }}
103+
>
104+
{isEncryptionEnabled ? 'Disable encryption' : 'Enable encryption'}
105+
</button>
89106
<VideoConference />
90107
</SessionProvider>
91108
)}

packages/react/etc/components-react.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { AudioAnalyserOptions } from 'livekit-client';
88
import { AudioCaptureOptions } from 'livekit-client';
9+
import { BaseKeyProvider } from 'livekit-client';
910
import { CaptureOptionsBySource } from '@livekit/components-core';
1011
import { ChatMessage } from '@livekit/components-core';
1112
import { ChatOptions } from '@livekit/components-core';

packages/react/src/hooks/useSession.ts

Lines changed: 126 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ import {
1111
TokenSourceFetchOptions,
1212
RoomConnectOptions,
1313
decodeTokenPayload,
14+
BaseKeyProvider,
15+
RoomOptions,
16+
ExternalE2EEKeyProvider,
1417
} from 'livekit-client';
1518
import { EventEmitter } from 'events';
1619

1720
import { useMaybeRoomContext } from '../context';
1821
import { AgentState, useAgent, useAgentTimeoutIdStore } from './useAgent';
19-
import { TrackReference } from '@livekit/components-core';
22+
import { TrackReference, log } from '@livekit/components-core';
2023
import { useLocalParticipant } from './useLocalParticipant';
2124

2225
/** @beta */
@@ -129,9 +132,9 @@ type SessionStateDisconnected = SessionStateCommon & {
129132

130133
type SessionActions = {
131134
/** Returns a promise that resolves once the room connects. */
132-
waitUntilConnected: (signal?: AbortSignal) => void;
135+
waitUntilConnected: (signal?: AbortSignal) => Promise<void>;
133136
/** Returns a promise that resolves once the room disconnects */
134-
waitUntilDisconnected: (signal?: AbortSignal) => void;
137+
waitUntilDisconnected: (signal?: AbortSignal) => Promise<void>;
135138

136139
prepareConnection: () => Promise<void>;
137140

@@ -140,6 +143,9 @@ type SessionActions = {
140143

141144
/** Disconnect from the underlying room */
142145
end: () => Promise<void>;
146+
147+
/** Enable or disable E2EE. */
148+
setEncryptionEnabled: (enabled: boolean) => Promise<void>;
143149
};
144150

145151
/** @beta */
@@ -162,17 +168,53 @@ export function isUseSessionReturn(value: unknown): value is UseSessionReturn {
162168
}
163169

164170
type UseSessionCommonOptions = {
165-
room?: Room;
166-
167171
/**
168172
* Amount of time in milliseonds the system will wait for an agent to join the room, before
169173
* transitioning to the "failure" state.
170174
*/
171175
agentConnectTimeoutMilliseconds?: number;
172176
};
173177

174-
type UseSessionConfigurableOptions = UseSessionCommonOptions & TokenSourceFetchOptions;
175-
type UseSessionFixedOptions = UseSessionCommonOptions;
178+
type UseSessionWithRoomOptions = {
179+
room: Room;
180+
encryption?: never;
181+
};
182+
183+
type UseSessionEncryptionOptions =
184+
| {
185+
/**
186+
* Accepts a passphrase that's used to create the crypto keys.
187+
* When passing in a string, PBKDF2 is used. (recommended for maximum compatibility across SDKs)
188+
* When passing in an ArrayBuffer of cryptographically random numbers, HKDF is used.
189+
*
190+
* Note: Not all client SDKs support HKDF.
191+
*/
192+
key: string | ArrayBuffer | BaseKeyProvider;
193+
194+
/** An instance of the E2EE webworker, which must be constructed using your js build tool's
195+
* webworker construction mechanism. */
196+
worker: Worker;
197+
}
198+
| {
199+
key?: undefined;
200+
worker?: undefined;
201+
};
202+
203+
type UseSessionWithoutRoomOptions = {
204+
// NOTE: This must be here to make typescript go down this discriminated union branch when
205+
// "room" is omitted.
206+
room?: never;
207+
208+
/** Configuration for room-level E2EE */
209+
encryption?: UseSessionEncryptionOptions;
210+
};
211+
212+
type UseSessionRoomOptions = UseSessionWithRoomOptions | UseSessionWithoutRoomOptions;
213+
214+
type UseSessionConfigurableOptions = UseSessionCommonOptions &
215+
UseSessionRoomOptions &
216+
TokenSourceFetchOptions;
217+
type UseSessionFixedOptions = UseSessionCommonOptions & UseSessionRoomOptions;
176218

177219
/**
178220
* Given two TokenSourceFetchOptions values, check to see if they are deep equal.
@@ -323,13 +365,63 @@ export function useSession(
323365
tokenSource: TokenSourceConfigurable | TokenSourceFixed,
324366
options: UseSessionConfigurableOptions | UseSessionFixedOptions = {},
325367
): UseSessionReturn {
326-
const { room: optionsRoom, agentConnectTimeoutMilliseconds, ...restOptions } = options;
368+
const {
369+
room: optionsRoom,
370+
agentConnectTimeoutMilliseconds,
371+
encryption: unstableEncryption,
372+
...unstableRestOptions
373+
} = options;
374+
375+
const encryptionKey = unstableEncryption?.key ?? null;
376+
const encryptionWorker = unstableEncryption?.worker ?? null;
327377

328378
const roomFromContext = useMaybeRoomContext();
329-
const room = React.useMemo(
330-
() => roomFromContext ?? optionsRoom ?? new Room(),
331-
[roomFromContext, optionsRoom],
332-
);
379+
380+
const externalKeyProviderRef = React.useRef<ExternalE2EEKeyProvider | null>(null);
381+
382+
const keyProvider = React.useMemo(() => {
383+
if (typeof encryptionKey === 'string' || encryptionKey instanceof ArrayBuffer) {
384+
if (externalKeyProviderRef.current === null) {
385+
externalKeyProviderRef.current = new ExternalE2EEKeyProvider();
386+
}
387+
externalKeyProviderRef.current.setKey(encryptionKey).catch((e) => log.error(e));
388+
return externalKeyProviderRef.current;
389+
} else {
390+
return encryptionKey;
391+
}
392+
}, [encryptionKey]);
393+
394+
const room = React.useMemo(() => {
395+
const preGeneratedRoom = roomFromContext ?? optionsRoom;
396+
if (preGeneratedRoom) {
397+
return preGeneratedRoom;
398+
}
399+
400+
const encryptionEnabled = !!(keyProvider && encryptionWorker);
401+
402+
const roomOptions: RoomOptions = {};
403+
if (encryptionEnabled) {
404+
roomOptions.encryption = {
405+
keyProvider,
406+
worker: encryptionWorker,
407+
};
408+
} else {
409+
log.warn(
410+
'useSession options encryption was set, but required keys encryption.key and encryption.worker were omitted.',
411+
);
412+
}
413+
const room = new Room(roomOptions);
414+
if (encryptionEnabled) {
415+
room.setE2EEEnabled(true);
416+
}
417+
return room;
418+
}, [roomFromContext, optionsRoom, keyProvider, encryptionWorker]);
419+
420+
React.useEffect(() => {
421+
return () => {
422+
room.disconnect();
423+
};
424+
}, [room]);
333425

334426
const emitter = React.useMemo(
335427
() => new EventEmitter() as TypedEventEmitter<SessionCallbacks>,
@@ -546,6 +638,11 @@ export function useSession(
546638
[waitUntilConnectionState],
547639
);
548640

641+
const setEncryptionEnabled = React.useCallback(
642+
async (enabled: boolean) => room.setE2EEEnabled(enabled),
643+
[room],
644+
);
645+
549646
const agent = useAgent(
550647
React.useMemo(
551648
() => ({
@@ -557,9 +654,9 @@ export function useSession(
557654
),
558655
);
559656

560-
const tokenSourceFetch = useSessionTokenSourceFetch(tokenSource, restOptions);
657+
const tokenSourceFetch = useSessionTokenSourceFetch(tokenSource, unstableRestOptions);
561658

562-
const [wasSessionEndCalled, setWasSessionEndCalled] = React.useState(false);
659+
const wasSessionEndCalledRef = React.useRef(false);
563660

564661
const start = React.useCallback(
565662
async (connectOptions: SessionConnectOptions = {}) => {
@@ -570,7 +667,7 @@ export function useSession(
570667
} = connectOptions;
571668

572669
await waitUntilDisconnected(signal);
573-
setWasSessionEndCalled(false);
670+
wasSessionEndCalledRef.current = false;
574671

575672
const onSignalAbort = () => {
576673
room.disconnect();
@@ -581,7 +678,7 @@ export function useSession(
581678
// on disconnection force a new token to be fetched in order to avoid reusing the same room right after
582679
// this works around the fact that agents won't rejoin a room that existed previously
583680
// and depends on the assumption that the endpoint will return a token for a different room
584-
if (!wasSessionEndCalled) {
681+
if (!wasSessionEndCalledRef.current) {
585682
tokenSourceFetch(true);
586683
}
587684
};
@@ -629,18 +726,11 @@ export function useSession(
629726

630727
signal?.removeEventListener('abort', onSignalAbort);
631728
},
632-
[
633-
room,
634-
waitUntilDisconnected,
635-
tokenSourceFetch,
636-
waitUntilConnected,
637-
agent.waitUntilConnected,
638-
wasSessionEndCalled,
639-
],
729+
[room, waitUntilDisconnected, tokenSourceFetch, waitUntilConnected, agent.waitUntilConnected],
640730
);
641731

642732
const end = React.useCallback(async () => {
643-
setWasSessionEndCalled(true);
733+
wasSessionEndCalledRef.current = true;
644734
tokenSourceFetch(true);
645735
await room.disconnect();
646736
}, [room, tokenSourceFetch]);
@@ -671,7 +761,17 @@ export function useSession(
671761
prepareConnection,
672762
start,
673763
end,
764+
765+
setEncryptionEnabled,
674766
}),
675-
[conversationState, waitUntilConnected, waitUntilDisconnected, prepareConnection, start, end],
767+
[
768+
conversationState,
769+
waitUntilConnected,
770+
waitUntilDisconnected,
771+
prepareConnection,
772+
start,
773+
end,
774+
setEncryptionEnabled,
775+
],
676776
);
677777
}

0 commit comments

Comments
 (0)