Skip to content

Commit c45f724

Browse files
authored
Show encryption key status from LiveKit (#2700)
* Refactor to make encryption system available in view models * WIP show encryption errors from LiveKit * Missing CSS * Show encryption status based on LK and RTC * Lint * Lint * Fix tests * Update wording * Refactor * Lint
1 parent bc0ab92 commit c45f724

File tree

9 files changed

+287
-11
lines changed

9 files changed

+287
-11
lines changed

public/locales/en-GB/app.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@
6464
"crypto_version": "Crypto version: {{version}}",
6565
"device_id": "Device ID: {{id}}",
6666
"disconnected_banner": "Connectivity to the server has been lost.",
67+
"e2ee_encryption_status": {
68+
"connecting": "Connecting...",
69+
"key_invalid": "The end-to-end encrypted media key for this person is invalid",
70+
"key_missing": "You haven't received the current end-to-end encrypted media key for this person yet",
71+
"password_invalid": "This person is using a different password so you won't be able to communicate with them"
72+
},
6773
"full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>",
6874
"full_screen_view_h1": "<0>Oops, something's gone wrong.</0>",
6975
"group_call_loader": {

src/state/CallViewModel.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,14 +194,23 @@ function withCallViewModel(
194194
),
195195
);
196196

197+
const roomEventSelectorSpy = vi
198+
.spyOn(ComponentsCore, "roomEventSelector")
199+
.mockImplementation((room, eventType) => of());
200+
201+
const liveKitRoom = mockLivekitRoom(
202+
{ localParticipant },
203+
{ remoteParticipants },
204+
);
205+
197206
const vm = new CallViewModel(
198207
mockMatrixRoom({
199208
client: {
200209
getUserId: () => "@carol:example.org",
201210
} as Partial<MatrixClient> as MatrixClient,
202211
getMember: (userId) => members.get(userId) ?? null,
203212
}),
204-
mockLivekitRoom({ localParticipant }),
213+
liveKitRoom,
205214
{
206215
kind: E2eeType.PER_PARTICIPANT,
207216
},
@@ -213,6 +222,7 @@ function withCallViewModel(
213222
participantsSpy!.mockRestore();
214223
mediaSpy!.mockRestore();
215224
eventsSpy!.mockRestore();
225+
roomEventSelectorSpy!.mockRestore();
216226
});
217227

218228
continuation(vm);

src/state/CallViewModel.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,19 +226,22 @@ class UserMedia {
226226
member: RoomMember | undefined,
227227
participant: LocalParticipant | RemoteParticipant,
228228
encryptionSystem: EncryptionSystem,
229+
livekitRoom: LivekitRoom,
229230
) {
230231
this.vm = participant.isLocal
231232
? new LocalUserMediaViewModel(
232233
id,
233234
member,
234235
participant as LocalParticipant,
235236
encryptionSystem,
237+
livekitRoom,
236238
)
237239
: new RemoteUserMediaViewModel(
238240
id,
239241
member,
240242
participant as RemoteParticipant,
241243
encryptionSystem,
244+
livekitRoom,
242245
);
243246

244247
this.speaker = this.vm.speaking.pipe(
@@ -282,12 +285,14 @@ class ScreenShare {
282285
member: RoomMember | undefined,
283286
participant: LocalParticipant | RemoteParticipant,
284287
encryptionSystem: EncryptionSystem,
288+
liveKitRoom: LivekitRoom,
285289
) {
286290
this.vm = new ScreenShareViewModel(
287291
id,
288292
member,
289293
participant,
290294
encryptionSystem,
295+
liveKitRoom,
291296
);
292297
}
293298

@@ -428,6 +433,7 @@ export class CallViewModel extends ViewModel {
428433
member,
429434
p,
430435
this.encryptionSystem,
436+
this.livekitRoom,
431437
),
432438
];
433439

@@ -441,6 +447,7 @@ export class CallViewModel extends ViewModel {
441447
member,
442448
p,
443449
this.encryptionSystem,
450+
this.livekitRoom,
444451
),
445452
];
446453
}

src/state/MediaViewModel.ts

Lines changed: 187 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
VideoSource,
1212
observeParticipantEvents,
1313
observeParticipantMedia,
14+
roomEventSelector,
1415
} from "@livekit/components-core";
1516
import {
1617
LocalParticipant,
@@ -21,20 +22,28 @@ import {
2122
Track,
2223
TrackEvent,
2324
facingModeFromLocalTrack,
25+
Room as LivekitRoom,
26+
RoomEvent as LivekitRoomEvent,
27+
RemoteTrack,
2428
} from "livekit-client";
2529
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
2630
import {
2731
BehaviorSubject,
2832
Observable,
2933
Subject,
3034
combineLatest,
35+
distinctUntilChanged,
3136
distinctUntilKeyChanged,
37+
filter,
3238
fromEvent,
39+
interval,
3340
map,
3441
merge,
3542
of,
43+
shareReplay,
3644
startWith,
3745
switchMap,
46+
throttleTime,
3847
} from "rxjs";
3948
import { useEffect } from "react";
4049

@@ -81,6 +90,115 @@ export function observeTrackReference(
8190
);
8291
}
8392

93+
function observeRemoteTrackReceivingOkay(
94+
participant: Participant,
95+
source: Track.Source,
96+
): Observable<boolean | undefined> {
97+
let lastStats: {
98+
framesDecoded: number | undefined;
99+
framesDropped: number | undefined;
100+
framesReceived: number | undefined;
101+
} = {
102+
framesDecoded: undefined,
103+
framesDropped: undefined,
104+
framesReceived: undefined,
105+
};
106+
107+
return combineLatest([
108+
observeTrackReference(participant, source),
109+
interval(1000).pipe(startWith(0)),
110+
]).pipe(
111+
switchMap(async ([trackReference]) => {
112+
const track = trackReference.publication?.track;
113+
if (!track || !(track instanceof RemoteTrack)) {
114+
return undefined;
115+
}
116+
const report = await track.getRTCStatsReport();
117+
if (!report) {
118+
return undefined;
119+
}
120+
121+
for (const v of report.values()) {
122+
if (v.type === "inbound-rtp") {
123+
const { framesDecoded, framesDropped, framesReceived } =
124+
v as RTCInboundRtpStreamStats;
125+
return {
126+
framesDecoded,
127+
framesDropped,
128+
framesReceived,
129+
};
130+
}
131+
}
132+
133+
return undefined;
134+
}),
135+
filter((newStats) => !!newStats),
136+
map((newStats): boolean | undefined => {
137+
const oldStats = lastStats;
138+
lastStats = newStats;
139+
if (
140+
typeof newStats.framesReceived === "number" &&
141+
typeof oldStats.framesReceived === "number" &&
142+
typeof newStats.framesDecoded === "number" &&
143+
typeof oldStats.framesDecoded === "number"
144+
) {
145+
const framesReceivedDelta =
146+
newStats.framesReceived - oldStats.framesReceived;
147+
const framesDecodedDelta =
148+
newStats.framesDecoded - oldStats.framesDecoded;
149+
150+
// if we received >0 frames and managed to decode >0 frames then we treat that as success
151+
152+
if (framesReceivedDelta > 0) {
153+
return framesDecodedDelta > 0;
154+
}
155+
}
156+
157+
// no change
158+
return undefined;
159+
}),
160+
filter((x) => typeof x === "boolean"),
161+
startWith(undefined),
162+
);
163+
}
164+
165+
function encryptionErrorObservable(
166+
room: LivekitRoom,
167+
participant: Participant,
168+
encryptionSystem: EncryptionSystem,
169+
criteria: string,
170+
): Observable<boolean> {
171+
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
172+
map((e) => {
173+
const [err] = e;
174+
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
175+
return (
176+
// Ideally we would pull the participant identity from the field on the error.
177+
// However, it gets lost in the serialization process between workers.
178+
// So, instead we do a string match
179+
(err?.message.includes(participant.identity) &&
180+
err?.message.includes(criteria)) ??
181+
false
182+
);
183+
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
184+
return !!err?.message.includes(criteria);
185+
}
186+
187+
return false;
188+
}),
189+
throttleTime(1000), // Throttle to avoid spamming the UI
190+
startWith(false),
191+
);
192+
}
193+
194+
export enum EncryptionStatus {
195+
Connecting,
196+
Okay,
197+
KeyMissing,
198+
KeyInvalid,
199+
PasswordInvalid,
200+
}
201+
84202
abstract class BaseMediaViewModel extends ViewModel {
85203
/**
86204
* Whether the media belongs to the local user.
@@ -95,6 +213,8 @@ abstract class BaseMediaViewModel extends ViewModel {
95213
*/
96214
public readonly unencryptedWarning: Observable<boolean>;
97215

216+
public readonly encryptionStatus: Observable<EncryptionStatus>;
217+
98218
public constructor(
99219
/**
100220
* An opaque identifier for this media.
@@ -110,6 +230,7 @@ abstract class BaseMediaViewModel extends ViewModel {
110230
encryptionSystem: EncryptionSystem,
111231
audioSource: AudioSource,
112232
videoSource: VideoSource,
233+
livekitRoom: LivekitRoom,
113234
) {
114235
super();
115236
const audio = observeTrackReference(participant, audioSource).pipe(
@@ -124,7 +245,64 @@ abstract class BaseMediaViewModel extends ViewModel {
124245
encryptionSystem.kind !== E2eeType.NONE &&
125246
(a.publication?.isEncrypted === false ||
126247
v.publication?.isEncrypted === false),
127-
).pipe(this.scope.state());
248+
).pipe(distinctUntilChanged(), shareReplay(1));
249+
250+
if (participant.isLocal || encryptionSystem.kind === E2eeType.NONE) {
251+
this.encryptionStatus = of(EncryptionStatus.Okay).pipe(
252+
this.scope.state(),
253+
);
254+
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
255+
this.encryptionStatus = combineLatest([
256+
encryptionErrorObservable(
257+
livekitRoom,
258+
participant,
259+
encryptionSystem,
260+
"MissingKey",
261+
),
262+
encryptionErrorObservable(
263+
livekitRoom,
264+
participant,
265+
encryptionSystem,
266+
"InvalidKey",
267+
),
268+
observeRemoteTrackReceivingOkay(participant, audioSource),
269+
observeRemoteTrackReceivingOkay(participant, videoSource),
270+
]).pipe(
271+
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
272+
if (keyMissing) return EncryptionStatus.KeyMissing;
273+
if (keyInvalid) return EncryptionStatus.KeyInvalid;
274+
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
275+
return undefined; // no change
276+
}),
277+
filter((x) => !!x),
278+
startWith(EncryptionStatus.Connecting),
279+
this.scope.state(),
280+
);
281+
} else {
282+
this.encryptionStatus = combineLatest([
283+
encryptionErrorObservable(
284+
livekitRoom,
285+
participant,
286+
encryptionSystem,
287+
"InvalidKey",
288+
),
289+
observeRemoteTrackReceivingOkay(participant, audioSource),
290+
observeRemoteTrackReceivingOkay(participant, videoSource),
291+
]).pipe(
292+
map(
293+
([keyInvalid, audioOkay, videoOkay]):
294+
| EncryptionStatus
295+
| undefined => {
296+
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
297+
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
298+
return undefined; // no change
299+
},
300+
),
301+
filter((x) => !!x),
302+
startWith(EncryptionStatus.Connecting),
303+
this.scope.state(),
304+
);
305+
}
128306
}
129307
}
130308

@@ -171,6 +349,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
171349
member: RoomMember | undefined,
172350
participant: LocalParticipant | RemoteParticipant,
173351
encryptionSystem: EncryptionSystem,
352+
livekitRoom: LivekitRoom,
174353
) {
175354
super(
176355
id,
@@ -179,6 +358,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
179358
encryptionSystem,
180359
Track.Source.Microphone,
181360
Track.Source.Camera,
361+
livekitRoom,
182362
);
183363

184364
const media = observeParticipantMedia(participant).pipe(this.scope.state());
@@ -228,8 +408,9 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
228408
member: RoomMember | undefined,
229409
participant: LocalParticipant,
230410
encryptionSystem: EncryptionSystem,
411+
livekitRoom: LivekitRoom,
231412
) {
232-
super(id, member, participant, encryptionSystem);
413+
super(id, member, participant, encryptionSystem, livekitRoom);
233414
}
234415
}
235416

@@ -288,8 +469,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
288469
member: RoomMember | undefined,
289470
participant: RemoteParticipant,
290471
encryptionSystem: EncryptionSystem,
472+
livekitRoom: LivekitRoom,
291473
) {
292-
super(id, member, participant, encryptionSystem);
474+
super(id, member, participant, encryptionSystem, livekitRoom);
293475

294476
// Sync the local volume with LiveKit
295477
this.localVolume
@@ -321,6 +503,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
321503
member: RoomMember | undefined,
322504
participant: LocalParticipant | RemoteParticipant,
323505
encryptionSystem: EncryptionSystem,
506+
livekitRoom: LivekitRoom,
324507
) {
325508
super(
326509
id,
@@ -329,6 +512,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
329512
encryptionSystem,
330513
Track.Source.ScreenShareAudio,
331514
Track.Source.ScreenShare,
515+
livekitRoom,
332516
);
333517
}
334518
}

0 commit comments

Comments
 (0)