Browse Source
Merge pull request #14005 from nextcloud/feat-webrtc-e2ee
Merge pull request #14005 from nextcloud/feat-webrtc-e2ee
feat: Enable end-to-end encryption for WebRTC streams.pull/14147/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1548 additions and 44 deletions
-
2.eslintignore
-
1jest.config.js
-
4lib/Controller/PageController.php
-
62package-lock.json
-
3package.json
-
49src/utils/e2ee/JitsiDeferred.js
-
149src/utils/e2ee/JitsiE2EEContext.js
-
103src/utils/e2ee/JitsiEncryptionWorker.worker.js
-
336src/utils/e2ee/JitsiEncryptionWorkerContext.js
-
64src/utils/e2ee/crypto-utils.js
-
686src/utils/e2ee/encryption.js
-
34src/utils/e2ee/olm.js
-
69src/utils/signaling.js
-
30src/utils/webrtc/index.js
@ -0,0 +1,49 @@ |
|||
/** |
|||
* SPDX-FileCopyrightText: 2020 Jitsi team at 8x8 and the community. |
|||
* SPDX-License-Identifier: Apache-2.0 |
|||
* |
|||
* Based on code from https://github.com/jitsi/jitsi-meet
|
|||
*/ |
|||
|
|||
/** |
|||
* Promise-like object which can be passed around for resolving it later. It |
|||
* implements the "thenable" interface, so it can be used wherever a Promise |
|||
* could be used. |
|||
* |
|||
* In addition a "reject on timeout" functionality is provided. |
|||
*/ |
|||
export default class Deferred { |
|||
/** |
|||
* Instantiates a Deferred object. |
|||
*/ |
|||
constructor() { |
|||
this.promise = new Promise((resolve, reject) => { |
|||
this.resolve = (...args) => { |
|||
this.clearRejectTimeout(); |
|||
resolve(...args); |
|||
}; |
|||
this.reject = (...args) => { |
|||
this.clearRejectTimeout(); |
|||
reject(...args); |
|||
}; |
|||
}); |
|||
this.then = this.promise.then.bind(this.promise); |
|||
this.catch = this.promise.catch.bind(this.promise); |
|||
} |
|||
|
|||
/** |
|||
* Clears the reject timeout. |
|||
*/ |
|||
clearRejectTimeout() { |
|||
clearTimeout(this._timeout); |
|||
} |
|||
|
|||
/** |
|||
* Rejects the promise after the given timeout. |
|||
*/ |
|||
setRejectTimeout(ms) { |
|||
this._timeout = setTimeout(() => { |
|||
this.reject(new Error('timeout')); |
|||
}, ms); |
|||
} |
|||
} |
|||
@ -0,0 +1,149 @@ |
|||
/** |
|||
* SPDX-FileCopyrightText: 2020 Jitsi team at 8x8 and the community. |
|||
* SPDX-License-Identifier: Apache-2.0 |
|||
* |
|||
* Based on code from https://github.com/jitsi/jitsi-meet and updated to load
|
|||
* the worker from Nextcloud. |
|||
*/ |
|||
|
|||
/* global RTCRtpScriptTransform */ |
|||
|
|||
import Worker from './JitsiEncryptionWorker.worker.js' |
|||
|
|||
// Flag to set on senders / receivers to avoid setting up the encryption transform
|
|||
// more than once.
|
|||
const kJitsiE2EE = Symbol('kJitsiE2EE'); |
|||
|
|||
/** |
|||
* Context encapsulating the cryptography bits required for E2EE. |
|||
* This uses the WebRTC Insertable Streams API which is explained in |
|||
* https://github.com/alvestrand/webrtc-media-streams/blob/master/explainer.md
|
|||
* that provides access to the encoded frames and allows them to be transformed. |
|||
* |
|||
* The encoded frame format is explained below in the _encodeFunction method. |
|||
* High level design goals were: |
|||
* - do not require changes to existing SFUs and retain (VP8) metadata. |
|||
* - allow the SFU to rewrite SSRCs, timestamp, pictureId. |
|||
* - allow for the key to be rotated frequently. |
|||
*/ |
|||
export default class E2EEcontext { |
|||
/** |
|||
* Build a new E2EE context instance, which will be used in a given conference. |
|||
* @param {boolean} [options.sharedKey] - whether there is a uniques key shared amoung all participants. |
|||
*/ |
|||
constructor({ sharedKey } = {}) { |
|||
this._worker = new Worker(); |
|||
|
|||
this._worker.onerror = e => console.error(e); |
|||
|
|||
this._worker.postMessage({ |
|||
operation: 'initialize', |
|||
sharedKey |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Cleans up all state associated with the given participant. This is needed when a |
|||
* participant leaves the current conference. |
|||
* |
|||
* @param {string} participantId - The participant that just left. |
|||
*/ |
|||
cleanup(participantId) { |
|||
this._worker.postMessage({ |
|||
operation: 'cleanup', |
|||
participantId |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Cleans up all state associated with all participants in the conference. This is needed when disabling e2ee. |
|||
* |
|||
*/ |
|||
cleanupAll() { |
|||
this._worker.postMessage({ |
|||
operation: 'cleanupAll' |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Handles the given {@code RTCRtpReceiver} by creating a {@code TransformStream} which will inject |
|||
* a frame decoder. |
|||
* |
|||
* @param {RTCRtpReceiver} receiver - The receiver which will get the decoding function injected. |
|||
* @param {string} kind - The kind of track this receiver belongs to. |
|||
* @param {string} participantId - The participant id that this receiver belongs to. |
|||
*/ |
|||
handleReceiver(receiver, kind, participantId) { |
|||
if (receiver[kJitsiE2EE]) { |
|||
return; |
|||
} |
|||
receiver[kJitsiE2EE] = true; |
|||
|
|||
if (window.RTCRtpScriptTransform) { |
|||
const options = { |
|||
operation: 'decode', |
|||
participantId |
|||
}; |
|||
|
|||
receiver.transform = new RTCRtpScriptTransform(this._worker, options); |
|||
} else { |
|||
const receiverStreams = receiver.createEncodedStreams(); |
|||
|
|||
this._worker.postMessage({ |
|||
operation: 'decode', |
|||
readableStream: receiverStreams.readable, |
|||
writableStream: receiverStreams.writable, |
|||
participantId |
|||
}, [ receiverStreams.readable, receiverStreams.writable ]); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Handles the given {@code RTCRtpSender} by creating a {@code TransformStream} which will inject |
|||
* a frame encoder. |
|||
* |
|||
* @param {RTCRtpSender} sender - The sender which will get the encoding function injected. |
|||
* @param {string} kind - The kind of track this sender belongs to. |
|||
* @param {string} participantId - The participant id that this sender belongs to. |
|||
*/ |
|||
handleSender(sender, kind, participantId) { |
|||
if (sender[kJitsiE2EE]) { |
|||
return; |
|||
} |
|||
sender[kJitsiE2EE] = true; |
|||
|
|||
if (window.RTCRtpScriptTransform) { |
|||
const options = { |
|||
operation: 'encode', |
|||
participantId |
|||
}; |
|||
|
|||
sender.transform = new RTCRtpScriptTransform(this._worker, options); |
|||
} else { |
|||
const senderStreams = sender.createEncodedStreams(); |
|||
|
|||
this._worker.postMessage({ |
|||
operation: 'encode', |
|||
readableStream: senderStreams.readable, |
|||
writableStream: senderStreams.writable, |
|||
participantId |
|||
}, [ senderStreams.readable, senderStreams.writable ]); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Set the E2EE key for the specified participant. |
|||
* |
|||
* @param {string} participantId - the ID of the participant who's key we are setting. |
|||
* @param {Uint8Array | boolean} key - they key for the given participant. |
|||
* @param {Number} keyIndex - the key index. |
|||
*/ |
|||
setKey(participantId, key, keyIndex) { |
|||
this._worker.postMessage({ |
|||
operation: 'setKey', |
|||
key, |
|||
keyIndex, |
|||
participantId |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,103 @@ |
|||
/** |
|||
* SPDX-FileCopyrightText: 2020 Jitsi team at 8x8 and the community. |
|||
* SPDX-License-Identifier: Apache-2.0 |
|||
* |
|||
* Based on code from https://github.com/jitsi/jitsi-meet
|
|||
*/ |
|||
|
|||
/* global TransformStream */ |
|||
/* eslint-disable no-bitwise */ |
|||
|
|||
// Worker for E2EE/Insertable streams.
|
|||
|
|||
import { Context } from './JitsiEncryptionWorkerContext.js'; |
|||
|
|||
const contexts = new Map(); // Map participant id => context
|
|||
|
|||
let sharedContext; |
|||
|
|||
/** |
|||
* Retrieves the participant {@code Context}, creating it if necessary. |
|||
* |
|||
* @param {string} participantId - The participant whose context we need. |
|||
* @returns {Object} The context. |
|||
*/ |
|||
function getParticipantContext(participantId) { |
|||
if (sharedContext) { |
|||
return sharedContext; |
|||
} |
|||
|
|||
if (!contexts.has(participantId)) { |
|||
contexts.set(participantId, new Context()); |
|||
} |
|||
|
|||
return contexts.get(participantId); |
|||
} |
|||
|
|||
/** |
|||
* Sets an encode / decode transform. |
|||
* |
|||
* @param {Object} context - The participant context where the transform will be applied. |
|||
* @param {string} operation - Encode / decode. |
|||
* @param {Object} readableStream - Readable stream part. |
|||
* @param {Object} writableStream - Writable stream part. |
|||
*/ |
|||
function handleTransform(context, operation, readableStream, writableStream) { |
|||
if (operation === 'encode' || operation === 'decode') { |
|||
const transformFn = operation === 'encode' ? context.encodeFunction : context.decodeFunction; |
|||
const transformStream = new TransformStream({ |
|||
transform: transformFn.bind(context) |
|||
}); |
|||
|
|||
readableStream |
|||
.pipeThrough(transformStream) |
|||
.pipeTo(writableStream); |
|||
} else { |
|||
console.error(`Invalid operation: ${operation}`); |
|||
} |
|||
} |
|||
|
|||
onmessage = async event => { |
|||
const { operation } = event.data; |
|||
|
|||
if (operation === 'initialize') { |
|||
const { sharedKey } = event.data; |
|||
|
|||
if (sharedKey) { |
|||
sharedContext = new Context({ sharedKey }); |
|||
} |
|||
} else if (operation === 'encode' || operation === 'decode') { |
|||
const { readableStream, writableStream, participantId } = event.data; |
|||
const context = getParticipantContext(participantId); |
|||
|
|||
handleTransform(context, operation, readableStream, writableStream); |
|||
} else if (operation === 'setKey') { |
|||
const { participantId, key, keyIndex } = event.data; |
|||
const context = getParticipantContext(participantId); |
|||
|
|||
if (key) { |
|||
context.setKey(key, keyIndex); |
|||
} else { |
|||
context.setKey(false, keyIndex); |
|||
} |
|||
} else if (operation === 'cleanup') { |
|||
const { participantId } = event.data; |
|||
|
|||
contexts.delete(participantId); |
|||
} else if (operation === 'cleanupAll') { |
|||
contexts.clear(); |
|||
} else { |
|||
console.error('e2ee worker', operation); |
|||
} |
|||
}; |
|||
|
|||
// Operations using RTCRtpScriptTransform.
|
|||
if (self.RTCTransformEvent) { |
|||
self.onrtctransform = event => { |
|||
const transformer = event.transformer; |
|||
const { operation, participantId } = transformer.options; |
|||
const context = getParticipantContext(participantId); |
|||
|
|||
handleTransform(context, operation, transformer.readable, transformer.writable); |
|||
}; |
|||
} |
|||
@ -0,0 +1,336 @@ |
|||
/** |
|||
* SPDX-FileCopyrightText: 2020 Jitsi team at 8x8 and the community. |
|||
* SPDX-License-Identifier: Apache-2.0 |
|||
* |
|||
* Based on code from https://github.com/jitsi/jitsi-meet
|
|||
*/ |
|||
|
|||
/* eslint-disable no-bitwise */ |
|||
/* global BigInt */ |
|||
|
|||
import { deriveKeys, importKey, ratchet } from './crypto-utils'; |
|||
|
|||
// We use a ringbuffer of keys so we can change them and still decode packets that were
|
|||
// encrypted with an old key. We use a size of 16 which corresponds to the four bits
|
|||
// in the frame trailer.
|
|||
const KEYRING_SIZE = 16; |
|||
|
|||
// We copy the first bytes of the VP8 payload unencrypted.
|
|||
// For keyframes this is 10 bytes, for non-keyframes (delta) 3. See
|
|||
// https://tools.ietf.org/html/rfc6386#section-9.1
|
|||
// This allows the bridge to continue detecting keyframes (only one byte needed in the JVB)
|
|||
// and is also a bit easier for the VP8 decoder (i.e. it generates funny garbage pictures
|
|||
// instead of being unable to decode).
|
|||
// This is a bit for show and we might want to reduce to 1 unconditionally in the final version.
|
|||
//
|
|||
// For audio (where frame.type is not set) we do not encrypt the opus TOC byte:
|
|||
// https://tools.ietf.org/html/rfc6716#section-3.1
|
|||
const UNENCRYPTED_BYTES = { |
|||
key: 10, |
|||
delta: 3, |
|||
undefined: 1 // frame.type is not set on audio
|
|||
}; |
|||
const ENCRYPTION_ALGORITHM = 'AES-GCM'; |
|||
|
|||
/* We use a 96 bit IV for AES GCM. This is signalled in plain together with the |
|||
packet. See https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams */
|
|||
const IV_LENGTH = 12; |
|||
|
|||
const RATCHET_WINDOW_SIZE = 8; |
|||
|
|||
/** |
|||
* Per-participant context holding the cryptographic keys and |
|||
* encode/decode functions |
|||
*/ |
|||
export class Context { |
|||
/** |
|||
* @param {Object} options |
|||
*/ |
|||
constructor({ sharedKey = false } = {}) { |
|||
// An array (ring) of keys that we use for sending and receiving.
|
|||
this._cryptoKeyRing = new Array(KEYRING_SIZE); |
|||
|
|||
// A pointer to the currently used key.
|
|||
this._currentKeyIndex = -1; |
|||
|
|||
this._sendCounts = new Map(); |
|||
|
|||
this._sharedKey = sharedKey; |
|||
} |
|||
|
|||
/** |
|||
* Derives the different subkeys and starts using them for encryption or |
|||
* decryption. |
|||
* @param {Uint8Array|false} key bytes. Pass false to disable. |
|||
* @param {Number} keyIndex |
|||
*/ |
|||
async setKey(key, keyIndex = -1) { |
|||
let newKey = false; |
|||
|
|||
if (key) { |
|||
if (this._sharedKey) { |
|||
newKey = key; |
|||
} else { |
|||
const material = await importKey(key); |
|||
|
|||
newKey = await deriveKeys(material); |
|||
} |
|||
} |
|||
|
|||
this._setKeys(newKey, keyIndex); |
|||
} |
|||
|
|||
/** |
|||
* Sets a set of keys and resets the sendCount. |
|||
* decryption. |
|||
* @param {Object} keys set of keys. |
|||
* @param {Number} keyIndex optional |
|||
* @private |
|||
*/ |
|||
_setKeys(keys, keyIndex = -1) { |
|||
if (keyIndex >= 0) { |
|||
this._currentKeyIndex = keyIndex % this._cryptoKeyRing.length; |
|||
} |
|||
|
|||
this._cryptoKeyRing[this._currentKeyIndex] = keys; |
|||
|
|||
this._sendCount = BigInt(0); // eslint-disable-line new-cap
|
|||
} |
|||
|
|||
/** |
|||
* 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) Leave the first (10, 3, 1) bytes unencrypted, depending on the 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. |
|||
*/ |
|||
encodeFunction(encodedFrame, controller) { |
|||
const keyIndex = this._currentKeyIndex; |
|||
|
|||
if (this._cryptoKeyRing[keyIndex]) { |
|||
const iv = this._makeIV(encodedFrame.getMetadata().synchronizationSource, encodedFrame.timestamp); |
|||
|
|||
// Thіs is not encrypted and contains the VP8 payload descriptor or the Opus TOC byte.
|
|||
const frameHeader = new Uint8Array(encodedFrame.data, 0, UNENCRYPTED_BYTES[encodedFrame.type]); |
|||
|
|||
// 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 |
|
|||
// ---------+-------------------------+-+---------+----
|
|||
|
|||
return crypto.subtle.encrypt({ |
|||
name: ENCRYPTION_ALGORITHM, |
|||
iv, |
|||
additionalData: new Uint8Array(encodedFrame.data, 0, frameHeader.byteLength) |
|||
}, this._cryptoKeyRing[keyIndex].encryptionKey, new Uint8Array(encodedFrame.data, |
|||
UNENCRYPTED_BYTES[encodedFrame.type])) |
|||
.then(cipherText => { |
|||
const newData = new ArrayBuffer(frameHeader.byteLength + cipherText.byteLength |
|||
+ iv.byteLength + frameTrailer.byteLength); |
|||
const newUint8 = new Uint8Array(newData); |
|||
|
|||
newUint8.set(frameHeader); // copy first bytes.
|
|||
newUint8.set( |
|||
new Uint8Array(cipherText), frameHeader.byteLength); // add ciphertext.
|
|||
newUint8.set( |
|||
new Uint8Array(iv), frameHeader.byteLength + cipherText.byteLength); // append IV.
|
|||
newUint8.set( |
|||
frameTrailer, |
|||
frameHeader.byteLength + cipherText.byteLength + iv.byteLength); // append frame trailer.
|
|||
|
|||
encodedFrame.data = newData; |
|||
|
|||
return controller.enqueue(encodedFrame); |
|||
}, e => { |
|||
// TODO: surface this to the app.
|
|||
console.error(e); |
|||
|
|||
// We are not enqueuing the frame here on purpose.
|
|||
}); |
|||
} |
|||
|
|||
/* NOTE WELL: |
|||
* This will send unencrypted data (only protected by DTLS transport encryption) when no key is configured. |
|||
* This is ok for demo purposes but should not be done once this becomes more relied upon. |
|||
*/ |
|||
controller.enqueue(encodedFrame); |
|||
} |
|||
|
|||
/** |
|||
* 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. |
|||
*/ |
|||
async decodeFunction(encodedFrame, controller) { |
|||
const data = new Uint8Array(encodedFrame.data); |
|||
const keyIndex = data[encodedFrame.data.byteLength - 1]; |
|||
|
|||
if (this._cryptoKeyRing[keyIndex]) { |
|||
|
|||
const decodedFrame = await this._decryptFrame( |
|||
encodedFrame, |
|||
keyIndex); |
|||
|
|||
if (decodedFrame) { |
|||
controller.enqueue(decodedFrame); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Function that will decrypt the given encoded frame. If the decryption fails, it will |
|||
* ratchet the key for up to RATCHET_WINDOW_SIZE times. |
|||
* |
|||
* @param {RTCEncodedVideoFrame|RTCEncodedAudioFrame} encodedFrame - Encoded video frame. |
|||
* @param {number} keyIndex - the index of the decryption data in _cryptoKeyRing array. |
|||
* @param {number} ratchetCount - the number of retries after ratcheting the key. |
|||
* @returns {Promise<RTCEncodedVideoFrame|RTCEncodedAudioFrame>} - The decrypted frame. |
|||
* @private |
|||
*/ |
|||
async _decryptFrame( |
|||
encodedFrame, |
|||
keyIndex, |
|||
initialKey = undefined, |
|||
ratchetCount = 0) { |
|||
|
|||
const { encryptionKey } = this._cryptoKeyRing[keyIndex]; |
|||
let { material } = this._cryptoKeyRing[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 frameHeader = new Uint8Array(encodedFrame.data, 0, UNENCRYPTED_BYTES[encodedFrame.type]); |
|||
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: 'AES-GCM', |
|||
iv, |
|||
additionalData: new Uint8Array(encodedFrame.data, 0, frameHeader.byteLength) |
|||
}, |
|||
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) { |
|||
if (this._sharedKey) { |
|||
return; |
|||
} |
|||
|
|||
if (ratchetCount < RATCHET_WINDOW_SIZE) { |
|||
const currentKey = this._cryptoKeyRing[this._currentKeyIndex]; |
|||
|
|||
material = await importKey(await ratchet(material)); |
|||
|
|||
const newKey = await deriveKeys(material); |
|||
|
|||
this._setKeys(newKey); |
|||
|
|||
return await this._decryptFrame( |
|||
encodedFrame, |
|||
keyIndex, |
|||
initialKey || currentKey, |
|||
ratchetCount + 1); |
|||
} |
|||
|
|||
/** |
|||
* Since the key it is first send and only afterwards actually used for encrypting, there were |
|||
* situations when the decrypting failed due to the fact that the received frame was not encrypted |
|||
* yet and ratcheting, of course, did not solve the problem. So if we fail RATCHET_WINDOW_SIZE times, |
|||
* we come back to the initial key. |
|||
*/ |
|||
this._setKeys(initialKey); |
|||
|
|||
// TODO: notify the application about error status.
|
|||
} |
|||
} |
|||
|
|||
|
|||
/** |
|||
* 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
|
|||
*/ |
|||
_makeIV(synchronizationSource, timestamp) { |
|||
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); |
|||
|
|||
ivView.setUint32(0, synchronizationSource); |
|||
ivView.setUint32(4, timestamp); |
|||
ivView.setUint32(8, sendCount % 0xFFFF); |
|||
|
|||
this._sendCounts.set(synchronizationSource, sendCount + 1); |
|||
|
|||
return iv; |
|||
} |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
/** |
|||
* SPDX-FileCopyrightText: 2020 Jitsi team at 8x8 and the community. |
|||
* SPDX-License-Identifier: Apache-2.0 |
|||
* |
|||
* Based on code from https://github.com/jitsi/jitsi-meet
|
|||
*/ |
|||
|
|||
/** |
|||
* Derives a set of keys from the master key. |
|||
* @param {CryptoKey} material - master key to derive from |
|||
* |
|||
* See https://tools.ietf.org/html/draft-omara-sframe-00#section-4.3.1
|
|||
*/ |
|||
export async function deriveKeys(material) { |
|||
const info = new ArrayBuffer(); |
|||
const textEncoder = new TextEncoder(); |
|||
|
|||
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveKey#HKDF
|
|||
// https://developer.mozilla.org/en-US/docs/Web/API/HkdfParams
|
|||
const encryptionKey = await crypto.subtle.deriveKey({ |
|||
name: 'HKDF', |
|||
salt: textEncoder.encode('TalkFrameEncryptionKey'), |
|||
hash: 'SHA-256', |
|||
info |
|||
}, material, { |
|||
name: 'AES-GCM', |
|||
length: 128 |
|||
}, false, [ 'encrypt', 'decrypt' ]); |
|||
|
|||
return { |
|||
material, |
|||
encryptionKey |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* Ratchets a key. See |
|||
* https://tools.ietf.org/html/draft-omara-sframe-00#section-4.3.5.1
|
|||
* @param {CryptoKey} material - base key material |
|||
* @returns {Promise<ArrayBuffer>} - ratcheted key material |
|||
*/ |
|||
export async function ratchet(material) { |
|||
const textEncoder = new TextEncoder(); |
|||
|
|||
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveBits
|
|||
return crypto.subtle.deriveBits({ |
|||
name: 'HKDF', |
|||
salt: textEncoder.encode('TalkFrameRatchetKey'), |
|||
hash: 'SHA-256', |
|||
info: new ArrayBuffer() |
|||
}, material, 256); |
|||
} |
|||
|
|||
/** |
|||
* Converts a raw key into a WebCrypto key object with default options |
|||
* suitable for our usage. |
|||
* @param {ArrayBuffer} keyBytes - raw key |
|||
* @param {Array} keyUsages - key usages, see importKey documentation |
|||
* @returns {Promise<CryptoKey>} - the WebCrypto key. |
|||
*/ |
|||
export async function importKey(keyBytes) { |
|||
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey
|
|||
return crypto.subtle.importKey('raw', keyBytes, 'HKDF', false, [ 'deriveBits', 'deriveKey' ]); |
|||
} |
|||
@ -0,0 +1,686 @@ |
|||
/** |
|||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors |
|||
* SPDX-License-Identifier: AGPL-3.0-or-later |
|||
*/ |
|||
|
|||
import Olm from '@matrix-org/olm' |
|||
import base64js from 'base64-js' |
|||
import debounce from 'debounce' |
|||
import { isEqual } from 'lodash' |
|||
import { v4 as uuidv4 } from 'uuid' |
|||
|
|||
import { importKey, ratchet } from './crypto-utils.js' |
|||
import Deferred from './JitsiDeferred.js' |
|||
import E2EEcontext from './JitsiE2EEContext.js' |
|||
import initializeOlm from './olm.js' |
|||
import { hasTalkFeature, getTalkConfig } from '../../services/CapabilitiesManager.ts' |
|||
import Signaling from '../signaling.js' |
|||
import Peer from '../webrtc/simplewebrtc/peer.js' |
|||
import SimpleWebRTC from '../webrtc/simplewebrtc/simplewebrtc.js' |
|||
|
|||
const supportsTransform |
|||
// Firefox
|
|||
= (window.RTCRtpScriptTransform && window.RTCRtpSender && 'transform' in RTCRtpSender.prototype) |
|||
// Chrome
|
|||
|| (window.RTCRtpReceiver && 'createEncodedStreams' in RTCRtpReceiver.prototype && window.RTCRtpSender && 'createEncodedStreams' in RTCRtpSender.prototype) |
|||
|
|||
// Period which we'll wait before updating / rotating our keys when a participant
|
|||
// joins or leaves.
|
|||
const DEBOUNCE_PERIOD = 5000 |
|||
|
|||
const REQUEST_TIMEOUT_MS = 5 * 1000 |
|||
|
|||
const TYPE_ENCRYPTION_START = 'encryption.start' |
|||
const TYPE_ENCRYPTION_FINISH = 'encryption.finish' |
|||
const TYPE_ENCRYPTION_SET_KEY = 'encryption.setkey' |
|||
const TYPE_ENCRYPTION_GOT_KEY = 'encryption.gotkey' |
|||
const TYPE_ENCRYPTION_ERROR = 'encryption.error' |
|||
|
|||
class Encryption { |
|||
|
|||
/** |
|||
* Check if the current browser supports encryption. |
|||
* |
|||
* @return {boolean} Returns true if supported and throws an error otherwise. |
|||
* @async |
|||
*/ |
|||
static async isSupported() { |
|||
if (!supportsTransform) { |
|||
throw new Error('stream transform is not supported') |
|||
} |
|||
|
|||
await initializeOlm() |
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* Check if encryption should be enabled for calls. |
|||
* |
|||
* @return {boolean} Returns true if encryption should be enabled, false otherwise. |
|||
*/ |
|||
static isEnabled() { |
|||
if (!hasTalkFeature('local', 'call-end-to-end-encryption')) { |
|||
return false |
|||
} |
|||
|
|||
const enabled = getTalkConfig('local', 'call', 'end-to-end-encryption') |
|||
return enabled || (enabled === undefined) |
|||
} |
|||
|
|||
/** |
|||
* Create the encryption session. |
|||
* |
|||
* @param {Signaling} signaling The signaling instance. |
|||
*/ |
|||
constructor(signaling) { |
|||
this.signaling = signaling |
|||
this._webrtc = null |
|||
|
|||
this._key = this._generateKey() |
|||
this._keyIndex = 0 |
|||
this._sessions = {} |
|||
this._requests = new Map() |
|||
|
|||
this._account = new Olm.Account() |
|||
this._account.create() |
|||
this._keys = JSON.parse(this._account.identity_keys()) |
|||
|
|||
this.context = new E2EEcontext() |
|||
|
|||
this._handleSessionIdBound = this._handleSessionId.bind(this) |
|||
this.signaling.on('sessionId', this._handleSessionIdBound) |
|||
this._handleUsersJoinedBound = this._handleUsersJoined.bind(this) |
|||
this.signaling.on('usersJoined', this._handleUsersJoinedBound) |
|||
this._handleUsersLeftBound = this._handleUsersLeft.bind(this) |
|||
this.signaling.on('usersLeft', this._handleUsersLeftBound) |
|||
this._handleMessageBound = this._handleMessage.bind(this) |
|||
this.signaling.on('message', this._handleMessageBound) |
|||
|
|||
this._rotateKey = debounce(this._rotateKeyImpl, DEBOUNCE_PERIOD) |
|||
this._ratchetKey = debounce(this._ratchetKeyImpl, DEBOUNCE_PERIOD) |
|||
|
|||
this._handlePeerCreatedBound = this._handlePeerCreated.bind(this) |
|||
|
|||
this._handleSessionId(signaling.sessionId || '') |
|||
this._handleUsersJoined(Object.values(signaling.joinedUsers)) |
|||
} |
|||
|
|||
/** |
|||
* Set the WebRTC instance to use. |
|||
* |
|||
* @param {SimpleWebRTC} webrtc The WebRTC instance. |
|||
*/ |
|||
setWebRtc(webrtc) { |
|||
if (this._webrtc) { |
|||
this._webrtc.off('createdPeer', this._handlePeerCreatedBound) |
|||
} |
|||
|
|||
webrtc.on('createdPeer', this._handlePeerCreatedBound) |
|||
this._webrtc = webrtc |
|||
} |
|||
|
|||
/** |
|||
* Close the encryption session. |
|||
*/ |
|||
close() { |
|||
this.signaling.off('sessionId', this._handleSessionIdBound) |
|||
this.signaling.off('usersJoined', this._handleUsersJoinedBound) |
|||
this.signaling.off('usersLeft', this._handleUsersLeftBound) |
|||
this.signaling.off('message', this._handleMessageBound) |
|||
if (this._webrtc) { |
|||
this._webrtc.off('createdPeer', this._handlePeerCreatedBound) |
|||
this._webrtc = null |
|||
} |
|||
this._sessions = {} |
|||
if (this._account) { |
|||
this._account.free() |
|||
this._account = null |
|||
} |
|||
this.context.cleanupAll() |
|||
} |
|||
|
|||
/** |
|||
* Handle an event to store the local session id. |
|||
* |
|||
* @param {string} sessionId The current local session id. |
|||
* @private |
|||
*/ |
|||
_handleSessionId(sessionId) { |
|||
this._sessionId = sessionId |
|||
if (sessionId) { |
|||
this.context.setKey(sessionId, this._key, this._keyIndex) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Handle event when users joined. |
|||
* |
|||
* @param {Array<object>} users The list of joined users. |
|||
* @private |
|||
*/ |
|||
_handleUsersJoined(users) { |
|||
users.forEach((user) => { |
|||
if (user.sessionid < this._sessionId) { |
|||
this._startSession(user.sessionid) |
|||
} |
|||
}) |
|||
|
|||
// Derieve new key from current so new users won't be able to decrypt past
|
|||
// content but existing users can deduct the new key by ratcheting
|
|||
// themselves.
|
|||
this._ratchetKey() |
|||
} |
|||
|
|||
/** |
|||
* Handle event when users left. |
|||
* |
|||
* @param {Array<string>} sessionIds The list of session ids that have left. |
|||
* @private |
|||
*/ |
|||
_handleUsersLeft(sessionIds) { |
|||
sessionIds.forEach((sessionId) => { |
|||
delete this._sessions[sessionId] |
|||
this.context.cleanup(sessionId) |
|||
}) |
|||
|
|||
// Generate new key so previously joined users won't be able to decrypt
|
|||
// future data.
|
|||
this._rotateKey() |
|||
} |
|||
|
|||
/** |
|||
* Handle received signaling message. |
|||
* |
|||
* @param {object} message The signaling message. |
|||
* @private |
|||
*/ |
|||
_handleMessage(message) { |
|||
const sender = message.from |
|||
switch (message.payload?.type) { |
|||
case TYPE_ENCRYPTION_START: |
|||
this._processStartSession(sender, message) |
|||
break |
|||
case TYPE_ENCRYPTION_FINISH: |
|||
this._processFinishSession(sender, message) |
|||
break |
|||
case TYPE_ENCRYPTION_SET_KEY: |
|||
this._processSessionSetKey(sender, message) |
|||
break |
|||
case TYPE_ENCRYPTION_GOT_KEY: |
|||
this._processSessionGotKey(sender, message) |
|||
break |
|||
case TYPE_ENCRYPTION_ERROR: |
|||
this._processError(sender, message) |
|||
break |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Returns the data for the given session id. |
|||
* |
|||
* @param {string} sessionId The session id. |
|||
* @return {object} The data for this session id. |
|||
* @private |
|||
*/ |
|||
_sessionData(sessionId) { |
|||
this._sessions[sessionId] = this._sessions[sessionId] || {} |
|||
return this._sessions[sessionId] |
|||
} |
|||
|
|||
/** |
|||
* Start encrypted session with remote session. |
|||
* |
|||
* @param {string} sessionId The remote session id. |
|||
* @return {Promise} A promise that will be resolved when the session has been established. |
|||
* @private |
|||
*/ |
|||
_startSession(sessionId) { |
|||
const sessionData = this._sessionData(sessionId) |
|||
if (sessionData.session) { |
|||
console.error('Already have a session') |
|||
return Promise.reject(new Error('Already have a session')) |
|||
} |
|||
|
|||
if (sessionData.startMsgId) { |
|||
console.error('Session request already started') |
|||
return Promise.reject(new Error('Session request already started')) |
|||
} |
|||
|
|||
console.debug('Starting e2s session with', sessionId) |
|||
this._account.generate_one_time_keys(1) |
|||
const keys = JSON.parse(this._account.one_time_keys()) |
|||
const key = Object.values(keys.curve25519)[0] |
|||
if (!key) { |
|||
return Promise.reject(new Error('No one-time key created')) |
|||
} |
|||
|
|||
this._account.mark_keys_as_published() |
|||
const msgId = uuidv4() |
|||
const message = { |
|||
type: 'message', |
|||
to: sessionId, |
|||
payload: { |
|||
id: msgId, |
|||
type: TYPE_ENCRYPTION_START, |
|||
identity: this._keys.curve25519, |
|||
key, |
|||
}, |
|||
} |
|||
sessionData.startMsgId = msgId |
|||
|
|||
const d = new Deferred() |
|||
|
|||
d.setRejectTimeout(REQUEST_TIMEOUT_MS) |
|||
d.catch((e) => { |
|||
console.debug('Starting e2e session failed', sessionId, e) |
|||
this._requests.delete(msgId) |
|||
delete sessionData.startMsgId |
|||
}) |
|||
this._requests.set(msgId, d) |
|||
|
|||
this.signaling.sendCallMessage(message) |
|||
|
|||
return d |
|||
} |
|||
|
|||
/** |
|||
* Rotates the local key. Rotating the key implies creating a new one, then distributing it |
|||
* to all participants and once they all received it, start using it. |
|||
* |
|||
* @private |
|||
* @async |
|||
*/ |
|||
async _rotateKeyImpl() { |
|||
console.debug('Rotating key') |
|||
|
|||
this._rotating = true |
|||
try { |
|||
this._key = this._generateKey() |
|||
|
|||
// Wait until new key is distributed before using it.
|
|||
const index = await this._updateKey(this._key) |
|||
|
|||
this.context.setKey(this._sessionId, this._key, index) |
|||
} finally { |
|||
this._rotating = false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Advances the current key by using ratcheting. |
|||
* |
|||
* @private |
|||
* @async |
|||
*/ |
|||
async _ratchetKeyImpl() { |
|||
if (this._rotating) { |
|||
console.debug('Not ratchetting key, currently rotating') |
|||
return |
|||
} |
|||
|
|||
console.debug('Ratchetting key') |
|||
const material = await importKey(this._key) |
|||
const newKey = await ratchet(material) |
|||
|
|||
this._key = new Uint8Array(newKey) |
|||
|
|||
const index = this._updateCurrentKey(this._key) |
|||
|
|||
if (this._sessionId) { |
|||
this.context.setKey(this._sessionId, this._key, index) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Generates a new 256 bit random key. |
|||
* |
|||
* @return {Uint8Array} The generated key. |
|||
* @private |
|||
*/ |
|||
_generateKey() { |
|||
return window.crypto.getRandomValues(new Uint8Array(32)) |
|||
} |
|||
|
|||
/** |
|||
* Update the key and send it to all sessions. Will only return after it has been received by all sessions. |
|||
* |
|||
* @param {Uint8Array} key The key to update to. |
|||
* @return {number} The updated key index. |
|||
* @async |
|||
*/ |
|||
async _updateKey(key) { |
|||
// Store it locally for new sessions.
|
|||
this._key = key |
|||
this._keyIndex++ |
|||
|
|||
const promises = [] |
|||
|
|||
Object.entries(this._sessions).forEach((entry) => { |
|||
const [sessionId, sessionData] = entry |
|||
promises.push(this._sendKey(sessionId, sessionData)) |
|||
}) |
|||
|
|||
await Promise.allSettled(promises) |
|||
|
|||
return this._keyIndex |
|||
} |
|||
|
|||
/** |
|||
* Updates the current participant key. |
|||
* @param {Uint8Array|boolean} key - The new key. |
|||
* @return {number} The current key index. |
|||
* @private |
|||
*/ |
|||
_updateCurrentKey(key) { |
|||
this._key = key |
|||
|
|||
return this._keyIndex |
|||
} |
|||
|
|||
/** |
|||
* Encrypt the current local key for the given session. |
|||
* |
|||
* @param {Olm.Session} session The Olm session to encrypt the key for. |
|||
* @return {object} The encrypted key data. |
|||
* @private |
|||
*/ |
|||
_encryptKey(session) { |
|||
const data = {} |
|||
|
|||
if (this._key !== undefined) { |
|||
data.key = this._key ? base64js.fromByteArray(this._key) : false |
|||
data.index = this._keyIndex |
|||
} |
|||
|
|||
return session.encrypt(JSON.stringify(data)) |
|||
} |
|||
|
|||
/** |
|||
* Process a request to start an encrypted session with the given peer. |
|||
* |
|||
* @param {string} sessionId The session id that sent the start request. |
|||
* @param {object} message The received message. |
|||
* @private |
|||
*/ |
|||
_processStartSession(sessionId, message) { |
|||
const sessionData = this._sessionData(sessionId) |
|||
if (sessionData.session) { |
|||
console.warn('Already has a session', sessionId) |
|||
this._sendError(sessionId, 'Session already created') |
|||
return |
|||
} |
|||
|
|||
console.debug('Received e2s session request from', sessionId) |
|||
const payload = message.payload |
|||
const session = new Olm.Session() |
|||
session.create_outbound(this._account, payload.identity, payload.key) |
|||
sessionData.session = session |
|||
const response = { |
|||
type: 'message', |
|||
to: sessionId, |
|||
payload: { |
|||
id: payload.id, |
|||
type: TYPE_ENCRYPTION_FINISH, |
|||
key: this._encryptKey(session), |
|||
}, |
|||
} |
|||
this.signaling.sendCallMessage(response) |
|||
} |
|||
|
|||
/** |
|||
* Finish the request to start an encrypted session with the given peer. |
|||
* |
|||
* @param {string} sessionId The session id that sent the finish request. |
|||
* @param {object} message The received message. |
|||
* @private |
|||
*/ |
|||
_processFinishSession(sessionId, message) { |
|||
const sessionData = this._sessionData(sessionId) |
|||
if (sessionData.session) { |
|||
console.warn('Already has a session', sessionId) |
|||
this._sendError(sessionId, 'Session already created') |
|||
return |
|||
} |
|||
|
|||
const payload = message.payload |
|||
if (payload.id !== sessionData.startMsgId) { |
|||
console.warn('Received finish with wrong id', sessionId) |
|||
this._sendError(sessionId, 'Finish has wrong id') |
|||
return |
|||
} |
|||
|
|||
console.debug('Finished e2s session with', sessionId) |
|||
const session = new Olm.Session() |
|||
session.create_inbound(this._account, payload.key.body) |
|||
this._account.remove_one_time_keys(session) |
|||
|
|||
// Get current key (if present).
|
|||
const data = session.decrypt(payload.key.type, payload.key.body) |
|||
sessionData.session = session |
|||
delete sessionData.startMsgId |
|||
|
|||
const d = this._requests.get(payload.id) |
|||
this._requests.delete(payload.id) |
|||
d.resolve() |
|||
|
|||
const decoded = JSON.parse(data) |
|||
if (decoded.key) { |
|||
const key = base64js.toByteArray(decoded.key) |
|||
const index = decoded.index |
|||
|
|||
sessionData.lastKey = key |
|||
console.debug('Key updated', sessionId, index, decoded.key) |
|||
this.context.setKey(sessionId, key, index) |
|||
} |
|||
|
|||
if (this._key !== undefined) { |
|||
// Notify remote session about local key.
|
|||
this._sendKey(sessionId, sessionData) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Process a key request from the given session. |
|||
* |
|||
* @param {string} sessionId The session id that sent the set key request. |
|||
* @param {object} message The received message. |
|||
* @private |
|||
*/ |
|||
_processSessionSetKey(sessionId, message) { |
|||
const sessionData = this._sessionData(sessionId) |
|||
if (!sessionData.session) { |
|||
console.warn('No session found', sessionId) |
|||
this._sendError(sessionId, 'No session for setting key') |
|||
return |
|||
} |
|||
|
|||
const payload = message.payload |
|||
const data = sessionData.session.decrypt(payload.key.type, payload.key.body) |
|||
|
|||
const decoded = JSON.parse(data) |
|||
if (decoded.key !== undefined && decoded.index !== undefined) { |
|||
const key = base64js.toByteArray(decoded.key) |
|||
const index = decoded.index |
|||
|
|||
if (!isEqual(sessionData.lastKey, key)) { |
|||
sessionData.lastKey = key |
|||
console.debug('Key updated', sessionId, index, decoded.key) |
|||
this.context.setKey(sessionId, key, index) |
|||
} |
|||
|
|||
// Confirm that we have received the key.
|
|||
const response = { |
|||
type: 'message', |
|||
to: sessionId, |
|||
payload: { |
|||
id: payload.id, |
|||
type: TYPE_ENCRYPTION_GOT_KEY, |
|||
key: this._encryptKey(sessionData.session), |
|||
}, |
|||
} |
|||
this.signaling.sendCallMessage(response) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Process request that a key has been received by the given session. |
|||
* |
|||
* @param {string} sessionId The session id that received the key. |
|||
* @param {object} message The received message. |
|||
* @private |
|||
*/ |
|||
_processSessionGotKey(sessionId, message) { |
|||
const sessionData = this._sessionData(sessionId) |
|||
if (!sessionData.session) { |
|||
console.warn('No session found', sessionId) |
|||
this._sendError(sessionId, 'No session for confirming key') |
|||
return |
|||
} |
|||
|
|||
const payload = message.payload |
|||
const data = sessionData.session.decrypt(payload.key.type, payload.key.body) |
|||
|
|||
const decoded = JSON.parse(data) |
|||
if (decoded.key !== undefined && decoded.index !== undefined) { |
|||
const key = base64js.toByteArray(decoded.key) |
|||
const index = decoded.index |
|||
|
|||
if (!isEqual(sessionData.lastKey, key)) { |
|||
sessionData.lastKey = key |
|||
console.debug('Key updated', sessionId, index, decoded.key) |
|||
this.context.setKey(sessionId, key, index) |
|||
} |
|||
} |
|||
|
|||
const d = this._requests.get(payload.id) |
|||
this._requests.delete(payload.id) |
|||
d.resolve() |
|||
} |
|||
|
|||
/** |
|||
* Process error message. |
|||
* |
|||
* @param {string} sessionId The session id that sent the error. |
|||
* @param {object} message The received message. |
|||
* @private |
|||
*/ |
|||
_processError(sessionId, message) { |
|||
console.error('Received error', sessionId, message.payload.error) |
|||
} |
|||
|
|||
/** |
|||
* Send error message to a remote session. |
|||
* |
|||
* @param {string} sessionId The session id to send the error to. |
|||
* @param {string|object} error The error message. |
|||
*/ |
|||
_sendError(sessionId, error) { |
|||
const message = { |
|||
type: 'message', |
|||
to: sessionId, |
|||
payload: { |
|||
type: TYPE_ENCRYPTION_ERROR, |
|||
error, |
|||
}, |
|||
} |
|||
this.signaling.sendCallMessage(message) |
|||
} |
|||
|
|||
/** |
|||
* Send the current encryption key to the given peer. |
|||
* |
|||
* @param {string} sessionId The session id to send the key to. |
|||
* @param {object|null|undefined} sessionData The optional data for the session. |
|||
* @return {Promise} A promise that will be resolved when the key has been received by the peer. |
|||
* @private |
|||
*/ |
|||
_sendKey(sessionId, sessionData) { |
|||
if (!sessionData) { |
|||
sessionData = this._sessionData(sessionId) |
|||
} |
|||
if (!sessionData.session) { |
|||
console.warn('No session found', sessionId, sessionData) |
|||
return Promise.reject(new Error('No session found')) |
|||
} |
|||
|
|||
const msgId = uuidv4() |
|||
const response = { |
|||
type: 'message', |
|||
to: sessionId, |
|||
payload: { |
|||
id: msgId, |
|||
type: TYPE_ENCRYPTION_SET_KEY, |
|||
key: this._encryptKey(sessionData.session), |
|||
}, |
|||
} |
|||
|
|||
const d = new Deferred() |
|||
|
|||
d.setRejectTimeout(REQUEST_TIMEOUT_MS) |
|||
d.catch(() => { |
|||
this._requests.delete(msgId) |
|||
}) |
|||
this._requests.set(msgId, d) |
|||
|
|||
this.signaling.sendCallMessage(response) |
|||
|
|||
return d |
|||
} |
|||
|
|||
/** |
|||
* A peer was created. |
|||
* |
|||
* @param {Peer} peer The peer that was created. |
|||
* @private |
|||
*/ |
|||
_handlePeerCreated(peer) { |
|||
if (peer.id === this._sessionId) { |
|||
// Own peers are sending.
|
|||
peer.pc.getSenders().forEach((sender) => { |
|||
this.context.handleSender(sender, sender.track.kind, peer.id) |
|||
}) |
|||
} else { |
|||
// Remote peers are receiving.
|
|||
if (peer.stream) { |
|||
this._processReceivePeerStream(peer, peer.stream) |
|||
} |
|||
peer.pc.addEventListener('addstream', (event) => { |
|||
this._processReceivePeerStream(peer, event.stream) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Returns the receiver for a given track. |
|||
* |
|||
* @param {RTCPeerConnection} pc The peer connection to search the receiver in. |
|||
* @param {MediaStreamTrack} track The track for which the receiver should be returned. |
|||
* @return {RTCRtpReceiver|undefined} The found receiver or undefined. |
|||
* @private |
|||
*/ |
|||
_findReceiverForTrack(pc, track) { |
|||
return pc && pc.getReceivers().find(r => r.track === track) |
|||
} |
|||
|
|||
/** |
|||
* Process streams of a receiving peer for decrypting. |
|||
* |
|||
* @param {Peer} peer The peer where the stream has been created. |
|||
* @param {MediaStream} stream The created stream. |
|||
* @private |
|||
*/ |
|||
_processReceivePeerStream(peer, stream) { |
|||
stream.getTracks().forEach((track) => { |
|||
const receiver = this._findReceiverForTrack(peer.pc, track) |
|||
this.context.handleReceiver(receiver, receiver.track.kind, peer.id) |
|||
}) |
|||
|
|||
stream.addEventListener('addtrack', (event) => { |
|||
const receiver = this._findReceiverForTrack(peer.pc, event.track) |
|||
this.context.handleReceiver(receiver, receiver.track.kind, peer.id) |
|||
}) |
|||
} |
|||
|
|||
} |
|||
|
|||
export default Encryption |
|||
@ -0,0 +1,34 @@ |
|||
/** |
|||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors |
|||
* SPDX-License-Identifier: AGPL-3.0-or-later |
|||
*/ |
|||
|
|||
import Olm from '@matrix-org/olm' |
|||
import wasmFile from '@matrix-org/olm/olm.wasm' |
|||
|
|||
import { generateFilePath } from '@nextcloud/router' |
|||
|
|||
let initialized = false |
|||
|
|||
/** |
|||
* Initializes the Olm library that is used for e2e encryption. |
|||
*/ |
|||
async function initialize() { |
|||
if (initialized) { |
|||
return |
|||
} |
|||
|
|||
await Olm.init({ |
|||
locateFile: () => { |
|||
if (IS_DESKTOP) { |
|||
return wasmFile |
|||
} |
|||
// FIXME this is a dirty hack and should be properly fixed so it does not break when changing the webpack config in a unknown way.
|
|||
return generateFilePath('spreed', 'js', wasmFile.split('/').pop()) |
|||
}, |
|||
}) |
|||
initialized = true |
|||
console.debug('Initialized Olm version', Olm.get_library_version().join('.')) |
|||
} |
|||
|
|||
export default initialize |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue