Skip to content

Commit bf81c4b

Browse files
SimonBrandnerdbkr
andauthored
Add E2EE for embedded mode of Element Call (#3667)
* WIP refactor for removing m.call events * Always remember rtcsessions since we need to only have one instance * Fix tests * Fix import loop * Fix more cyclic imports & tests * Test session joining * Attempt to make tests happy * Always leave calls in the tests to clean up * comment + desperate attempt to work out what's failing * More test debugging * Okay, so these ones are fine? * Stop more timers and hopefully have happy tests * Test no rejoin * Test malformed m.call.member events * Test event emitting and also move some code to a more sensible place in the file * Test getActiveFoci() * Test event emitting (and also fix it) * Test membership updating & pruning on join * Test getOldestMembership() * Test member event renewal * Don't start the rtc manager until the client has synced Then we can initialise from the state once it's completed. * Fix type * Remove listeners added in constructor * Stop the client here too * Stop the client here also also * ARGH. Disable tests to work out which one is causing the exception * Disable everything * Re-jig to avoid setting listeners in the constructor and re-enable tests * No need to rename this anymore * argh, remove the right listener * Is it this test??? * Re-enable some tests * Try mocking getRooms to return something valid * Re-enable other tests * Give up trying to get the tests to work sensibly and deal with getRooms() returning nothing * Oops, don't enable the ones that were skipped before * One more try at the sensible way * Didn't work, go back to the hack way. * Log when we manage to send the member event update * Support `getOpenIdToken()` in embedded mode (#3676) * Call `sendContentLoaded()` (#3677) * Start MatrixRTC in embedded mode (#3679) * Reschedule the membership event check * Bump widget api version * Add mock for sendContentLoaded() * Embeded mode pre-requisites Signed-off-by: Šimon Brandner <[email protected]> * Embeded mode E2EE Signed-off-by: Šimon Brandner <[email protected]> * Encryption condition Signed-off-by: Šimon Brandner <[email protected]> * Revert "Embeded mode pre-requisites" This reverts commit 8cd7370. * Get back event type Signed-off-by: Šimon Brandner <[email protected]> fds Signed-off-by: Šimon Brandner <[email protected]> * Change embedded E2EE implementation Signed-off-by: Šimon Brandner <[email protected]> * More log detail * Fix tests and also better assert because the tests were passing undefined which was considered fine because we were only checking for null. * Simplify updateCallMembershipEvent a bit * Split up updateCallMembershipEvent some more * Use `crypto.getRandomValues()` Signed-off-by: Šimon Brandner <[email protected]> * Rename to `membershipToUserAndDeviceId()` Signed-off-by: Šimon Brandner <[email protected]> * Better error Signed-off-by: Šimon Brandner <[email protected]> * Add log line Signed-off-by: Šimon Brandner <[email protected]> * Add comment Signed-off-by: Šimon Brandner <[email protected]> * Send call ID in enc events (also a small refactor) Signed-off-by: Šimon Brandner <[email protected]> * Revert making `joinRoomSession()` async Signed-off-by: Šimon Brandner <[email protected]> * Make `client` `private` again Signed-off-by: Šimon Brandner <[email protected]> * Just use `toString()` Signed-off-by: Šimon Brandner <[email protected]> * Fix `callId` check Signed-off-by: Šimon Brandner <[email protected]> * Fix map Signed-off-by: Šimon Brandner <[email protected]> * Fix map compare Signed-off-by: Šimon Brandner <[email protected]> * Fix emitting Signed-off-by: Šimon Brandner <[email protected]> * Explicit logging Signed-off-by: Šimon Brandner <[email protected]> * Refactor Signed-off-by: Šimon Brandner <[email protected]> * Make `updateEncryptionKeyEvent()` public Signed-off-by: Šimon Brandner <[email protected]> * Only update keys based on others Signed-off-by: Šimon Brandner <[email protected]> * Fix call order Signed-off-by: Šimon Brandner <[email protected]> * Improve logging Signed-off-by: Šimon Brandner <[email protected]> * Avoid races Signed-off-by: Šimon Brandner <[email protected]> * Revert "Avoid races" This reverts commit f65ed72. * Add try-catch Signed-off-by: Šimon Brandner <[email protected]> * Make `updateEncryptionKeyEvent()` private Signed-off-by: Šimon Brandner <[email protected]> * Handle indices and throttling Signed-off-by: Šimon Brandner <[email protected]> * Fix merge mistakes Signed-off-by: Šimon Brandner <[email protected]> * Mort post-merge fixes Signed-off-by: Šimon Brandner <[email protected]> * Split out key generation from key sending And send all keys in a key event (changes the format of the key event) rather than just the one we just generated. * Remember and clear the timeout for the send key event So we don't schedule more key updates if one is already pending. Also don't update the last sent time when we didn't actually send the keys. * Make key event resends more robust * Attempt to make tests pass * crypto wasn't defined at all * Hopefully get interface right * Fix key format on the wire to base64 * Add comment * More standard method order * Rename encryptMedia The js-sdk doesn't do media and therefore doesn't do media encryption * Stop logging encryption keys now * Use regular base64 It's not going in a URL, so no need * Re-add base64url randomstring was using it. Also give it a test. * Add tests for randomstring * Switch between either browser or node crypto Let's see if this will work... * Obviously crypto has already solved this * Some tests for MatrixRTCSession key stuff * Test keys object contents * Change keys event format To move away from m. keys * Test key event retries * Test onCallEncryption * Test event sending & spam prevention * Test event cancelation * Test onCallEncryption called * Some errors didn't have data * Fix binary key comparison & add log line * Fix compare function with undefined values * Remove more key logging * Check content.keys is an array * Check key index & key * Better function name * Tests too --------- Signed-off-by: Šimon Brandner <[email protected]> Co-authored-by: David Baker <[email protected]> Co-authored-by: David Baker <[email protected]>
1 parent 370dd6a commit bf81c4b

11 files changed

+682
-20
lines changed

spec/unit/base64.spec.ts

+13-5
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ limitations under the License.
1717
import { TextEncoder, TextDecoder } from "util";
1818
import NodeBuffer from "node:buffer";
1919

20-
import { decodeBase64, encodeBase64, encodeUnpaddedBase64 } from "../../src/base64";
20+
import { decodeBase64, encodeBase64, encodeUnpaddedBase64, encodeUnpaddedBase64Url } from "../../src/base64";
2121

2222
describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
2323
let origBuffer = Buffer;
@@ -43,19 +43,27 @@ describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
4343
global.btoa = undefined;
4444
});
4545

