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

View File

@ -16,15 +16,7 @@ limitations under the License.
import { type Mock } from "jest-mock"; import { type Mock } from "jest-mock";
import { import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
ClientEvent,
EventTimeline,
EventType,
type IRoomTimelineData,
MatrixClient,
type MatrixEvent,
RoomEvent,
} from "../../../src";
import { RoomStateEvent } from "../../../src/models/room-state"; import { RoomStateEvent } from "../../../src/models/room-state";
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks"; import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
@ -77,117 +69,4 @@ describe("MatrixRTCSessionManager", () => {
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); 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. 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 { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { secureRandomString } from "../../../src/randomstring"; 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); const roomId = secureRandomString(8);
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
const roomState = makeMockRoomState(membershipData, roomId); const roomState = makeMockRoomState(membershipData, roomId);
const room = { const room = Object.assign(new EventEmitter(), {
roomId: roomId, roomId: roomId,
hasMembershipState: jest.fn().mockReturnValue(true), hasMembershipState: jest.fn().mockReturnValue(true),
getLiveTimeline: jest.fn().mockReturnValue({ getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue(roomState), getState: jest.fn().mockReturnValue(roomState),
}), }),
getVersion: jest.fn().mockReturnValue("default"), getVersion: jest.fn().mockReturnValue("default"),
} as unknown as Room; }) as unknown as Room;
return 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) { 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 { export function makeMockEvent(
const sender = customSender ?? "@mock:user.example"; type: string,
sender: string,
roomId: string,
content: any,
timestamp?: number,
): MatrixEvent {
return { return {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), getType: jest.fn().mockReturnValue(type),
getContent: jest.fn().mockReturnValue(membershipData), getContent: jest.fn().mockReturnValue(content),
getSender: jest.fn().mockReturnValue(sender), getSender: jest.fn().mockReturnValue(sender),
getTs: jest.fn().mockReturnValue(Date.now()), getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()),
getRoomId: jest.fn().mockReturnValue(roomId), getRoomId: jest.fn().mockReturnValue(roomId),
getId: jest.fn().mockReturnValue(secureRandomString(8)),
isDecryptionFailure: jest.fn().mockReturnValue(false), isDecryptionFailure: jest.fn().mockReturnValue(false),
} as unknown as MatrixEvent; } 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 { export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership {
return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData); return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData);
} }
export function makeKey(id: number, key: string): { key: string; index: number } {
return {
key: key,
index: id,
};
}

View File

