1
0
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:
Valere Fedronic
2025-04-10 10:28:01 +02:00
committed by GitHub
parent eb793aaa08
commit 3f03c1da89
14 changed files with 584 additions and 57 deletions

View File

@@ -722,13 +722,14 @@ describe("RoomWidgetClient", () => {
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData); 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"] }); await makeClient({ sendToDevice: ["org.example.foo"] });
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("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; const embeddedClient = client as RoomWidgetClient;
await embeddedClient.encryptAndSendToDevices( await embeddedClient.encryptAndSendToDevice(
"org.example.foo",
[ [
{ userId: "@alice:example.org", deviceId: "aliceWeb" }, { userId: "@alice:example.org", deviceId: "aliceWeb" },
{ userId: "@bob:example.org", deviceId: "bobDesktop" }, { userId: "@bob:example.org", deviceId: "bobDesktop" },

View File

@@ -486,14 +486,17 @@ describe("MatrixRTCSession", () => {
let sendStateEventMock: jest.Mock; let sendStateEventMock: jest.Mock;
let sendDelayedStateMock: jest.Mock; let sendDelayedStateMock: jest.Mock;
let sendEventMock: jest.Mock; let sendEventMock: jest.Mock;
let sendToDeviceMock: jest.Mock;
beforeEach(() => { beforeEach(() => {
sendStateEventMock = jest.fn(); sendStateEventMock = jest.fn();
sendDelayedStateMock = jest.fn(); sendDelayedStateMock = jest.fn();
sendEventMock = jest.fn(); sendEventMock = jest.fn();
sendToDeviceMock = jest.fn();
client.sendStateEvent = sendStateEventMock; client.sendStateEvent = sendStateEventMock;
client._unstable_sendDelayedStateEvent = sendDelayedStateMock; client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
client.sendEvent = sendEventMock; client.sendEvent = sendEventMock;
client.encryptAndSendToDevice = sendToDeviceMock;
mockRoom = makeMockRoom([]); mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
@@ -832,6 +835,7 @@ describe("MatrixRTCSession", () => {
it("rotates key if a member leaves", async () => { it("rotates key if a member leaves", async () => {
jest.useFakeTimers(); jest.useFakeTimers();
try { try {
const KEY_DELAY = 3000;
const member2 = Object.assign({}, membershipTemplate, { const member2 = Object.assign({}, membershipTemplate, {
device_id: "BBBBBBB", device_id: "BBBBBBB",
}); });
@@ -852,7 +856,8 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); 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; const firstKeysPayload = await keysSentPromise1;
expect(firstKeysPayload.keys).toHaveLength(1); expect(firstKeysPayload.keys).toHaveLength(1);
expect(firstKeysPayload.keys[0].index).toEqual(0); expect(firstKeysPayload.keys[0].index).toEqual(0);
@@ -869,14 +874,24 @@ describe("MatrixRTCSession", () => {
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId)); .mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
sess.onRTCSessionMemberUpdate(); 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; const secondKeysPayload = await keysSentPromise2;
expect(secondKeysPayload.keys).toHaveLength(1); expect(secondKeysPayload.keys).toHaveLength(1);
expect(secondKeysPayload.keys[0].index).toEqual(1); expect(secondKeysPayload.keys[0].index).toEqual(1);
expect(onMyEncryptionKeyChanged).toHaveBeenCalledTimes(2); 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 { } finally {
jest.useRealTimers(); jest.useRealTimers();
} }
@@ -965,6 +980,29 @@ describe("MatrixRTCSession", () => {
jest.useRealTimers(); 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", () => { describe("receiving", () => {

View File

@@ -20,7 +20,7 @@ import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
import { EventType, MatrixClient, RoomEvent } from "../../../src"; import { EventType, MatrixClient, RoomEvent } from "../../../src";
import type { IRoomTimelineData, MatrixEvent, Room } from "../../../src"; import type { IRoomTimelineData, MatrixEvent, Room } from "../../../src";
describe("RoomKyTransport", () => { describe("RoomKeyTransport", () => {
let client: MatrixClient; let client: MatrixClient;
let room: Room & { let room: Room & {
emitTimelineEvent: (event: MatrixEvent) => void; emitTimelineEvent: (event: MatrixEvent) => void;

View 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);
});
});
});

View File

@@ -123,7 +123,7 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
export function makeMockEvent( export function makeMockEvent(
type: string, type: string,
sender: string, sender: string,
roomId: string, roomId: string | undefined,
content: any, content: any,
timestamp?: number, timestamp?: number,
): MatrixEvent { ): MatrixEvent {

View File

@@ -207,7 +207,7 @@ import {
import { M_BEACON_INFO, type MBeaconInfoEventContent } from "./@types/beacon.ts"; import { M_BEACON_INFO, type MBeaconInfoEventContent } from "./@types/beacon.ts";
import { NamespacedValue, UnstableValue } from "./NamespacedValue.ts"; import { NamespacedValue, UnstableValue } from "./NamespacedValue.ts";
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue.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 { IgnoredInvites } from "./models/invites-ignorer.ts";
import { type UIARequest } from "./@types/uia.ts"; import { type UIARequest } from "./@types/uia.ts";
import { type LocalNotificationSettings } from "./@types/local_notifications.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); 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 * Sends events directly to specific devices using Matrix's to-device
* messaging system. The batch will be split up into appropriately sized * messaging system. The batch will be split up into appropriately sized

View File

@@ -62,17 +62,6 @@ interface IStateEventRequest {
stateKey?: string; 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 { export interface ICapabilities {
/** /**
* Event types that this client expects to send. * Event types that this client expects to send.
@@ -464,6 +453,25 @@ export class RoomWidgetClient extends MatrixClient {
return {}; 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> { public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<EmptyObject> {
await this.widgetApi await this.widgetApi
.sendToDevice(eventType, false, recursiveMapToObject(contentMap)) .sendToDevice(eventType, false, recursiveMapToObject(contentMap))
@@ -495,18 +503,6 @@ export class RoomWidgetClient extends MatrixClient {
.catch(timeoutToConnectionError); .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. * Send an event to a specific list of devices via the widget API. Optionally encrypts the event.
* *

View File

@@ -13,10 +13,6 @@ const logger = rootLogger.getChild("MatrixRTCSession");
* This interface is for testing and for making it possible to interchange the encryption manager. * This interface is for testing and for making it possible to interchange the encryption manager.
* @internal * @internal
*/ */
/**
* Interface representing an encryption manager for handling encryption-related
* operations in a real-time communication context.
*/
export interface IEncryptionManager { export interface IEncryptionManager {
/** /**
* Joins the encryption manager with the provided configuration. * 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. // if it looks like a membership has been updated.
private lastMembershipFingerprints: Set<string> | undefined; private lastMembershipFingerprints: Set<string> | undefined;
private currentEncryptionKeyIndex = -1; private latestGeneratedKeyIndex = -1;
private joinConfig: EncryptionConfig | undefined; private joinConfig: EncryptionConfig | undefined;
public constructor( public constructor(
@@ -254,8 +249,6 @@ export class EncryptionManager implements IEncryptionManager {
if (!this.joined) return; if (!this.joined) return;
logger.info(`Sending encryption keys event. indexToSend=${indexToSend}`);
const myKeys = this.getKeysForParticipant(this.userId, this.deviceId); const myKeys = this.getKeysForParticipant(this.userId, this.deviceId);
if (!myKeys) { if (!myKeys) {
@@ -263,19 +256,23 @@ export class EncryptionManager implements IEncryptionManager {
return; 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!"); logger.warn("Tried to send encryption keys event but no current key index found!");
return; 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]; const keyToSend = myKeys[keyIndexToSend];
try { try {
this.statistics.counters.roomEventEncryptionKeysSent += 1; this.statistics.counters.roomEventEncryptionKeysSent += 1;
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships()); await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships());
logger.debug( 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, this.encryptionKeys,
); );
} catch (error) { } catch (error) {
@@ -290,6 +287,7 @@ export class EncryptionManager implements IEncryptionManager {
}; };
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => { 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); this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp);
}; };
@@ -302,12 +300,12 @@ export class EncryptionManager implements IEncryptionManager {
} }
private getNewEncryptionKeyIndex(): number { private getNewEncryptionKeyIndex(): number {
if (this.currentEncryptionKeyIndex === -1) { if (this.latestGeneratedKeyIndex === -1) {
return 0; return 0;
} }
// maximum key index is 255 // 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, timestamp: number,
delayBeforeUse = false, delayBeforeUse = false,
): void { ): void {
logger.debug(`Setting encryption key for ${userId}:${deviceId} at index ${encryptionKeyIndex}`);
const keyBin = decodeBase64(encryptionKeyString); const keyBin = decodeBase64(encryptionKeyString);
const participantId = getParticipantId(userId, deviceId); 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] = { participantKeys[encryptionKeyIndex] = {
key: keyBin, key: keyBin,
timestamp, timestamp,
@@ -364,17 +372,12 @@ export class EncryptionManager implements IEncryptionManager {
if (delayBeforeUse) { if (delayBeforeUse) {
const useKeyTimeout = setTimeout(() => { const useKeyTimeout = setTimeout(() => {
this.setNewKeyTimeouts.delete(useKeyTimeout); this.setNewKeyTimeouts.delete(useKeyTimeout);
logger.info(`Delayed-emitting key changed event for ${participantId} idx ${encryptionKeyIndex}`); logger.info(`Delayed-emitting key changed event for ${participantId} index ${encryptionKeyIndex}`);
if (userId === this.userId && deviceId === this.deviceId) {
this.currentEncryptionKeyIndex = encryptionKeyIndex;
}
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId); this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
}, this.useKeyDelay); }, this.useKeyDelay);
this.setNewKeyTimeouts.add(useKeyTimeout); this.setNewKeyTimeouts.add(useKeyTimeout);
} else { } else {
if (userId === this.userId && deviceId === this.deviceId) {
this.currentEncryptionKeyIndex = encryptionKeyIndex;
}
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId); this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
} }
} }

View File

@@ -45,9 +45,17 @@ export interface IKeyTransport {
*/ */
sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void>; sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void>;
/** Subscribe to keys from this transport. */
on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this; on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
/** Unsubscribe from keys from this transport. */
off(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this; 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; 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; stop(): void;
} }

View File

@@ -28,9 +28,10 @@ import { MembershipManager } from "./NewMembershipManager.ts";
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts"; import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
import { logDurationSync } from "../utils.ts"; import { logDurationSync } from "../utils.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts"; import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
import { type IMembershipManager } from "./IMembershipManager.ts";
import { type Statistics } from "./types.ts"; import { type Statistics } from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import type { IMembershipManager } from "./IMembershipManager.ts";
const logger = rootLogger.getChild("MatrixRTCSession"); 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. * 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; maximumNetworkErrorRetryCount?: number;
/**
* If true, use the new to-device transport for sending encryption keys.
*/
useExperimentalToDeviceTransport?: boolean;
} }
export interface EncryptionConfig { export interface EncryptionConfig {
@@ -303,6 +309,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
| "_unstable_updateDelayedEvent" | "_unstable_updateDelayedEvent"
| "sendEvent" | "sendEvent"
| "cancelPendingEvent" | "cancelPendingEvent"
| "encryptAndSendToDevice"
| "off"
| "on"
| "decryptEventIfNeeded" | "decryptEventIfNeeded"
>, >,
private roomSubset: Pick< private roomSubset: Pick<
@@ -370,7 +379,20 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
); );
} }
// Create Encryption manager // 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.encryptionManager = new EncryptionManager(
this.client.getUserId()!, this.client.getUserId()!,
this.client.getDeviceId()!, this.client.getDeviceId()!,

View File

@@ -168,7 +168,7 @@ export class RoomKeyTransport
); );
} else { } else {
logger.debug( 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( this.emit(
KeyTransportEvents.ReceivedKeys, KeyTransportEvents.ReceivedKeys,

View 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;
}
}

View File

@@ -28,6 +28,24 @@ export interface EncryptionKeysEventContent {
sent_ts?: number; 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 type CallNotifyType = "ring" | "notify";
export interface ICallNotifyContent { export interface ICallNotifyContent {

View File

@@ -2510,7 +2510,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
return; 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. // However due to the switch to Rust cryptography we need to migrate to the new encryptToDeviceMessages API.
throw new Error("Unimplemented"); throw new Error("Unimplemented");
} else { } else {