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

@@ -21,6 +21,7 @@ import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
import { secureRandomString } from "../../../src/randomstring"; import { secureRandomString } from "../../../src/randomstring";
import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks"; import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks";
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
const mockFocus = { type: "mock" }; const mockFocus = { type: "mock" };
@@ -745,11 +746,27 @@ describe("MatrixRTCSession", () => {
expect(sendKeySpy).toHaveBeenCalledTimes(1); expect(sendKeySpy).toHaveBeenCalledTimes(1);
// check that we send the key with index 1 even though the send gets delayed when leaving. // check that we send the key with index 1 even though the send gets delayed when leaving.
// this makes sure we do not use an index that is one too old. // this makes sure we do not use an index that is one too old.
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships); expect(sendKeySpy).toHaveBeenLastCalledWith(
expect.any(String),
1,
sess.memberships.map((m) => ({
userId: m.sender,
deviceId: m.deviceId,
membershipTs: m.createdTs(),
})),
);
// fake a condition in which we send another encryption key event. // fake a condition in which we send another encryption key event.
// this could happen do to someone joining the call. // this could happen do to someone joining the call.
(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent(); (sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships); expect(sendKeySpy).toHaveBeenLastCalledWith(
expect.any(String),
1,
sess.memberships.map((m) => ({
userId: m.sender,
deviceId: m.deviceId,
membershipTs: m.createdTs(),
})),
);
jest.advanceTimersByTime(7000); jest.advanceTimersByTime(7000);
const secondKeysPayload = await keysSentPromise2; const secondKeysPayload = await keysSentPromise2;
@@ -862,10 +879,14 @@ describe("MatrixRTCSession", () => {
manageMediaKeys: true, manageMediaKeys: true,
useExperimentalToDeviceTransport: true, useExperimentalToDeviceTransport: true,
}); });
sess.onRTCSessionMemberUpdate();
await keySentPromise; await keySentPromise;
expect(sendToDeviceMock).toHaveBeenCalled(); expect(sendToDeviceMock).toHaveBeenCalled();
// Access private to test
expect(sess["encryptionManager"]).toBeInstanceOf(RTCEncryptionManager);
} finally { } finally {
jest.useRealTimers(); jest.useRealTimers();
} }

View File