@ -1,57 +1,50 @@
import { type MatrixClient } from "../client.ts";
import { logger as rootLogger } from "../logger.ts"; import { logger as rootLogger } from "../logger.ts";
import { type MatrixEvent } from "../models/event.ts";
import { type Room } from "../models/room.ts";
import { type EncryptionConfig } from "./MatrixRTCSession.ts"; import { type EncryptionConfig } from "./MatrixRTCSession.ts";
import { secureRandomBase64Url } from "../randomstring.ts"; import { secureRandomBase64Url } from "../randomstring.ts";
import { type EncryptionKeysEventContent } from "./types.ts";
import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts"; import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
import { type MatrixError, safeGetRetryAfterMs } from "../http-api/errors.ts"; import { safeGetRetryAfterMs } from "../http-api/errors.ts";
import { type CallMembership } from "./CallMembership.ts"; import { type CallMembership } from "./CallMembership.ts";
import { EventType } from "../@types/event.ts"; import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
const logger = rootLogger.getChild("MatrixRTCSession"); import { isMyMembership, type Statistics } from "./types.ts";
/** const logger = rootLogger.getChild("MatrixRTCSession");
* A type collecting call encryption statistics for a session.
*/
export type Statistics = {
counters: {
/**
* The number of times we have sent a room event containing encryption keys.
*/
roomEventEncryptionKeysSent: number;
/**
* The number of times we have received a room event containing encryption keys.
*/
roomEventEncryptionKeysReceived: number;
};
totals: {
/**
* The total age (in milliseconds) of all room events containing encryption keys that we have received.
* We track the total age so that we can later calculate the average age of all keys received.
*/
roomEventEncryptionKeysReceivedTotalAge: number;
};
};
/** /**
* 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 {
join(joinConfig: EncryptionConfig | undefined): void;
leave(): void;
onMembershipsUpdate(oldMemberships: CallMembership[]): void;
/** /**
* Process `m.call.encryption_keys` events to track the encryption keys for call participants. * Joins the encryption manager with the provided configuration.
* This should be called each time the relevant event is received from a room timeline.
* If the event is malformed then it will be logged and ignored.
* *
* @param event the event to process * @param joinConfig - The configuration for joining encryption, or undefined
* if no specific configuration is provided.
*/
join(joinConfig: EncryptionConfig | undefined): void;
/**
* Leaves the encryption manager, cleaning up any associated resources.
*/
leave(): void;
/**
* Called from the MatrixRTCSession when the memberships in this session updated.
*
* @param oldMemberships - The previous state of call memberships before the update.
*/
onMembershipsUpdate(oldMemberships: CallMembership[]): void;
/**
* Retrieves the encryption keys currently managed by the encryption manager.
*
* @returns A map where the keys are identifiers and the values are arrays of
* objects containing encryption keys and their associated timestamps.
*/ */
onCallEncryptionEventReceived(event: MatrixEvent): void;
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>; getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
statistics: Statistics;
} }
/** /**
@ -71,9 +64,11 @@ export class EncryptionManager implements IEncryptionManager {
private get updateEncryptionKeyThrottle(): number { private get updateEncryptionKeyThrottle(): number {
return this.joinConfig?.updateEncryptionKeyThrottle ?? 3_000; return this.joinConfig?.updateEncryptionKeyThrottle ?? 3_000;
} }
private get makeKeyDelay(): number { private get makeKeyDelay(): number {
return this.joinConfig?.makeKeyDelay ?? 3_000; return this.joinConfig?.makeKeyDelay ?? 3_000;
} }
private get useKeyDelay(): number { private get useKeyDelay(): number {
return this.joinConfig?.useKeyDelay ?? 5_000; return this.joinConfig?.useKeyDelay ?? 5_000;
} }
@ -87,21 +82,14 @@ export class EncryptionManager implements IEncryptionManager {
private currentEncryptionKeyIndex = -1; private currentEncryptionKeyIndex = -1;
public statistics: Statistics = {
counters: {
roomEventEncryptionKeysSent: 0,
roomEventEncryptionKeysReceived: 0,
},
totals: {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
private joinConfig: EncryptionConfig | undefined; private joinConfig: EncryptionConfig | undefined;
public constructor( public constructor(
private client: Pick<MatrixClient, "sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent">, private userId: string,
private room: Pick<Room, "roomId">, private deviceId: string,
private getMemberships: () => CallMembership[], private getMemberships: () => CallMembership[],
private transport: IKeyTransport,
private statistics: Statistics,
private onEncryptionKeysChanged: ( private onEncryptionKeysChanged: (
keyBin: Uint8Array<ArrayBufferLike>, keyBin: Uint8Array<ArrayBufferLike>,
encryptionKeyIndex: number, encryptionKeyIndex: number,
@ -112,11 +100,16 @@ export class EncryptionManager implements IEncryptionManager {
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> { public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
return this.encryptionKeys; return this.encryptionKeys;
} }
private joined = false; private joined = false;
public join(joinConfig: EncryptionConfig): void { public join(joinConfig: EncryptionConfig): void {
this.joinConfig = joinConfig; this.joinConfig = joinConfig;
this.joined = true; this.joined = true;
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys; this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.start();
if (this.joinConfig?.manageMediaKeys) { if (this.joinConfig?.manageMediaKeys) {
this.makeNewSenderKey(); this.makeNewSenderKey();
this.requestSendCurrentKey(); this.requestSendCurrentKey();
@ -124,15 +117,12 @@ export class EncryptionManager implements IEncryptionManager {
} }
public leave(): void { public leave(): void {
const userId = this.client.getUserId();
const deviceId = this.client.getDeviceId();
if (!userId) throw new Error("No userId");
if (!deviceId) throw new Error("No deviceId");
// clear our encryption keys as we're done with them now (we'll // clear our encryption keys as we're done with them now (we'll
// make new keys if we rejoin). We leave keys for other participants // make new keys if we rejoin). We leave keys for other participants
// as they may still be using the same ones. // as they may still be using the same ones.
this.encryptionKeys.set(getParticipantId(userId, deviceId), []); this.encryptionKeys.set(getParticipantId(this.userId, this.deviceId), []);
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.stop();
if (this.makeNewKeyTimeout !== undefined) { if (this.makeNewKeyTimeout !== undefined) {
clearTimeout(this.makeNewKeyTimeout); clearTimeout(this.makeNewKeyTimeout);
@ -146,18 +136,17 @@ export class EncryptionManager implements IEncryptionManager {
this.manageMediaKeys = false; this.manageMediaKeys = false;
this.joined = false; this.joined = false;
} }
// TODO deduplicate this method. It also is in MatrixRTCSession.
private isMyMembership = (m: CallMembership): boolean =>
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
public onMembershipsUpdate(oldMemberships: CallMembership[]): void { public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
if (this.manageMediaKeys && this.joined) { if (this.manageMediaKeys && this.joined) {
const oldMembershipIds = new Set( const oldMembershipIds = new Set(
oldMemberships.filter((m) => !this.isMyMembership(m)).map(getParticipantIdFromMembership), oldMemberships
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
.map(getParticipantIdFromMembership),
); );
const newMembershipIds = new Set( const newMembershipIds = new Set(
this.getMemberships() this.getMemberships()
.filter((m) => !this.isMyMembership(m)) .filter((m) => !isMyMembership(m, this.userId, this.deviceId))
.map(getParticipantIdFromMembership), .map(getParticipantIdFromMembership),
); );
@ -204,16 +193,17 @@ export class EncryptionManager implements IEncryptionManager {
* @returns The index of the new key * @returns The index of the new key
*/ */
private makeNewSenderKey(delayBeforeUse = false): number { private makeNewSenderKey(delayBeforeUse = false): number {
const userId = this.client.getUserId();
const deviceId = this.client.getDeviceId();
if (!userId) throw new Error("No userId");
if (!deviceId) throw new Error("No deviceId");
const encryptionKey = secureRandomBase64Url(16); const encryptionKey = secureRandomBase64Url(16);
const encryptionKeyIndex = this.getNewEncryptionKeyIndex(); const encryptionKeyIndex = this.getNewEncryptionKeyIndex();
logger.info("Generated new key at index " + encryptionKeyIndex); logger.info("Generated new key at index " + encryptionKeyIndex);
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse); this.setEncryptionKey(
this.userId,
this.deviceId,
encryptionKeyIndex,
encryptionKey,
Date.now(),
delayBeforeUse,
);
return encryptionKeyIndex; return encryptionKeyIndex;
} }
@ -266,13 +256,7 @@ export class EncryptionManager implements IEncryptionManager {
logger.info(`Sending encryption keys event. indexToSend=${indexToSend}`); logger.info(`Sending encryption keys event. indexToSend=${indexToSend}`);
const userId = this.client.getUserId(); const myKeys = this.getKeysForParticipant(this.userId, this.deviceId);
const deviceId = this.client.getDeviceId();
if (!userId) throw new Error("No userId");
if (!deviceId) throw new Error("No deviceId");
const myKeys = this.getKeysForParticipant(userId, deviceId);
if (!myKeys) { if (!myKeys) {
logger.warn("Tried to send encryption keys event but no keys found!"); logger.warn("Tried to send encryption keys event but no keys found!");
@ -288,35 +272,15 @@ export class EncryptionManager implements IEncryptionManager {
const keyToSend = myKeys[keyIndexToSend]; const keyToSend = myKeys[keyIndexToSend];
try { try {
const content: EncryptionKeysEventContent = {
keys: [
{
index: keyIndexToSend,
key: encodeUnpaddedBase64(keyToSend),
},
],
device_id: deviceId,
call_id: "",
sent_ts: Date.now(),
};
this.statistics.counters.roomEventEncryptionKeysSent += 1; this.statistics.counters.roomEventEncryptionKeysSent += 1;
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships());
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
logger.debug( logger.debug(
`Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`, `Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`,
this.encryptionKeys, this.encryptionKeys,
); );
} catch (error) { } catch (error) {
const matrixError = error as MatrixError;
if (matrixError.event) {
// cancel the pending event: we'll just generate a new one with our latest
// keys when we resend
this.client.cancelPendingEvent(matrixError.event);
}
if (this.keysEventUpdateTimeout === undefined) { if (this.keysEventUpdateTimeout === undefined) {
const resendDelay = safeGetRetryAfterMs(matrixError, 5000); const resendDelay = safeGetRetryAfterMs(error, 5000);
logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error); logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error);
this.keysEventUpdateTimeout = setTimeout(() => void this.sendEncryptionKeysEvent(), resendDelay); this.keysEventUpdateTimeout = setTimeout(() => void this.sendEncryptionKeysEvent(), resendDelay);
} else { } else {
@ -325,79 +289,14 @@ export class EncryptionManager implements IEncryptionManager {
} }
}; };
public onCallEncryptionEventReceived = (event: MatrixEvent): void => { public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
const userId = event.getSender(); this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp);
const content = event.getContent<EncryptionKeysEventContent>();
const deviceId = content["device_id"];
const callId = content["call_id"];
if (!userId) {
logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
return;
}
// We currently only handle callId = "" (which is the default for room scoped calls)
if (callId !== "") {
logger.warn(
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
);
return;
}
if (!Array.isArray(content.keys)) {
logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
return;
}
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
// We store our own sender key in the same set along with keys from others, so it's
// important we don't allow our own keys to be set by one of these events (apart from
// the fact that we don't need it anyway because we already know our own keys).
logger.info("Ignoring our own keys event");
return;
}
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
for (const key of content.keys) {
if (!key) {
logger.info("Ignoring false-y key in keys event");
continue;
}
const encryptionKey = key.key;
const encryptionKeyIndex = key.index;
if (
!encryptionKey ||
encryptionKeyIndex === undefined ||
encryptionKeyIndex === null ||
callId === undefined ||
callId === null ||
typeof deviceId !== "string" ||
typeof callId !== "string" ||
typeof encryptionKey !== "string" ||
typeof encryptionKeyIndex !== "number"
) {
logger.warn(
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
);
} else {
logger.debug(
`Embedded-E2EE-LOG onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
this.encryptionKeys,
);
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, event.getTs());
}
}
}; };
private storeLastMembershipFingerprints(): void { private storeLastMembershipFingerprints(): void {
this.lastMembershipFingerprints = new Set( this.lastMembershipFingerprints = new Set(
this.getMemberships() this.getMemberships()
.filter((m) => !this.isMyMembership(m)) .filter((m) => !isMyMembership(m, this.userId, this.deviceId))
.map((m) => `${getParticipantIdFromMembership(m)}:${m.createdTs()}`), .map((m) => `${getParticipantIdFromMembership(m)}:${m.createdTs()}`),
); );
} }
@ -466,14 +365,14 @@ export class EncryptionManager implements IEncryptionManager {
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} idx ${encryptionKeyIndex}`);
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) { if (userId === this.userId && deviceId === this.deviceId) {
this.currentEncryptionKeyIndex = encryptionKeyIndex; 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.client.getUserId() && deviceId === this.client.getDeviceId()) { if (userId === this.userId && deviceId === this.deviceId) {
this.currentEncryptionKeyIndex = encryptionKeyIndex; this.currentEncryptionKeyIndex = encryptionKeyIndex;
} }
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId); this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
@ -493,8 +392,10 @@ export class EncryptionManager implements IEncryptionManager {
} }
const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`; const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`;
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean { function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
if (a === b) return true; if (a === b) return true;
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]); return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
} }
const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId); const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId);

