Skip to content

Commit 1d798cb

Browse files
committed
RTCEncryptionManager: Joiner key rotation grace period
1 parent 024b62b commit 1d798cb

File tree

3 files changed

+164
-23
lines changed

3 files changed

+164
-23
lines changed

spec/unit/matrixrtc/RTCEncrytionManager.spec.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ describe("RTCEncryptionManager", () => {
8888
});
8989

9090
it("Should distribute keys to members on join", async () => {
91+
jest.useFakeTimers();
9192
const members = [
9293
aCallMembership("@bob:example.org", "BOBDEVICE"),
9394
aCallMembership("@bob:example.org", "BOBDEVICE2"),
@@ -165,7 +166,7 @@ describe("RTCEncryptionManager", () => {
165166
);
166167
});
167168

168-
it("Should not rotate key when a user join", async () => {
169+
it("Should not rotate key when a user join within the rotation grace period", async () => {
169170
jest.useFakeTimers();
170171

171172
const members = [
@@ -174,8 +175,9 @@ describe("RTCEncryptionManager", () => {
174175
];
175176
getMembershipMock.mockReturnValue(members);
176177

178+
const gracePeriod = 15_000; // 15 seconds
177179
// initial rollout
178-
encryptionManager.join(undefined);
180+
encryptionManager.join({ keyRotationGracePeriodMs: gracePeriod });
179181
encryptionManager.onMembershipsUpdate([]);
180182
await jest.runOnlyPendingTimersAsync();
181183

@@ -196,6 +198,8 @@ describe("RTCEncryptionManager", () => {
196198
];
197199
getMembershipMock.mockReturnValue(updatedMembers);
198200

201+
await jest.advanceTimersByTimeAsync(8_000);
202+
199203
encryptionManager.onMembershipsUpdate(updatedMembers);
200204

201205
await jest.runOnlyPendingTimersAsync();
@@ -214,6 +218,104 @@ describe("RTCEncryptionManager", () => {
214218
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
215219
});
216220

221+
it("Should rotate key when a user join past the rotation grace period", async () => {
222+
jest.useFakeTimers();
223+
224+
const members = [
225+
aCallMembership("@bob:example.org", "BOBDEVICE"),
226+
aCallMembership("@bob:example.org", "BOBDEVICE2"),
227+
];
228+
getMembershipMock.mockReturnValue(members);
229+
230+
const gracePeriod = 15_000; // 15 seconds
231+
// initial rollout
232+
encryptionManager.join({ keyRotationGracePeriodMs: gracePeriod });
233+
encryptionManager.onMembershipsUpdate([]);
234+
await jest.runOnlyPendingTimersAsync();
235+
236+
onEncryptionKeysChanged.mockClear();
237+
mockTransport.sendKey.mockClear();
238+
239+
const updatedMembers = [
240+
aCallMembership("@bob:example.org", "BOBDEVICE"),
241+
aCallMembership("@bob:example.org", "BOBDEVICE2"),
242+
aCallMembership("@carl:example.org", "CARLDEVICE"),
243+
];
244+
getMembershipMock.mockReturnValue(updatedMembers);
245+
246+
await jest.advanceTimersByTimeAsync(gracePeriod + 1000);
247+
248+
encryptionManager.onMembershipsUpdate(updatedMembers);
249+
250+
await jest.runOnlyPendingTimersAsync();
251+
252+
expect(mockTransport.sendKey).toHaveBeenCalledWith(
253+
expect.any(String),
254+
// It should have incremented the key index
255+
1,
256+
// And send it to everyone
257+
[
258+
expect.objectContaining({ userId: "@bob:example.org", deviceId: "BOBDEVICE" }),
259+
expect.objectContaining({ userId: "@bob:example.org", deviceId: "BOBDEVICE2" }),
260+
expect.objectContaining({ userId: "@carl:example.org", deviceId: "CARLDEVICE" }),
261+
],
262+
);
263+
264+
expect(onEncryptionKeysChanged).toHaveBeenCalled();
265+
await jest.advanceTimersByTimeAsync(1000);
266+
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
267+
});
268+
269+
it("Should rotate key when several users join within the rotation grace period", async () => {
270+
jest.useFakeTimers();
271+
272+
const members = [
273+
aCallMembership("@bob:example.org", "BOBDEVICE"),
274+
aCallMembership("@bob:example.org", "BOBDEVICE2"),
275+
];
276+
getMembershipMock.mockReturnValue(members);
277+
278+
// initial rollout
279+
encryptionManager.join(undefined);
280+
encryptionManager.onMembershipsUpdate([]);
281+
await jest.runOnlyPendingTimersAsync();
282+
283+
onEncryptionKeysChanged.mockClear();
284+
mockTransport.sendKey.mockClear();
285+
286+
const newJoiners = [
287+
aCallMembership("@carl:example.org", "CARLDEVICE"),
288+
aCallMembership("@dave:example.org", "DAVEDEVICE"),
289+
aCallMembership("@eve:example.org", "EVEDEVICE"),
290+
aCallMembership("@frank:example.org", "FRANKDEVICE"),
291+
aCallMembership("@george:example.org", "GEORGEDEVICE"),
292+
];
293+
294+
for (const newJoiner of newJoiners) {
295+
members.push(newJoiner);
296+
getMembershipMock.mockReturnValue(members);
297+
await jest.advanceTimersByTimeAsync(1_000);
298+
encryptionManager.onMembershipsUpdate(members);
299+
await jest.runOnlyPendingTimersAsync();
300+
}
301+
302+
expect(mockTransport.sendKey).toHaveBeenCalledTimes(newJoiners.length);
303+
304+
for (const newJoiner of newJoiners) {
305+
expect(mockTransport.sendKey).toHaveBeenCalledWith(
306+
expect.any(String),
307+
// It should not have incremented the key index
308+
0,
309+
// And send it to the new joiners only
310+
expect.arrayContaining([
311+
expect.objectContaining({ userId: newJoiner.sender, deviceId: newJoiner.deviceId }),
312+
]),
313+
);
314+
}
315+
316+
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
317+
});
318+
217319
it("Should not resend keys when no changes", async () => {
218320
jest.useFakeTimers();
219321

src/matrixrtc/MatrixRTCSession.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,18 +153,31 @@ export interface EncryptionConfig {
153153
/**
154154
* The minimum time (in milliseconds) between each attempt to send encryption key(s).
155155
* e.g. if this is set to 1000, then we will send at most one key event every second.
156+
* @deprecated - Not used by the new encryption manager.
156157
*/
157158
updateEncryptionKeyThrottle?: number;
159+
160+
/**
161+
* Sometimes it is necessary to rotate the encryption key after a membership update.
162+
* For performance reasons we might not want to rotate the key immediately but allow future memberships to use the same key.
163+
* If 5 people join in a row in less than 5 seconds, we don't want to rotate the key for each of them.
164+
* If 5 people leave in a row in less than 5 seconds, we don't want to rotate the key for each of them.
165+
*/
166+
keyRotationGracePeriodMs?: number;
167+
158168
/**
159169
* The delay (in milliseconds) after a member leaves before we create and publish a new key, because people
160170
* tend to leave calls at the same time.
171+
* @deprecated - Not used by the new encryption manager.
161172
*/
162173
makeKeyDelay?: number;
163174
/**
164-
* The delay (in milliseconds) between creating and sending a new key and starting to encrypt with it. This
165-
* gives other a chance to receive the new key to minimise the chance they don't get media they can't decrypt.
166-
* The total time between a member leaving and the call switching to new keys is therefore:
167-
* makeKeyDelay + useKeyDelay
175+
* The delay (in milliseconds) between sending a new key and starting to encrypt with it. This
176+
* gives others a chance to receive the new key to minimize the chance they get media they can't decrypt.
177+
*
178+
* The higher this value is, the better it is for existing members as they will have a smoother experience.
179+
* But it impacts new joiners: They will always have to wait `useKeyDelay` before being able to decrypt the media
180+
* (as it will be encrypted with the new key after the delay only), even if the key is already arrived before the delay.
168181
*/
169182
useKeyDelay?: number;
170183
}

src/matrixrtc/RTCEncryptionManager.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,21 @@ export class RTCEncryptionManager implements IEncryptionManager {
5353
* Ensures that there is only one distribute operation at a time for that call.
5454
*/
5555
private currentKeyDistributionPromise: Promise<void> | null = null;
56+
5657
/**
5758
* The time to wait before using the outbound session after it has been distributed.
5859
* This is to ensure that the key is delivered to all participants before it is used.
5960
* When creating the first key, this is set to 0, so that the key can be used immediately.
6061
*/
61-
private delayRolloutTimeMillis = 1000;
62+
private delayRolloutTimeMillis = 5000;
63+
64+
/**
65+
* We want to avoid rolling out a new outbound key when the previous one was created less than `skipRotationGracePeriod` milliseconds ago.
66+
* This is to avoid expensive key rotations when users quickly join the call in a row.
67+
* @private
68+
*/
69+
private skipRotationGracePeriod = 10_000;
70+
6271
/**
6372
* If a new key distribution is being requested while one is going on, we will set this flag to true.
6473
* This will ensure that a new round is started after the current one.
@@ -102,6 +111,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
102111
public join(joinConfig: EncryptionConfig | undefined): void {
103112
this.logger?.info(`Joining room`);
104113
this.delayRolloutTimeMillis = joinConfig?.useKeyDelay ?? 1000;
114+
this.skipRotationGracePeriod = joinConfig?.keyRotationGracePeriodMs ?? 10_000;
105115
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
106116
// Deprecate RoomKeyTransport: this can get removed.
107117
if (this.transport instanceof RoomAndToDeviceTransport) {
@@ -264,26 +274,28 @@ export class RTCEncryptionManager implements IEncryptionManager {
264274
let hasKeyChanged = false;
265275
if (anyLeft.length > 0) {
266276
// We need to rotate the key
267-
const newOutboundKey: OutboundEncryptionSession = {
268-
key: this.generateRandomKey(),
269-
creationTS: Date.now(),
270-
sharedWith: [],
271-
keyId: this.nextKeyIndex(),
272-
};
277+
const newOutboundKey = this.createNewOutboundSession();
273278
hasKeyChanged = true;
274-
275-
this.logger?.info(`creating new outbound key index:${newOutboundKey.keyId}`);
276-
// Set this new key as the current one
277-
this.outboundSession = newOutboundKey;
278-
279-
// Send
280279
toDistributeTo = toShareWith;
281280
outboundKey = newOutboundKey;
282281
} else if (anyJoined.length > 0) {
283-
// keep the same key
284-
// XXX In the future we want to distribute a ratcheted key not the current one
285-
toDistributeTo = anyJoined;
286-
outboundKey = this.outboundSession!;
282+
const now = Date.now();
283+
const keyAge = now - this.outboundSession!.creationTS;
284+
// If the current key is recently created (less than `skipRotationGracePeriod`), we can keep it and just distribute it to the new joiners.
285+
if (keyAge < this.skipRotationGracePeriod) {
286+
// keep the same key
287+
// XXX In the future we want to distribute a ratcheted key, not the current one
288+
this.logger?.debug(`New joiners detected, but the key is recent enough (age:${keyAge}), keeping it`);
289+
toDistributeTo = anyJoined;
290+
outboundKey = this.outboundSession!;
291+
} else {
292+
// We need to rotate the key
293+
this.logger?.debug(`New joiners detected, rotating the key`);
294+
const newOutboundKey = this.createNewOutboundSession();
295+
hasKeyChanged = true;
296+
toDistributeTo = toShareWith;
297+
outboundKey = newOutboundKey;
298+
}
287299
} else {
288300
// no changes
289301
return;
@@ -314,6 +326,20 @@ export class RTCEncryptionManager implements IEncryptionManager {
314326
}
315327
}
316328

329+
private createNewOutboundSession(): OutboundEncryptionSession {
330+
const newOutboundKey: OutboundEncryptionSession = {
331+
key: this.generateRandomKey(),
332+
creationTS: Date.now(),
333+
sharedWith: [],
334+
keyId: this.nextKeyIndex(),
335+
};
336+
337+
this.logger?.info(`creating new outbound key index:${newOutboundKey.keyId}`);
338+
// Set this new key as the current one
339+
this.outboundSession = newOutboundKey;
340+
return newOutboundKey;
341+
}
342+
317343
private nextKeyIndex(): number {
318344
if (this.outboundSession) {
319345
return (this.outboundSession!.keyId + 1) % 256;

0 commit comments

Comments
 (0)