|
@@ -4,327 +4,8 @@
|
4
|
4
|
// Worker for E2EE/Insertable streams.
|
5
|
5
|
//
|
6
|
6
|
|
7
|
|
-import { deriveKeys, importKey, ratchet } from './crypto-utils';
|
8
|
|
-import { polyFillEncodedFrameMetadata, isArrayEqual } from './utils';
|
9
|
|
-
|
10
|
|
-// We use a ringbuffer of keys so we can change them and still decode packets that were
|
11
|
|
-// encrypted with an old key.
|
12
|
|
-const keyRingSize = 3;
|
13
|
|
-
|
14
|
|
-// We copy the first bytes of the VP8 payload unencrypted.
|
15
|
|
-// For keyframes this is 10 bytes, for non-keyframes (delta) 3. See
|
16
|
|
-// https://tools.ietf.org/html/rfc6386#section-9.1
|
17
|
|
-// This allows the bridge to continue detecting keyframes (only one byte needed in the JVB)
|
18
|
|
-// and is also a bit easier for the VP8 decoder (i.e. it generates funny garbage pictures
|
19
|
|
-// instead of being unable to decode).
|
20
|
|
-// This is a bit for show and we might want to reduce to 1 unconditionally in the final version.
|
21
|
|
-//
|
22
|
|
-// For audio (where frame.type is not set) we do not encrypt the opus TOC byte:
|
23
|
|
-// https://tools.ietf.org/html/rfc6716#section-3.1
|
24
|
|
-const unencryptedBytes = {
|
25
|
|
- key: 10,
|
26
|
|
- delta: 3,
|
27
|
|
- undefined: 1 // frame.type is not set on audio
|
28
|
|
-};
|
29
|
|
-
|
30
|
|
-// Use truncated SHA-256 hashes, 80 bіts for video, 32 bits for audio.
|
31
|
|
-// This follows the same principles as DTLS-SRTP.
|
32
|
|
-const authenticationTagOptions = {
|
33
|
|
- name: 'HMAC',
|
34
|
|
- hash: 'SHA-256'
|
35
|
|
-};
|
36
|
|
-const digestLength = {
|
37
|
|
- key: 10,
|
38
|
|
- delta: 10,
|
39
|
|
- undefined: 4 // frame.type is not set on audio
|
40
|
|
-};
|
41
|
|
-
|
42
|
|
-// Maximum number of forward ratchets to attempt when the authentication
|
43
|
|
-// tag on a remote packet does not match the current key.
|
44
|
|
-const ratchetWindow = 8;
|
45
|
|
-
|
46
|
|
-/**
|
47
|
|
- * Per-participant context holding the cryptographic keys and
|
48
|
|
- * encode/decode functions
|
49
|
|
- */
|
50
|
|
-class Context {
|
51
|
|
- /**
|
52
|
|
- * @param {string} id - local muc resourcepart
|
53
|
|
- */
|
54
|
|
- constructor(id) {
|
55
|
|
- // An array (ring) of keys that we use for sending and receiving.
|
56
|
|
- this._cryptoKeyRing = new Array(keyRingSize);
|
57
|
|
-
|
58
|
|
- // A pointer to the currently used key.
|
59
|
|
- this._currentKeyIndex = -1;
|
60
|
|
-
|
61
|
|
- // A per-sender counter that is used create the AES CTR.
|
62
|
|
- // Must be incremented on every frame that is sent, can be reset on
|
63
|
|
- // key changes.
|
64
|
|
- this._sendCount = 0n;
|
65
|
|
-
|
66
|
|
- this._id = id;
|
67
|
|
- }
|
68
|
|
-
|
69
|
|
- /**
|
70
|
|
- * Derives the different subkeys and starts using them for encryption or
|
71
|
|
- * decryption.
|
72
|
|
- * @param {Uint8Array|false} key bytes. Pass false to disable.
|
73
|
|
- * @param {Number} keyIndex
|
74
|
|
- */
|
75
|
|
- async setKey(keyBytes, keyIndex) {
|
76
|
|
- let newKey;
|
77
|
|
-
|
78
|
|
- if (keyBytes) {
|
79
|
|
- const material = await importKey(keyBytes);
|
80
|
|
-
|
81
|
|
- newKey = await deriveKeys(material);
|
82
|
|
- } else {
|
83
|
|
- newKey = false;
|
84
|
|
- }
|
85
|
|
- this._currentKeyIndex = keyIndex % this._cryptoKeyRing.length;
|
86
|
|
- this._setKeys(newKey);
|
87
|
|
- }
|
88
|
|
-
|
89
|
|
- /**
|
90
|
|
- * Sets a set of keys and resets the sendCount.
|
91
|
|
- * decryption.
|
92
|
|
- * @param {Object} keys set of keys.
|
93
|
|
- * @private
|
94
|
|
- */
|
95
|
|
- _setKeys(keys) {
|
96
|
|
- this._cryptoKeyRing[this._currentKeyIndex] = keys;
|
97
|
|
- this._sendCount = 0n; // Reset the send count (bigint).
|
98
|
|
- }
|
99
|
|
-
|
100
|
|
- /**
|
101
|
|
- * Function that will be injected in a stream and will encrypt the given encoded frames.
|
102
|
|
- *
|
103
|
|
- * @param {RTCEncodedVideoFrame|RTCEncodedAudioFrame} encodedFrame - Encoded video frame.
|
104
|
|
- * @param {TransformStreamDefaultController} controller - TransportStreamController.
|
105
|
|
- *
|
106
|
|
- * The packet format is a variant of
|
107
|
|
- * https://tools.ietf.org/html/draft-omara-sframe-00
|
108
|
|
- * using a trailer instead of a header. One of the design goals was to not require
|
109
|
|
- * changes to the SFU which for video requires not encrypting the keyframe bit of VP8
|
110
|
|
- * as SFUs need to detect a keyframe (framemarking or the generic frame descriptor will
|
111
|
|
- * solve this eventually). This also "hides" that a client is using E2EE a bit.
|
112
|
|
- *
|
113
|
|
- * Note that this operates on the full frame, i.e. for VP8 the data described in
|
114
|
|
- * https://tools.ietf.org/html/rfc6386#section-9.1
|
115
|
|
- *
|
116
|
|
- * The VP8 payload descriptor described in
|
117
|
|
- * https://tools.ietf.org/html/rfc7741#section-4.2
|
118
|
|
- * is part of the RTP packet and not part of the encoded frame and is therefore not
|
119
|
|
- * controllable by us. This is fine as the SFU keeps having access to it for routing.
|
120
|
|
- */
|
121
|
|
- encodeFunction(encodedFrame, controller) {
|
122
|
|
- const keyIndex = this._currentKeyIndex;
|
123
|
|
-
|
124
|
|
- if (this._cryptoKeyRing[keyIndex]) {
|
125
|
|
- this._sendCount++;
|
126
|
|
-
|
127
|
|
- // Thіs is not encrypted and contains the VP8 payload descriptor or the Opus TOC byte.
|
128
|
|
- const frameHeader = new Uint8Array(encodedFrame.data, 0, unencryptedBytes[encodedFrame.type]);
|
129
|
|
-
|
130
|
|
- // Construct frame trailer. Similar to the frame header described in
|
131
|
|
- // https://tools.ietf.org/html/draft-omara-sframe-00#section-4.2
|
132
|
|
- // but we put it at the end.
|
133
|
|
- // 0 1 2 3 4 5 6 7
|
134
|
|
- // ---------+---------------------------------+-+-+-+-+-+-+-+-+
|
135
|
|
- // payload | CTR... (length=LEN) |S|LEN |0| KID |
|
136
|
|
- // ---------+---------------------------------+-+-+-+-+-+-+-+-+
|
137
|
|
- const counter = new Uint8Array(16);
|
138
|
|
- const counterView = new DataView(counter.buffer);
|
139
|
|
-
|
140
|
|
- // The counter is encoded as a variable-length field.
|
141
|
|
- counterView.setBigUint64(8, this._sendCount);
|
142
|
|
- let counterLength = 8;
|
143
|
|
-
|
144
|
|
- for (let i = 8; i < counter.byteLength; i++ && counterLength--) {
|
145
|
|
- if (counterView.getUint8(i) !== 0) {
|
146
|
|
- break;
|
147
|
|
- }
|
148
|
|
- }
|
149
|
|
-
|
150
|
|
- const frameTrailer = new Uint8Array(counterLength + 1);
|
151
|
|
-
|
152
|
|
- frameTrailer.set(new Uint8Array(counter.buffer, counter.byteLength - counterLength));
|
153
|
|
-
|
154
|
|
- // Since we never send a counter of 0 we send counterLength - 1 on the wire.
|
155
|
|
- // This is different from the sframe draft, increases the key space and lets us
|
156
|
|
- // ignore the case of a zero-length counter at the receiver.
|
157
|
|
- frameTrailer[frameTrailer.byteLength - 1] = keyIndex | ((counterLength - 1) << 4);
|
158
|
|
-
|
159
|
|
- // XOR the counter with the saltKey to construct the AES CTR.
|
160
|
|
- const saltKey = new DataView(this._cryptoKeyRing[keyIndex].saltKey);
|
161
|
|
-
|
162
|
|
- for (let i = 0; i < counter.byteLength; i++) {
|
163
|
|
- counterView.setUint8(i, counterView.getUint8(i) ^ saltKey.getUint8(i));
|
164
|
|
- }
|
165
|
|
-
|
166
|
|
- return crypto.subtle.encrypt({
|
167
|
|
- name: 'AES-CTR',
|
168
|
|
- counter,
|
169
|
|
- length: 64
|
170
|
|
- }, this._cryptoKeyRing[keyIndex].encryptionKey, new Uint8Array(encodedFrame.data,
|
171
|
|
- unencryptedBytes[encodedFrame.type]))
|
172
|
|
- .then(cipherText => {
|
173
|
|
- const newData = new ArrayBuffer(frameHeader.byteLength + cipherText.byteLength
|
174
|
|
- + digestLength[encodedFrame.type] + frameTrailer.byteLength);
|
175
|
|
- const newUint8 = new Uint8Array(newData);
|
176
|
|
-
|
177
|
|
- newUint8.set(frameHeader); // copy first bytes.
|
178
|
|
- newUint8.set(new Uint8Array(cipherText), unencryptedBytes[encodedFrame.type]); // add ciphertext.
|
179
|
|
- // Leave some space for the authentication tag. This is filled with 0s initially, similar to
|
180
|
|
- // STUN message-integrity described in https://tools.ietf.org/html/rfc5389#section-15.4
|
181
|
|
- newUint8.set(frameTrailer, frameHeader.byteLength + cipherText.byteLength
|
182
|
|
- + digestLength[encodedFrame.type]); // append trailer.
|
183
|
|
-
|
184
|
|
- return crypto.subtle.sign(authenticationTagOptions, this._cryptoKeyRing[keyIndex].authenticationKey,
|
185
|
|
- new Uint8Array(newData)).then(authTag => {
|
186
|
|
- // Set the truncated authentication tag.
|
187
|
|
- newUint8.set(new Uint8Array(authTag, 0, digestLength[encodedFrame.type]),
|
188
|
|
- unencryptedBytes[encodedFrame.type] + cipherText.byteLength);
|
189
|
|
- encodedFrame.data = newData;
|
190
|
|
-
|
191
|
|
- return controller.enqueue(encodedFrame);
|
192
|
|
- });
|
193
|
|
- }, e => {
|
194
|
|
- // TODO: surface this to the app.
|
195
|
|
- console.error(e);
|
196
|
|
-
|
197
|
|
- // We are not enqueuing the frame here on purpose.
|
198
|
|
- });
|
199
|
|
- }
|
200
|
|
-
|
201
|
|
- /* NOTE WELL:
|
202
|
|
- * This will send unencrypted data (only protected by DTLS transport encryption) when no key is configured.
|
203
|
|
- * This is ok for demo purposes but should not be done once this becomes more relied upon.
|
204
|
|
- */
|
205
|
|
- controller.enqueue(encodedFrame);
|
206
|
|
- }
|
207
|
|
-
|
208
|
|
- /**
|
209
|
|
- * Function that will be injected in a stream and will decrypt the given encoded frames.
|
210
|
|
- *
|
211
|
|
- * @param {RTCEncodedVideoFrame|RTCEncodedAudioFrame} encodedFrame - Encoded video frame.
|
212
|
|
- * @param {TransformStreamDefaultController} controller - TransportStreamController.
|
213
|
|
- */
|
214
|
|
- async decodeFunction(encodedFrame, controller) {
|
215
|
|
- const data = new Uint8Array(encodedFrame.data);
|
216
|
|
- const keyIndex = data[encodedFrame.data.byteLength - 1] & 0x7;
|
217
|
|
-
|
218
|
|
- if (this._cryptoKeyRing[keyIndex]) {
|
219
|
|
- const counterLength = 1 + ((data[encodedFrame.data.byteLength - 1] >> 4) & 0x7);
|
220
|
|
- const frameHeader = new Uint8Array(encodedFrame.data, 0, unencryptedBytes[encodedFrame.type]);
|
221
|
|
-
|
222
|
|
- // Extract the truncated authentication tag.
|
223
|
|
- const authTagOffset = encodedFrame.data.byteLength - (digestLength[encodedFrame.type]
|
224
|
|
- + counterLength + 1);
|
225
|
|
- const authTag = encodedFrame.data.slice(authTagOffset, authTagOffset
|
226
|
|
- + digestLength[encodedFrame.type]);
|
227
|
|
-
|
228
|
|
- // Set authentication tag bytes to 0.
|
229
|
|
- const zeros = new Uint8Array(digestLength[encodedFrame.type]);
|
230
|
|
-
|
231
|
|
- data.set(zeros, encodedFrame.data.byteLength - (digestLength[encodedFrame.type] + counterLength + 1));
|
232
|
|
-
|
233
|
|
- // Do truncated hash comparison. If the hash does not match we might have to advance the
|
234
|
|
- // ratchet a limited number of times. See (even though the description there is odd)
|
235
|
|
- // https://tools.ietf.org/html/draft-omara-sframe-00#section-4.3.5.1
|
236
|
|
- let { authenticationKey, material } = this._cryptoKeyRing[keyIndex];
|
237
|
|
- let valid = false;
|
238
|
|
- let newKeys = null;
|
239
|
|
-
|
240
|
|
- for (let distance = 0; distance < ratchetWindow; distance++) {
|
241
|
|
- const calculatedTag = await crypto.subtle.sign(authenticationTagOptions,
|
242
|
|
- authenticationKey, encodedFrame.data);
|
243
|
|
-
|
244
|
|
- if (isArrayEqual(new Uint8Array(authTag),
|
245
|
|
- new Uint8Array(calculatedTag.slice(0, digestLength[encodedFrame.type])))) {
|
246
|
|
- valid = true;
|
247
|
|
- if (distance > 0) {
|
248
|
|
- this._setKeys(newKeys);
|
249
|
|
- }
|
250
|
|
- break;
|
251
|
|
- }
|
252
|
|
-
|
253
|
|
- // Attempt to ratchet and generate the next set of keys.
|
254
|
|
- material = await importKey(await ratchet(material));
|
255
|
|
- newKeys = await deriveKeys(material);
|
256
|
|
- authenticationKey = newKeys.authenticationKey;
|
257
|
|
- }
|
258
|
|
-
|
259
|
|
- // Check whether we found a valid signature.
|
260
|
|
- if (!valid) {
|
261
|
|
- // TODO: return an error to the app.
|
262
|
|
-
|
263
|
|
- console.error('Authentication tag mismatch');
|
264
|
|
-
|
265
|
|
- return;
|
266
|
|
- }
|
267
|
|
-
|
268
|
|
- // Extract the counter.
|
269
|
|
- const counter = new Uint8Array(16);
|
270
|
|
-
|
271
|
|
- counter.set(data.slice(encodedFrame.data.byteLength - (counterLength + 1),
|
272
|
|
- encodedFrame.data.byteLength - 1), 16 - counterLength);
|
273
|
|
- const counterView = new DataView(counter.buffer);
|
274
|
|
-
|
275
|
|
- // XOR the counter with the saltKey to construct the AES CTR.
|
276
|
|
- const saltKey = new DataView(this._cryptoKeyRing[keyIndex].saltKey);
|
277
|
|
-
|
278
|
|
- for (let i = 0; i < counter.byteLength; i++) {
|
279
|
|
- counterView.setUint8(i,
|
280
|
|
- counterView.getUint8(i) ^ saltKey.getUint8(i));
|
281
|
|
- }
|
282
|
|
-
|
283
|
|
- return crypto.subtle.decrypt({
|
284
|
|
- name: 'AES-CTR',
|
285
|
|
- counter,
|
286
|
|
- length: 64
|
287
|
|
- }, this._cryptoKeyRing[keyIndex].encryptionKey, new Uint8Array(encodedFrame.data,
|
288
|
|
- unencryptedBytes[encodedFrame.type],
|
289
|
|
- encodedFrame.data.byteLength - (unencryptedBytes[encodedFrame.type]
|
290
|
|
- + digestLength[encodedFrame.type] + counterLength + 1))
|
291
|
|
- ).then(plainText => {
|
292
|
|
- const newData = new ArrayBuffer(unencryptedBytes[encodedFrame.type] + plainText.byteLength);
|
293
|
|
- const newUint8 = new Uint8Array(newData);
|
294
|
|
-
|
295
|
|
- newUint8.set(frameHeader);
|
296
|
|
- newUint8.set(new Uint8Array(plainText), unencryptedBytes[encodedFrame.type]);
|
297
|
|
- encodedFrame.data = newData;
|
298
|
|
-
|
299
|
|
- return controller.enqueue(encodedFrame);
|
300
|
|
- }, e => {
|
301
|
|
- console.error(e);
|
302
|
|
-
|
303
|
|
- // TODO: notify the application about error status.
|
304
|
|
- // TODO: For video we need a better strategy since we do not want to based any
|
305
|
|
- // non-error frames on a garbage keyframe.
|
306
|
|
- if (encodedFrame.type === undefined) { // audio, replace with silence.
|
307
|
|
- const newData = new ArrayBuffer(3);
|
308
|
|
- const newUint8 = new Uint8Array(newData);
|
309
|
|
-
|
310
|
|
- newUint8.set([ 0xd8, 0xff, 0xfe ]); // opus silence frame.
|
311
|
|
- encodedFrame.data = newData;
|
312
|
|
- controller.enqueue(encodedFrame);
|
313
|
|
- }
|
314
|
|
- });
|
315
|
|
- } else if (keyIndex >= this._cryptoKeyRing.length && this._cryptoKeyRing[this._currentKeyIndex]) {
|
316
|
|
- // If we are encrypting but don't have a key for the remote drop the frame.
|
317
|
|
- // This is a heuristic since we don't know whether a packet is encrypted,
|
318
|
|
- // do not have a checksum and do not have signaling for whether a remote participant does
|
319
|
|
- // encrypt or not.
|
320
|
|
- return;
|
321
|
|
- }
|
322
|
|
-
|
323
|
|
- // TODO: this just passes through to the decoder. Is that ok? If we don't know the key yet
|
324
|
|
- // we might want to buffer a bit but it is still unclear how to do that (and for how long etc).
|
325
|
|
- controller.enqueue(encodedFrame);
|
326
|
|
- }
|
327
|
|
-}
|
|
7
|
+import { Context } from './Context';
|
|
8
|
+import { polyFillEncodedFrameMetadata } from './utils';
|
328
|
9
|
|
329
|
10
|
const contexts = new Map(); // Map participant id => context
|
330
|
11
|
|