@@ -0,0 +1,43 @@
/*
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 { OutdatedKeyFilter } from "../../../src/matrixrtc/utils.ts";
import { type InboundEncryptionSession } from "../../../src/matrixrtc";
describe("OutdatedKeyFilter Test", () => {
it("Should buffer and disambiguate keys by timestamp", () => {
const filter = new OutdatedKeyFilter();
const aKey = fakeInboundSessionWithTimestamp(1000);
const olderKey = fakeInboundSessionWithTimestamp(300);
// Simulate receiving out of order keys
expect(filter.isOutdated(aKey.participantId, aKey)).toBe(false);
// Then we receive the most recent key out of order
const isOutdated = filter.isOutdated(aKey.participantId, olderKey);
// this key is older and should be ignored even if received after
expect(isOutdated).toBe(true);
});
function fakeInboundSessionWithTimestamp(ts: number): InboundEncryptionSession {
return {
keyIndex: 0,
creationTS: ts,
participantId: "@alice:localhost|ABCDE",
key: new Uint8Array(16),
};
}
});

View File

@@ -0,0 +1,598 @@
/*
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 Mocked } from "jest-mock";
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
import { type CallMembership, type Statistics } from "../../../src/matrixrtc";
import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
import { decodeBase64, TypedEventEmitter } from "../../../src";
import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
import type { Logger } from "../../../src/logger.ts";
describe("RTCEncryptionManager", () => {
// The manager being tested
let encryptionManager: RTCEncryptionManager;
let getMembershipMock: jest.Mock;
let mockTransport: Mocked<ToDeviceKeyTransport>;
let statistics: Statistics;
let onEncryptionKeysChanged: jest.Mock;
beforeEach(() => {
statistics = {
counters: {
roomEventEncryptionKeysSent: 0,
roomEventEncryptionKeysReceived: 0,
},
totals: {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
getMembershipMock = jest.fn().mockReturnValue([]);
onEncryptionKeysChanged = jest.fn();
mockTransport = {
start: jest.fn(),
stop: jest.fn(),
sendKey: jest.fn().mockResolvedValue(undefined),
on: jest.fn(),
off: jest.fn(),
} as unknown as Mocked<ToDeviceKeyTransport>;
encryptionManager = new RTCEncryptionManager(
"@alice:example.org",
"DEVICE01",
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
);
});
it("should start and stop the transport properly", () => {
encryptionManager.join(undefined);
expect(mockTransport.start).toHaveBeenCalledTimes(1);
expect(mockTransport.on).toHaveBeenCalledTimes(1);
expect(mockTransport.on).toHaveBeenCalledWith(KeyTransportEvents.ReceivedKeys, expect.any(Function));
encryptionManager.leave();
expect(mockTransport.stop).toHaveBeenCalledTimes(1);
expect(mockTransport.off).toHaveBeenCalledWith(KeyTransportEvents.ReceivedKeys, expect.any(Function));
});
describe("Sharing Keys", () => {
it("Set up my key asap even if no key distribution is needed", () => {
getMembershipMock.mockReturnValue([]);
encryptionManager.join(undefined);
// After join it is too early, key might be lost as no one is listening yet
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
encryptionManager.onMembershipsUpdate([]);
// The key should have been rolled out immediately
expect(onEncryptionKeysChanged).toHaveBeenCalled();
});
it("Should distribute keys to members on join", async () => {
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key
0,
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
await jest.runOnlyPendingTimersAsync();
// The key should have been rolled out immediately
expect(onEncryptionKeysChanged).toHaveBeenCalled();
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
"@alice:example.org:DEVICE01",
);
});
it("Should re-distribute keys to members whom callMemberhsip ts has changed", async () => {
let members = [aCallMembership("@bob:example.org", "BOBDEVICE", 1000)];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key
0,
[
{
userId: "@bob:example.org",
deviceId: "BOBDEVICE",
membershipTs: 1000,
},
],
);
await jest.runOnlyPendingTimersAsync();
// The key should have been rolled out immediately
expect(onEncryptionKeysChanged).toHaveBeenCalled();
mockTransport.sendKey.mockClear();
onEncryptionKeysChanged.mockClear();
members = [aCallMembership("@bob:example.org", "BOBDEVICE", 2000)];
getMembershipMock.mockReturnValue(members);
// There are no membership change but the callMembership ts has changed (reset?)
// Resend the key
encryptionManager.onMembershipsUpdate(members);
await jest.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// Re send the same key to that user
0,
[
{
userId: "@bob:example.org",
deviceId: "BOBDEVICE",
membershipTs: 2000,
},
],
);
});
it("Should not rotate key when a user join", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
// initial rollout
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
await jest.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key
0,
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
onEncryptionKeysChanged.mockClear();
mockTransport.sendKey.mockClear();
const updatedMembers = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(updatedMembers);
encryptionManager.onMembershipsUpdate(updatedMembers);
await jest.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It should not have incremented the key index
0,
// And send it to the newly joined only
[{ userId: "@carl:example.org", deviceId: "CARLDEVICE", membershipTs: 1000 }],
);
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1000);
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
});
it("Should not resend keys when no changes", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
// initial rollout
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
await jest.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
onEncryptionKeysChanged.mockClear();
mockTransport.sendKey.mockClear();
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(200);
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(100);
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(50);
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(100);
expect(mockTransport.sendKey).not.toHaveBeenCalled();
});
it("Should rotate key when a user leaves and delay the rollout", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
await jest.advanceTimersByTimeAsync(10);
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key
0,
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
// initial rollout
expect(mockTransport.sendKey).toHaveBeenCalled();
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(1);
onEncryptionKeysChanged.mockClear();
const updatedMembers = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(updatedMembers);
encryptionManager.onMembershipsUpdate(updatedMembers);
await jest.advanceTimersByTimeAsync(200);
// The is rotated but not rolled out yet to give time for the key to be sent
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It should have incremented the key index
1,
// And send it to the updated members
updatedMembers.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1000);
// now should be rolled out
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
1,
"@alice:example.org:DEVICE01",
);
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
});
});
describe("Receiving Keys", () => {
beforeEach(() => {
const emitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
mockTransport = {
start: jest.fn(),
stop: jest.fn(),
sendKey: jest.fn().mockResolvedValue(undefined),
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter),
} as unknown as Mocked<ToDeviceKeyTransport>;
encryptionManager = new RTCEncryptionManager(
"@alice:example.org",
"DEVICE01",
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
);
});
it("should accept keys from transport", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
await jest.advanceTimersByTimeAsync(10);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE",
"AAAAAAAAAAA",
0 /* KeyId */,
0 /* Timestamp */,
);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE2",
"BBBBBBBBBBB",
4 /* KeyId */,
0 /* Timestamp */,
);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carl:example.org",
"CARLDEVICE",
"CCCCCCCCCC",
8 /* KeyId */,
0 /* Timestamp */,
);
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(4);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
decodeBase64("AAAAAAAAAAA"),
0,
"@bob:example.org:BOBDEVICE",
);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
decodeBase64("BBBBBBBBBBB"),
4,
"@bob:example.org:BOBDEVICE2",
);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
decodeBase64("CCCCCCCCCC"),
8,
"@carl:example.org:CARLDEVICE",
);
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(3);
});
it("Should support quick re-joiner if keys received out of order", async () => {
jest.useFakeTimers();
const members = [aCallMembership("@carl:example.org", "CARLDEVICE")];
getMembershipMock.mockReturnValue(members);
// Let's join
encryptionManager.join(undefined);
await jest.advanceTimersByTimeAsync(10);
// Simulate Carl leaving then joining back, and key received out of order
const initialKey0TimeStamp = 1000;
const newKey0TimeStamp = 2000;
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carol:example.org",
"CAROLDEVICE",
"BBBBBBBBBBB",
0 /* KeyId */,
newKey0TimeStamp,
);
await jest.advanceTimersByTimeAsync(20);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carol:example.org",
"CAROLDEVICE",
"AAAAAAAAAAA",
0 /* KeyId */,
initialKey0TimeStamp,
);
await jest.advanceTimersByTimeAsync(20);
// The latest key used for carol should be the one with the latest timestamp
expect(onEncryptionKeysChanged).toHaveBeenLastCalledWith(
decodeBase64("BBBBBBBBBBB"),
0,
"@carol:example.org:CAROLDEVICE",
);
});
});
it("Should only rotate once again if several membership changes during a rollout", async () => {
jest.useFakeTimers();
let members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
// Let's join
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
await jest.advanceTimersByTimeAsync(10);
// The initial rollout
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
"@alice:example.org:DEVICE01",
);
onEncryptionKeysChanged.mockClear();
// Trigger a key rotation with a leaver
members = [aCallMembership("@bob:example.org", "BOBDEVICE"), aCallMembership("@bob:example.org", "BOBDEVICE2")];
getMembershipMock.mockReturnValue(members);
// This should start a new key rollout
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(10);
// Now simulate a new leaver
members = [aCallMembership("@bob:example.org", "BOBDEVICE")];
getMembershipMock.mockReturnValue(members);
// The key `1` rollout is in progress
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(10);
// And another one ( plus a joiner)
const lastMembership = [aCallMembership("@bob:example.org", "BOBDEVICE3")];
getMembershipMock.mockReturnValue(lastMembership);
// The key `1` rollout is still in progress
encryptionManager.onMembershipsUpdate(lastMembership);
await jest.advanceTimersByTimeAsync(10);
// Let all rollouts finish
await jest.advanceTimersByTimeAsync(2000);
// There should 2 rollout. The `1` rollout, then just one additional one
// that has "buffered" the 2 membership changes with leavers
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(2);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
1,
"@alice:example.org:DEVICE01",
);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
2,
"@alice:example.org:DEVICE01",
);
// Key `2` should only be distributed to the last membership
expect(mockTransport.sendKey).toHaveBeenLastCalledWith(
expect.any(String),
2,
// And send only to the last membership
[
{
userId: "@bob:example.org",
deviceId: "BOBDEVICE3",
membershipTs: 1000,
},
],
);
});
it("Should re-distribute key on transport switch", async () => {
const toDeviceEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
const mockToDeviceTransport = {
start: jest.fn(),
stop: jest.fn(),
sendKey: jest.fn().mockResolvedValue(undefined),
on: toDeviceEmitter.on.bind(toDeviceEmitter),
off: toDeviceEmitter.off.bind(toDeviceEmitter),
emit: toDeviceEmitter.emit.bind(toDeviceEmitter),
setParentLogger: jest.fn(),
} as unknown as Mocked<ToDeviceKeyTransport>;
const roomEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
const mockRoomTransport = {
start: jest.fn(),
stop: jest.fn(),
sendKey: jest.fn().mockResolvedValue(undefined),
on: roomEmitter.on.bind(roomEmitter),
off: roomEmitter.off.bind(roomEmitter),
emit: roomEmitter.emit.bind(roomEmitter),
setParentLogger: jest.fn(),
} as unknown as Mocked<RoomKeyTransport>;
const mockLogger = {
debug: jest.fn(),
warn: jest.fn(),
} as unknown as Mocked<Logger>;
const transport = new RoomAndToDeviceTransport(mockToDeviceTransport, mockRoomTransport, {
getChild: jest.fn().mockReturnValue(mockLogger),
} as unknown as Mocked<Logger>);
encryptionManager = new RTCEncryptionManager(
"@alice:example.org",
"DEVICE01",
getMembershipMock,
transport,
statistics,
onEncryptionKeysChanged,
);
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
// Let's join
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
await jest.advanceTimersByTimeAsync(10);
// Should have sent the key to the toDevice transport
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockRoomTransport.sendKey).not.toHaveBeenCalled();
// Simulate receiving a key by room transport
roomEmitter.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE",
"AAAAAAAAAAA",
0 /* KeyId */,
0 /* Timestamp */,
);
await jest.runOnlyPendingTimersAsync();
// The key should have beed re-distributed to the room transport
expect(mockRoomTransport.sendKey).toHaveBeenCalled();
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key re-distributed
0,
// to all the members
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
});
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
return mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: deviceId, created_ts: ts }),
"!room:id",
userId,
);
}
});