View File

@ -0,0 +1,53 @@
/*
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 CallMembership } from "./CallMembership.ts";
export enum KeyTransportEvents {
ReceivedKeys = "received_keys",
}
export type KeyTransportEventListener = (
userId: string,
deviceId: string,
keyBase64Encoded: string,
index: number,
timestamp: number,
) => void;
export type KeyTransportEventsHandlerMap = {
[KeyTransportEvents.ReceivedKeys]: KeyTransportEventListener;
};
/**
* Generic interface for the transport used to share room keys.
* Keys can be shared using different transports, e.g. to-device messages or room messages.
*/
export interface IKeyTransport {
/**
* Sends the current user media key to the given members.
* @param keyBase64Encoded
* @param index
* @param members - The participants that should get they key
*/
sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void>;
on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
off(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
start(): void;
stop(): void;
}

View File

@ -0,0 +1,88 @@
/*
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 { CallMembership } from "./CallMembership.ts";
import type { Focus } from "./focus.ts";
import type { Status } from "./types.ts";
export enum MembershipManagerEvent {
StatusChanged = "StatusChanged",
}
export type MembershipManagerEventHandlerMap = {
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void;
};
/**
* This interface defines what a MembershipManager uses and exposes.
* This interface is what we use to write tests and allows changing the actual implementation
* without breaking tests because of some internal method renaming.
*
* @internal
*/
export interface IMembershipManager {
/**
* If we are trying to join, or have successfully joined the session.
* It does not reflect if the room state is already configured to represent us being joined.
* It only means that the Manager should be trying to connect or to disconnect running.
* The Manager is still running right after isJoined becomes false to send the disconnect events.
* @returns true if we intend to be participating in the MatrixRTC session
* @deprecated This name is confusing and replaced by `isActivated()`. (Returns the same as `isActivated()`)
*/
isJoined(): boolean;
/**
* If the manager is activated. This means it tries to do its job to join the call, resend state events...
* It does not imply that the room state is already configured to represent being joined.
* It means that the Manager tries to connect or is connected. ("the manager is still active")
* Once `leave()` is called the manager is not activated anymore but still running until `leave()` resolves.
* @returns `true` if we intend to be participating in the MatrixRTC session
*/
isActivated(): boolean;
/**
* Get the actual connection status of the manager.
*/
get status(): Status;
/**
* The current status while the manager is activated
*/
/**
* Start sending all necessary events to make this user participate in the RTC session.
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
* @param fociActive the active focus to use in the joined RTC membership event.
* @throws can throw if it exceeds a configured maximum retry.
*/
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
/**
* Send all necessary events to make this user leave the RTC session.
* @param timeout the maximum duration in ms until the promise is forced to resolve.
* @returns It resolves with true in case the leave was sent successfully.
* It resolves with false in case we hit the timeout before sending successfully.
*/
leave(timeout?: number): Promise<boolean>;
/**
* Call this if the MatrixRTC session members have changed.
*/
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
/**
* The used active focus in the currently joined session.
* @returns the used active focus in the currently joined session or undefined if not joined.
*/
getActiveFocus(): Focus | undefined;
// TypedEventEmitter methods:
on(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
off(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
}

View File

@ -27,7 +27,8 @@ import { type Focus } from "./focus.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type MembershipConfig } from "./MatrixRTCSession.ts"; import { type MembershipConfig } from "./MatrixRTCSession.ts";
import { type EmptyObject } from "../@types/common.ts"; import { type EmptyObject } from "../@types/common.ts";
import { type IMembershipManager, type MembershipManagerEvent, Status } from "./types.ts"; import { Status } from "./types.ts";
import type { IMembershipManager, MembershipManagerEvent } from "./IMembershipManager.ts";
/** /**
* This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session. * This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session.

View File

@ -24,12 +24,13 @@ import { CallMembership } from "./CallMembership.ts";
import { RoomStateEvent } from "../models/room-state.ts"; import { RoomStateEvent } from "../models/room-state.ts";
import { type Focus } from "./focus.ts"; import { type Focus } from "./focus.ts";
import { KnownMembership } from "../@types/membership.ts"; import { KnownMembership } from "../@types/membership.ts";
import { type MatrixEvent } from "../models/event.ts";
import { MembershipManager } from "./NewMembershipManager.ts"; import { MembershipManager } from "./NewMembershipManager.ts";
import { EncryptionManager, type IEncryptionManager, type Statistics } 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 type { IMembershipManager } from "./types.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import { type IMembershipManager } from "./IMembershipManager.ts";
import { type Statistics } from "./types.ts";
const logger = rootLogger.getChild("MatrixRTCSession"); const logger = rootLogger.getChild("MatrixRTCSession");
@ -159,7 +160,7 @@ export type JoinSessionConfig = MembershipConfig & EncryptionConfig;
*/ */
export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, MatrixRTCSessionEventHandlerMap> { export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, MatrixRTCSessionEventHandlerMap> {
private membershipManager?: IMembershipManager; private membershipManager?: IMembershipManager;
private encryptionManager: IEncryptionManager; private encryptionManager?: IEncryptionManager;
// The session Id of the call, this is the call_id of the call Member event. // The session Id of the call, this is the call_id of the call Member event.
private _callId: string | undefined; private _callId: string | undefined;
@ -173,9 +174,15 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
/** /**
* The statistics for this session. * The statistics for this session.
*/ */
public get statistics(): Statistics { public statistics: Statistics = {
return this.encryptionManager.statistics; counters: {
} roomEventEncryptionKeysSent: 0,
roomEventEncryptionKeysReceived: 0,
},
totals: {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
/** /**
* The callId (sessionId) of the call. * The callId (sessionId) of the call.
@ -296,8 +303,12 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
| "_unstable_updateDelayedEvent" | "_unstable_updateDelayedEvent"
| "sendEvent" | "sendEvent"
| "cancelPendingEvent" | "cancelPendingEvent"
| "decryptEventIfNeeded"
>,
private roomSubset: Pick<
Room,
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
>, >,
private roomSubset: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState">,
public memberships: CallMembership[], public memberships: CallMembership[],
) { ) {
super(); super();
@ -306,14 +317,6 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager // TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate); roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
this.setExpiryTimer(); this.setExpiryTimer();
this.encryptionManager = new EncryptionManager(
this.client,
this.roomSubset,
() => this.memberships,
(keyBin: Uint8Array<ArrayBufferLike>, encryptionKeyIndex: number, participantId: string) => {
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
},
);
} }
/* /*
@ -366,6 +369,18 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
this.getOldestMembership(), this.getOldestMembership(),
); );
} }
// Create Encryption manager
const transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
this.encryptionManager = new EncryptionManager(
this.client.getUserId()!,
this.client.getDeviceId()!,
() => this.memberships,
transport,
this.statistics,
(keyBin: Uint8Array<ArrayBufferLike>, encryptionKeyIndex: number, participantId: string) => {
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
},
);
} }
// Join! // Join!
@ -397,7 +412,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
logger.info(`Leaving call session in room ${this.roomSubset.roomId}`); logger.info(`Leaving call session in room ${this.roomSubset.roomId}`);
this.encryptionManager.leave(); this.encryptionManager!.leave();
const leavePromise = this.membershipManager!.leave(timeout); const leavePromise = this.membershipManager!.leave(timeout);
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false); this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
@ -437,7 +452,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
* the keys. * the keys.
*/ */
public reemitEncryptionKeys(): void { public reemitEncryptionKeys(): void {
this.encryptionManager.getEncryptionKeys().forEach((keys, participantId) => { this.encryptionManager?.getEncryptionKeys().forEach((keys, participantId) => {
keys.forEach((key, index) => { keys.forEach((key, index) => {
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, key.key, index, participantId); this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, key.key, index, participantId);
}); });
@ -452,7 +467,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
*/ */
public getEncryptionKeys(): IterableIterator<[string, Array<Uint8Array>]> { public getEncryptionKeys(): IterableIterator<[string, Array<Uint8Array>]> {
const keys = const keys =
this.encryptionManager.getEncryptionKeys() ?? this.encryptionManager?.getEncryptionKeys() ??
new Map<string, Array<{ key: Uint8Array; timestamp: number }>>(); new Map<string, Array<{ key: Uint8Array; timestamp: number }>>();
// the returned array doesn't contain the timestamps // the returned array doesn't contain the timestamps
return Array.from(keys.entries()) return Array.from(keys.entries())
@ -484,25 +499,6 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
} }
} }
/**
* Process `m.call.encryption_keys` events to track the encryption keys for call participants.
* This should be called each time the relevant event is received from a room timeline.
* If the event is malformed then it will be logged and ignored.
*
* @param event the event to process
*/
public onCallEncryption = (event: MatrixEvent): void => {
this.encryptionManager.onCallEncryptionEventReceived(event);
};
/**
* @deprecated use onRoomMemberUpdate or onRTCSessionMemberUpdate instead. this should be called when any membership in the call is updated
* the old name might have implied to only need to call this when your own membership changes.
*/
public onMembershipUpdate = (): void => {
this.recalculateSessionMembers();
};
/** /**
* Call this when the Matrix room members have changed. * Call this when the Matrix room members have changed.
*/ */
@ -544,7 +540,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
} }
// This also needs to be done if `changed` = false // This also needs to be done if `changed` = false
// A member might have updated their fingerprint (created_ts) // A member might have updated their fingerprint (created_ts)
void this.encryptionManager.onMembershipsUpdate(oldMemberships); void this.encryptionManager?.onMembershipsUpdate(oldMemberships);
this.setExpiryTimer(); this.setExpiryTimer();
}; };

View File

@ -17,7 +17,7 @@ limitations under the License.
import { logger as rootLogger } from "../logger.ts"; import { logger as rootLogger } from "../logger.ts";
import { type MatrixClient, ClientEvent } from "../client.ts"; import { type MatrixClient, ClientEvent } from "../client.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room, RoomEvent } from "../models/room.ts"; import { type Room } from "../models/room.ts";
import { type RoomState, RoomStateEvent } from "../models/room-state.ts"; import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
import { type MatrixEvent } from "../models/event.ts"; import { type MatrixEvent } from "../models/event.ts";
import { MatrixRTCSession } from "./MatrixRTCSession.ts"; import { MatrixRTCSession } from "./MatrixRTCSession.ts";
@ -65,7 +65,6 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
} }
this.client.on(ClientEvent.Room, this.onRoom); this.client.on(ClientEvent.Room, this.onRoom);
this.client.on(RoomEvent.Timeline, this.onTimeline);
this.client.on(RoomStateEvent.Events, this.onRoomState); this.client.on(RoomStateEvent.Events, this.onRoomState);
} }
@ -76,7 +75,6 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
this.roomSessions.clear(); this.roomSessions.clear();
this.client.off(ClientEvent.Room, this.onRoom); this.client.off(ClientEvent.Room, this.onRoom);
this.client.off(RoomEvent.Timeline, this.onTimeline);
this.client.off(RoomStateEvent.Events, this.onRoomState); this.client.off(RoomStateEvent.Events, this.onRoomState);
} }
@ -100,37 +98,6 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
return this.roomSessions.get(room.roomId)!; return this.roomSessions.get(room.roomId)!;
} }
private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
await this.client.decryptEventIfNeeded(event);
if (event.isDecryptionFailure()) {
if (!isRetry) {
logger.warn(
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
);
// retry after 1 second. After this we give up.
setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000);
} else {
logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
}
return;
} else if (isRetry) {
logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
}
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
const room = this.client.getRoom(event.getRoomId());
if (!room) {
logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
return Promise.resolve();
}
this.getRoomSession(room).onCallEncryption(event);
}
private onTimeline = (event: MatrixEvent): void => {
void this.consumeCallEncryptionEvent(event);
};
private onRoom = (room: Room): void => { private onRoom = (room: Room): void => {
this.refreshRoom(room); this.refreshRoom(room);
}; };
@ -149,17 +116,23 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
private refreshRoom(room: Room): void { private refreshRoom(room: Room): void {
const isNewSession = !this.roomSessions.has(room.roomId); const isNewSession = !this.roomSessions.has(room.roomId);
const sess = this.getRoomSession(room); const session = this.getRoomSession(room);
const wasActiveAndKnown = sess.memberships.length > 0 && !isNewSession; const wasActiveAndKnown = session.memberships.length > 0 && !isNewSession;
// This needs to be here and the event listener cannot be setup in the MatrixRTCSession,
// because we need the update to happen between:
// wasActiveAndKnown = session.memberships.length > 0 and
// nowActive = session.memberships.length
// Alternatively we would need to setup some event emission when the RTC session ended.
session.onRTCSessionMemberUpdate();
sess.onRTCSessionMemberUpdate(); const nowActive = session.memberships.length > 0;
const nowActive = sess.memberships.length > 0;
if (wasActiveAndKnown && !nowActive) { if (wasActiveAndKnown && !nowActive) {
logger.trace(`Session ended for ${room.roomId} (${session.memberships.length} members)`);
this.emit(MatrixRTCSessionManagerEvents.SessionEnded, room.roomId, this.roomSessions.get(room.roomId)!); this.emit(MatrixRTCSessionManagerEvents.SessionEnded, room.roomId, this.roomSessions.get(room.roomId)!);
} else if (!wasActiveAndKnown && nowActive) { } else if (!wasActiveAndKnown && nowActive) {
logger.trace(`Session started for ${room.roomId} (${session.memberships.length} members)`);
this.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, this.roomSessions.get(room.roomId)!); this.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, this.roomSessions.get(room.roomId)!);
} }
} }

