1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-06 12:02:40 +03:00

MatrixRTC: Refactor | Introduce a new Encryption manager (used with experimental to device transport) (#4799)

* refactor: New encryption manager BasicEncryptionManager for todevice

fixup: bad do not commit

* fix: ToDevice transport not setting the sent_ts

* test: BasicEncryptionManager add statistics tests

* code review

* feat: Encryption manager just reshare on new joiner

* refactor: Rename BasicEncryptionManger to RTCEncryptionManager

* fixup: RTC experimental todevice should use new encryption mgr

* fixup: use proper logger hierarchy

* fixup: RTC rollout first key asap even if no members to send to

* fixup: RTC add test for first key use

* fixup! emitting outbound key before anyone registered

* fix: quick patch for transport switch, need test

* test: RTC encryption manager, add test for transport switch

* post rebase fix

* Remove bad corepack commit

* review: cleaning, renaming

* review: cleaning and renaming

* stop using root logger in favor of a parent logger

* post merge fix broken test

* remove corepack again

* fix reverted changes after a merge

* review: Properly deprecate getEncryptionKeys

* review: rename ensureMediaKeyDistribution to ensureKeyDistribution

* review: use OutdatedKeyFilter instead of KeyBuffer
This commit is contained in:
Valere Fedronic
2025-07-08 14:43:16 +02:00
committed by GitHub
parent 137379b7b7
commit e5c8c20a34
14 changed files with 1165 additions and 65 deletions

View File

@@ -6,6 +6,7 @@ import { safeGetRetryAfterMs } from "../http-api/errors.ts";
import { type CallMembership } from "./CallMembership.ts";
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
import { isMyMembership, type Statistics } from "./types.ts";
import { getParticipantId } from "./utils.ts";
import {
type EnabledTransports,
RoomAndToDeviceEvents,
@@ -42,6 +43,10 @@ export interface IEncryptionManager {
*
* @returns A map where the keys are identifiers and the values are arrays of
* objects containing encryption keys and their associated timestamps.
* @deprecated This method is used internally for testing. It is also used to re-emit keys when there is a change
* of RTCSession (matrixKeyProvider#setRTCSession) -Not clear why/when switch RTCSession would occur-. Note that if we switch focus, we do keep the same RTC session,
* so no need to re-emit. But it requires the encryption manager to store all keys of all participants, and this is already done
* by the key provider. We don't want to add another layer of key storage.
*/
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
}
@@ -82,6 +87,7 @@ export class EncryptionManager implements IEncryptionManager {
private latestGeneratedKeyIndex = -1;
private joinConfig: EncryptionConfig | undefined;
private logger: Logger;
public constructor(
private userId: string,
private deviceId: string,
@@ -280,7 +286,18 @@ export class EncryptionManager implements IEncryptionManager {
try {
this.statistics.counters.roomEventEncryptionKeysSent += 1;
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships());
const targets = this.getMemberships()
.filter((membership) => {
return membership.sender != undefined;
})
.map((membership) => {
return {
userId: membership.sender!,
deviceId: membership.deviceId,
membershipTs: membership.createdTs(),
};
});
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, targets);
this.logger.debug(
`sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
this.encryptionKeys,
@@ -408,8 +425,6 @@ export class EncryptionManager implements IEncryptionManager {
};
}
const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`;
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
if (a === b) return true;
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type CallMembership } from "./CallMembership.ts";
import { type ParticipantDeviceInfo } from "./types.ts";
export enum KeyTransportEvents {
ReceivedKeys = "received_keys",
@@ -45,7 +45,7 @@ export interface IKeyTransport {
* @param index
* @param members - The participants that should get they key
*/
sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void>;
sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void>;
/** Subscribe to keys from this transport. */
on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;

View File

@@ -30,6 +30,7 @@ import { logDurationSync } from "../utils.ts";
import { type Statistics } from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import type { IMembershipManager } from "./IMembershipManager.ts";
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
import {
RoomAndToDeviceEvents,
type RoomAndToDeviceEventsHandlerMap,
@@ -399,6 +400,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
// Create Encryption manager
let transport;
if (joinConfig?.useExperimentalToDeviceTransport) {
this.logger.info("Using experimental to-device transport for encryption keys");
this.logger.info("Using to-device with room fallback transport for encryption keys");
const [uId, dId] = [this.client.getUserId()!, this.client.getDeviceId()!];
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
@@ -409,20 +411,40 @@ export class MatrixRTCSession extends TypedEventEmitter<
// Expose the changes so the ui can display the currently used transport.
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]);
this.encryptionManager = new RTCEncryptionManager(
this.client.getUserId()!,
this.client.getDeviceId()!,
() => this.memberships,
transport,
this.statistics,
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
participantId,
);
},
this.logger,
);
} else {
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
this.encryptionManager = new EncryptionManager(
this.client.getUserId()!,
this.client.getDeviceId()!,
() => this.memberships,
transport,
this.statistics,
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
participantId,
);
},
);
}
this.encryptionManager = new EncryptionManager(
this.client.getUserId()!,
this.client.getDeviceId()!,
() => this.memberships,
transport,
this.statistics,
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
},
this.logger,
);
}
// Join!