View File

@@ -16,7 +16,7 @@ limitations under the License.
import { type Mocked } from "jest-mock"; import { type Mocked } from "jest-mock";
import { makeKey, makeMockEvent, makeMockRoom, membershipTemplate, mockCallMembership } from "./mocks"; import { makeKey, makeMockEvent, makeMockRoom } from "./mocks";
import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src"; import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src";
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts"; import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { import {
@@ -24,7 +24,7 @@ import {
mockClientMethodsEvents, mockClientMethodsEvents,
mockClientMethodsUser, mockClientMethodsUser,
} from "../../test-utils/client.ts"; } from "../../test-utils/client.ts";
import { type Statistics } from "../../../src/matrixrtc"; import { type ParticipantDeviceInfo, type Statistics } from "../../../src/matrixrtc";
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts"; import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
import { type Logger } from "../../../src/logger.ts"; import { type Logger } from "../../../src/logger.ts";
import { RoomAndToDeviceEvents, RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts"; import { RoomAndToDeviceEvents, RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
@@ -88,7 +88,9 @@ describe("RoomAndToDeviceTransport", () => {
}); });
it("only sends to device keys when sending a key", async () => { it("only sends to device keys when sending a key", async () => {
transport.start(); transport.start();
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]); await transport.sendKey("1235", 0, [
{ userId: "@alice:example.org", deviceId: "ALICEDEVICE", membershipTs: 1234 },
]);
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1); expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1);
expect(roomSendKeySpy).toHaveBeenCalledTimes(0); expect(roomSendKeySpy).toHaveBeenCalledTimes(0);
expect(transport.enabled.room).toBeFalsy(); expect(transport.enabled.room).toBeFalsy();
@@ -118,7 +120,9 @@ describe("RoomAndToDeviceTransport", () => {
expect(transport.enabled.room).toBeTruthy(); expect(transport.enabled.room).toBeTruthy();
expect(transport.enabled.toDevice).toBeFalsy(); expect(transport.enabled.toDevice).toBeFalsy();
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]); await transport.sendKey("1235", 0, [
{ userId: "@alice:example.org", deviceId: "AlICEDEV", membershipTs: 1234 },
]);
expect(sendEventMock).toHaveBeenCalledTimes(1); expect(sendEventMock).toHaveBeenCalledTimes(1);
expect(roomSendKeySpy).toHaveBeenCalledTimes(1); expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0); expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
@@ -132,7 +136,11 @@ describe("RoomAndToDeviceTransport", () => {
}); });
transport.start(); transport.start();
const membership = mockCallMembership(membershipTemplate, roomId, "@alice:example.org"); const membership: ParticipantDeviceInfo = {
userId: "@alice:example.org",
deviceId: "ALICEDEVICE",
membershipTs: 1234,
};
const onTransportEnabled = jest.fn(); const onTransportEnabled = jest.fn();
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled); transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);

