You've already forked matrix-js-sdk
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:
@@ -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();
|
||||||
}
|
}
|
||||||
|
43
spec/unit/matrixrtc/OutdatedKeyFilter.spec.ts
Normal file
43
spec/unit/matrixrtc/OutdatedKeyFilter.spec.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
598
spec/unit/matrixrtc/RTCEncrytionManager.spec.ts
Normal file
598
spec/unit/matrixrtc/RTCEncrytionManager.spec.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
@@ -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);
|
||||||
|
|
||||||
|
@@ -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();
|
||||||
|
@@ -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]);
|
||||||
|
@@ -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;
|
||||||
|
@@ -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!
|
||||||
|
329
src/matrixrtc/RTCEncryptionManager.ts
Normal file
329
src/matrixrtc/RTCEncryptionManager.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@@ -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" : "") +
|
||||||
|
@@ -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: [
|
||||||
|
@@ -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
|
||||||
|
@@ -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
51
src/matrixrtc/utils.ts
Normal 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}`;
|
||||||
|
}
|
Reference in New Issue
Block a user