You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-08-07 23:02:56 +03:00
MatrixRTC: ToDevice distribution for media stream keys (#4785)
* MatrixRTC: ToDevice distribution for media stream keys * test: Add RTC to device transport test * lint * fix key indexing * fix indexing take two - use correct value for: `onEncryptionKeysChanged` - only update `latestGeneratedKeyIndex` for "this user" key * test: add test for join config `useExperimentalToDeviceTransport` * update test to fail without the fixed encryption key index * review * review (dave) --------- Co-authored-by: Timo <toger5@hotmail.de>
This commit is contained in:
@@ -722,13 +722,14 @@ describe("RoomWidgetClient", () => {
|
||||
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
|
||||
});
|
||||
|
||||
it("sends encrypted (encryptAndSendToDevices)", async () => {
|
||||
it("sends encrypted (encryptAndSendToDevice)", async () => {
|
||||
await makeClient({ sendToDevice: ["org.example.foo"] });
|
||||
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
||||
|
||||
const payload = { type: "org.example.foo", hello: "world" };
|
||||
const payload = { hello: "world" };
|
||||
const embeddedClient = client as RoomWidgetClient;
|
||||
await embeddedClient.encryptAndSendToDevices(
|
||||
await embeddedClient.encryptAndSendToDevice(
|
||||
"org.example.foo",
|
||||
[
|
||||
{ userId: "@alice:example.org", deviceId: "aliceWeb" },
|
||||
{ userId: "@bob:example.org", deviceId: "bobDesktop" },
|
||||
|
@@ -486,14 +486,17 @@ describe("MatrixRTCSession", () => {
|
||||
let sendStateEventMock: jest.Mock;
|
||||
let sendDelayedStateMock: jest.Mock;
|
||||
let sendEventMock: jest.Mock;
|
||||
let sendToDeviceMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
sendStateEventMock = jest.fn();
|
||||
sendDelayedStateMock = jest.fn();
|
||||
sendEventMock = jest.fn();
|
||||
sendToDeviceMock = jest.fn();
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
|
||||
client.sendEvent = sendEventMock;
|
||||
client.encryptAndSendToDevice = sendToDeviceMock;
|
||||
|
||||
mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
@@ -832,6 +835,7 @@ describe("MatrixRTCSession", () => {
|
||||
it("rotates key if a member leaves", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const KEY_DELAY = 3000;
|
||||
const member2 = Object.assign({}, membershipTemplate, {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
@@ -852,7 +856,8 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, makeKeyDelay: KEY_DELAY });
|
||||
const sendKeySpy = jest.spyOn((sess as unknown as any).encryptionManager.transport, "sendKey");
|
||||
const firstKeysPayload = await keysSentPromise1;
|
||||
expect(firstKeysPayload.keys).toHaveLength(1);
|
||||
expect(firstKeysPayload.keys[0].index).toEqual(0);
|
||||
@@ -869,14 +874,24 @@ describe("MatrixRTCSession", () => {
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
jest.advanceTimersByTime(10000);
|
||||
jest.advanceTimersByTime(KEY_DELAY);
|
||||
expect(sendKeySpy).toHaveBeenCalledTimes(1);
|
||||
// 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.
|
||||
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships);
|
||||
// fake a condition in which we send another encryption key event.
|
||||
// this could happen do to someone joining the call.
|
||||
(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();
|
||||
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships);
|
||||
jest.advanceTimersByTime(7000);
|
||||
|
||||
const secondKeysPayload = await keysSentPromise2;
|
||||
|
||||
expect(secondKeysPayload.keys).toHaveLength(1);
|
||||
expect(secondKeysPayload.keys[0].index).toEqual(1);
|
||||
expect(onMyEncryptionKeyChanged).toHaveBeenCalledTimes(2);
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2);
|
||||
// initial, on leave and the fake one we do with: `(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();`
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(3);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
@@ -965,6 +980,29 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("send key as to device", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const keySentPromise = new Promise((resolve) => {
|
||||
sendToDeviceMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, {
|
||||
manageMediaKeys: true,
|
||||
useExperimentalToDeviceTransport: true,
|
||||
});
|
||||
|
||||
await keySentPromise;
|
||||
|
||||
expect(sendToDeviceMock).toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("receiving", () => {
|
||||
|
@@ -20,7 +20,7 @@ import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
|
||||
import { EventType, MatrixClient, RoomEvent } from "../../../src";
|
||||
import type { IRoomTimelineData, MatrixEvent, Room } from "../../../src";
|
||||
|
||||
describe("RoomKyTransport", () => {
|
||||
describe("RoomKeyTransport", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room & {
|
||||
emitTimelineEvent: (event: MatrixEvent) => void;
|
||||
|
249
spec/unit/matrixrtc/ToDeviceKeyTransport.spec.ts
Normal file
249
spec/unit/matrixrtc/ToDeviceKeyTransport.spec.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
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 { makeMockEvent, membershipTemplate, mockCallMembership } from "./mocks";
|
||||
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
|
||||
import { type Statistics } from "../../../src/matrixrtc";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { defer } from "../../../src/utils.ts";
|
||||
import { type Logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("ToDeviceKeyTransport", () => {
|
||||
const roomId = "!room:id";
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let statistics: Statistics;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
let transport: ToDeviceKeyTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
encryptAndSendToDevice: jest.fn(),
|
||||
});
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
|
||||
transport = new ToDeviceKeyTransport("@alice:example.org", "MYDEVICE", roomId, mockClient, statistics, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
});
|
||||
|
||||
it("should send my keys on via to device", async () => {
|
||||
transport.start();
|
||||
|
||||
const keyBase64Encoded = "ABCDEDF";
|
||||
const keyIndex = 2;
|
||||
await transport.sendKey(keyBase64Encoded, keyIndex, [
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "BOBDEVICE" }),
|
||||
roomId,
|
||||
"@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).toHaveBeenCalledWith(
|
||||
"io.element.call.encryption_keys",
|
||||
[
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE" },
|
||||
{ userId: "@carl:example.org", deviceId: "CARLDEVICE" },
|
||||
{ userId: "@mat:example.org", deviceId: "MATDEVICE" },
|
||||
],
|
||||
{
|
||||
keys: {
|
||||
index: keyIndex,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "MYDEVICE",
|
||||
},
|
||||
room_id: roomId,
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(1);
|
||||
});
|
||||
|
||||
it("should emit when a key is received", async () => {
|
||||
const deferred = defer<{ userId: string; deviceId: string; keyBase64Encoded: string; index: number }>();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
deferred.resolve({ userId, deviceId, keyBase64Encoded, index });
|
||||
});
|
||||
transport.start();
|
||||
|
||||
const testEncoded = "ABCDEDF";
|
||||
const testKeyIndex = 2;
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, {
|
||||
keys: {
|
||||
index: testKeyIndex,
|
||||
key: testEncoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "BOBDEVICE",
|
||||
},
|
||||
room_id: roomId,
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { userId, deviceId, keyBase64Encoded, index } = await deferred.promise;
|
||||
expect(userId).toBe("@bob:example.org");
|
||||
expect(deviceId).toBe("BOBDEVICE");
|
||||
expect(keyBase64Encoded).toBe(testEncoded);
|
||||
expect(index).toBe(testKeyIndex);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(1);
|
||||
});
|
||||
|
||||
it("should not sent to ourself", async () => {
|
||||
const keyBase64Encoded = "ABCDEDF";
|
||||
const keyIndex = 2;
|
||||
await transport.sendKey(keyBase64Encoded, keyIndex, [
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "MYDEVICE" }),
|
||||
roomId,
|
||||
"@alice:example.org",
|
||||
),
|
||||
]);
|
||||
|
||||
transport.start();
|
||||
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("should warn when there is a room mismatch", () => {
|
||||
transport.start();
|
||||
|
||||
const testEncoded = "ABCDEDF";
|
||||
const testKeyIndex = 2;
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, {
|
||||
keys: {
|
||||
index: testKeyIndex,
|
||||
key: testEncoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "BOBDEVICE",
|
||||
},
|
||||
room_id: "!anotherroom:id",
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith("Malformed Event: Mismatch roomId");
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
|
||||
describe("malformed events", () => {
|
||||
const MALFORMED_EVENT = [
|
||||
{
|
||||
keys: {},
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { index: 0 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { keys: "ABCDEF" },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { keys: "ABCDEF", index: 2 },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { keys: "ABCDEF", index: 2 },
|
||||
member: {},
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { keys: "ABCDEF", index: 2 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { keys: "ABCDEF", index: 2 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
];
|
||||
|
||||
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
|
||||
transport.start();
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, event),
|
||||
);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
@@ -123,7 +123,7 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
|
||||
export function makeMockEvent(
|
||||
type: string,
|
||||
sender: string,
|
||||
roomId: string,
|
||||
roomId: string | undefined,
|
||||
content: any,
|
||||
timestamp?: number,
|
||||
): MatrixEvent {
|
||||
|
@@ -207,7 +207,7 @@ import {
|
||||
import { M_BEACON_INFO, type MBeaconInfoEventContent } from "./@types/beacon.ts";
|
||||
import { NamespacedValue, UnstableValue } from "./NamespacedValue.ts";
|
||||
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue.ts";
|
||||
import { type ToDeviceBatch } from "./models/ToDeviceMessage.ts";
|
||||
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
|
||||
import { IgnoredInvites } from "./models/invites-ignorer.ts";
|
||||
import { type UIARequest } from "./@types/uia.ts";
|
||||
import { type LocalNotificationSettings } from "./@types/local_notifications.ts";
|
||||
@@ -7942,6 +7942,29 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Put, path, undefined, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* This will encrypt the payload for all devices in the list and will queue it.
|
||||
* The type of the sent to-device message will be `m.room.encrypted`.
|
||||
* @param eventType - The type of event to send
|
||||
* @param devices - The list of devices to send the event to.
|
||||
* @param payload - The payload to send. This will be encrypted.
|
||||
* @returns Promise which resolves once queued there is no error feedback when sending fails.
|
||||
*/
|
||||
public async encryptAndSendToDevice(
|
||||
eventType: string,
|
||||
devices: { userId: string; deviceId: string }[],
|
||||
payload: ToDevicePayload,
|
||||
): Promise<void> {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("Cannot encrypt to device event, your client does not support encryption.");
|
||||
}
|
||||
const batch = await this.cryptoBackend.encryptToDeviceMessages(eventType, devices, payload);
|
||||
|
||||
// TODO The batch mechanism removes all possibility to get error feedbacks..
|
||||
// We might want instead to do the API call directly and pass the errors back.
|
||||
await this.queueToDevice(batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends events directly to specific devices using Matrix's to-device
|
||||
* messaging system. The batch will be split up into appropriately sized
|
||||
|
@@ -62,17 +62,6 @@ interface IStateEventRequest {
|
||||
stateKey?: string;
|
||||
}
|
||||
|
||||
export interface OlmDevice {
|
||||
/**
|
||||
* The user ID of the device owner.
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* The device ID of the device.
|
||||
*/
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface ICapabilities {
|
||||
/**
|
||||
* Event types that this client expects to send.
|
||||
@@ -464,6 +453,25 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* by {@link MatrixClient.encryptAndSendToDevice}.
|
||||
*/
|
||||
public async encryptAndSendToDevice(
|
||||
eventType: string,
|
||||
devices: { userId: string; deviceId: string }[],
|
||||
payload: ToDevicePayload,
|
||||
): Promise<void> {
|
||||
// map: user Id → device Id → payload
|
||||
const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
|
||||
for (const { userId, deviceId } of devices) {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, true, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<EmptyObject> {
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
|
||||
@@ -495,18 +503,6 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
public async encryptAndSendToDevices(userDeviceInfoArr: OlmDevice[], payload: object): Promise<void> {
|
||||
// map: user Id → device Id → payload
|
||||
const contentMap: MapWithDefault<string, Map<string, object>> = new MapWithDefault(() => new Map());
|
||||
for (const { userId, deviceId } of userDeviceInfoArr) {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi
|
||||
.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an event to a specific list of devices via the widget API. Optionally encrypts the event.
|
||||
*
|
||||
|
@@ -13,10 +13,6 @@ const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
* This interface is for testing and for making it possible to interchange the encryption manager.
|
||||
* @internal
|
||||
*/
|
||||
/**
|
||||
* Interface representing an encryption manager for handling encryption-related
|
||||
* operations in a real-time communication context.
|
||||
*/
|
||||
export interface IEncryptionManager {
|
||||
/**
|
||||
* Joins the encryption manager with the provided configuration.
|
||||
@@ -80,8 +76,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
// if it looks like a membership has been updated.
|
||||
private lastMembershipFingerprints: Set<string> | undefined;
|
||||
|
||||
private currentEncryptionKeyIndex = -1;
|
||||
|
||||
private latestGeneratedKeyIndex = -1;
|
||||
private joinConfig: EncryptionConfig | undefined;
|
||||
|
||||
public constructor(
|
||||
@@ -254,8 +249,6 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
|
||||
if (!this.joined) return;
|
||||
|
||||
logger.info(`Sending encryption keys event. indexToSend=${indexToSend}`);
|
||||
|
||||
const myKeys = this.getKeysForParticipant(this.userId, this.deviceId);
|
||||
|
||||
if (!myKeys) {
|
||||
@@ -263,19 +256,23 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof indexToSend !== "number" && this.currentEncryptionKeyIndex === -1) {
|
||||
if (typeof indexToSend !== "number" && this.latestGeneratedKeyIndex === -1) {
|
||||
logger.warn("Tried to send encryption keys event but no current key index found!");
|
||||
return;
|
||||
}
|
||||
|
||||
const keyIndexToSend = indexToSend ?? this.currentEncryptionKeyIndex;
|
||||
const keyIndexToSend = indexToSend ?? this.latestGeneratedKeyIndex;
|
||||
|
||||
logger.info(
|
||||
`Try sending encryption keys event. keyIndexToSend=${keyIndexToSend} (method parameter: ${indexToSend})`,
|
||||
);
|
||||
const keyToSend = myKeys[keyIndexToSend];
|
||||
|
||||
try {
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships());
|
||||
logger.debug(
|
||||
`Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
`sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
this.encryptionKeys,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -290,6 +287,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
};
|
||||
|
||||
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
logger.debug(`Received key over key transport ${userId}:${deviceId} at index ${index}`);
|
||||
this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp);
|
||||
};
|
||||
|
||||
@@ -302,12 +300,12 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
|
||||
private getNewEncryptionKeyIndex(): number {
|
||||
if (this.currentEncryptionKeyIndex === -1) {
|
||||
if (this.latestGeneratedKeyIndex === -1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// maximum key index is 255
|
||||
return (this.currentEncryptionKeyIndex + 1) % 256;
|
||||
return (this.latestGeneratedKeyIndex + 1) % 256;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -332,6 +330,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
timestamp: number,
|
||||
delayBeforeUse = false,
|
||||
): void {
|
||||
logger.debug(`Setting encryption key for ${userId}:${deviceId} at index ${encryptionKeyIndex}`);
|
||||
const keyBin = decodeBase64(encryptionKeyString);
|
||||
|
||||
const participantId = getParticipantId(userId, deviceId);
|
||||
@@ -356,6 +355,15 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (userId === this.userId && deviceId === this.deviceId) {
|
||||
// It is important to already update the latestGeneratedKeyIndex here
|
||||
// NOT IN THE `delayBeforeUse` `setTimeout`.
|
||||
// Even though this is where we call onEncryptionKeysChanged and set the key in EC (and livekit).
|
||||
// It needs to happen here because we will send the key before the timeout has passed and sending
|
||||
// the key will use latestGeneratedKeyIndex as the index. if we update it in the `setTimeout` callback
|
||||
// it will use the wrong index (index - 1)!
|
||||
this.latestGeneratedKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
participantKeys[encryptionKeyIndex] = {
|
||||
key: keyBin,
|
||||
timestamp,
|
||||
@@ -364,17 +372,12 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
if (delayBeforeUse) {
|
||||
const useKeyTimeout = setTimeout(() => {
|
||||
this.setNewKeyTimeouts.delete(useKeyTimeout);
|
||||
logger.info(`Delayed-emitting key changed event for ${participantId} idx ${encryptionKeyIndex}`);
|
||||
if (userId === this.userId && deviceId === this.deviceId) {
|
||||
this.currentEncryptionKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
logger.info(`Delayed-emitting key changed event for ${participantId} index ${encryptionKeyIndex}`);
|
||||
|
||||
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
|
||||
}, this.useKeyDelay);
|
||||
this.setNewKeyTimeouts.add(useKeyTimeout);
|
||||
} else {
|
||||
if (userId === this.userId && deviceId === this.deviceId) {
|
||||
this.currentEncryptionKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
|
||||
}
|
||||
}
|
||||
|
@@ -45,9 +45,17 @@ export interface IKeyTransport {
|
||||
*/
|
||||
sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void>;
|
||||
|
||||
/** Subscribe to keys from this transport. */
|
||||
on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
|
||||
/** Unsubscribe from keys from this transport. */
|
||||
off(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
|
||||
|
||||
/** Once start is called the underlying transport will subscribe to its transport system.
|
||||
* Before start is called this transport will not emit any events.
|
||||
*/
|
||||
start(): void;
|
||||
/** Once stop is called the underlying transport will unsubscribe from its transport system.
|
||||
* After stop is called this transport will not emit any events.
|
||||
*/
|
||||
stop(): void;
|
||||
}
|
||||
|
@@ -28,9 +28,10 @@ import { MembershipManager } from "./NewMembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
|
||||
import { logDurationSync } from "../utils.ts";
|
||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import { type IMembershipManager } from "./IMembershipManager.ts";
|
||||
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
import { type Statistics } from "./types.ts";
|
||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import type { IMembershipManager } from "./IMembershipManager.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
@@ -125,6 +126,11 @@ export interface MembershipConfig {
|
||||
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a network error occurs.
|
||||
*/
|
||||
maximumNetworkErrorRetryCount?: number;
|
||||
|
||||
/**
|
||||
* If true, use the new to-device transport for sending encryption keys.
|
||||
*/
|
||||
useExperimentalToDeviceTransport?: boolean;
|
||||
}
|
||||
|
||||
export interface EncryptionConfig {
|
||||
@@ -303,6 +309,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "sendEvent"
|
||||
| "cancelPendingEvent"
|
||||
| "encryptAndSendToDevice"
|
||||
| "off"
|
||||
| "on"
|
||||
| "decryptEventIfNeeded"
|
||||
>,
|
||||
private roomSubset: Pick<
|
||||
@@ -370,7 +379,20 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
);
|
||||
}
|
||||
// Create Encryption manager
|
||||
const transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
|
||||
let transport;
|
||||
if (joinConfig?.useExperimentalToDeviceTransport) {
|
||||
logger.info("Using experimental to-device transport for encryption keys");
|
||||
transport = new ToDeviceKeyTransport(
|
||||
this.client.getUserId()!,
|
||||
this.client.getDeviceId()!,
|
||||
this.roomSubset.roomId,
|
||||
this.client,
|
||||
this.statistics,
|
||||
logger,
|
||||
);
|
||||
} else {
|
||||
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
|
||||
}
|
||||
this.encryptionManager = new EncryptionManager(
|
||||
this.client.getUserId()!,
|
||||
this.client.getDeviceId()!,
|
||||
|
@@ -168,7 +168,7 @@ export class RoomKeyTransport
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Embedded-E2EE-LOG onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
|
||||
`onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
|
||||
);
|
||||
this.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
|
169
src/matrixrtc/ToDeviceKeyTransport.ts
Normal file
169
src/matrixrtc/ToDeviceKeyTransport.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
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 { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type IKeyTransport, KeyTransportEvents, type KeyTransportEventsHandlerMap } from "./IKeyTransport.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
import type { EncryptionKeysToDeviceEventContent, Statistics } from "./types.ts";
|
||||
import { ClientEvent, type MatrixClient } from "../client.ts";
|
||||
import type { MatrixEvent } from "../models/event.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
|
||||
/**
|
||||
* ToDeviceKeyTransport is used to send MatrixRTC keys to other devices using the
|
||||
* to-device CS-API.
|
||||
*/
|
||||
export class ToDeviceKeyTransport
|
||||
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private readonly prefixedLogger: Logger;
|
||||
|
||||
public constructor(
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
private roomId: string,
|
||||
private client: Pick<MatrixClient, "encryptAndSendToDevice" | "on" | "off">,
|
||||
private statistics: Statistics,
|
||||
logger: Logger,
|
||||
) {
|
||||
super();
|
||||
this.prefixedLogger = logger.getChild(`[${roomId} ToDeviceKeyTransport]`);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
|
||||
const content: EncryptionKeysToDeviceEventContent = {
|
||||
keys: {
|
||||
index: index,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
room_id: this.roomId,
|
||||
member: {
|
||||
claimed_device_id: this.deviceId,
|
||||
},
|
||||
session: {
|
||||
call_id: "",
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
},
|
||||
};
|
||||
|
||||
const targets = members
|
||||
.filter((member) => {
|
||||
// filter malformed call members
|
||||
if (member.sender == undefined || member.deviceId == undefined) {
|
||||
this.prefixedLogger.warn(`Malformed call member: ${member.sender}|${member.deviceId}`);
|
||||
return false;
|
||||
}
|
||||
// Filter out me
|
||||
return !(member.sender == this.userId && member.deviceId == this.deviceId);
|
||||
})
|
||||
.map((member) => {
|
||||
return {
|
||||
userId: member.sender!,
|
||||
deviceId: member.deviceId!,
|
||||
};
|
||||
});
|
||||
|
||||
if (targets.length > 0) {
|
||||
await this.client.encryptAndSendToDevice(EventType.CallEncryptionKeysPrefix, targets, content);
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
} else {
|
||||
this.prefixedLogger.warn("No targets found for sending key");
|
||||
}
|
||||
}
|
||||
|
||||
private receiveCallKeyEvent(fromUser: string, content: EncryptionKeysToDeviceEventContent): void {
|
||||
// The event has already been validated at this point.
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
|
||||
// What is this, and why is it needed?
|
||||
// Also to device events do not have an origin server ts
|
||||
const now = Date.now();
|
||||
const age = now - (typeof content.sent_ts === "number" ? content.sent_ts : now);
|
||||
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
|
||||
|
||||
this.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
// TODO this is claimed information
|
||||
fromUser,
|
||||
// TODO: This is claimed information
|
||||
content.member.claimed_device_id!,
|
||||
content.keys.key,
|
||||
content.keys.index,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
private onToDeviceEvent = (event: MatrixEvent): void => {
|
||||
if (event.getType() !== EventType.CallEncryptionKeysPrefix) {
|
||||
// Ignore this is not a call encryption event
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Not possible to check if the event is encrypted or not
|
||||
// see https://github.com/matrix-org/matrix-rust-sdk/issues/4883
|
||||
// if (evnt.getWireType() != EventType.RoomMessageEncrypted) {
|
||||
// // WARN: The call keys were sent in clear. Ignore them
|
||||
// logger.warn(`Call encryption keys sent in clear from: ${event.getSender()}`);
|
||||
// return;
|
||||
// }
|
||||
|
||||
const content = this.getValidEventContent(event);
|
||||
if (!content) return;
|
||||
|
||||
if (!event.getSender()) return;
|
||||
|
||||
this.receiveCallKeyEvent(event.getSender()!, content);
|
||||
};
|
||||
|
||||
private getValidEventContent(event: MatrixEvent): EncryptionKeysToDeviceEventContent | undefined {
|
||||
const content = event.getContent();
|
||||
const roomId = content.room_id;
|
||||
if (!roomId) {
|
||||
// Invalid event
|
||||
this.prefixedLogger.warn("Malformed Event: invalid call encryption keys event, no roomId");
|
||||
return;
|
||||
}
|
||||
if (roomId !== this.roomId) {
|
||||
this.prefixedLogger.warn("Malformed Event: Mismatch roomId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.keys || !content.keys.key || typeof content.keys.index !== "number") {
|
||||
this.prefixedLogger.warn("Malformed Event: Missing keys field");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.member || !content.member.claimed_device_id) {
|
||||
this.prefixedLogger.warn("Malformed Event: Missing claimed_device_id");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO check for session related fields once the to-device encryption uses the new format.
|
||||
return content as EncryptionKeysToDeviceEventContent;
|
||||
}
|
||||
}
|
@@ -28,6 +28,24 @@ export interface EncryptionKeysEventContent {
|
||||
sent_ts?: number;
|
||||
}
|
||||
|
||||
export interface EncryptionKeysToDeviceEventContent {
|
||||
keys: { index: number; key: string };
|
||||
member: {
|
||||
// id: ParticipantId,
|
||||
// TODO Remove that it is claimed, need to get the sealed sender from decryption info
|
||||
claimed_device_id: string;
|
||||
// user_id: string
|
||||
};
|
||||
room_id: string;
|
||||
session: {
|
||||
application: string;
|
||||
call_id: string;
|
||||
scope: string;
|
||||
};
|
||||
// Why is this needed?
|
||||
sent_ts?: number;
|
||||
}
|
||||
|
||||
export type CallNotifyType = "ring" | "notify";
|
||||
|
||||
export interface ICallNotifyContent {
|
||||
|
@@ -2510,7 +2510,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Here we were sending the event to the opponent's device as a to-device message with MatrixClient.encryptAndSendToDevices.
|
||||
// TODO: Here we were sending the event to the opponent's device as a to-device message with MatrixClient.encryptAndSendToDevice.
|
||||
// However due to the switch to Rust cryptography we need to migrate to the new encryptToDeviceMessages API.
|
||||
throw new Error("Unimplemented");
|
||||
} else {
|
||||
|
Reference in New Issue
Block a user