View File

@@ -16,7 +16,7 @@ limitations under the License.
import { type Mocked } from "jest-mock"; import { type Mocked } from "jest-mock";
import { makeMockEvent, membershipTemplate, mockCallMembership } from "./mocks"; import { makeMockEvent } from "./mocks";
import { ClientEvent, EventType, type MatrixClient } from "../../../src"; import { ClientEvent, EventType, type MatrixClient } from "../../../src";
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts"; import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts"; import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
@@ -61,21 +61,9 @@ describe("ToDeviceKeyTransport", () => {
const keyBase64Encoded = "ABCDEDF"; const keyBase64Encoded = "ABCDEDF";
const keyIndex = 2; const keyIndex = 2;
await transport.sendKey(keyBase64Encoded, keyIndex, [ await transport.sendKey(keyBase64Encoded, keyIndex, [
mockCallMembership( { userId: "@bob:example.org", deviceId: "BOBDEVICE", membershipTs: 1234 },
Object.assign({}, membershipTemplate, { device_id: "BOBDEVICE" }), { userId: "@carl:example.org", deviceId: "CARLDEVICE", membershipTs: 1234 },
roomId, { userId: "@mat:example.org", deviceId: "MATDEVICE", membershipTs: 1234 },
"@bob:example.org",
),
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "CARLDEVICE" }),
roomId,
"@carl:example.org",
),
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "MATDEVICE" }),
roomId,
"@mat:example.org",
),
]); ]);
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(1); expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(1);
@@ -100,6 +88,7 @@ describe("ToDeviceKeyTransport", () => {
call_id: "", call_id: "",
scope: "m.room", scope: "m.room",
}, },
sent_ts: expect.any(Number),
}, },
); );
@@ -153,11 +142,7 @@ describe("ToDeviceKeyTransport", () => {
const keyBase64Encoded = "ABCDEDF"; const keyBase64Encoded = "ABCDEDF";
const keyIndex = 2; const keyIndex = 2;
await transport.sendKey(keyBase64Encoded, keyIndex, [ await transport.sendKey(keyBase64Encoded, keyIndex, [
mockCallMembership( { userId: "@alice:example.org", deviceId: "MYDEVICE", membershipTs: 1234 },
Object.assign({}, membershipTemplate, { device_id: "MYDEVICE" }),
roomId,
"@alice:example.org",
),
]); ]);
transport.start(); transport.start();

View File

@@ -6,6 +6,7 @@ import { safeGetRetryAfterMs } from "../http-api/errors.ts";
import { type CallMembership } from "./CallMembership.ts"; import { type CallMembership } from "./CallMembership.ts";
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts"; import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
import { isMyMembership, type Statistics } from "./types.ts"; import { isMyMembership, type Statistics } from "./types.ts";
import { getParticipantId } from "./utils.ts";
import { import {
type EnabledTransports, type EnabledTransports,
RoomAndToDeviceEvents, RoomAndToDeviceEvents,
@@ -42,6 +43,10 @@ export interface IEncryptionManager {
* *
* @returns A map where the keys are identifiers and the values are arrays of * @returns A map where the keys are identifiers and the values are arrays of
* objects containing encryption keys and their associated timestamps. * 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 }>>; getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
} }
@@ -82,6 +87,7 @@ export class EncryptionManager implements IEncryptionManager {
private latestGeneratedKeyIndex = -1; private latestGeneratedKeyIndex = -1;
private joinConfig: EncryptionConfig | undefined; private joinConfig: EncryptionConfig | undefined;
private logger: Logger; private logger: Logger;
public constructor( public constructor(
private userId: string, private userId: string,
private deviceId: string, private deviceId: string,
@@ -280,7 +286,18 @@ export class EncryptionManager implements IEncryptionManager {
try { try {
this.statistics.counters.roomEventEncryptionKeysSent += 1; 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( this.logger.debug(
`sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`, `sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
this.encryptionKeys, 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 { function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
if (a === b) return true; if (a === b) return true;
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]); 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. limitations under the License.
*/ */
import { type CallMembership } from "./CallMembership.ts"; import { type ParticipantDeviceInfo } from "./types.ts";
export enum KeyTransportEvents { export enum KeyTransportEvents {
ReceivedKeys = "received_keys", ReceivedKeys = "received_keys",
@@ -45,7 +45,7 @@ export interface IKeyTransport {
* @param index * @param index
* @param members - The participants that should get they key * @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. */ /** Subscribe to keys from this transport. */
on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this; 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 { type Statistics } from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import type { IMembershipManager } from "./IMembershipManager.ts"; import type { IMembershipManager } from "./IMembershipManager.ts";
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
import { import {
RoomAndToDeviceEvents, RoomAndToDeviceEvents,
type RoomAndToDeviceEventsHandlerMap, type RoomAndToDeviceEventsHandlerMap,
@@ -399,6 +400,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
// Create Encryption manager // Create Encryption manager
let transport; let transport;
if (joinConfig?.useExperimentalToDeviceTransport) { 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"); this.logger.info("Using to-device with room fallback transport for encryption keys");
const [uId, dId] = [this.client.getUserId()!, this.client.getDeviceId()!]; const [uId, dId] = [this.client.getUserId()!, this.client.getDeviceId()!];
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics]; 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. // Expose the changes so the ui can display the currently used transport.
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]); 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 { } else {
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics); 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! // 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 { logger as rootLogger, type Logger } from "../logger.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts"; import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
import { type CallMembership } from "./CallMembership.ts";
import type { RoomKeyTransport } from "./RoomKeyTransport.ts"; import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
import { NotSupportedError, type ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; import { NotSupportedError, type ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.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. // Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
export interface EnabledTransports { export interface EnabledTransports {
@@ -106,7 +106,7 @@ export class RoomAndToDeviceTransport
this.toDeviceTransport.stop(); 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( this.logger.debug(
`Sending key with index ${index} to call members (count=${members.length}) via:` + `Sending key with index ${index} to call members (count=${members.length}) via:` +
(this._enabled.room ? "room transport" : "") + (this._enabled.room ? "room transport" : "") +

View File

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

View File

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

View File

@@ -16,11 +16,44 @@ limitations under the License.
import type { IMentions } from "../matrix.ts"; import type { IMentions } from "../matrix.ts";
import type { CallMembership } from "./CallMembership.ts"; import type { CallMembership } from "./CallMembership.ts";
export type ParticipantId = string;
export interface EncryptionKeyEntry { export interface EncryptionKeyEntry {
index: number; index: number;
key: string; 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 { export interface EncryptionKeysEventContent {
keys: EncryptionKeyEntry[]; keys: EncryptionKeyEntry[];
device_id: string; device_id: string;
@@ -28,13 +61,15 @@ export interface EncryptionKeysEventContent {
sent_ts?: number; sent_ts?: number;
} }
/**
* THe content of a to-device event that contains encryption keys.
*/
export interface EncryptionKeysToDeviceEventContent { export interface EncryptionKeysToDeviceEventContent {
keys: { index: number; key: string }; keys: { index: number; key: string };
member: { member: {
// id: ParticipantId,
// TODO Remove that it is claimed, need to get the sealed sender from decryption info // 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; claimed_device_id: string;
// user_id: string
}; };
room_id: string; room_id: string;
session: { 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}`;
}