Compare commits
17 Commits
v34.3.0
...
v34.4.0-rc.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 43e58871de | |||
| 2544c14032 | |||
| 8d19782c57 | |||
| 340bbe1a8f | |||
| 8214fd7156 | |||
| a0efed8b88 | |||
| c408c0d1d5 | |||
| d6080398db | |||
| 467908703b | |||
| 87eddaf51a | |||
| c65ef03567 | |||
| 78cbf7cd28 | |||
| dc7c1a4fef | |||
| affaa95fb4 | |||
| 9176d3a671 | |||
| e0ef467d7d | |||
| 79299891fd |
@@ -1,3 +1,8 @@
|
||||
Changes in [34.3.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v34.3.1) (2024-08-20)
|
||||
==================================================================================================
|
||||
# Security
|
||||
- Fixes for [CVE-2024-42369](https://nvd.nist.gov/vuln/detail/CVE-2024-42369) / [GHSA-vhr5-g3pm-49fm](https://github.com/matrix-org/matrix-js-sdk/security/advisories/GHSA-vhr5-g3pm-49fm).
|
||||
|
||||
Changes in [34.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v34.3.0) (2024-08-13)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "34.3.0",
|
||||
"version": "34.4.0-rc.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -131,5 +131,6 @@
|
||||
"outputName": "jest-sonar-report.xml",
|
||||
"relativePaths": true
|
||||
},
|
||||
"typings": "./lib/index.d.ts"
|
||||
"typings": "./lib/index.d.ts",
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -12,3 +12,6 @@ do
|
||||
jq ".$i = .matrix_lib_$i" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
|
||||
fi
|
||||
done
|
||||
|
||||
# Ensure that "type": "module" is present
|
||||
jq '.type = "module"' package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
|
||||
|
||||
@@ -98,20 +98,6 @@ describe("CallMembership", () => {
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
|
||||
});
|
||||
|
||||
it("considers memberships unexpired if local age low enough", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(false);
|
||||
});
|
||||
|
||||
it("considers memberships expired when local age large", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.localTimestamp = Date.now() - 6000;
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns preferred foci", () => {
|
||||
const fakeEvent = makeMockEvent();
|
||||
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||
@@ -198,13 +184,6 @@ describe("CallMembership", () => {
|
||||
beforeEach(() => {
|
||||
// server origin timestamp for this event is 1000
|
||||
fakeEvent = makeMockEvent(1000);
|
||||
// our clock would have been at 2000 at the creation time (our clock at event receive time - age)
|
||||
// (ie. the local clock is 1 second ahead of the servers' clocks)
|
||||
fakeEvent.localTimestamp = 2000;
|
||||
|
||||
// for simplicity's sake, we say that the event's age is zero
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(0);
|
||||
|
||||
membership = new CallMembership(fakeEvent!, membershipTemplate);
|
||||
|
||||
jest.useFakeTimers();
|
||||
@@ -215,6 +194,13 @@ describe("CallMembership", () => {
|
||||
});
|
||||
|
||||
it("converts expiry time into local clock", () => {
|
||||
// our clock would have been at 2000 at the creation time (our clock at event receive time - age)
|
||||
// (ie. the local clock is 1 second ahead of the servers' clocks)
|
||||
fakeEvent.localTimestamp = 2000;
|
||||
|
||||
// for simplicity's sake, we say that the event's age is zero
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(0);
|
||||
|
||||
// for sanity's sake, make sure the server-relative expiry time is what we expect
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(6000);
|
||||
// therefore the expiry time converted to our clock should be 1 second later
|
||||
@@ -223,7 +209,8 @@ describe("CallMembership", () => {
|
||||
|
||||
it("calculates time until expiry", () => {
|
||||
jest.setSystemTime(2000);
|
||||
expect(membership.getMsUntilExpiry()).toEqual(5000);
|
||||
// should be using absolute expiry time
|
||||
expect(membership.getMsUntilExpiry()).toEqual(4000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src";
|
||||
import { encodeBase64, EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import {
|
||||
CallMembershipData,
|
||||
@@ -73,14 +73,17 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("ignores expired memberships events", () => {
|
||||
jest.useFakeTimers();
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.expires = 1000;
|
||||
expiredMembership.device_id = "EXPIRED";
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], 10000);
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]);
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("ignores memberships events of members not in the room", () => {
|
||||
@@ -91,12 +94,15 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("honours created_ts", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(500);
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.created_ts = 500;
|
||||
expiredMembership.expires = 1000;
|
||||
const mockRoom = makeMockRoom([expiredMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns empty session if no membership events are present", () => {
|
||||
@@ -304,6 +310,8 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
describe("getOldestMembership", () => {
|
||||
it("returns the oldest membership event", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(4000);
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, { device_id: "foo", created_ts: 3000 }),
|
||||
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
||||
@@ -312,12 +320,15 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.getOldestMembership()!.deviceId).toEqual("old");
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getsActiveFocus", () => {
|
||||
const activeFociConfig = { type: "livekit", livekit_service_url: "https://active.url" };
|
||||
it("gets the correct active focus with oldest_membership", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(3000);
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "foo",
|
||||
@@ -335,6 +346,7 @@ describe("MatrixRTCSession", () => {
|
||||
focus_selection: "oldest_membership",
|
||||
});
|
||||
expect(sess.getActiveFocus()).toBe(activeFociConfig);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
it("does not provide focus if the selction method is unknown", () => {
|
||||
const mockRoom = makeMockRoom([
|
||||
@@ -356,6 +368,8 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess.getActiveFocus()).toBe(undefined);
|
||||
});
|
||||
it("gets the correct active focus legacy", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(3000);
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "foo",
|
||||
@@ -370,6 +384,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }]);
|
||||
expect(sess.getActiveFocus()).toBe(activeFociConfig);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -513,9 +528,8 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
const eventContent = await eventSentPromise;
|
||||
|
||||
// definitely should have renewed by 1 second before the expiry!
|
||||
const timeElapsed = 60 * 60 * 1000 - 1000;
|
||||
const event = mockRTCEvent(eventContent.memberships, mockRoom.roomId, timeElapsed);
|
||||
jest.setSystemTime(1000);
|
||||
const event = mockRTCEvent(eventContent.memberships, mockRoom.roomId);
|
||||
const getState = mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
getState.getStateEvents = jest.fn().mockReturnValue(event);
|
||||
getState.events = new Map([
|
||||
@@ -538,6 +552,8 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
sendStateEventMock.mockReset().mockImplementation(resolveFn);
|
||||
|
||||
// definitely should have renewed by 1 second before the expiry!
|
||||
const timeElapsed = 60 * 60 * 1000 - 1000;
|
||||
jest.setSystemTime(Date.now() + timeElapsed);
|
||||
jest.advanceTimersByTime(timeElapsed);
|
||||
await eventReSentPromise;
|
||||
@@ -598,6 +614,17 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send key if join called when already joined", () => {
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client.sendEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client.sendEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries key sends", async () => {
|
||||
jest.useFakeTimers();
|
||||
let firstEventSent = false;
|
||||
@@ -674,7 +701,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId, undefined));
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
await keysSentPromise2;
|
||||
@@ -685,6 +712,214 @@ describe("MatrixRTCSession", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("Does not re-send key if memberships stays same", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const keysSentPromise1 = new Promise((resolve) => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
const member1 = membershipTemplate;
|
||||
const member2 = Object.assign({}, membershipTemplate, {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
|
||||
const mockRoom = makeMockRoom([member1, member2]);
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId));
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
await keysSentPromise1;
|
||||
|
||||
// make sure an encryption key was sent
|
||||
expect(sendEventMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(".*"),
|
||||
"io.element.call.encryption_keys",
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
sendEventMock.mockClear();
|
||||
|
||||
// these should be a no-op:
|
||||
sess.onMembershipUpdate();
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("Re-sends key if a member changes membership ID", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const keysSentPromise1 = new Promise((resolve) => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
const member1 = membershipTemplate;
|
||||
const member2 = {
|
||||
...membershipTemplate,
|
||||
device_id: "BBBBBBB",
|
||||
};
|
||||
|
||||
const mockRoom = makeMockRoom([member1, member2]);
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId));
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
await keysSentPromise1;
|
||||
|
||||
// make sure an encryption key was sent
|
||||
expect(sendEventMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(".*"),
|
||||
"io.element.call.encryption_keys",
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
sendEventMock.mockClear();
|
||||
|
||||
// this should be a no-op:
|
||||
sess.onMembershipUpdate();
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// advance time to avoid key throttling
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
// update membership ID
|
||||
member2.membershipID = "newID";
|
||||
|
||||
const keysSentPromise2 = new Promise((resolve) => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
// this should re-send the key
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
await keysSentPromise2;
|
||||
|
||||
expect(sendEventMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(".*"),
|
||||
"io.element.call.encryption_keys",
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("Re-sends key if a member changes created_ts", async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(1000);
|
||||
try {
|
||||
const keysSentPromise1 = new Promise((resolve) => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
const member1 = { ...membershipTemplate, created_ts: 1000 };
|
||||
const member2 = {
|
||||
...membershipTemplate,
|
||||
created_ts: 1000,
|
||||
device_id: "BBBBBBB",
|
||||
};
|
||||
|
||||
const mockRoom = makeMockRoom([member1, member2]);
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId));
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
await keysSentPromise1;
|
||||
|
||||
// make sure an encryption key was sent
|
||||
expect(sendEventMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(".*"),
|
||||
"io.element.call.encryption_keys",
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
sendEventMock.mockClear();
|
||||
|
||||
// this should be a no-op:
|
||||
sess.onMembershipUpdate();
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// advance time to avoid key throttling
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
// update created_ts
|
||||
member2.created_ts = 5000;
|
||||
|
||||
const keysSentPromise2 = new Promise((resolve) => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
// this should re-send the key
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
await keysSentPromise2;
|
||||
|
||||
expect(sendEventMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(".*"),
|
||||
"io.element.call.encryption_keys",
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("Rotates key if a member leaves", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
@@ -720,7 +955,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId, undefined));
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
jest.advanceTimersByTime(10000);
|
||||
@@ -759,7 +994,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId, undefined));
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId));
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
await new Promise((resolve) => {
|
||||
@@ -791,9 +1026,7 @@ describe("MatrixRTCSession", () => {
|
||||
const onMembershipsChanged = jest.fn();
|
||||
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
||||
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([], mockRoom.roomId, undefined));
|
||||
mockRoom.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState([], mockRoom.roomId));
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
expect(onMembershipsChanged).toHaveBeenCalled();
|
||||
@@ -803,7 +1036,7 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const membership = Object.assign({}, membershipTemplate);
|
||||
const mockRoom = makeMockRoom([membership], 0);
|
||||
const mockRoom = makeMockRoom([membership]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
const membershipObject = sess.memberships[0];
|
||||
@@ -831,7 +1064,7 @@ describe("MatrixRTCSession", () => {
|
||||
expires: 1000,
|
||||
}),
|
||||
];
|
||||
const mockRoomNoExpired = makeMockRoom(mockMemberships, 0);
|
||||
const mockRoomNoExpired = makeMockRoom(mockMemberships);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoomNoExpired);
|
||||
|
||||
@@ -870,6 +1103,7 @@ describe("MatrixRTCSession", () => {
|
||||
it("fills in created_ts for other memberships on update", () => {
|
||||
client.sendStateEvent = jest.fn();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(1000);
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "OTHERDEVICE",
|
||||
@@ -927,6 +1161,7 @@ describe("MatrixRTCSession", () => {
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
|
||||
@@ -950,6 +1185,7 @@ describe("MatrixRTCSession", () => {
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
|
||||
@@ -961,6 +1197,130 @@ describe("MatrixRTCSession", () => {
|
||||
expect(bobKeys[4]).toEqual(Buffer.from("this is the key", "utf-8"));
|
||||
});
|
||||
|
||||
it("collects keys by merging", () => {
|
||||
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: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
let bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
|
||||
expect(bobKeys).toHaveLength(1);
|
||||
expect(bobKeys[0]).toEqual(Buffer.from("this is the key", "utf-8"));
|
||||
|
||||
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);
|
||||
|
||||
bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
|
||||
expect(bobKeys).toHaveLength(5);
|
||||
expect(bobKeys[4]).toEqual(Buffer.from("this is the key", "utf-8"));
|
||||
});
|
||||
|
||||
it("ignores older keys at same index", () => {
|
||||
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.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);
|
||||
|
||||
const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
|
||||
expect(bobKeys).toHaveLength(1);
|
||||
expect(bobKeys[0]).toEqual(Buffer.from("newer key", "utf-8"));
|
||||
});
|
||||
|
||||
it("key timestamps are treated as monotonic", () => {
|
||||
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.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);
|
||||
|
||||
const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
|
||||
expect(bobKeys).toHaveLength(1);
|
||||
expect(bobKeys[0]).toEqual(Buffer.from("second key", "utf-8"));
|
||||
});
|
||||
|
||||
it("ignores keys event for the local participant", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
@@ -977,6 +1337,7 @@ describe("MatrixRTCSession", () => {
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue(client.getUserId()),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
const myKeys = sess.getKeysForParticipant(client.getUserId()!, client.getDeviceId()!)!;
|
||||
|
||||
@@ -102,6 +102,7 @@ describe("MatrixRTCSessionManager", () => {
|
||||
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",
|
||||
},
|
||||
@@ -110,4 +111,93 @@ describe("MatrixRTCSessionManager", () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,10 +20,10 @@ import { randomString } from "../../../src/randomstring";
|
||||
|
||||
type MembershipData = CallMembershipData[] | SessionMembershipData;
|
||||
|
||||
export function makeMockRoom(membershipData: MembershipData, localAge: number | null = null): Room {
|
||||
export function makeMockRoom(membershipData: MembershipData): Room {
|
||||
const roomId = randomString(8);
|
||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||
const roomState = makeMockRoomState(membershipData, roomId, localAge);
|
||||
const roomState = makeMockRoomState(membershipData, roomId);
|
||||
return {
|
||||
roomId: roomId,
|
||||
hasMembershipState: jest.fn().mockReturnValue(true),
|
||||
@@ -34,8 +34,8 @@ export function makeMockRoom(membershipData: MembershipData, localAge: number |
|
||||
} as unknown as Room;
|
||||
}
|
||||
|
||||
export function makeMockRoomState(membershipData: MembershipData, roomId: string, localAge: number | null = null) {
|
||||
const event = mockRTCEvent(membershipData, roomId, localAge);
|
||||
export function makeMockRoomState(membershipData: MembershipData, roomId: string) {
|
||||
const event = mockRTCEvent(membershipData, roomId);
|
||||
return {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
@@ -57,7 +57,7 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
|
||||
};
|
||||
}
|
||||
|
||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string, localAge: number | null): MatrixEvent {
|
||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue(
|
||||
@@ -68,11 +68,11 @@ export function mockRTCEvent(membershipData: MembershipData, roomId: string, loc
|
||||
},
|
||||
),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
localTimestamp: Date.now() - (localAge ?? 10),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
@@ -5587,10 +5587,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
private findPredecessorRooms(room: Room, verifyLinks: boolean, msc3946ProcessDynamicPredecessor: boolean): Room[] {
|
||||
const ret: Room[] = [];
|
||||
const seenRoomIDs = new Set<string>([room.roomId]);
|
||||
|
||||
// Work backwards from newer to older rooms
|
||||
let predecessorRoomId = room.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
|
||||
while (predecessorRoomId !== null) {
|
||||
if (predecessorRoomId) {
|
||||
if (seenRoomIDs.has(predecessorRoomId)) break;
|
||||
seenRoomIDs.add(predecessorRoomId);
|
||||
}
|
||||
const predecessorRoom = this.getRoom(predecessorRoomId);
|
||||
if (predecessorRoom === null) {
|
||||
break;
|
||||
|
||||
@@ -156,8 +156,14 @@ export class CallMembership {
|
||||
return this.membershipData.created_ts ?? this.parentEvent.getTs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the absolute expiry time of the membership if applicable to this membership type.
|
||||
* @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable
|
||||
*/
|
||||
public getAbsoluteExpiry(): number | undefined {
|
||||
// if the membership is not a legacy membership, we assume it is MSC4143
|
||||
if (!isLegacyCallMembershipData(this.membershipData)) return undefined;
|
||||
|
||||
if ("expires" in this.membershipData) {
|
||||
// we know createdTs exists since we already do the isLegacyCallMembershipData check
|
||||
return this.createdTs() + this.membershipData.expires;
|
||||
@@ -167,9 +173,15 @@ export class CallMembership {
|
||||
}
|
||||
}
|
||||
|
||||
// gets the expiry time of the event, converted into the device's local time
|
||||
/**
|
||||
* Gets the expiry time of the event, converted into the device's local time.
|
||||
* @deprecated This function has been observed returning bad data and is no longer used by MatrixRTC.
|
||||
* @returns The local expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable
|
||||
*/
|
||||
public getLocalExpiry(): number | undefined {
|
||||
// if the membership is not a legacy membership, we assume it is MSC4143
|
||||
if (!isLegacyCallMembershipData(this.membershipData)) return undefined;
|
||||
|
||||
if ("expires" in this.membershipData) {
|
||||
// we know createdTs exists since we already do the isLegacyCallMembershipData check
|
||||
const relativeCreationTime = this.parentEvent.getTs() - this.createdTs();
|
||||
@@ -184,10 +196,24 @@ export class CallMembership {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The number of milliseconds until the membership expires or undefined if applicable
|
||||
*/
|
||||
public getMsUntilExpiry(): number | undefined {
|
||||
if (isLegacyCallMembershipData(this.membershipData)) return this.getLocalExpiry()! - Date.now();
|
||||
if (isLegacyCallMembershipData(this.membershipData)) {
|
||||
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
|
||||
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
|
||||
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
|
||||
return this.getAbsoluteExpiry()! - Date.now();
|
||||
}
|
||||
|
||||
// Assumed to be MSC4143
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the membership has expired, otherwise false
|
||||
*/
|
||||
public isExpired(): boolean {
|
||||
if (isLegacyCallMembershipData(this.membershipData)) return this.getMsUntilExpiry()! <= 0;
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ const USE_KEY_DELAY = 5000;
|
||||
const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`;
|
||||
const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId);
|
||||
|
||||
function keysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
|
||||
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]);
|
||||
}
|
||||
|
||||
export enum MatrixRTCSessionEvent {
|
||||
@@ -134,10 +134,14 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
private manageMediaKeys = false;
|
||||
private useLegacyMemberEvents = true;
|
||||
// userId:deviceId => array of keys
|
||||
private encryptionKeys = new Map<string, Array<Uint8Array>>();
|
||||
// userId:deviceId => array of (key, timestamp)
|
||||
private encryptionKeys = new Map<string, Array<{ key: Uint8Array; timestamp: number }>>();
|
||||
private lastEncryptionKeyUpdateRequest?: number;
|
||||
|
||||
// We use this to store the last membership fingerprints we saw, so we can proactively re-send encryption keys
|
||||
// if it looks like a membership has been updated.
|
||||
private lastMembershipFingerprints: Set<string> | undefined;
|
||||
|
||||
/**
|
||||
* The callId (sessionId) of the call.
|
||||
*
|
||||
@@ -374,8 +378,15 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the known encryption keys for a given participant device.
|
||||
*
|
||||
* @param userId the user ID of the participant
|
||||
* @param deviceId the device ID of the participant
|
||||
* @returns The encryption keys for the given participant, or undefined if they are not known.
|
||||
*/
|
||||
public getKeysForParticipant(userId: string, deviceId: string): Array<Uint8Array> | undefined {
|
||||
return this.encryptionKeys.get(getParticipantId(userId, deviceId));
|
||||
return this.encryptionKeys.get(getParticipantId(userId, deviceId))?.map((entry) => entry.key);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -383,7 +394,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* cipher) given participant's media. This also includes our own key
|
||||
*/
|
||||
public getEncryptionKeys(): IterableIterator<[string, Array<Uint8Array>]> {
|
||||
return this.encryptionKeys.entries();
|
||||
// the returned array doesn't contain the timestamps
|
||||
return Array.from(this.encryptionKeys.entries())
|
||||
.map(([participantId, keys]): [string, Uint8Array[]] => [participantId, keys.map((k) => k.key)])
|
||||
.values();
|
||||
}
|
||||
|
||||
private getNewEncryptionKeyIndex(): number {
|
||||
@@ -398,13 +412,15 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
/**
|
||||
* Sets an encryption key at a specified index for a participant.
|
||||
* The encryption keys for the local participanmt are also stored here under the
|
||||
* The encryption keys for the local participant are also stored here under the
|
||||
* user and device ID of the local participant.
|
||||
* If the key is older than the existing key at the index, it will be ignored.
|
||||
* @param userId - The user ID of the participant
|
||||
* @param deviceId - Device ID of the participant
|
||||
* @param encryptionKeyIndex - The index of the key to set
|
||||
* @param encryptionKeyString - The string representation of the key to set in base64
|
||||
* @param delayBeforeuse - If true, delay before emitting a key changed event. Useful when setting
|
||||
* @param timestamp - The timestamp of the key. We assume that these are monotonic for each participant device.
|
||||
* @param delayBeforeUse - If true, delay before emitting a key changed event. Useful when setting
|
||||
* encryption keys for the local participant to allow time for the key to
|
||||
* be distributed.
|
||||
*/
|
||||
@@ -413,18 +429,39 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
deviceId: string,
|
||||
encryptionKeyIndex: number,
|
||||
encryptionKeyString: string,
|
||||
delayBeforeuse = false,
|
||||
timestamp: number,
|
||||
delayBeforeUse = false,
|
||||
): void {
|
||||
const keyBin = decodeBase64(encryptionKeyString);
|
||||
|
||||
const participantId = getParticipantId(userId, deviceId);
|
||||
const encryptionKeys = this.encryptionKeys.get(participantId) ?? [];
|
||||
if (!this.encryptionKeys.has(participantId)) {
|
||||
this.encryptionKeys.set(participantId, []);
|
||||
}
|
||||
const participantKeys = this.encryptionKeys.get(participantId)!;
|
||||
|
||||
if (keysEqual(encryptionKeys[encryptionKeyIndex], keyBin)) return;
|
||||
const existingKeyAtIndex = participantKeys[encryptionKeyIndex];
|
||||
|
||||
encryptionKeys[encryptionKeyIndex] = keyBin;
|
||||
this.encryptionKeys.set(participantId, encryptionKeys);
|
||||
if (delayBeforeuse) {
|
||||
if (existingKeyAtIndex) {
|
||||
if (existingKeyAtIndex.timestamp > timestamp) {
|
||||
logger.info(
|
||||
`Ignoring new key at index ${encryptionKeyIndex} for ${participantId} as it is older than existing known key`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keysEqual(existingKeyAtIndex.key, keyBin)) {
|
||||
existingKeyAtIndex.timestamp = timestamp;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
participantKeys[encryptionKeyIndex] = {
|
||||
key: keyBin,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
if (delayBeforeUse) {
|
||||
const useKeyTimeout = setTimeout(() => {
|
||||
this.setNewKeyTimeouts.delete(useKeyTimeout);
|
||||
logger.info(`Delayed-emitting key changed event for ${participantId} idx ${encryptionKeyIndex}`);
|
||||
@@ -451,7 +488,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
const encryptionKey = secureRandomBase64Url(16);
|
||||
const encryptionKeyIndex = this.getNewEncryptionKeyIndex();
|
||||
logger.info("Generated new key at index " + encryptionKeyIndex);
|
||||
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, delayBeforeUse);
|
||||
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -570,6 +607,13 @@ 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 => {
|
||||
const userId = event.getSender();
|
||||
const content = event.getContent<EncryptionKeysEventContent>();
|
||||
@@ -631,11 +675,19 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
`Embedded-E2EE-LOG onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
this.encryptionKeys,
|
||||
);
|
||||
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey);
|
||||
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, event.getTs());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private isMyMembership = (m: CallMembership): boolean =>
|
||||
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
|
||||
|
||||
/**
|
||||
* Examines the latest call memberships and handles any encryption key sending or rotation that is needed.
|
||||
*
|
||||
* This function should be called when the room members or call memberships might have changed.
|
||||
*/
|
||||
public onMembershipUpdate = (): void => {
|
||||
const oldMemberships = this.memberships;
|
||||
this.memberships = MatrixRTCSession.callMembershipsForRoom(this.room);
|
||||
@@ -651,19 +703,22 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
|
||||
}
|
||||
|
||||
const isMyMembership = (m: CallMembership): boolean =>
|
||||
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
|
||||
|
||||
if (this.manageMediaKeys && this.isJoined() && this.makeNewKeyTimeout === undefined) {
|
||||
const oldMebershipIds = new Set(
|
||||
oldMemberships.filter((m) => !isMyMembership(m)).map(getParticipantIdFromMembership),
|
||||
const oldMembershipIds = new Set(
|
||||
oldMemberships.filter((m) => !this.isMyMembership(m)).map(getParticipantIdFromMembership),
|
||||
);
|
||||
const newMebershipIds = new Set(
|
||||
this.memberships.filter((m) => !isMyMembership(m)).map(getParticipantIdFromMembership),
|
||||
const newMembershipIds = new Set(
|
||||
this.memberships.filter((m) => !this.isMyMembership(m)).map(getParticipantIdFromMembership),
|
||||
);
|
||||
|
||||
const anyLeft = Array.from(oldMebershipIds).some((x) => !newMebershipIds.has(x));
|
||||
const anyJoined = Array.from(newMebershipIds).some((x) => !oldMebershipIds.has(x));
|
||||
// We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference
|
||||
// for this once available
|
||||
const anyLeft = Array.from(oldMembershipIds).some((x) => !newMembershipIds.has(x));
|
||||
const anyJoined = Array.from(newMembershipIds).some((x) => !oldMembershipIds.has(x));
|
||||
|
||||
const oldFingerprints = this.lastMembershipFingerprints;
|
||||
// always store the fingerprints of these latest memberships
|
||||
this.storeLastMembershipFingerprints();
|
||||
|
||||
if (anyLeft) {
|
||||
logger.debug(`Member(s) have left: queueing sender key rotation`);
|
||||
@@ -671,12 +726,33 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
} else if (anyJoined) {
|
||||
logger.debug(`New member(s) have joined: re-sending keys`);
|
||||
this.requestKeyEventSend();
|
||||
} else if (oldFingerprints) {
|
||||
// does it look like any of the members have updated their memberships?
|
||||
const newFingerprints = this.lastMembershipFingerprints!;
|
||||
|
||||
// We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference
|
||||
// for this once available
|
||||
const candidateUpdates =
|
||||
Array.from(oldFingerprints).some((x) => !newFingerprints.has(x)) ||
|
||||
Array.from(newFingerprints).some((x) => !oldFingerprints.has(x));
|
||||
if (candidateUpdates) {
|
||||
logger.debug(`Member(s) have updated/reconnected: re-sending keys`);
|
||||
this.requestKeyEventSend();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.setExpiryTimer();
|
||||
};
|
||||
|
||||
private storeLastMembershipFingerprints(): void {
|
||||
this.lastMembershipFingerprints = new Set(
|
||||
this.memberships
|
||||
.filter((m) => !this.isMyMembership(m))
|
||||
.map((m) => `${getParticipantIdFromMembership(m)}:${m.membershipID}:${m.createdTs()}`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs our own membership
|
||||
* @param prevMembership - The previous value of our call membership, if any
|
||||
@@ -826,9 +902,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!");
|
||||
|
||||
const callMemberEvents = roomState.events.get(EventType.GroupCallMemberPrefix);
|
||||
const legacy =
|
||||
!!this.useLegacyMemberEvents ||
|
||||
(callMemberEvents?.size && this.stateEventsContainOngoingLegacySession(callMemberEvents));
|
||||
const legacy = this.stateEventsContainOngoingLegacySession(callMemberEvents);
|
||||
let newContent: {} | ExperimentalGroupCallRoomMemberState | SessionMembershipData = {};
|
||||
if (legacy) {
|
||||
const myCallMemberEvent = callMemberEvents?.get(localUserId);
|
||||
@@ -917,7 +991,13 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
}
|
||||
}
|
||||
|
||||
private stateEventsContainOngoingLegacySession(callMemberEvents: Map<string, MatrixEvent>): boolean {
|
||||
private stateEventsContainOngoingLegacySession(callMemberEvents: Map<string, MatrixEvent> | undefined): boolean {
|
||||
if (!callMemberEvents?.size) {
|
||||
return this.useLegacyMemberEvents;
|
||||
}
|
||||
|
||||
let containsAnyOngoingSession = false;
|
||||
let containsUnknownOngoingSession = false;
|
||||
for (const callMemberEvent of callMemberEvents.values()) {
|
||||
const content = callMemberEvent.getContent();
|
||||
if (Array.isArray(content["memberships"])) {
|
||||
@@ -926,9 +1006,12 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (Object.keys(content).length > 0) {
|
||||
containsAnyOngoingSession ||= true;
|
||||
containsUnknownOngoingSession ||= !("focus_active" in content);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return containsAnyOngoingSession && !containsUnknownOngoingSession ? false : this.useLegacyMemberEvents;
|
||||
}
|
||||
|
||||
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
|
||||
|
||||
@@ -98,8 +98,23 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
return this.roomSessions.get(room.roomId)!;
|
||||
}
|
||||
|
||||
private async consumeCallEncryptionEvent(event: MatrixEvent): Promise<void> {
|
||||
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(() => 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());
|
||||
|
||||
Reference in New Issue
Block a user