View File

@@ -0,0 +1,329 @@
/*
Copyright 2025 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 { type IEncryptionManager } from "./EncryptionManager.ts";
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
import { type CallMembership } from "./CallMembership.ts";
import { decodeBase64, encodeBase64 } from "../base64.ts";
import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts";
import { type Logger } from "../logger.ts";
import { sleep } from "../utils.ts";
import type {
InboundEncryptionSession,
OutboundEncryptionSession,
ParticipantDeviceInfo,
ParticipantId,
Statistics,
} from "./types.ts";
import { getParticipantId, OutdatedKeyFilter } from "./utils.ts";
import {
type EnabledTransports,
RoomAndToDeviceEvents,
RoomAndToDeviceTransport,
} from "./RoomAndToDeviceKeyTransport.ts";
/**
* 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 implements IEncryptionManager {
// The current per-sender media key for this device
private outboundSession: OutboundEncryptionSession | null = null;
/**
* Ensures that there is only one distribute operation at a time for that call.
*/
private currentKeyDistributionPromise: Promise<void> | null = 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.
*/
private delayRolloutTimeMillis = 1000;
/**
* 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
*/
private 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
*/
private keyBuffer = new OutdatedKeyFilter();
private logger: Logger | undefined = undefined;
public constructor(
private userId: string,
private deviceId: string,
private getMemberships: () => CallMembership[],
private transport: IKeyTransport,
private statistics: Statistics,
// Callback to notify the media layer of new keys
private onEncryptionKeysChanged: (
keyBin: Uint8Array,
encryptionKeyIndex: number,
participantId: ParticipantId,
) => void,
parentLogger?: Logger,
) {
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
}
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
// This is deprecated should be ignored. Only used by tests?
return new Map();
}
public join(joinConfig: EncryptionConfig | undefined): void {
this.logger?.info(`Joining room`);
this.delayRolloutTimeMillis = joinConfig?.useKeyDelay ?? 1000;
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
// Deprecate RoomKeyTransport: this can get removed.
if (this.transport instanceof RoomAndToDeviceTransport) {
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
}
this.transport.start();
}
public leave(): void {
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.stop();
}
// Temporary for backwards compatibility
// TODO: Remove this in the future
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
this.logger?.info("Transport change detected, restarting key distribution");
if (this.currentKeyDistributionPromise) {
this.currentKeyDistributionPromise
.then(() => {
if (this.outboundSession) {
this.outboundSession.sharedWith = [];
this.ensureKeyDistribution();
}
})
.catch((e) => {
this.logger?.error("Failed to restart key distribution", e);
});
} else {
if (this.outboundSession) {
this.outboundSession.sharedWith = [];
this.ensureKeyDistribution();
}
}
};
/**
* 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).
*/
private ensureKeyDistribution(): void {
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 membership 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;
}
}
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
this.logger?.debug(`Received key over transport ${userId}:${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 participantId = getParticipantId(userId, deviceId);
const keyBin = decodeBase64(keyBase64Encoded);
const candidateInboundSession: InboundEncryptionSession = {
key: keyBin,
participantId,
keyIndex: index,
creationTS: timestamp,
};
const outdated = this.keyBuffer.isOutdated(participantId, candidateInboundSession);
if (!outdated) {
this.onEncryptionKeysChanged(
candidateInboundSession.key,
candidateInboundSession.keyIndex,
candidateInboundSession.participantId,
);
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
} else {
this.logger?.info(`Received an out of order key for ${userId}:${deviceId}, dropping it`);
}
};
/**
* Called when the membership of the call changes.
* This encryption manager is very basic, it will rotate the key everytime this is called.
* @param oldMemberships
*/
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
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();
}
private async rolloutOutboundKey(): Promise<void> {
const isFirstKey = this.outboundSession == null;
if (isFirstKey) {
// create the first key
this.outboundSession = {
key: this.generateRandomKey(),
creationTS: Date.now(),
sharedWith: [],
keyId: 0,
};
this.onEncryptionKeysChanged(
this.outboundSession.key,
this.outboundSession.keyId,
getParticipantId(this.userId, this.deviceId),
);
}
// get current memberships
const toShareWith: ParticipantDeviceInfo[] = 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 membership 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: ParticipantDeviceInfo[] = [];
let outboundKey: OutboundEncryptionSession;
let hasKeyChanged = false;
if (anyLeft.length > 0) {
// We need to rotate the key
const newOutboundKey: OutboundEncryptionSession = {
key: this.generateRandomKey(),
creationTS: Date.now(),
sharedWith: [],
keyId: this.nextKeyIndex(),
};
hasKeyChanged = true;
this.logger?.info(`creating new outbound key index:${newOutboundKey.keyId}`);
// Set this new key as the current one
this.outboundSession = newOutboundKey;
// Send
toDistributeTo = toShareWith;
outboundKey = newOutboundKey;
} else if (anyJoined.length > 0) {
// keep the same key
// XXX In the future we want to distribute a ratcheted key not the current one
toDistributeTo = anyJoined;
outboundKey = this.outboundSession!;
} else {
// no changes
return;
}
try {
this.logger?.trace(`Sending key...`);
await this.transport.sendKey(encodeBase64(outboundKey.key), outboundKey.keyId, toDistributeTo);
this.statistics.counters.roomEventEncryptionKeysSent += 1;
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.delayRolloutTimeMillis);
this.logger?.trace(`...Delayed rollout of index:${outboundKey.keyId} `);
this.onEncryptionKeysChanged(
outboundKey.key,
outboundKey.keyId,
getParticipantId(this.userId, this.deviceId),
);
}
} catch (err) {
this.logger?.error(`Failed to rollout key`, err);
}
}
private nextKeyIndex(): number {
if (this.outboundSession) {
return (this.outboundSession!.keyId + 1) % 256;
}
return 0;
}
private generateRandomKey(): Uint8Array {
const key = new Uint8Array(16);
globalThis.crypto.getRandomValues(key);
return key;
}
}

View File

@@ -16,10 +16,10 @@ limitations under the License.
import { logger as rootLogger, type Logger } from "../logger.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
import { type CallMembership } from "./CallMembership.ts";
import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
import { NotSupportedError, type ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type ParticipantDeviceInfo } from "./types.ts";
// Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
export interface EnabledTransports {
@@ -106,7 +106,7 @@ export class RoomAndToDeviceTransport
this.toDeviceTransport.stop();
}
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
this.logger.debug(
`Sending key with index ${index} to call members (count=${members.length}) via:` +
(this._enabled.room ? "room transport" : "") +

View File

@@ -15,13 +15,12 @@ limitations under the License.
*/
import type { MatrixClient } from "../client.ts";
import type { EncryptionKeysEventContent, Statistics } from "./types.ts";
import { type EncryptionKeysEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
import { EventType } from "../@types/event.ts";
import { type MatrixError } from "../http-api/errors.ts";
import { logger as rootLogger, type Logger } from "../logger.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
import { type MatrixEvent } from "../models/event.ts";
import { type CallMembership } from "./CallMembership.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room, RoomEvent } from "../models/room.ts";
@@ -81,7 +80,7 @@ export class RoomKeyTransport
}
/** implements {@link IKeyTransport#sendKey} */
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
// members not used in room transports as the keys are sent to all room members
const content: EncryptionKeysEventContent = {
keys: [

View File

@@ -19,8 +19,7 @@ import { type WidgetApiResponseError } from "matrix-widget-api";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type IKeyTransport, KeyTransportEvents, type KeyTransportEventsHandlerMap } from "./IKeyTransport.ts";
import { type Logger, logger as rootLogger } from "../logger.ts";
import type { CallMembership } from "./CallMembership.ts";
import type { EncryptionKeysToDeviceEventContent, Statistics } from "./types.ts";
import { type EncryptionKeysToDeviceEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
import { ClientEvent, type MatrixClient } from "../client.ts";
import type { MatrixEvent } from "../models/event.ts";
import { EventType } from "../@types/event.ts";
@@ -42,6 +41,7 @@ export class ToDeviceKeyTransport
implements IKeyTransport
{
private logger: Logger = rootLogger;
public setParentLogger(parentLogger: Logger): void {
this.logger = parentLogger.getChild(`[ToDeviceKeyTransport]`);
}
@@ -66,7 +66,7 @@ export class ToDeviceKeyTransport
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
const content: EncryptionKeysToDeviceEventContent = {
keys: {
index: index,
@@ -81,24 +81,18 @@ export class ToDeviceKeyTransport
application: "m.call",
scope: "m.room",
},
sent_ts: Date.now(),
};
const targets = members
.filter((member) => {
// filter malformed call members
if (member.sender == undefined || member.deviceId == undefined) {
this.logger.warn(`Malformed call member: ${member.sender}|${member.deviceId}`);
return false;
}
// Filter out me
return !(member.sender == this.userId && member.deviceId == this.deviceId);
})
.map((member) => {
return {
userId: member.sender!,
userId: member.userId!,
deviceId: member.deviceId!,
};
});
})
// filter out me
.filter((member) => !(member.userId == this.userId && member.deviceId == this.deviceId));
if (targets.length > 0) {
await this.client

View File

@@ -16,11 +16,44 @@ limitations under the License.
import type { IMentions } from "../matrix.ts";
import type { CallMembership } from "./CallMembership.ts";
export type ParticipantId = string;
export interface EncryptionKeyEntry {
index: number;
key: string;
}
/**
* The mxID, deviceId and membership timestamp of a RTC session participant.
*/
export type ParticipantDeviceInfo = {
userId: string;
deviceId: string;
membershipTs: number;
};
/**
* A type representing the information needed to decrypt video streams.
*/
export type InboundEncryptionSession = {
key: Uint8Array;
participantId: ParticipantId;
keyIndex: number;
creationTS: number;
};
/**
* The information about the key used to encrypt video streams.
*/
export type OutboundEncryptionSession = {
key: Uint8Array;
creationTS: number;
// The devices that this key is shared with.
sharedWith: Array<ParticipantDeviceInfo>;
// This is an index acting as the id of the key
keyId: number;
};
export interface EncryptionKeysEventContent {
keys: EncryptionKeyEntry[];
device_id: string;
@@ -28,13 +61,15 @@ export interface EncryptionKeysEventContent {
sent_ts?: number;
}
/**
* THe content of a to-device event that contains encryption keys.
*/
export interface EncryptionKeysToDeviceEventContent {
keys: { index: number; key: string };
member: {
// id: ParticipantId,
// TODO Remove that it is claimed, need to get the sealed sender from decryption info
// Or add some validation on it based on the encryption info
claimed_device_id: string;
// user_id: string
};
room_id: string;
session: {

51
src/matrixrtc/utils.ts Normal file
View File

@@ -0,0 +1,51 @@
/*
Copyright 2025 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 type { InboundEncryptionSession, ParticipantId } from "./types.ts";
/**
* Detects when a key for a given index is outdated.
*/
export class OutdatedKeyFilter {
// Map of participantId -> keyIndex -> timestamp
private tsBuffer: Map<ParticipantId, Map<number, number>> = new Map();
public constructor() {}
/**
* Check if there is a recent key with the same keyId (index) and then use the creationTS to decide what to
* do with the key. If the key received is older than the one already in the buffer, it is ignored.
* @param participantId
* @param item
*/
public isOutdated(participantId: ParticipantId, item: InboundEncryptionSession): boolean {
if (!this.tsBuffer.has(participantId)) {
this.tsBuffer.set(participantId, new Map<number, number>());
}
const latestTimestamp = this.tsBuffer.get(participantId)?.get(item.keyIndex);
if (latestTimestamp && latestTimestamp > item.creationTS) {
// The existing key is more recent, ignore this one
return true;
}
this.tsBuffer.get(participantId)!.set(item.keyIndex, item.creationTS);
return false;
}
}
export function getParticipantId(userId: string, deviceId: string): ParticipantId {
return `${userId}:${deviceId}`;
}