UNPKG

matrix-js-sdk

Version:
376 lines (366 loc) 20.2 kB
import _asyncToGenerator from "@babel/runtime/helpers/asyncToGenerator"; import _defineProperty from "@babel/runtime/helpers/defineProperty"; /* Copyright 2025-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { getEncryptionKeyMapKey } from "./EncryptionManager.js"; import { decodeBase64, encodeBase64 } from "../base64.js"; import { KeyTransportEvents } from "./IKeyTransport.js"; import { sleep } from "../utils.js"; import { OutdatedKeyFilter } from "./utils.js"; import { computeRtcIdentityRaw } from "./membershipData/rtc.js"; /** * RTCEncryptionManager is used to manage the encryption keys for a call. * * It is responsible for distributing the keys to the other participants and rotating the keys if needed. * * This manager when used with to-device transport will share the existing key only to new joiners, and rotate * if there is a leaver. * * XXX In the future we want to distribute a ratcheted key not the current one for new joiners. */ export class RTCEncryptionManager { /** * * @param ownMembership - our own membership info * @param getMemberships - function to get current memberships * @param transport - key transport (room or to-device) * @param statistics - statistics collector * @param onEncryptionKeysChanged - callback to notify the media layer of new keys * @param parentLogger - optional parent logger * @param rtcBackendIdProvider - A function to compute the rtc backend identity, exposed for testing purposes */ constructor(ownMembership, getMemberships, transport, // Callback to notify the media layer of new keys onEncryptionKeysChanged, parentLogger, rtcBackendIdProvider) { this.ownMembership = ownMembership; this.getMemberships = getMemberships; this.transport = transport; this.onEncryptionKeysChanged = onEncryptionKeysChanged; // This is a stop-gap solution for now. The preferred way to handle this case would be instead // to create a NoOpEncryptionManager that does nothing and use it for the session. // This will be done when removing the legacy EncryptionManager. _defineProperty(this, "manageMediaKeys", false); _defineProperty(this, "useHashedRtcBackendIdentity", false); _defineProperty(this, "ownRtcBackendIdentityCache", void 0); /** * Store the key rings for each participant. * The encryption manager stores the keys because the application layer might not be ready yet to handle the keys. * The keys are stored and can be retrieved later when the application layer is ready {@link RTCEncryptionManager#getEncryptionKeys}. */ _defineProperty(this, "participantKeyRings", new Map()); // The current per-sender media key for this device _defineProperty(this, "outboundSession", null); /** * Ensures that there is only one distribute operation at a time for that call. */ _defineProperty(this, "currentKeyDistributionPromise", null); /** * The time to wait before using the outbound session after it has been distributed. * This is to ensure that the key is delivered to all participants before it is used. * When creating the first key, this is set to 0 so that the key can be used immediately. */ _defineProperty(this, "useKeyDelay", 5000); /** * We want to avoid rolling out a new outbound key when the previous one was created less than `keyRotationGracePeriodMs` milliseconds ago. * This is to avoid expensive key rotations when users quickly join the call in a row. * * This must be higher than `useKeyDelay` to have an effect. * If it is lower, the current key will always be older than the grace period. * @private */ _defineProperty(this, "keyRotationGracePeriodMs", 10000); /** * If a new key distribution is being requested while one is going on, we will set this flag to true. * This will ensure that a new round is started after the current one. * @private */ _defineProperty(this, "needToEnsureKeyAgain", false); /** * There is a possibility that keys arrive in the wrong order. * For example, after a quick join/leave/join, there will be 2 keys of index 0 distributed, and * if they are received in the wrong order, the stream won't be decryptable. * For that reason we keep a small buffer of keys for a limited time to disambiguate. * @private */ _defineProperty(this, "keyBuffer", new OutdatedKeyFilter()); _defineProperty(this, "logger", undefined); _defineProperty(this, "rtcIdentityProvider", void 0); _defineProperty(this, "keysWithoutMatchingRTCMembership", []); _defineProperty(this, "onNewKeyReceived", (membership, keyBase64Encoded, index, timestamp) => { var _this$logger2; // `manageMediaKeys` is a stop-gap solution for now. The preferred way to handle this case would be instead // to create a NoOpEncryptionManager that does nothing and use it for the session. // This will be done when removing the legacy EncryptionManager. if (!this.manageMediaKeys) { var _this$logger; (_this$logger = this.logger) === null || _this$logger === void 0 || _this$logger.warn("Received key over transport ".concat(membership.userId, ":").concat(membership.deviceId, " at index ").concat(index, " but media keys are disabled")); return; } (_this$logger2 = this.logger) === null || _this$logger2 === void 0 || _this$logger2.debug("Received key over transport ".concat(membership.userId, ":").concat(membership.deviceId, " at index ").concat(index)); // We received a new key, notify the video layer of this new key so that it can decrypt the frames properly. var keyBin = decodeBase64(keyBase64Encoded); var candidateInboundSession = { key: keyBin, membership, keyIndex: index, creationTS: timestamp }; var outdated = this.keyBuffer.isOutdated(membership, candidateInboundSession); if (!outdated) { this.addKeyToParticipant(candidateInboundSession.key, candidateInboundSession.keyIndex, candidateInboundSession.membership); } else { var _this$logger3; (_this$logger3 = this.logger) === null || _this$logger3 === void 0 || _this$logger3.info("Received an out of order key for ".concat(membership.userId, ":").concat(membership.deviceId, ", dropping it")); } }); this.logger = parentLogger === null || parentLogger === void 0 ? void 0 : parentLogger.getChild("[EncryptionManager]"); this.rtcIdentityProvider = rtcBackendIdProvider !== null && rtcBackendIdProvider !== void 0 ? rtcBackendIdProvider : computeRtcIdentityRaw; } getOwnRtcBackendIdentity() { var _this = this; return _asyncToGenerator(function* () { if (_this.ownRtcBackendIdentityCache) return _this.ownRtcBackendIdentityCache; if (_this.useHashedRtcBackendIdentity) { var _this$logger4; var { userId, deviceId, memberId } = _this.ownMembership; (_this$logger4 = _this.logger) === null || _this$logger4 === void 0 || _this$logger4.info(// If we see this log multiple times, we need to reconsider the precompute call of getOwnRtcBackendIdentity "Computing RTC backend identity for ".concat(userId, ":").concat(deviceId, ":").concat(memberId, " (SHOULD ONLY BE CALLED ONCE)")); _this.ownRtcBackendIdentityCache = yield _this.rtcIdentityProvider(userId, deviceId, memberId); } else { _this.ownRtcBackendIdentityCache = "".concat(_this.ownMembership.userId, ":").concat(_this.ownMembership.deviceId); } return _this.ownRtcBackendIdentityCache; })(); } getEncryptionKeys() { return new Map(this.participantKeyRings); } checkKeysWithoutMatchingRTCMembership() { var keyInfoTemp = this.keysWithoutMatchingRTCMembership; this.keysWithoutMatchingRTCMembership = []; keyInfoTemp.forEach(keyInfo => { this.addKeyToParticipant(keyInfo.key, keyInfo.keyIndex, keyInfo.membership); }); } addKeyToParticipant(key, keyIndex, membership) { var knownRtcMembership = this.getMemberships(); var fullMembership = knownRtcMembership.find(member => member.userId === membership.userId && member.deviceId === membership.deviceId); if (!fullMembership) { var _this$logger5; (_this$logger5 = this.logger) === null || _this$logger5 === void 0 || _this$logger5.info("No matching RTC membership for key from ".concat(membership.userId, ":").concat(membership.deviceId, ", delaying key addition")); this.keysWithoutMatchingRTCMembership.push({ key, keyIndex, membership }); return; } this.addKeyToParticipantWithBackendIdentity(key, keyIndex, membership, fullMembership.rtcBackendIdentity); } addKeyToParticipantWithBackendIdentity(key, keyIndex, membership, rtcBackendIdentity) { var mapKey = getEncryptionKeyMapKey(membership); if (!this.participantKeyRings.has(mapKey)) { this.participantKeyRings.set(mapKey, []); } this.participantKeyRings.get(mapKey).push({ key, keyIndex, membership, rtcBackendIdentity }); this.onEncryptionKeysChanged(key, keyIndex, membership, rtcBackendIdentity); } join(joinConfig) { var _joinConfig$manageMed, _joinConfig$unstableS, _joinConfig$useKeyDel, _joinConfig$keyRotati, _this$logger6; this.manageMediaKeys = (_joinConfig$manageMed = joinConfig === null || joinConfig === void 0 ? void 0 : joinConfig.manageMediaKeys) !== null && _joinConfig$manageMed !== void 0 ? _joinConfig$manageMed : true; // default to true this.useHashedRtcBackendIdentity = (_joinConfig$unstableS = joinConfig === null || joinConfig === void 0 ? void 0 : joinConfig.unstableSendStickyEvents) !== null && _joinConfig$unstableS !== void 0 ? _joinConfig$unstableS : false; this.useKeyDelay = (_joinConfig$useKeyDel = joinConfig === null || joinConfig === void 0 ? void 0 : joinConfig.useKeyDelay) !== null && _joinConfig$useKeyDel !== void 0 ? _joinConfig$useKeyDel : 1000; this.keyRotationGracePeriodMs = (_joinConfig$keyRotati = joinConfig === null || joinConfig === void 0 ? void 0 : joinConfig.keyRotationGracePeriodMs) !== null && _joinConfig$keyRotati !== void 0 ? _joinConfig$keyRotati : 10000; this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived); void this.getOwnRtcBackendIdentity(); // precompute own identity (_this$logger6 = this.logger) === null || _this$logger6 === void 0 || _this$logger6.info("Joining room"); this.transport.start(); } leave() { this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived); this.transport.stop(); this.participantKeyRings.clear(); } /** * Will ensure that a new key is distributed and used to encrypt our media. * If there is already a key distribution in progress, it will schedule a new distribution round just after the current one is completed. * If this function is called repeatedly while a distribution is in progress, * the calls will be coalesced to a single new distribution (that will start just after the current one has completed). */ ensureKeyDistribution() { // `manageMediaKeys` is a stop-gap solution for now. The preferred way to handle this case would be instead // to create a NoOpEncryptionManager that does nothing and use it for the session. // This will be done when removing the legacy EncryptionManager. if (!this.manageMediaKeys) return; if (this.currentKeyDistributionPromise == null) { var _this$logger7; (_this$logger7 = this.logger) === null || _this$logger7 === void 0 || _this$logger7.debug("No active rollout, start a new one"); // start a rollout this.currentKeyDistributionPromise = this.rolloutOutboundKey().then(() => { var _this$logger8; (_this$logger8 = this.logger) === null || _this$logger8 === void 0 || _this$logger8.debug("Rollout completed"); this.currentKeyDistributionPromise = null; if (this.needToEnsureKeyAgain) { var _this$logger9; (_this$logger9 = this.logger) === null || _this$logger9 === void 0 || _this$logger9.debug("New Rollout needed"); this.needToEnsureKeyAgain = false; // rollout a new one this.ensureKeyDistribution(); } }); } else { var _this$logger0; // There is a rollout in progress, but a key rotation is requested (could be caused by a ownMembership change) // Remember that a new rotation is needed after the current one. (_this$logger0 = this.logger) === null || _this$logger0 === void 0 || _this$logger0.debug("Rollout in progress, a new rollout will be started after the current one"); this.needToEnsureKeyAgain = true; } } /** * Called when the ownMembership of the call changes. * This encryption manager is very basic, it will rotate the key everytime this is called. * @param oldMemberships - This parameter is not used here, but it is kept for compatibility with the interface. */ onMembershipsUpdate() { var _this$logger1; var oldMemberships = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; (_this$logger1 = this.logger) === null || _this$logger1 === void 0 || _this$logger1.trace("onMembershipsUpdate"); // Ensure the key is distributed. This will be no-op if the key is already being distributed to everyone. // If there is an ongoing distribution, it will be completed before a new one is started. this.ensureKeyDistribution(); // ensure key emission to the rtc backend this.checkKeysWithoutMatchingRTCMembership(); } rolloutOutboundKey() { var _this2 = this; return _asyncToGenerator(function* () { var _this2$outboundSessio, _this2$outboundSessio2; var isFirstKey = _this2.outboundSession == null; if (isFirstKey) { // create the first key var firstKey = { key: _this2.generateRandomKey(), creationTS: Date.now(), sharedWith: [], keyId: 0 }; _this2.outboundSession = firstKey; _this2.addKeyToParticipantWithBackendIdentity(firstKey.key, firstKey.keyId, _this2.ownMembership, yield _this2.getOwnRtcBackendIdentity()); } // get current memberships var toShareWith = _this2.getMemberships().filter(membership => { return membership.sender != undefined; }).map(membership => { return { userId: membership.sender, deviceId: membership.deviceId, membershipTs: membership.createdTs() }; }); var alreadySharedWith = (_this2$outboundSessio = (_this2$outboundSessio2 = _this2.outboundSession) === null || _this2$outboundSessio2 === void 0 ? void 0 : _this2$outboundSessio2.sharedWith) !== null && _this2$outboundSessio !== void 0 ? _this2$outboundSessio : []; // Some users might have rotate their ownMembership event (formally called fingerprint) meaning they might have // clear their key. Reset the `alreadySharedWith` flag for them. alreadySharedWith = alreadySharedWith.filter(x => // If there was a member with same userId and deviceId but different membershipTs, we need to clear it !toShareWith.some(o => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs != o.membershipTs)); var anyLeft = alreadySharedWith.filter(x => !toShareWith.some(o => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs)); var anyJoined = toShareWith.filter(x => !alreadySharedWith.some(o => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs)); var toDistributeTo = []; var outboundKey; var hasKeyChanged = false; if (anyLeft.length > 0) { // We need to rotate the key var newOutboundKey = _this2.createNewOutboundSession(); hasKeyChanged = true; toDistributeTo = toShareWith; outboundKey = newOutboundKey; } else if (anyJoined.length > 0) { var now = Date.now(); var keyAge = now - _this2.outboundSession.creationTS; // If the current key is recently created (less than `keyRotationGracePeriodMs`), we can keep it and just distribute it to the new joiners. if (keyAge < _this2.keyRotationGracePeriodMs) { var _this2$logger; // keep the same key // XXX In the future we want to distribute a ratcheted key, not the current one (_this2$logger = _this2.logger) === null || _this2$logger === void 0 || _this2$logger.debug("New joiners detected, but the key is recent enough (age:".concat(keyAge, "), keeping it")); toDistributeTo = anyJoined; outboundKey = _this2.outboundSession; } else { var _this2$logger2; // We need to rotate the key (_this2$logger2 = _this2.logger) === null || _this2$logger2 === void 0 || _this2$logger2.debug("New joiners detected, rotating the key"); var _newOutboundKey = _this2.createNewOutboundSession(); hasKeyChanged = true; toDistributeTo = toShareWith; outboundKey = _newOutboundKey; } } else { // no changes return; } try { var _this2$logger3, _this2$logger4; (_this2$logger3 = _this2.logger) === null || _this2$logger3 === void 0 || _this2$logger3.trace("Sending key..."); yield _this2.transport.sendKey(encodeBase64(outboundKey.key), outboundKey.keyId, toDistributeTo); outboundKey.sharedWith.push(...toDistributeTo); (_this2$logger4 = _this2.logger) === null || _this2$logger4 === void 0 || _this2$logger4.trace("key index:".concat(outboundKey.keyId, " sent to ").concat(outboundKey.sharedWith.map(m => "".concat(m.userId, ":").concat(m.deviceId)).join(","))); if (hasKeyChanged) { var _this2$logger5, _this2$logger6; // Delay a bit before using this key // It is recommended not to start using a key immediately but instead wait for a short time to make sure it is delivered. (_this2$logger5 = _this2.logger) === null || _this2$logger5 === void 0 || _this2$logger5.trace("Delay Rollout for key:".concat(outboundKey.keyId, "...")); yield sleep(_this2.useKeyDelay); (_this2$logger6 = _this2.logger) === null || _this2$logger6 === void 0 || _this2$logger6.trace("...Delayed rollout of index:".concat(outboundKey.keyId, " ")); _this2.addKeyToParticipantWithBackendIdentity(outboundKey.key, outboundKey.keyId, _this2.ownMembership, yield _this2.getOwnRtcBackendIdentity()); } } catch (err) { var _this2$logger7; (_this2$logger7 = _this2.logger) === null || _this2$logger7 === void 0 || _this2$logger7.error("Failed to rollout key", err); } })(); } createNewOutboundSession() { var _this$logger10; var newOutboundKey = { key: this.generateRandomKey(), creationTS: Date.now(), sharedWith: [], keyId: this.nextKeyIndex() }; (_this$logger10 = this.logger) === null || _this$logger10 === void 0 || _this$logger10.info("creating new outbound key index:".concat(newOutboundKey.keyId)); // Set this new key as the current one this.outboundSession = newOutboundKey; return newOutboundKey; } nextKeyIndex() { if (this.outboundSession) { return (this.outboundSession.keyId + 1) % 256; } return 0; } generateRandomKey() { var key = new Uint8Array(16); globalThis.crypto.getRandomValues(key); return key; } } //# sourceMappingURL=RTCEncryptionManager.js.map