351 lines
16 KiB
JavaScript
351 lines
16 KiB
JavaScript
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 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) => {
|
|
// `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) {
|
|
this.logger?.warn(`Received key over transport ${membership.userId}:${membership.deviceId} at index ${index} but media keys are disabled`);
|
|
return;
|
|
}
|
|
this.logger?.debug(`Received key over transport ${membership.userId}:${membership.deviceId} at index ${index}`);
|
|
|
|
// We received a new key, notify the video layer of this new key so that it can decrypt the frames properly.
|
|
const keyBin = decodeBase64(keyBase64Encoded);
|
|
const candidateInboundSession = {
|
|
key: keyBin,
|
|
membership,
|
|
keyIndex: index,
|
|
creationTS: timestamp
|
|
};
|
|
const outdated = this.keyBuffer.isOutdated(membership, candidateInboundSession);
|
|
if (!outdated) {
|
|
this.addKeyToParticipant(candidateInboundSession.key, candidateInboundSession.keyIndex, candidateInboundSession.membership);
|
|
} else {
|
|
this.logger?.info(`Received an out of order key for ${membership.userId}:${membership.deviceId}, dropping it`);
|
|
}
|
|
});
|
|
this.ownMembership = ownMembership;
|
|
this.getMemberships = getMemberships;
|
|
this.transport = transport;
|
|
this.onEncryptionKeysChanged = onEncryptionKeysChanged;
|
|
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
|
|
this.rtcIdentityProvider = rtcBackendIdProvider ?? computeRtcIdentityRaw;
|
|
}
|
|
async getOwnRtcBackendIdentity() {
|
|
if (this.ownRtcBackendIdentityCache) return this.ownRtcBackendIdentityCache;
|
|
if (this.useHashedRtcBackendIdentity) {
|
|
const {
|
|
userId,
|
|
deviceId,
|
|
memberId
|
|
} = this.ownMembership;
|
|
this.logger?.info(
|
|
// If we see this log multiple times, we need to reconsider the precompute call of getOwnRtcBackendIdentity
|
|
`Computing RTC backend identity for ${userId}:${deviceId}:${memberId} (SHOULD ONLY BE CALLED ONCE)`);
|
|
this.ownRtcBackendIdentityCache = await this.rtcIdentityProvider(userId, deviceId, memberId);
|
|
} else {
|
|
this.ownRtcBackendIdentityCache = `${this.ownMembership.userId}:${this.ownMembership.deviceId}`;
|
|
}
|
|
return this.ownRtcBackendIdentityCache;
|
|
}
|
|
getEncryptionKeys() {
|
|
return new Map(this.participantKeyRings);
|
|
}
|
|
checkKeysWithoutMatchingRTCMembership() {
|
|
const keyInfoTemp = this.keysWithoutMatchingRTCMembership;
|
|
this.keysWithoutMatchingRTCMembership = [];
|
|
keyInfoTemp.forEach(keyInfo => {
|
|
this.addKeyToParticipant(keyInfo.key, keyInfo.keyIndex, keyInfo.membership);
|
|
});
|
|
}
|
|
addKeyToParticipant(key, keyIndex, membership) {
|
|
const knownRtcMembership = this.getMemberships();
|
|
const fullMembership = knownRtcMembership.find(member => member.userId === membership.userId && member.deviceId === membership.deviceId);
|
|
if (!fullMembership) {
|
|
this.logger?.info(`No matching RTC membership for key from ${membership.userId}:${membership.deviceId}, delaying key addition`);
|
|
this.keysWithoutMatchingRTCMembership.push({
|
|
key,
|
|
keyIndex,
|
|
membership
|
|
});
|
|
return;
|
|
}
|
|
this.addKeyToParticipantWithBackendIdentity(key, keyIndex, membership, fullMembership.rtcBackendIdentity);
|
|
}
|
|
addKeyToParticipantWithBackendIdentity(key, keyIndex, membership, rtcBackendIdentity) {
|
|
const 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) {
|
|
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? true; // default to true
|
|
this.useHashedRtcBackendIdentity = joinConfig?.unstableSendStickyEvents ?? false;
|
|
this.useKeyDelay = joinConfig?.useKeyDelay ?? 1000;
|
|
this.keyRotationGracePeriodMs = joinConfig?.keyRotationGracePeriodMs ?? 10000;
|
|
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
|
void this.getOwnRtcBackendIdentity(); // precompute own identity
|
|
|
|
this.logger?.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) {
|
|
this.logger?.debug(`No active rollout, start a new one`);
|
|
// start a rollout
|
|
this.currentKeyDistributionPromise = this.rolloutOutboundKey().then(() => {
|
|
this.logger?.debug(`Rollout completed`);
|
|
this.currentKeyDistributionPromise = null;
|
|
if (this.needToEnsureKeyAgain) {
|
|
this.logger?.debug(`New Rollout needed`);
|
|
this.needToEnsureKeyAgain = false;
|
|
// rollout a new one
|
|
this.ensureKeyDistribution();
|
|
}
|
|
});
|
|
} else {
|
|
// 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.logger?.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(oldMemberships = []) {
|
|
this.logger?.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();
|
|
}
|
|
async rolloutOutboundKey() {
|
|
const isFirstKey = this.outboundSession == null;
|
|
if (isFirstKey) {
|
|
// create the first key
|
|
const firstKey = {
|
|
key: this.generateRandomKey(),
|
|
creationTS: Date.now(),
|
|
sharedWith: [],
|
|
keyId: 0
|
|
};
|
|
this.outboundSession = firstKey;
|
|
this.addKeyToParticipantWithBackendIdentity(firstKey.key, firstKey.keyId, this.ownMembership, await this.getOwnRtcBackendIdentity());
|
|
}
|
|
// get current memberships
|
|
const toShareWith = this.getMemberships().filter(membership => {
|
|
return membership.sender != undefined;
|
|
}).map(membership => {
|
|
return {
|
|
userId: membership.sender,
|
|
deviceId: membership.deviceId,
|
|
membershipTs: membership.createdTs()
|
|
};
|
|
});
|
|
let alreadySharedWith = this.outboundSession?.sharedWith ?? [];
|
|
|
|
// 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));
|
|
const anyLeft = alreadySharedWith.filter(x => !toShareWith.some(o => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs));
|
|
const anyJoined = toShareWith.filter(x => !alreadySharedWith.some(o => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs));
|
|
let toDistributeTo = [];
|
|
let outboundKey;
|
|
let hasKeyChanged = false;
|
|
if (anyLeft.length > 0) {
|
|
// We need to rotate the key
|
|
const newOutboundKey = this.createNewOutboundSession();
|
|
hasKeyChanged = true;
|
|
toDistributeTo = toShareWith;
|
|
outboundKey = newOutboundKey;
|
|
} else if (anyJoined.length > 0) {
|
|
const now = Date.now();
|
|
const keyAge = now - this.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 < this.keyRotationGracePeriodMs) {
|
|
// keep the same key
|
|
// XXX In the future we want to distribute a ratcheted key, not the current one
|
|
this.logger?.debug(`New joiners detected, but the key is recent enough (age:${keyAge}), keeping it`);
|
|
toDistributeTo = anyJoined;
|
|
outboundKey = this.outboundSession;
|
|
} else {
|
|
// We need to rotate the key
|
|
this.logger?.debug(`New joiners detected, rotating the key`);
|
|
const newOutboundKey = this.createNewOutboundSession();
|
|
hasKeyChanged = true;
|
|
toDistributeTo = toShareWith;
|
|
outboundKey = newOutboundKey;
|
|
}
|
|
} else {
|
|
// no changes
|
|
return;
|
|
}
|
|
try {
|
|
this.logger?.trace(`Sending key...`);
|
|
await this.transport.sendKey(encodeBase64(outboundKey.key), outboundKey.keyId, toDistributeTo);
|
|
outboundKey.sharedWith.push(...toDistributeTo);
|
|
this.logger?.trace(`key index:${outboundKey.keyId} sent to ${outboundKey.sharedWith.map(m => `${m.userId}:${m.deviceId}`).join(",")}`);
|
|
if (hasKeyChanged) {
|
|
// 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.
|
|
this.logger?.trace(`Delay Rollout for key:${outboundKey.keyId}...`);
|
|
await sleep(this.useKeyDelay);
|
|
this.logger?.trace(`...Delayed rollout of index:${outboundKey.keyId} `);
|
|
this.addKeyToParticipantWithBackendIdentity(outboundKey.key, outboundKey.keyId, this.ownMembership, await this.getOwnRtcBackendIdentity());
|
|
}
|
|
} catch (err) {
|
|
this.logger?.error(`Failed to rollout key`, err);
|
|
}
|
|
}
|
|
createNewOutboundSession() {
|
|
const newOutboundKey = {
|
|
key: this.generateRandomKey(),
|
|
creationTS: Date.now(),
|
|
sharedWith: [],
|
|
keyId: this.nextKeyIndex()
|
|
};
|
|
this.logger?.info(`creating new outbound key index:${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() {
|
|
const key = new Uint8Array(16);
|
|
globalThis.crypto.getRandomValues(key);
|
|
return key;
|
|
}
|
|
}
|
|
//# sourceMappingURL=RTCEncryptionManager.js.map
|