View File

@ -24,16 +24,16 @@ import { type Room } from "../models/room.ts";
import { defer, type IDeferred } from "../utils.ts"; import { defer, type IDeferred } from "../utils.ts";
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts"; import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
import { type Focus } from "./focus.ts"; import { type Focus } from "./focus.ts";
import { import { isMyMembership, Status } from "./types.ts";
type IMembershipManager,
type MembershipManagerEventHandlerMap,
MembershipManagerEvent,
Status,
} from "./types.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type MembershipConfig } from "./MatrixRTCSession.ts"; import { type MembershipConfig } from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.ts"; import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import {
MembershipManagerEvent,
type IMembershipManager,
type MembershipManagerEventHandlerMap,
} from "./IMembershipManager.ts";
const logger = rootLogger.getChild("MatrixRTCSession"); const logger = rootLogger.getChild("MatrixRTCSession");
@ -219,10 +219,9 @@ export class MembershipManager
private leavePromiseDefer?: IDeferred<boolean>; private leavePromiseDefer?: IDeferred<boolean>;
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> { public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
const isMyMembership = (m: CallMembership): boolean => const userId = this.client.getUserId();
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId(); const deviceId = this.client.getDeviceId();
if (userId && deviceId && this.isJoined() && !memberships.some((m) => isMyMembership(m, userId, deviceId))) {
if (this.isJoined() && !memberships.some(isMyMembership)) {
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already // If one of these actions are scheduled or are getting inserted in the next iteration, we should already
// take care of our missing membership. // take care of our missing membership.
const sendingMembershipActions = [ const sendingMembershipActions = [

View File

@ -0,0 +1,184 @@
/*
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 { MatrixClient } from "../client.ts";
import type { EncryptionKeysEventContent, Statistics } from "./types.ts";
import { EventType } from "../@types/event.ts";
import { type MatrixError } from "../http-api/errors.ts";
import { logger, type Logger } from "../logger.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
import { type MatrixEvent } from "../models/event.ts";
import { type CallMembership } from "./CallMembership.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room, RoomEvent } from "../models/room.ts";
export class RoomKeyTransport
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
implements IKeyTransport
{
private readonly prefixedLogger: Logger;
public constructor(
private room: Pick<Room, "on" | "off" | "roomId">,
private client: Pick<
MatrixClient,
"sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent" | "decryptEventIfNeeded"
>,
private statistics: Statistics,
) {
super();
this.prefixedLogger = logger.getChild(`[RTC: ${room.roomId} RoomKeyTransport]`);
}
public start(): void {
this.room.on(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
}
public stop(): void {
this.room.off(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
}
private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
await this.client.decryptEventIfNeeded(event);
if (event.isDecryptionFailure()) {
if (!isRetry) {
logger.warn(
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
);
// retry after 1 second. After this we give up.
setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000);
} else {
logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
}
return;
} else if (isRetry) {
logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
}
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
if (!this.room) {
logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
return Promise.resolve();
}
this.onEncryptionEvent(event);
}
/** implements {@link IKeyTransport#sendKey} */
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
// members not used in room transports as the keys are sent to all room members
const content: EncryptionKeysEventContent = {
keys: [
{
index: index,
key: keyBase64Encoded,
},
],
device_id: this.client.getDeviceId()!,
call_id: "",
sent_ts: Date.now(),
};
try {
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
} catch (error) {
this.prefixedLogger.error("Failed to send call encryption keys", error);
const matrixError = error as MatrixError;
if (matrixError.event) {
// cancel the pending event: we'll just generate a new one with our latest
// keys when we resend
this.client.cancelPendingEvent(matrixError.event);
}
throw error;
}
}
public onEncryptionEvent(event: MatrixEvent): void {
const userId = event.getSender();
const content = event.getContent<EncryptionKeysEventContent>();
const deviceId = content["device_id"];
const callId = content["call_id"];
if (!userId) {
logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
return;
}
// We currently only handle callId = "" (which is the default for room scoped calls)
if (callId !== "") {
logger.warn(
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
);
return;
}
if (!Array.isArray(content.keys)) {
logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
return;
}
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
// We store our own sender key in the same set along with keys from others, so it's
// important we don't allow our own keys to be set by one of these events (apart from
// the fact that we don't need it anyway because we already know our own keys).
logger.info("Ignoring our own keys event");
return;
}
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
for (const key of content.keys) {
if (!key) {
logger.info("Ignoring false-y key in keys event");
continue;
}
const encryptionKey = key.key;
const encryptionKeyIndex = key.index;
if (
!encryptionKey ||
encryptionKeyIndex === undefined ||
encryptionKeyIndex === null ||
callId === undefined ||
callId === null ||
typeof deviceId !== "string" ||
typeof callId !== "string" ||
typeof encryptionKey !== "string" ||
typeof encryptionKeyIndex !== "number"
) {
logger.warn(
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
);
} else {
logger.debug(
`Embedded-E2EE-LOG onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
);
this.emit(
KeyTransportEvents.ReceivedKeys,
userId,
deviceId,
encryptionKey,
encryptionKeyIndex,
event.getTs(),
);
}
}
}
}

View File

@ -20,4 +20,5 @@ export * from "./LivekitFocus.ts";
export * from "./MatrixRTCSession.ts"; export * from "./MatrixRTCSession.ts";
export * from "./MatrixRTCSessionManager.ts"; export * from "./MatrixRTCSessionManager.ts";
export type * from "./types.ts"; export type * from "./types.ts";
export { Status, MembershipManagerEvent } from "./types.ts"; export { Status } from "./types.ts";
export { MembershipManagerEvent } from "./IMembershipManager.ts";

View File

@ -15,7 +15,6 @@ limitations under the License.
*/ */
import type { IMentions } from "../matrix.ts"; import type { IMentions } from "../matrix.ts";
import type { CallMembership } from "./CallMembership.ts"; import type { CallMembership } from "./CallMembership.ts";
import type { Focus } from "./focus.ts";
export interface EncryptionKeyEntry { export interface EncryptionKeyEntry {
index: number; index: number;
@ -49,71 +48,28 @@ export enum Status {
Unknown = "Unknown", Unknown = "Unknown",
} }
export enum MembershipManagerEvent { /**
StatusChanged = "StatusChanged", * A type collecting call encryption statistics for a session.
} */
export type Statistics = {
export type MembershipManagerEventHandlerMap = { counters: {
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void; /**
* The number of times we have sent a room event containing encryption keys.
*/
roomEventEncryptionKeysSent: number;
/**
* The number of times we have received a room event containing encryption keys.
*/
roomEventEncryptionKeysReceived: number;
};
totals: {
/**
* The total age (in milliseconds) of all room events containing encryption keys that we have received.
* We track the total age so that we can later calculate the average age of all keys received.
*/
roomEventEncryptionKeysReceivedTotalAge: number;
};
}; };
/** export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean =>
* This interface defines what a MembershipManager uses and exposes. m.sender === userId && m.deviceId === deviceId;
* This interface is what we use to write tests and allows changing the actual implementation
* without breaking tests because of some internal method renaming.
*
* @internal
*/
export interface IMembershipManager {
/**
* If we are trying to join, or have successfully joined the session.
* It does not reflect if the room state is already configured to represent us being joined.
* It only means that the Manager should be trying to connect or to disconnect running.
* The Manager is still running right after isJoined becomes false to send the disconnect events.
* @returns true if we intend to be participating in the MatrixRTC session
* @deprecated This name is confusing and replaced by `isActivated()`. (Returns the same as `isActivated()`)
*/
isJoined(): boolean;
/**
* If the manager is activated. This means it tries to do its job to join the call, resend state events...
* It does not imply that the room state is already configured to represent being joined.
* It means that the Manager tries to connect or is connected. ("the manager is still active")
* Once `leave()` is called the manager is not activated anymore but still running until `leave()` resolves.
* @returns `true` if we intend to be participating in the MatrixRTC session
*/
isActivated(): boolean;
/**
* Get the actual connection status of the manager.
*/
get status(): Status;
/**
* The current status while the manager is activated
*/
/**
* Start sending all necessary events to make this user participate in the RTC session.
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
* @param fociActive the active focus to use in the joined RTC membership event.
* @throws can throw if it exceeds a configured maximum retry.
*/
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
/**
* Send all necessary events to make this user leave the RTC session.
* @param timeout the maximum duration in ms until the promise is forced to resolve.
* @returns It resolves with true in case the leave was sent successfully.
* It resolves with false in case we hit the timeout before sending successfully.
*/
leave(timeout?: number): Promise<boolean>;
/**
* Call this if the MatrixRTC session members have changed.
*/
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
/**
* The used active focus in the currently joined session.
* @returns the used active focus in the currently joined session or undefined if not joined.
*/
getActiveFocus(): Focus | undefined;
// TypedEventEmitter methods:
on(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
off(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
}