Browse Source

Merge pull request #14005 from nextcloud/feat-webrtc-e2ee

feat: Enable end-to-end encryption for WebRTC streams.
pull/14147/head
Joas Schilling 11 months ago
committed by GitHub
parent
commit
63e8348b73
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .eslintignore
  2. 1
      jest.config.js
  3. 4
      lib/Controller/PageController.php
  4. 62
      package-lock.json
  5. 3
      package.json
  6. 49
      src/utils/e2ee/JitsiDeferred.js
  7. 149
      src/utils/e2ee/JitsiE2EEContext.js
  8. 103
      src/utils/e2ee/JitsiEncryptionWorker.worker.js
  9. 336
      src/utils/e2ee/JitsiEncryptionWorkerContext.js
  10. 64
      src/utils/e2ee/crypto-utils.js
  11. 686
      src/utils/e2ee/encryption.js
  12. 34
      src/utils/e2ee/olm.js
  13. 69
      src/utils/signaling.js
  14. 30
      src/utils/webrtc/index.js

2
.eslintignore

@ -2,5 +2,7 @@
# SPDX-License-Identifier: CC0-1.0
/js/*
/src/types/openapi/*
/src/utils/e2ee/crypto-utils.js
/src/utils/e2ee/Jitsi*.js
/src/utils/media/effects/virtual-background/vendor/*
/tests/*

1
jest.config.js

@ -67,6 +67,7 @@ module.exports = {
'\\.(css|scss)$': 'jest-transform-stub',
'^.+\\.svg(\\?raw)?$': '<rootDir>/src/__mocks__/svg.js',
'vendor/tflite/(.*).wasm$': '<rootDir>/src/utils/media/effects/virtual-background/vendor/tflite/$1.js',
'@matrix-org/olm/(.*).wasm$': '<rootDir>/node_modules/@matrix-org/olm/$1.js',
},
transform: {

4
lib/Controller/PageController.php

@ -270,6 +270,7 @@ class PageController extends Controller {
$csp->addAllowedChildSrcDomain("'self'");
$csp->addAllowedScriptDomain('blob:');
$csp->addAllowedScriptDomain("'self'");
$csp->addAllowedScriptDomain("'wasm-unsafe-eval'");
$csp->addAllowedConnectDomain('blob:');
$csp->addAllowedConnectDomain("'self'");
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
@ -332,6 +333,7 @@ class PageController extends Controller {
$csp->addAllowedChildSrcDomain("'self'");
$csp->addAllowedScriptDomain('blob:');
$csp->addAllowedScriptDomain("'self'");
$csp->addAllowedScriptDomain("'wasm-unsafe-eval'");
$csp->addAllowedConnectDomain('blob:');
$csp->addAllowedConnectDomain("'self'");
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
@ -418,6 +420,7 @@ class PageController extends Controller {
$csp->addAllowedChildSrcDomain("'self'");
$csp->addAllowedScriptDomain('blob:');
$csp->addAllowedScriptDomain("'self'");
$csp->addAllowedScriptDomain("'wasm-unsafe-eval'");
$csp->addAllowedConnectDomain('blob:');
$csp->addAllowedConnectDomain("'self'");
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
@ -475,6 +478,7 @@ class PageController extends Controller {
$csp->addAllowedChildSrcDomain("'self'");
$csp->addAllowedScriptDomain('blob:');
$csp->addAllowedScriptDomain("'self'");
$csp->addAllowedScriptDomain("'wasm-unsafe-eval'");
$csp->addAllowedConnectDomain('blob:');
$csp->addAllowedConnectDomain("'self'");
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');

62
package-lock.json

@ -10,6 +10,7 @@
"license": "agpl",
"dependencies": {
"@linusborg/vue-simple-portal": "^0.1.5",
"@matrix-org/olm": "^3.2.15",
"@nextcloud/auth": "^2.4.0",
"@nextcloud/axios": "^2.5.1",
"@nextcloud/browser-storage": "^0.4.0",
@ -28,6 +29,7 @@
"@nextcloud/vue": "^8.22.0",
"@vueuse/components": "^11.3.0",
"@vueuse/core": "^11.2.0",
"base64-js": "^1.5.1",
"blurhash": "^2.0.5",
"crypto-js": "^4.2.0",
"debounce": "^2.2.0",
@ -45,6 +47,7 @@
"pinia": "^2.3.0",
"ua-parser-js": "^2.0.0",
"util": "^0.12.5",
"uuid": "^11.0.3",
"vue": "^2.7.16",
"vue-cropperjs": "^4.2.0",
"vue-draggable-resizable": "^2.3.0",
@ -3371,6 +3374,11 @@
"unist-util-is": "^3.0.0"
}
},
"node_modules/@matrix-org/olm": {
"version": "3.2.15",
"resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
"integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q=="
},
"node_modules/@mdi/js": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
@ -6214,7 +6222,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
@ -6228,8 +6235,7 @@
"type": "consulting",
"url": "https://feross.org/support"
}
],
"peer": true
]
},
"node_modules/batch": {
"version": "0.6.1",
@ -18256,6 +18262,16 @@
"websocket-driver": "^0.7.4"
}
},
"node_modules/sockjs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"peer": true,
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/source-list-map": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
@ -20147,13 +20163,15 @@
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"peer": true,
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/v8-to-istanbul": {
@ -23754,6 +23772,11 @@
}
}
},
"@matrix-org/olm": {
"version": "3.2.15",
"resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
"integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q=="
},
"@mdi/js": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
@ -25946,9 +25969,7 @@
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"peer": true
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"batch": {
"version": "0.6.1",
@ -34815,6 +34836,15 @@
"faye-websocket": "^0.11.3",
"uuid": "^8.3.2",
"websocket-driver": "^0.7.4"
},
"dependencies": {
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"peer": true
}
}
},
"source-list-map": {
@ -36210,11 +36240,9 @@
"peer": true
},
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"peer": true
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg=="
},
"v8-to-istanbul": {
"version": "9.1.0",

3
package.json

@ -25,6 +25,7 @@
},
"dependencies": {
"@linusborg/vue-simple-portal": "^0.1.5",
"@matrix-org/olm": "^3.2.15",
"@nextcloud/auth": "^2.4.0",
"@nextcloud/axios": "^2.5.1",
"@nextcloud/browser-storage": "^0.4.0",
@ -43,6 +44,7 @@
"@nextcloud/vue": "^8.22.0",
"@vueuse/components": "^11.3.0",
"@vueuse/core": "^11.2.0",
"base64-js": "^1.5.1",
"blurhash": "^2.0.5",
"crypto-js": "^4.2.0",
"debounce": "^2.2.0",
@ -60,6 +62,7 @@
"pinia": "^2.3.0",
"ua-parser-js": "^2.0.0",
"util": "^0.12.5",
"uuid": "^11.0.3",
"vue": "^2.7.16",
"vue-cropperjs": "^4.2.0",
"vue-draggable-resizable": "^2.3.0",

49
src/utils/e2ee/JitsiDeferred.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);
}
}

149
src/utils/e2ee/JitsiE2EEContext.js

@ -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
});
}
}

103
src/utils/e2ee/JitsiEncryptionWorker.worker.js

@ -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);
};
}

336
src/utils/e2ee/JitsiEncryptionWorkerContext.js

@ -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;
}
}

64
src/utils/e2ee/crypto-utils.js

@ -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' ]);
}

686
src/utils/e2ee/encryption.js

@ -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

34
src/utils/e2ee/olm.js

@ -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

69
src/utils/signaling.js

@ -15,6 +15,7 @@ import {
} from '@nextcloud/router'
import CancelableRequest from './cancelableRequest.js'
import Encryption from './e2ee/encryption.js'
import { PARTICIPANT } from '../constants.js'
import { hasTalkFeature } from '../services/CapabilitiesManager.ts'
import { EventBus } from '../services/EventBus.ts'
@ -981,42 +982,56 @@ Signaling.Standalone.prototype._getBackendUrl = function(baseURL = undefined) {
}
Signaling.Standalone.prototype.sendHello = function() {
let msg
if (this.resumeId) {
console.debug('Trying to resume session', this.sessionId)
msg = {
const msg = {
type: 'hello',
hello: {
version: '1.0',
resumeid: this.resumeId,
},
}
this.doSend(msg, this.helloResponseReceived.bind(this))
return
}
// Already reconnected with a new session.
this._forceReconnect = false
const url = this._getBackendUrl()
let helloVersion
if (this.hasFeature('hello-v2') && this.settings.helloAuthParams['2.0']) {
helloVersion = '2.0'
} else {
// Already reconnected with a new session.
this._forceReconnect = false
const url = this._getBackendUrl()
let helloVersion
if (this.hasFeature('hello-v2') && this.settings.helloAuthParams['2.0']) {
helloVersion = '2.0'
} else {
helloVersion = '1.0'
}
msg = {
type: 'hello',
hello: {
version: helloVersion,
auth: {
url,
params: this.settings.helloAuthParams[helloVersion],
},
},
}
if (this.settings.helloAuthParams.internal) {
msg.hello.auth.type = 'internal'
msg.hello.auth.params = this.settings.helloAuthParams.internal
}
helloVersion = '1.0'
}
this.doSend(msg, this.helloResponseReceived.bind(this))
const features = []
Encryption.isSupported()
.then(() => {
features.push('encryption')
})
.catch(() => {
// Ignore any errors.
})
.finally(() => {
const msg = {
type: 'hello',
hello: {
version: helloVersion,
auth: {
url,
params: this.settings.helloAuthParams[helloVersion],
},
},
}
if (features.length > 0) {
msg.hello.features = features
}
if (this.settings.helloAuthParams.internal) {
msg.hello.auth.type = 'internal'
msg.hello.auth.params = this.settings.helloAuthParams.internal
}
this.doSend(msg, this.helloResponseReceived.bind(this))
})
}
Signaling.Standalone.prototype.helloResponseReceived = function(data) {
@ -1354,7 +1369,7 @@ Signaling.Standalone.prototype.processRoomEvent = function(data) {
let userListIsDirty = false
for (i = 0; i < joinedUsers.length; i++) {
this.joinedUsers[joinedUsers[i].sessionid] = true
this.joinedUsers[joinedUsers[i].sessionid] = joinedUsers[i]
delete leftUsers[joinedUsers[i].sessionid]
if (this.settings.userId && joinedUsers[i].userid === this.settings.userId) {

30
src/utils/webrtc/index.js

@ -23,6 +23,7 @@ import { fetchSignalingSettings } from '../../services/signalingService.js'
import store from '../../store/index.js'
import { isSafari } from '../browserCheck.ts'
import CancelableRequest from '../cancelableRequest.js'
import Encryption from '../e2ee/encryption.js'
import Signaling from '../signaling.js'
import SignalingTypingHandler from '../SignalingTypingHandler.js'
@ -44,6 +45,8 @@ const signalingTypingHandler = enableTypingIndicators ? new SignalingTypingHandl
let cancelFetchSignalingSettings = null
let signaling = null
let tokensInSignaling = {}
/** @type {Encryption} */
let encryption = null
/**
* @param {string} token The token of the conversation to get the signaling settings for
@ -112,6 +115,10 @@ async function connectSignaling(token) {
}
signaling.disconnect()
signaling = null
if (encryption) {
encryption.close()
encryption = null
}
tokensInSignaling = {}
}
@ -125,6 +132,26 @@ async function connectSignaling(token) {
})
signalingTypingHandler?.setSignaling(signaling)
if (encryption) {
encryption.close()
encryption = null
}
if (Encryption.isEnabled()) {
let supported
try {
supported = await Encryption.isSupported()
} catch (e) {
console.error('Encryption is not supported', e)
}
if (supported) {
encryption = new Encryption(signaling)
if (webRtc) {
encryption.setWebRtc(webRtc)
}
}
}
} else {
signaling.setSettings(settings)
}
@ -171,6 +198,9 @@ function setupWebRtc() {
}
webRtc = initWebRtc(signaling, callParticipantCollection, localCallParticipantModel)
if (encryption) {
encryption.setWebRtc(webRtc)
}
localCallParticipantModel.setWebRtc(webRtc)
localMediaModel.setWebRtc(webRtc)

Loading…
Cancel
Save