-
Notifications
You must be signed in to change notification settings - Fork 268
Expand file tree
/
Copy pathFrameCryptor.ts
More file actions
802 lines (707 loc) · 28 KB
/
FrameCryptor.ts
File metadata and controls
802 lines (707 loc) · 28 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
/* eslint-disable @typescript-eslint/no-unused-vars */
// TODO code inspired by https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption/js/worker.js
import { EventEmitter } from 'events';
import type TypedEventEmitter from 'typed-emitter';
import { workerLogger } from '../../logger';
import type { VideoCodec } from '../../room/track/options';
import { stripPacketTrailerFromEncodedFrame } from '../../packet_trailer/PacketTrailerTransformer';
import { ENCRYPTION_ALGORITHM, IV_LENGTH, UNENCRYPTED_BYTES } from '../constants';
import { CryptorError, CryptorErrorReason } from '../errors';
import { type CryptorCallbacks, CryptorEvent } from '../events';
import type {
DecodeRatchetOptions,
KeyProviderOptions,
KeySet,
RatchetResult,
PacketTrailerMessage,
} from '../types';
import { deriveKeys, isVideoFrame, needsRbspUnescaping, parseRbsp, writeRbsp } from '../utils';
import type { ParticipantKeyHandler } from './ParticipantKeyHandler';
import { processNALUsForEncryption } from './naluUtils';
import { identifySifPayload } from './sifPayload';
export const encryptionEnabledMap: Map<string, boolean> = new Map();
export interface FrameCryptorConstructor {
new (opts?: unknown): BaseFrameCryptor;
}
export interface TransformerInfo {
readable: ReadableStream;
writable: WritableStream;
transformer: TransformStream;
trackId: string;
symbol: symbol;
}
export class BaseFrameCryptor extends (EventEmitter as new () => TypedEventEmitter<CryptorCallbacks>) {
protected encodeFunction(
encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
controller: TransformStreamDefaultController,
): Promise<any> {
throw Error('not implemented for subclass');
}
protected decodeFunction(
encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
controller: TransformStreamDefaultController,
): Promise<any> {
throw Error('not implemented for subclass');
}
}
/**
* Cryptor is responsible for en-/decrypting media frames.
* Each Cryptor instance is responsible for en-/decrypting a single mediaStreamTrack.
*/
export class FrameCryptor extends BaseFrameCryptor {
private sendCounts: Map<number, number>;
private participantIdentity: string | undefined;
private trackId: string | undefined;
private keys: ParticipantKeyHandler;
private videoCodec?: VideoCodec;
private rtpMap: Map<number, VideoCodec>;
private keyProviderOptions: KeyProviderOptions;
/**
* used for detecting server injected unencrypted frames
*/
private sifTrailer: Uint8Array;
private detectedCodec?: VideoCodec;
private currentTransform?: TransformerInfo;
/**
* Throttling mechanism for decryption errors to prevent memory leaks
*/
private lastErrorTimestamp: Map<string, number> = new Map();
private errorCounts: Map<string, number> = new Map();
private readonly ERROR_THROTTLE_MS = 1000; // Emit error at most once per second
private readonly MAX_ERRORS_PER_MINUTE = 5; // Maximum errors to emit per minute per key
private readonly ERROR_WINDOW_MS = 60000; // 1 minute window
constructor(opts: {
keys: ParticipantKeyHandler;
participantIdentity: string;
keyProviderOptions: KeyProviderOptions;
sifTrailer?: Uint8Array;
}) {
super();
this.sendCounts = new Map();
this.keys = opts.keys;
this.participantIdentity = opts.participantIdentity;
this.rtpMap = new Map();
this.keyProviderOptions = opts.keyProviderOptions;
this.sifTrailer = opts.sifTrailer ?? Uint8Array.from([]);
}
private get logContext() {
return {
participant: this.participantIdentity,
mediaTrackId: this.trackId,
fallbackCodec: this.videoCodec,
};
}
/**
* Assign a different participant to the cryptor.
* useful for transceiver re-use
* @param id
* @param keys
*/
setParticipant(id: string, keys: ParticipantKeyHandler) {
workerLogger.debug('setting new participant on cryptor', {
...this.logContext,
newParticipant: id,
hadPreviousParticipant: !!this.participantIdentity,
});
if (this.participantIdentity && this.participantIdentity !== id) {
workerLogger.warn('cryptor has already a participant set, cleaning up before switching', {
oldParticipant: this.participantIdentity,
newParticipant: id,
trackId: this.trackId,
});
// Clean up state from previous participant
this.unsetParticipant();
}
this.participantIdentity = id;
this.keys = keys;
}
unsetParticipant() {
workerLogger.debug('unsetting participant', this.logContext);
if (this.currentTransform) {
this.currentTransform = undefined;
}
this.participantIdentity = undefined;
this.lastErrorTimestamp = new Map();
this.errorCounts = new Map();
}
isEnabled() {
if (this.participantIdentity) {
return encryptionEnabledMap.get(this.participantIdentity);
} else {
return undefined;
}
}
getParticipantIdentity() {
return this.participantIdentity;
}
getTrackId() {
return this.trackId;
}
/**
* Update the video codec used by the mediaStreamTrack
* @param codec
*/
setVideoCodec(codec: VideoCodec) {
this.videoCodec = codec;
}
/**
* rtp payload type map used for figuring out codec of payload type when encoding
* @param map
*/
setRtpMap(map: Map<number, VideoCodec>) {
this.rtpMap = map;
}
setupTransform(
operation: 'encode' | 'decode',
readable: ReadableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>,
writable: WritableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>,
trackId: string,
isReuse: boolean,
codec?: VideoCodec,
) {
if (codec) {
workerLogger.info('setting codec on cryptor to', { codec });
this.videoCodec = codec;
}
workerLogger.debug('Setting up frame cryptor transform', {
operation,
passedTrackId: trackId,
codec,
isReuse,
hasCurrentTransform: !!this.currentTransform,
...this.logContext,
});
// Always update trackId, even on reuse
this.trackId = trackId;
// If we're reusing and have an active transform skip setup
if (
isReuse &&
this.currentTransform &&
readable === this.currentTransform.readable &&
writable === this.currentTransform.writable
) {
workerLogger.debug('reusing existing transform', {
...this.logContext,
trackId,
});
return;
}
const symbol = Symbol('transform');
const transformFn = operation === 'encode' ? this.encodeFunction : this.decodeFunction;
const transformStream = new TransformStream({
transform: transformFn.bind(this),
});
// Store transform info before starting the pipe
this.currentTransform = {
readable,
writable,
transformer: transformStream,
trackId,
symbol,
};
readable
.pipeThrough(transformStream)
.pipeTo(writable)
.catch((e) => {
if (e instanceof TypeError && e.message === 'Destination stream closed') {
// this can happen when subscriptions happen in quick successions, but doesn't influence functionality
workerLogger.debug('destination stream closed');
} else {
workerLogger.warn('transform error', { error: e, ...this.logContext });
this.emit(
CryptorEvent.Error,
e instanceof CryptorError
? e
: new CryptorError(e.message, undefined, this.participantIdentity),
);
}
})
.finally(() => {
// Only clear currentTransform if it's still the same one we started
if (this.currentTransform?.symbol === symbol) {
workerLogger.debug('transform completed', {
...this.logContext,
trackId,
});
this.currentTransform = undefined;
}
});
}
setSifTrailer(trailer: Uint8Array) {
workerLogger.debug('setting SIF trailer', { ...this.logContext, trailer });
this.sifTrailer = trailer;
}
/**
* Checks if we should emit an error based on throttling rules to prevent memory leaks
* @param errorKey - unique key identifying the error context
* @returns true if the error should be emitted, false otherwise
*/
private shouldEmitError(errorKey: string): boolean {
const now = Date.now();
const lastErrorTime = this.lastErrorTimestamp.get(errorKey) ?? 0;
const errorCount = this.errorCounts.get(errorKey) ?? 0;
// Reset count if we're in a new time window
if (now - lastErrorTime > this.ERROR_WINDOW_MS) {
this.errorCounts.set(errorKey, 0);
this.lastErrorTimestamp.set(errorKey, now);
return true;
}
// Check if we've exceeded the throttle time
if (now - lastErrorTime < this.ERROR_THROTTLE_MS) {
return false;
}
// Check if we've exceeded the max errors per window
if (errorCount >= this.MAX_ERRORS_PER_MINUTE) {
// Only log a warning once when hitting the limit
if (errorCount === this.MAX_ERRORS_PER_MINUTE) {
workerLogger.warn(`Suppressing further decryption errors for ${this.participantIdentity}`, {
...this.logContext,
errorKey,
});
this.errorCounts.set(errorKey, errorCount + 1);
}
return false;
}
// Update tracking
this.lastErrorTimestamp.set(errorKey, now);
this.errorCounts.set(errorKey, errorCount + 1);
return true;
}
/**
* Emits a throttled error to prevent memory leaks from repeated decryption failures
* @param error - the CryptorError to emit
*/
private emitThrottledError(error: CryptorError) {
const errorKey = `${this.participantIdentity}-${error.reason}-decrypt`;
if (this.shouldEmitError(errorKey)) {
const errorCount = this.errorCounts.get(errorKey) ?? 0;
if (errorCount > 1) {
workerLogger.debug(`Decryption error (${errorCount} occurrences in window)`, {
...this.logContext,
reason: CryptorErrorReason[error.reason],
});
}
this.emit(CryptorEvent.Error, error);
}
}
/**
* Function that will be injected in a stream and will encrypt the given encoded frames.
*
* @param {RTCEncodedVideoFrame|RTCEncodedAudioFrame} encodedFrame - Encoded video frame.
* @param {TransformStreamDefaultController} controller - TransportStreamController.
*
* The VP8 payload descriptor described in
* https://tools.ietf.org/html/rfc7741#section-4.2
* is part of the RTP packet and not part of the frame and is not controllable by us.
* This is fine as the SFU keeps having access to it for routing.
*
* The encrypted frame is formed as follows:
* 1) Find unencrypted byte length, depending on the codec, frame type and kind.
* 2) Form the GCM IV for the frame as described above.
* 3) Encrypt the rest of the frame using AES-GCM.
* 4) Allocate space for the encrypted frame.
* 5) Copy the unencrypted bytes to the start of the encrypted frame.
* 6) Append the ciphertext to the encrypted frame.
* 7) Append the IV.
* 8) Append a single byte for the key identifier.
* 9) Enqueue the encrypted frame for sending.
*/
protected async encodeFunction(
encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
controller: TransformStreamDefaultController,
) {
if (
!this.isEnabled() ||
// skip for encryption for empty dtx frames
encodedFrame.data.byteLength === 0
) {
return controller.enqueue(encodedFrame);
}
const keySet = this.keys.getKeySet();
if (!keySet) {
this.emitThrottledError(
new CryptorError(
`key set not found for ${
this.participantIdentity
} at index ${this.keys.getCurrentKeyIndex()}`,
CryptorErrorReason.MissingKey,
this.participantIdentity,
),
);
return;
}
const { encryptionKey } = keySet;
const keyIndex = this.keys.getCurrentKeyIndex();
if (encryptionKey) {
const iv = this.makeIV(
encodedFrame.getMetadata().synchronizationSource ?? -1,
encodedFrame.timestamp,
);
let frameInfo = this.getUnencryptedBytes(encodedFrame);
// Thіs is not encrypted and contains the VP8 payload descriptor or the Opus TOC byte.
const frameHeader = new Uint8Array(encodedFrame.data, 0, frameInfo.unencryptedBytes);
// Frame trailer contains the R|IV_LENGTH and key index
const frameTrailer = new Uint8Array(2);
frameTrailer[0] = IV_LENGTH;
frameTrailer[1] = keyIndex;
// Construct frame trailer. Similar to the frame header described in
// https://tools.ietf.org/html/draft-omara-sframe-00#section-4.2
// but we put it at the end.
//
// ---------+-------------------------+-+---------+----
// payload |IV...(length = IV_LENGTH)|R|IV_LENGTH|KID |
// ---------+-------------------------+-+---------+----
try {
const cipherText = await crypto.subtle.encrypt(
{
name: ENCRYPTION_ALGORITHM,
iv,
additionalData: new Uint8Array(encodedFrame.data, 0, frameHeader.byteLength),
},
encryptionKey,
new Uint8Array(encodedFrame.data, frameInfo.unencryptedBytes),
);
let newDataWithoutHeader = new Uint8Array(
cipherText.byteLength + iv.byteLength + frameTrailer.byteLength,
);
newDataWithoutHeader.set(new Uint8Array(cipherText)); // add ciphertext.
newDataWithoutHeader.set(new Uint8Array(iv), cipherText.byteLength); // append IV.
newDataWithoutHeader.set(frameTrailer, cipherText.byteLength + iv.byteLength); // append frame trailer.
if (frameInfo.requiresNALUProcessing) {
newDataWithoutHeader = writeRbsp(newDataWithoutHeader);
}
var newData = new Uint8Array(frameHeader.byteLength + newDataWithoutHeader.byteLength);
newData.set(frameHeader);
newData.set(newDataWithoutHeader, frameHeader.byteLength);
encodedFrame.data = newData.buffer;
return controller.enqueue(encodedFrame);
} catch (e: any) {
// TODO: surface this to the app.
workerLogger.error(e);
}
} else {
workerLogger.debug('failed to encrypt, emitting error', this.logContext);
this.emitThrottledError(
new CryptorError(
`encryption key missing for encoding`,
CryptorErrorReason.MissingKey,
this.participantIdentity,
),
);
}
}
/**
* Function that will be injected in a stream and will decrypt the given encoded frames.
*
* @param {RTCEncodedVideoFrame|RTCEncodedAudioFrame} encodedFrame - Encoded video frame.
* @param {TransformStreamDefaultController} controller - TransportStreamController.
*/
protected async decodeFunction(
encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
controller: TransformStreamDefaultController,
) {
// Always attempt to strip LKTS packet trailer before any e2ee
// processing. On the send side, the trailer is appended *after* encryption,
// so it must be removed *before* decryption.
if (isVideoFrame(encodedFrame) && encodedFrame.data.byteLength > 0) {
try {
const packetTrailerResult = stripPacketTrailerFromEncodedFrame(
encodedFrame as RTCEncodedVideoFrame,
);
if (packetTrailerResult !== undefined && this.trackId && this.participantIdentity) {
const msg: PacketTrailerMessage = {
kind: 'packetTrailer',
data: {
trackId: this.trackId,
participantIdentity: this.participantIdentity,
timestampUs: packetTrailerResult.timestampUs,
frameId: packetTrailerResult.frameId,
rtpTimestamp: packetTrailerResult.rtpTimestamp,
},
};
postMessage(msg);
}
} catch {
// Best-effort: never break media pipeline if timestamp parsing fails.
}
}
if (
!this.isEnabled() ||
// skip for decryption for empty dtx frames
encodedFrame.data.byteLength === 0
) {
return controller.enqueue(encodedFrame);
}
if (isFrameServerInjected(encodedFrame.data, this.sifTrailer)) {
encodedFrame.data = encodedFrame.data.slice(
0,
encodedFrame.data.byteLength - this.sifTrailer.byteLength,
);
if (await identifySifPayload(encodedFrame.data)) {
workerLogger.debug('enqueue SIF', this.logContext);
return controller.enqueue(encodedFrame);
} else {
workerLogger.warn('Unexpected SIF frame payload, dropping frame', this.logContext);
return;
}
}
const data = new Uint8Array(encodedFrame.data);
const keyIndex = data[encodedFrame.data.byteLength - 1];
if (this.keys.hasInvalidKeyAtIndex(keyIndex)) {
// drop frame
return;
}
if (this.keys.getKeySet(keyIndex)) {
try {
const decodedFrame = await this.decryptFrame(encodedFrame, keyIndex);
this.keys.decryptionSuccess(keyIndex);
if (decodedFrame) {
return controller.enqueue(decodedFrame);
}
} catch (error) {
if (error instanceof CryptorError && error.reason === CryptorErrorReason.InvalidKey) {
// emit an error if the key handler thinks we have a valid key
if (this.keys.hasValidKey) {
this.emitThrottledError(error);
this.keys.decryptionFailure(keyIndex);
}
} else {
workerLogger.warn('decoding frame failed', { error });
}
}
} else {
// emit an error if the key index is out of bounds but the key handler thinks we still have a valid key
workerLogger.warn(`skipping decryption due to missing key at index ${keyIndex}`);
this.emitThrottledError(
new CryptorError(
`missing key at index ${keyIndex} for participant ${this.participantIdentity}`,
CryptorErrorReason.MissingKey,
this.participantIdentity,
),
);
this.keys.decryptionFailure(keyIndex);
}
}
/**
* Function that will decrypt the given encoded frame. If the decryption fails, it will
* ratchet the key for up to RATCHET_WINDOW_SIZE times.
*/
private async decryptFrame(
encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
keyIndex: number,
initialMaterial: KeySet | undefined = undefined,
ratchetOpts: DecodeRatchetOptions = { ratchetCount: 0 },
): Promise<RTCEncodedVideoFrame | RTCEncodedAudioFrame | undefined> {
const keySet = this.keys.getKeySet(keyIndex);
if (!ratchetOpts.encryptionKey && !keySet) {
throw new TypeError(`no encryption key found for decryption of ${this.participantIdentity}`);
}
let frameInfo = this.getUnencryptedBytes(encodedFrame);
// Construct frame trailer. Similar to the frame header described in
// https://tools.ietf.org/html/draft-omara-sframe-00#section-4.2
// but we put it at the end.
//
// ---------+-------------------------+-+---------+----
// payload |IV...(length = IV_LENGTH)|R|IV_LENGTH|KID |
// ---------+-------------------------+-+---------+----
try {
const frameHeader = new Uint8Array(encodedFrame.data, 0, frameInfo.unencryptedBytes);
var encryptedData = new Uint8Array(
encodedFrame.data,
frameHeader.length,
encodedFrame.data.byteLength - frameHeader.length,
);
if (frameInfo.requiresNALUProcessing && needsRbspUnescaping(encryptedData)) {
encryptedData = parseRbsp(encryptedData);
const newUint8 = new Uint8Array(frameHeader.byteLength + encryptedData.byteLength);
newUint8.set(frameHeader);
newUint8.set(encryptedData, frameHeader.byteLength);
encodedFrame.data = newUint8.buffer;
}
const frameTrailer = new Uint8Array(encodedFrame.data, encodedFrame.data.byteLength - 2, 2);
const ivLength = frameTrailer[0];
const iv = new Uint8Array(
encodedFrame.data,
encodedFrame.data.byteLength - ivLength - frameTrailer.byteLength,
ivLength,
);
const cipherTextStart = frameHeader.byteLength;
const cipherTextLength =
encodedFrame.data.byteLength -
(frameHeader.byteLength + ivLength + frameTrailer.byteLength);
const plainText = await crypto.subtle.decrypt(
{
name: ENCRYPTION_ALGORITHM,
iv,
additionalData: new Uint8Array(encodedFrame.data, 0, frameHeader.byteLength),
},
ratchetOpts.encryptionKey ?? keySet!.encryptionKey,
new Uint8Array(encodedFrame.data, cipherTextStart, cipherTextLength),
);
const newData = new ArrayBuffer(frameHeader.byteLength + plainText.byteLength);
const newUint8 = new Uint8Array(newData);
newUint8.set(new Uint8Array(encodedFrame.data, 0, frameHeader.byteLength));
newUint8.set(new Uint8Array(plainText), frameHeader.byteLength);
encodedFrame.data = newData;
return encodedFrame;
} catch (error: any) {
if (this.keyProviderOptions.ratchetWindowSize > 0) {
if (ratchetOpts.ratchetCount < this.keyProviderOptions.ratchetWindowSize) {
workerLogger.debug(
`ratcheting key attempt ${ratchetOpts.ratchetCount} of ${
this.keyProviderOptions.ratchetWindowSize
}, for kind ${encodedFrame instanceof RTCEncodedAudioFrame ? 'audio' : 'video'}`,
);
let ratchetedKeySet: KeySet | undefined;
let ratchetResult: RatchetResult | undefined;
if ((initialMaterial ?? keySet) === this.keys.getKeySet(keyIndex)) {
// only ratchet if the currently set key is still the same as the one used to decrypt this frame
// if not, it might be that a different frame has already ratcheted and we try with that one first
ratchetResult = await this.keys.ratchetKey(keyIndex, false);
ratchetedKeySet = await deriveKeys(ratchetResult.cryptoKey, this.keyProviderOptions);
}
const frame = await this.decryptFrame(encodedFrame, keyIndex, initialMaterial || keySet, {
ratchetCount: ratchetOpts.ratchetCount + 1,
encryptionKey: ratchetedKeySet?.encryptionKey,
});
if (frame && ratchetedKeySet) {
// before updating the keys, make sure that the keySet used for this frame is still the same as the currently set key
// if it's not, a new key might have been set already, which we don't want to override
if ((initialMaterial ?? keySet) === this.keys.getKeySet(keyIndex)) {
this.keys.setKeySet(ratchetedKeySet, keyIndex, ratchetResult);
// decryption was successful, set the new key index to reflect the ratcheted key set
this.keys.setCurrentKeyIndex(keyIndex);
}
}
return frame;
} else {
/**
* Because we only set a new key once decryption has been successful,
* we can be sure that we don't need to reset the key to the initial material at this point
* as the key has not been updated on the keyHandler instance
*/
workerLogger.warn('maximum ratchet attempts exceeded');
throw new CryptorError(
`valid key missing for participant ${this.participantIdentity}`,
CryptorErrorReason.InvalidKey,
this.participantIdentity,
);
}
} else {
throw new CryptorError(
`Decryption failed: ${error.message}`,
CryptorErrorReason.InvalidKey,
this.participantIdentity,
);
}
}
}
/**
* Construct the IV used for AES-GCM and sent (in plain) with the packet similar to
* https://tools.ietf.org/html/rfc7714#section-8.1
* It concatenates
* - the 32 bit synchronization source (SSRC) given on the encoded frame,
* - the 32 bit rtp timestamp given on the encoded frame,
* - a send counter that is specific to the SSRC. Starts at a random number.
* The send counter is essentially the pictureId but we currently have to implement this ourselves.
* There is no XOR with a salt. Note that this IV leaks the SSRC to the receiver but since this is
* randomly generated and SFUs may not rewrite this is considered acceptable.
* The SSRC is used to allow demultiplexing multiple streams with the same key, as described in
* https://tools.ietf.org/html/rfc3711#section-4.1.1
* The RTP timestamp is 32 bits and advances by the codec clock rate (90khz for video, 48khz for
* opus audio) every second. For video it rolls over roughly every 13 hours.
* The send counter will advance at the frame rate (30fps for video, 50fps for 20ms opus audio)
* every second. It will take a long time to roll over.
*
* See also https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams
*/
private makeIV(synchronizationSource: number, timestamp: number) {
const iv = new ArrayBuffer(IV_LENGTH);
const ivView = new DataView(iv);
// having to keep our own send count (similar to a picture id) is not ideal.
if (!this.sendCounts.has(synchronizationSource)) {
// Initialize with a random offset, similar to the RTP sequence number.
this.sendCounts.set(synchronizationSource, Math.floor(Math.random() * 0xffff));
}
const sendCount = this.sendCounts.get(synchronizationSource) ?? 0;
ivView.setUint32(0, synchronizationSource);
ivView.setUint32(4, timestamp);
ivView.setUint32(8, timestamp - (sendCount % 0xffff));
this.sendCounts.set(synchronizationSource, sendCount + 1);
return iv;
}
private getUnencryptedBytes(frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame): {
unencryptedBytes: number;
requiresNALUProcessing: boolean;
} {
// Handle audio frames
if (!isVideoFrame(frame)) {
return { unencryptedBytes: UNENCRYPTED_BYTES.audio, requiresNALUProcessing: false };
}
// Detect and track codec changes
const detectedCodec = this.getVideoCodec(frame) ?? this.videoCodec;
if (detectedCodec !== this.detectedCodec) {
workerLogger.debug('detected different codec', {
detectedCodec,
oldCodec: this.detectedCodec,
...this.logContext,
});
this.detectedCodec = detectedCodec;
}
// Check for unsupported codecs
if (detectedCodec === 'av1') {
throw new Error(`${detectedCodec} is not yet supported for end to end encryption`);
}
// Handle VP8/VP9 codecs (no NALU processing needed)
if (detectedCodec === 'vp8') {
return { unencryptedBytes: UNENCRYPTED_BYTES[frame.type], requiresNALUProcessing: false };
}
if (detectedCodec === 'vp9') {
return { unencryptedBytes: 0, requiresNALUProcessing: false };
}
// Try NALU processing for H.264/H.265 codecs
try {
const knownCodec =
detectedCodec === 'h264' || detectedCodec === 'h265' ? detectedCodec : undefined;
const naluResult = processNALUsForEncryption(new Uint8Array(frame.data), knownCodec);
if (naluResult.requiresNALUProcessing) {
return {
unencryptedBytes: naluResult.unencryptedBytes,
requiresNALUProcessing: true,
};
}
} catch (e) {
workerLogger.debug('NALU processing failed, falling back to VP8 handling', {
error: e,
...this.logContext,
});
}
// Fallback to VP8 handling
return { unencryptedBytes: UNENCRYPTED_BYTES[frame.type], requiresNALUProcessing: false };
}
/**
* inspects frame payloadtype if available and maps it to the codec specified in rtpMap
*/
private getVideoCodec(frame: RTCEncodedVideoFrame): VideoCodec | undefined {
if (this.rtpMap.size === 0) {
return undefined;
}
const payloadType = frame.getMetadata().payloadType;
const codec = payloadType ? this.rtpMap.get(payloadType) : undefined;
return codec;
}
}
/**
* we use a magic frame trailer to detect whether a frame is injected
* by the livekit server and thus to be treated as unencrypted
* @internal
*/
export function isFrameServerInjected(frameData: ArrayBuffer, trailerBytes: Uint8Array): boolean {
if (trailerBytes.byteLength === 0) {
return false;
}
const frameTrailer = new Uint8Array(
frameData.slice(frameData.byteLength - trailerBytes.byteLength),
);
return trailerBytes.every((value, index) => value === frameTrailer[index]);
}