46-
it("Should decode properly encoded data", async () => {
46+
it("Should decode properly encoded data", () => {
4747
const decoded = new TextDecoder().decode(decodeBase64("ZW5jb2RpbmcgaGVsbG8gd29ybGQ="));
4848

4949
expect(decoded).toStrictEqual("encoding hello world");
5050
});
5151

52-
it("Should decode URL-safe base64", async () => {
52+
it("Should encode unpadded URL-safe base64", () => {
53+
const toEncode = "?????";
54+
const data = new TextEncoder().encode(toEncode);
55+
56+
const encoded = encodeUnpaddedBase64Url(data);
57+
expect(encoded).toEqual("Pz8_Pz8");
58+
});
59+
60+
it("Should decode URL-safe base64", () => {
5361
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8="));
5462

5563
expect(decoded).toStrictEqual("?????");
5664
});
5765

58-
it("Encode unpadded should not have padding", async () => {
66+
it("Encode unpadded should not have padding", () => {
5967
const toEncode = "encoding hello world";
6068
const data = new TextEncoder().encode(toEncode);
6169

@@ -68,7 +76,7 @@ describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
6876
expect(padding).toStrictEqual("=");
6977
});
7078

71-
it("Decode should be indifferent to padding", async () => {
79+
it("Decode should be indifferent to padding", () => {
7280
const withPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ=";
7381
const withoutPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ";
7482

spec/unit/matrixrtc/MatrixRTCSession.spec.ts

+241-7
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { EventTimeline, EventType, MatrixClient, Room } from "../../../src";
17+
import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src";
1818
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
1919
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
2020
import { randomString } from "../../../src/randomstring";
21-
import { makeMockRoom, mockRTCEvent } from "./mocks";
21+
import { makeMockRoom, makeMockRoomState, mockRTCEvent } from "./mocks";
2222

2323
const membershipTemplate: CallMembershipData = {
2424
call_id: "",
@@ -184,8 +184,15 @@ describe("MatrixRTCSession", () => {
184184

185185
describe("joining", () => {
186186
let mockRoom: Room;
187+
let sendStateEventMock: jest.Mock;
188+
let sendEventMock: jest.Mock;
187189

188190
beforeEach(() => {
191+
sendStateEventMock = jest.fn();
192+
sendEventMock = jest.fn();
193+
client.sendStateEvent = sendStateEventMock;
194+
client.sendEvent = sendEventMock;
195+
189196
mockRoom = makeMockRoom([]);
190197
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
191198
});
@@ -205,8 +212,6 @@ describe("MatrixRTCSession", () => {
205212
});
206213

207214
it("sends a membership event when joining a call", () => {
208-
client.sendStateEvent = jest.fn();
209-
210215
sess!.joinRoomSession([mockFocus]);
211216

212217
expect(client.sendStateEvent).toHaveBeenCalledWith(
@@ -230,9 +235,6 @@ describe("MatrixRTCSession", () => {
230235
});
231236

232237
it("does nothing if join called when already joined", () => {
233-
const sendStateEventMock = jest.fn();
234-
client.sendStateEvent = sendStateEventMock;
235-
236238
sess!.joinRoomSession([mockFocus]);
237239

238240
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
@@ -299,6 +301,188 @@ describe("MatrixRTCSession", () => {
299301
jest.useRealTimers();
300302
}
301303
});
304+
305+
it("creates a key when joining", () => {
306+
sess!.joinRoomSession([mockFocus], true);
307+
const keys = sess?.getKeysForParticipant("@alice:example.org", "AAAAAAA");
308+
expect(keys).toHaveLength(1);
309+
310+
const allKeys = sess!.getEncryptionKeys();
311+
expect(allKeys).toBeTruthy();
312+
expect(Array.from(allKeys)).toHaveLength(1);
313+
});
314+
315+
it("sends keys when joining", async () => {
316+
const eventSentPromise = new Promise((resolve) => {
317+
sendEventMock.mockImplementation(resolve);
318+
});
319+
320+
sess!.joinRoomSession([mockFocus], true);
321+
322+
await eventSentPromise;
323+
324+
expect(sendEventMock).toHaveBeenCalledWith(expect.stringMatching(".*"), "io.element.call.encryption_keys", {
325+
call_id: "",
326+
device_id: "AAAAAAA",
327+
keys: [
328+
{
329+
index: 0,
330+
key: expect.stringMatching(".*"),
331+
},
332+
],
333+
});
334+
});
335+
336+
it("retries key sends", async () => {
337+
jest.useFakeTimers();
338+
let firstEventSent = false;
339+
340+
try {
341+
const eventSentPromise = new Promise<void>((resolve) => {
342+
sendEventMock.mockImplementation(() => {
343+
if (!firstEventSent) {
344+
jest.advanceTimersByTime(10000);
345+
346+
firstEventSent = true;
347+
const e = new Error() as MatrixError;
348+
e.data = {};
349+
throw e;
350+
} else {
351+
resolve();
352+
}
353+
});
354+
});
355+
356+
sess!.joinRoomSession([mockFocus], true);
357+
jest.advanceTimersByTime(10000);
358+
359+
await eventSentPromise;
360+
361+
expect(sendEventMock).toHaveBeenCalledTimes(2);
362+
} finally {
363+
jest.useRealTimers();
364+
}
365+
});
366+
367+
it("cancels key send event that fail", async () => {
368+
const eventSentinel = {} as unknown as MatrixEvent;
369+
370+
client.cancelPendingEvent = jest.fn();
371+
sendEventMock.mockImplementation(() => {
372+
const e = new Error() as MatrixError;
373+
e.data = {};
374+
e.event = eventSentinel;
375+
throw e;
376+
});
377+
378+
sess!.joinRoomSession([mockFocus], true);
379+
380+
expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel);
381+
});
382+
383+
it("Re-sends key if a new member joins", async () => {
384+
jest.useFakeTimers();
385+
try {
386+
const mockRoom = makeMockRoom([membershipTemplate]);
387+
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
388+
389+
const keysSentPromise1 = new Promise((resolve) => {
390+
sendEventMock.mockImplementation(resolve);
391+
});
392+
393+
sess.joinRoomSession([mockFocus], true);
394+
await keysSentPromise1;
395+
396+
sendEventMock.mockClear();
397+
jest.advanceTimersByTime(10000);
398+
399+
const keysSentPromise2 = new Promise((resolve) => {
400+
sendEventMock.mockImplementation(resolve);
401+
});
402+
403+
const onMembershipsChanged = jest.fn();
404+
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
405+
406+
const member2 = Object.assign({}, membershipTemplate, {
407+
device_id: "BBBBBBB",
408+
});
409+
410+
mockRoom.getLiveTimeline().getState = jest
411+
.fn()
412+
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId, undefined));
413+
sess.onMembershipUpdate();
414+
415+
await keysSentPromise2;
416+
417+
expect(sendEventMock).toHaveBeenCalled();
418+
} finally {
419+
jest.useRealTimers();
420+
}
421+
});
422+
423+
it("Doesn't re-send key immediately", async () => {
424+
const realSetImmediate = setImmediate;
425+
jest.useFakeTimers();
426+
try {
427+
const mockRoom = makeMockRoom([membershipTemplate]);
428+
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
429+
430+
const keysSentPromise1 = new Promise((resolve) => {
431+
sendEventMock.mockImplementation(resolve);
432+
});
433+
434+
sess.joinRoomSession([mockFocus], true);
435+
await keysSentPromise1;
436+
437+
sendEventMock.mockClear();
438+
439+
const onMembershipsChanged = jest.fn();
440+
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
441+
442+
const member2 = Object.assign({}, membershipTemplate, {
443+
device_id: "BBBBBBB",
444+
});
445+
446+
mockRoom.getLiveTimeline().getState = jest
447+
.fn()
448+
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId, undefined));
449+
sess.onMembershipUpdate();
450+
451+
await new Promise((resolve) => {
452+
realSetImmediate(resolve);
453+
});
454+
455+
expect(sendEventMock).not.toHaveBeenCalled();
456+
} finally {
457+
jest.useRealTimers();
458+
}
459+
});
460+
});
461+
462+
it("Does not emits if no membership changes", () => {
463+
const mockRoom = makeMockRoom([membershipTemplate]);
464+
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
465+
466+
const onMembershipsChanged = jest.fn();
467+
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
468+
sess.onMembershipUpdate();
469+
470+
expect(onMembershipsChanged).not.toHaveBeenCalled();
471+
});
472+
473+
it("Emits on membership changes", () => {
474+
const mockRoom = makeMockRoom([membershipTemplate]);
475+
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
476+
477+
const onMembershipsChanged = jest.fn();
478+
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
479+
480+
mockRoom.getLiveTimeline().getState = jest
481+
.fn()
482+
.mockReturnValue(makeMockRoomState([], mockRoom.roomId, undefined));
483+
sess.onMembershipUpdate();
484+
485+
expect(onMembershipsChanged).toHaveBeenCalled();
302486
});
303487

304488
it("emits an event at the time a membership event expires", () => {
@@ -409,4 +593,54 @@ describe("MatrixRTCSession", () => {
409593
"@alice:example.org",
410594
);
411595
});
596+
597+
it("collects keys from encryption events", () => {
598+
const mockRoom = makeMockRoom([membershipTemplate]);
599+
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
600+
sess.onCallEncryption({
601+
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
602+
getContent: jest.fn().mockReturnValue({
603+
device_id: "bobsphone",
604+
call_id: "",
605+
keys: [
606+
{
607+
index: 0,
608+
key: "dGhpcyBpcyB0aGUga2V5",
609+
},
610+
],
611+
}),
612+
getSender: jest.fn().mockReturnValue("@bob:example.org"),
613+
} as unknown as MatrixEvent);
614+
615+
const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
616+
expect(bobKeys).toHaveLength(1);
617+
expect(bobKeys[0]).toEqual(Buffer.from("this is the key", "utf-8"));
618+
});
619+
620+
it("collects keys at non-zero indices", () => {
621+
const mockRoom = makeMockRoom([membershipTemplate]);
622+
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
623+
sess.onCallEncryption({
624+
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
625+
getContent: jest.fn().mockReturnValue({
626+
device_id: "bobsphone",
627+
call_id: "",
628+
keys: [
629+
{
630+
index: 4,
631+
key: "dGhpcyBpcyB0aGUga2V5",
632+
},
633+
],
634+
}),
635+
getSender: jest.fn().mockReturnValue("@bob:example.org"),
636+
} as unknown as MatrixEvent);
637+
638+
const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
639+
expect(bobKeys).toHaveLength(5);
640+
expect(bobKeys[0]).toBeFalsy();
641+
expect(bobKeys[1]).toBeFalsy();
642+
expect(bobKeys[2]).toBeFalsy();
643+
expect(bobKeys[3]).toBeFalsy();
644+
expect(bobKeys[4]).toEqual(Buffer.from("this is the key", "utf-8"));
645+
});
412646
});

spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts

+31-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
17+
import {
18+
ClientEvent,
19+
EventTimeline,
20+
EventType,
21+
IRoomTimelineData,
22+
MatrixClient,
23+
MatrixEvent,
24+
RoomEvent,
25+
} from "../../../src";
1826
import { RoomStateEvent } from "../../../src/models/room-state";
1927
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
2028
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
@@ -78,4 +86,26 @@ describe("MatrixRTCSessionManager", () => {
7886

7987
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
8088
});
89+
90+
it("Calls onCallEncryption on encryption keys event", () => {
91+
const room1 = makeMockRoom([membershipTemplate]);
92+
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
93+
jest.spyOn(client, "getRoom").mockReturnValue(room1);
94+
95+
client.emit(ClientEvent.Room, room1);
96+
const onCallEncryptionMock = jest.fn();
97+
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
98+
99+
const timelineEvent = {
100+
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
101+
getContent: jest.fn().mockReturnValue({}),
102+
getSender: jest.fn().mockReturnValue("@mock:user.example"),
103+
getRoomId: jest.fn().mockReturnValue("!room:id"),
104+
sender: {
105+
userId: "@mock:user.example",
106+
},
107+
} as unknown as MatrixEvent;
108+
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
109+
expect(onCallEncryptionMock).toHaveBeenCalled();
110+
});
81111
});

spec/unit/matrixrtc/mocks.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ export function makeMockRoom(
3131
} as unknown as Room;
3232
}
3333

34-
function makeMockRoomState(memberships: CallMembershipData[], roomId: string, getLocalAge: (() => number) | undefined) {
34+
export function makeMockRoomState(
35+
memberships: CallMembershipData[],
36+
roomId: string,
37+
getLocalAge: (() => number) | undefined,
38+
) {
3539
return {
3640
getStateEvents: (_: string, stateKey: string) => {
3741
const event = mockRTCEvent(memberships, roomId, getLocalAge);

0 commit comments

Comments
 (0)