1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-30 04:23:07 +03:00

MatrixRTC: Introduce key transport abstraction as prep work for to-device encryption (#4773)

* refactor: extract RoomKeyTransport class for key distribution

* refact: Call key transport, pass the target recipients to sendKey

* update IKeyTransport interface to event emitter.

* fix not subscribing to KeyTransportEvents in the EncryptionManager + cleanup

* fix one test and broken bits needed for the test (mostly statistics wrangling)

* fix tests

* add back decryptEventIfNeeded

* move and fix room transport tests

* dedupe isMyMembership

* move type declarations around to be at more reasonable places

* remove deprecated `onMembershipUpdate`

* fix imports

* only start keytransport when session is joined

* use makeKey to reduce test loc

* fix todo comment -> note comment

---------

Co-authored-by: Timo <toger5@hotmail.de>
This commit is contained in:
Valere Fedronic
2025-04-07 10:30:10 +02:00
committed by GitHub
parent d6ede767c9
commit ba71235539
14 changed files with 819 additions and 671 deletions

View File

@ -20,7 +20,7 @@ import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../sr
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
import { secureRandomString } from "../../../src/randomstring";
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks";
const mockFocus = { type: "mock" };
@ -34,6 +34,8 @@ describe("MatrixRTCSession", () => {
client = new MatrixClient({ baseUrl: "base_url" });
client.getUserId = jest.fn().mockReturnValue("@alice:example.org");
client.getDeviceId = jest.fn().mockReturnValue("AAAAAAA");
client.sendEvent = jest.fn().mockResolvedValue({ event_id: "success" });
client.decryptEventIfNeeded = jest.fn();
});
afterEach(async () => {
@ -478,6 +480,7 @@ describe("MatrixRTCSession", () => {
});
describe("key management", () => {
// TODO make this test suit only test the encryption manager. And mock the transport directly not the session.
describe("sending", () => {
let mockRoom: Room;
let sendStateEventMock: jest.Mock;
@ -531,12 +534,7 @@ describe("MatrixRTCSession", () => {
{
call_id: "",
device_id: "AAAAAAA",
keys: [
{
index: 0,
key: expect.stringMatching(".*"),
},
],
keys: [makeKey(0, expect.stringMatching(".*"))],
sent_ts: Date.now(),
},
);
@ -584,7 +582,7 @@ describe("MatrixRTCSession", () => {
});
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
jest.advanceTimersByTime(10000);
await jest.runAllTimersAsync();
await eventSentPromise;
@ -739,12 +737,7 @@ describe("MatrixRTCSession", () => {
{
call_id: "",
device_id: "AAAAAAA",
keys: [
{
index: 0,
key: expect.stringMatching(".*"),
},
],
keys: [makeKey(0, expect.stringMatching(".*"))],
sent_ts: Date.now(),
},
);
@ -793,12 +786,7 @@ describe("MatrixRTCSession", () => {
{
call_id: "",
device_id: "AAAAAAA",
keys: [
{
index: 0,
key: expect.stringMatching(".*"),
},
],
keys: [makeKey(0, expect.stringMatching(".*"))],
sent_ts: Date.now(),
},
);
@ -831,12 +819,7 @@ describe("MatrixRTCSession", () => {
{
call_id: "",
device_id: "AAAAAAA",
keys: [
{
index: 0,
key: expect.stringMatching(".*"),
},
],
keys: [makeKey(0, expect.stringMatching(".*"))],
sent_ts: Date.now(),
},
);
@ -985,61 +968,48 @@ describe("MatrixRTCSession", () => {
});
describe("receiving", () => {
it("collects keys from encryption events", () => {
it("collects keys from encryption events", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 0,
key: "dGhpcyBpcyB0aGUga2V5",
},
],
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
getTs: jest.fn().mockReturnValue(Date.now()),
} as unknown as MatrixEvent);
);
await jest.advanceTimersToNextTimerAsync();
const encryptionKeyChangedListener = jest.fn();
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
sess!.reemitEncryptionKeys();
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("this is the key"),
0,
"@bob:example.org:bobsphone",
);
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
});
it("collects keys at non-zero indices", () => {
it("collects keys at non-zero indices", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 4,
key: "dGhpcyBpcyB0aGUga2V5",
},
],
keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")],
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
getTs: jest.fn().mockReturnValue(Date.now()),
} as unknown as MatrixEvent);
);
await jest.advanceTimersToNextTimerAsync();
const encryptionKeyChangedListener = jest.fn();
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
sess!.reemitEncryptionKeys();
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("this is the key"),
4,
@ -1049,61 +1019,48 @@ describe("MatrixRTCSession", () => {
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
});
it("collects keys by merging", () => {
it("collects keys by merging", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 0,
key: "dGhpcyBpcyB0aGUga2V5",
},
],
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
getTs: jest.fn().mockReturnValue(Date.now()),
} as unknown as MatrixEvent);
);
await jest.advanceTimersToNextTimerAsync();
const encryptionKeyChangedListener = jest.fn();
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
sess!.reemitEncryptionKeys();
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("this is the key"),
0,
"@bob:example.org:bobsphone",
);
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 4,
key: "dGhpcyBpcyB0aGUga2V5",
},
],
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
getTs: jest.fn().mockReturnValue(Date.now()),
} as unknown as MatrixEvent);
encryptionKeyChangedListener.mockClear();
sess!.reemitEncryptionKeys();
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("this is the key"),
0,
"@bob:example.org:bobsphone",
);
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
device_id: "bobsphone",
call_id: "",
keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")],
}),
);
await jest.advanceTimersToNextTimerAsync();
encryptionKeyChangedListener.mockClear();
sess!.reemitEncryptionKeys();
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(3);
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("this is the key"),
0,
"@bob:example.org:bobsphone",
);
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("this is the key"),
4,
@ -1113,93 +1070,102 @@ describe("MatrixRTCSession", () => {
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2);
});
it("ignores older keys at same index", () => {
it("ignores older keys at same index", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 0,
key: encodeBase64(Buffer.from("newer key", "utf-8")),
},
],
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
getTs: jest.fn().mockReturnValue(2000),
} as unknown as MatrixEvent);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent(
"io.element.call.encryption_keys",
"@bob:example.org",
"1234roomId",
{
device_id: "bobsphone",
call_id: "",
keys: [makeKey(0, encodeBase64(Buffer.from("newer key", "utf-8")))],
},
2000,
),
);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 0,
key: encodeBase64(Buffer.from("older key", "utf-8")),
},
],
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
getTs: jest.fn().mockReturnValue(1000), // earlier timestamp than the newer key
} as unknown as MatrixEvent);
mockRoom.emitTimelineEvent(
makeMockEvent(
"io.element.call.encryption_keys",
"@bob:example.org",
"1234roomId",
{
device_id: "bobsphone",
call_id: "",
keys: [makeKey(0, encodeBase64(Buffer.from("newer key", "utf-8")))],
},
2000,
),
);
mockRoom.emitTimelineEvent(
makeMockEvent(
"io.element.call.encryption_keys",
"@bob:example.org",
"1234roomId",
{
device_id: "bobsphone",
call_id: "",
keys: [makeKey(0, encodeBase64(Buffer.from("older key", "utf-8")))],
},
1000,
),
);
await jest.advanceTimersToNextTimerAsync();
const encryptionKeyChangedListener = jest.fn();
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
sess!.reemitEncryptionKeys();
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("newer key"),
0,
"@bob:example.org:bobsphone",
);
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2);
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(3);
});
it("key timestamps are treated as monotonic", () => {
it("key timestamps are treated as monotonic", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 0,
key: encodeBase64(Buffer.from("first key", "utf-8")),
},
],
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
getTs: jest.fn().mockReturnValue(1000),
} as unknown as MatrixEvent);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent(
"io.element.call.encryption_keys",
"@bob:example.org",
"1234roomId",
{
device_id: "bobsphone",
call_id: "",
keys: [makeKey(0, encodeBase64(Buffer.from("older key", "utf-8")))],
},
1000,
),
);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 0,
key: encodeBase64(Buffer.from("second key", "utf-8")),
},
],
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
getTs: jest.fn().mockReturnValue(1000), // same timestamp as the first key
} as unknown as MatrixEvent);
mockRoom.emitTimelineEvent(
makeMockEvent(
"io.element.call.encryption_keys",
"@bob:example.org",
"1234roomId",
{
device_id: "bobsphone",
call_id: "",
keys: [makeKey(0, encodeBase64(Buffer.from("second key", "utf-8")))],
},
1000,
),
);
await jest.advanceTimersToNextTimerAsync();
const encryptionKeyChangedListener = jest.fn();
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
sess!.reemitEncryptionKeys();
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
textEncoder.encode("second key"),
0,
@ -1210,31 +1176,25 @@ describe("MatrixRTCSession", () => {
it("ignores keys event for the local participant", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", client.getUserId()!, "1234roomId", {
device_id: client.getDeviceId(),
call_id: "",
keys: [
{
index: 4,
key: "dGhpcyBpcyB0aGUga2V5",
},
],
keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")],
}),
getSender: jest.fn().mockReturnValue(client.getUserId()),
getTs: jest.fn().mockReturnValue(Date.now()),
} as unknown as MatrixEvent);
);
const encryptionKeyChangedListener = jest.fn();
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
sess!.reemitEncryptionKeys();
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(0);
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(0);
});
it("tracks total age statistics for collected keys", () => {
it("tracks total age statistics for collected keys", async () => {
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
@ -1242,59 +1202,49 @@ describe("MatrixRTCSession", () => {
// defaults to getTs()
jest.setSystemTime(1000);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 0,
key: "dGhpcyBpcyB0aGUga2V5",
},
],
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
getTs: jest.fn().mockReturnValue(0),
} as unknown as MatrixEvent);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent(
"io.element.call.encryption_keys",
"@bob:example.org",
"1234roomId",
{
device_id: "bobsphone",
call_id: "",
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
},
0,
),
);
await jest.advanceTimersToNextTimerAsync();
expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(1000);
jest.setSystemTime(2000);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 0,
key: "dGhpcyBpcyB0aGUga2V5",
},
],
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
sent_ts: 0,
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
getTs: jest.fn().mockReturnValue(Date.now()),
} as unknown as MatrixEvent);
);
await jest.advanceTimersToNextTimerAsync();
expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(3000);
jest.setSystemTime(3000);
sess.onCallEncryption({
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
getContent: jest.fn().mockReturnValue({
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
device_id: "bobsphone",
call_id: "",
keys: [
{
index: 0,
key: "dGhpcyBpcyB0aGUga2V5",
},
],
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
sent_ts: 1000,
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
getTs: jest.fn().mockReturnValue(Date.now()),
} as unknown as MatrixEvent);
);
await jest.advanceTimersToNextTimerAsync();
expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(5000);
} finally {
jest.useRealTimers();

View File

@ -16,15 +16,7 @@ limitations under the License.
import { type Mock } from "jest-mock";
import {
ClientEvent,
EventTimeline,
EventType,
type IRoomTimelineData,
MatrixClient,
type MatrixEvent,
RoomEvent,
} from "../../../src";
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
import { RoomStateEvent } from "../../../src/models/room-state";
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
@ -77,117 +69,4 @@ describe("MatrixRTCSessionManager", () => {
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
it("Calls onCallEncryption on encryption keys event", async () => {
const room1 = makeMockRoom([membershipTemplate]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
const onCallEncryptionMock = jest.fn();
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
client.decryptEventIfNeeded = () => Promise.resolve();
const timelineEvent = {
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
getContent: jest.fn().mockReturnValue({}),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getRoomId: jest.fn().mockReturnValue("!room:id"),
isDecryptionFailure: jest.fn().mockReturnValue(false),
sender: {
userId: "@mock:user.example",
},
} as unknown as MatrixEvent;
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
await new Promise(process.nextTick);
expect(onCallEncryptionMock).toHaveBeenCalled();
});
describe("event decryption", () => {
it("Retries decryption and processes success", async () => {
try {
jest.useFakeTimers();
const room1 = makeMockRoom([membershipTemplate]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
const onCallEncryptionMock = jest.fn();
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
let isDecryptionFailure = true;
client.decryptEventIfNeeded = jest
.fn()
.mockReturnValueOnce(Promise.resolve())
.mockImplementation(() => {
isDecryptionFailure = false;
return Promise.resolve();
});
const timelineEvent = {
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
getContent: jest.fn().mockReturnValue({}),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getRoomId: jest.fn().mockReturnValue("!room:id"),
isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure),
getId: jest.fn().mockReturnValue("event_id"),
sender: {
userId: "@mock:user.example",
},
} as unknown as MatrixEvent;
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
// should retry after one second:
await jest.advanceTimersByTimeAsync(1500);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
it("Retries decryption and processes failure", async () => {
try {
jest.useFakeTimers();
const room1 = makeMockRoom([membershipTemplate]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
const onCallEncryptionMock = jest.fn();
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve());
const timelineEvent = {
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
getContent: jest.fn().mockReturnValue({}),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getRoomId: jest.fn().mockReturnValue("!room:id"),
isDecryptionFailure: jest.fn().mockReturnValue(true), // always fail
getId: jest.fn().mockReturnValue("event_id"),
sender: {
userId: "@mock:user.example",
},
} as unknown as MatrixEvent;
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
// should retry after one second:
await jest.advanceTimersByTimeAsync(1500);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
// doesn't retry again:
await jest.advanceTimersByTimeAsync(1500);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
} finally {
jest.useRealTimers();
}
});
});
});

View File

@ -0,0 +1,141 @@
/*
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 { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks";
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport";
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
import { EventType, MatrixClient, RoomEvent } from "../../../src";
import type { IRoomTimelineData, MatrixEvent, Room } from "../../../src";
describe("RoomKyTransport", () => {
let client: MatrixClient;
let room: Room & {
emitTimelineEvent: (event: MatrixEvent) => void;
};
let transport: RoomKeyTransport;
const onCallEncryptionMock = jest.fn();
beforeEach(() => {
onCallEncryptionMock.mockReset();
const statistics = {
counters: {
roomEventEncryptionKeysSent: 0,
roomEventEncryptionKeysReceived: 0,
},
totals: {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
room = makeMockRoom([membershipTemplate]);
client = new MatrixClient({ baseUrl: "base_url" });
client.matrixRTC.start();
transport = new RoomKeyTransport(room, client, statistics);
transport.on(KeyTransportEvents.ReceivedKeys, (...p) => {
onCallEncryptionMock(...p);
});
transport.start();
});
afterEach(() => {
client.stopClient();
client.matrixRTC.stop();
transport.stop();
});
it("Calls onCallEncryption on encryption keys event", async () => {
client.decryptEventIfNeeded = () => Promise.resolve();
const timelineEvent = makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
call_id: "",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
});
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
await new Promise(process.nextTick);
expect(onCallEncryptionMock).toHaveBeenCalled();
});
describe("event decryption", () => {
it("Retries decryption and processes success", async () => {
jest.useFakeTimers();
let isDecryptionFailure = true;
client.decryptEventIfNeeded = jest
.fn()
.mockReturnValueOnce(Promise.resolve())
.mockImplementation(() => {
isDecryptionFailure = false;
return Promise.resolve();
});
const timelineEvent = Object.assign(
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
call_id: "",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
}),
{ isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure) },
);
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
// should retry after one second:
await jest.advanceTimersByTimeAsync(1500);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});
it("Retries decryption and processes failure", async () => {
try {
jest.useFakeTimers();
const onCallEncryptionMock = jest.fn();
client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve());
const timelineEvent = Object.assign(
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
call_id: "",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
}),
{ isDecryptionFailure: jest.fn().mockReturnValue(true) },
);
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
// should retry after one second:
await jest.advanceTimersByTimeAsync(1500);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
// doesn't retry again:
await jest.advanceTimersByTimeAsync(1500);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
} finally {
jest.useRealTimers();
}
});
});
});

View File

@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventType, type MatrixClient, type MatrixEvent, type Room } from "../../../src";
import { EventEmitter } from "stream";
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { secureRandomString } from "../../../src/randomstring";
@ -65,19 +67,24 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
};
}
export function makeMockRoom(membershipData: MembershipData): Room {
export function makeMockRoom(
membershipData: MembershipData,
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
const roomId = secureRandomString(8);
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
const roomState = makeMockRoomState(membershipData, roomId);
const room = {
const room = Object.assign(new EventEmitter(), {
roomId: roomId,
hasMembershipState: jest.fn().mockReturnValue(true),
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue(roomState),
}),
getVersion: jest.fn().mockReturnValue("default"),
} as unknown as Room;
return room;
}) as unknown as Room;
return Object.assign(room, {
emitTimelineEvent: (event: MatrixEvent) =>
room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any),
});
}
export function makeMockRoomState(membershipData: MembershipData, roomId: string) {
@ -113,17 +120,36 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
};
}
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
const sender = customSender ?? "@mock:user.example";
export function makeMockEvent(
type: string,
sender: string,
roomId: string,
content: any,
timestamp?: number,
): MatrixEvent {
return {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue(membershipData),
getType: jest.fn().mockReturnValue(type),
getContent: jest.fn().mockReturnValue(content),
getSender: jest.fn().mockReturnValue(sender),
getTs: jest.fn().mockReturnValue(Date.now()),
getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()),
getRoomId: jest.fn().mockReturnValue(roomId),
getId: jest.fn().mockReturnValue(secureRandomString(8)),
isDecryptionFailure: jest.fn().mockReturnValue(false),
} as unknown as MatrixEvent;
}
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
const sender = customSender ?? "@mock:user.example";
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
}
export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership {
return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData);
}
export function makeKey(id: number, key: string): { key: string; index: number } {
return {
key: key,
index: id,
};
}