Skip to content

Commit

Permalink
Add E2EE for embedded mode of Element Call (#3667)
Browse files Browse the repository at this point in the history
* 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 <simon.bra.ag@gmail.com>

* Embeded mode E2EE

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Encryption condition

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Revert "Embeded mode pre-requisites"

This reverts commit 8cd7370.

* Get back event type

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

fds

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Change embedded E2EE implementation

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* 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 <simon.bra.ag@gmail.com>

* Rename to `membershipToUserAndDeviceId()`

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Better error

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add log line

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Add comment

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Send call ID in enc events

(also a small refactor)

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Revert making `joinRoomSession()` async

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Make `client` `private` again

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Just use `toString()`

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix `callId` check

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix map

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix map compare

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix emitting

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Explicit logging

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Refactor

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Make `updateEncryptionKeyEvent()` public

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Only update keys based on others

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix call order

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Improve logging

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Avoid races

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Revert "Avoid races"

This reverts commit f65ed72.

* Add try-catch

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Make `updateEncryptionKeyEvent()` private

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Handle indices and throttling

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix merge mistakes

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Mort post-merge fixes

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* 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 <simon.bra.ag@gmail.com>
Co-authored-by: David Baker <dave@matrix.org>
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
  • Loading branch information
3 people committed Oct 31, 2023
1 parent 370dd6a commit bf81c4b
Show file tree
Hide file tree
Showing 11 changed files with 682 additions and 20 deletions.
18 changes: 13 additions & 5 deletions spec/unit/base64.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ limitations under the License.
import { TextEncoder, TextDecoder } from "util";
import NodeBuffer from "node:buffer";

import { decodeBase64, encodeBase64, encodeUnpaddedBase64 } from "../../src/base64";
import { decodeBase64, encodeBase64, encodeUnpaddedBase64, encodeUnpaddedBase64Url } from "../../src/base64";

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

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

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

it("Should decode URL-safe base64", async () => {
it("Should encode unpadded URL-safe base64", () => {
const toEncode = "?????";
const data = new TextEncoder().encode(toEncode);

const encoded = encodeUnpaddedBase64Url(data);
expect(encoded).toEqual("Pz8_Pz8");
});

it("Should decode URL-safe base64", () => {
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8="));

expect(decoded).toStrictEqual("?????");
});

it("Encode unpadded should not have padding", async () => {
it("Encode unpadded should not have padding", () => {
const toEncode = "encoding hello world";
const data = new TextEncoder().encode(toEncode);

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

it("Decode should be indifferent to padding", async () => {
it("Decode should be indifferent to padding", () => {
const withPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ=";
const withoutPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ";

Expand Down
248 changes: 241 additions & 7 deletions spec/unit/matrixrtc/MatrixRTCSession.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { EventTimeline, EventType, MatrixClient, Room } from "../../../src";
import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src";
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
import { randomString } from "../../../src/randomstring";
import { makeMockRoom, mockRTCEvent } from "./mocks";
import { makeMockRoom, makeMockRoomState, mockRTCEvent } from "./mocks";

const membershipTemplate: CallMembershipData = {
call_id: "",
Expand Down Expand Up @@ -184,8 +184,15 @@ describe("MatrixRTCSession", () => {

describe("joining", () => {
let mockRoom: Room;
let sendStateEventMock: jest.Mock;
let sendEventMock: jest.Mock;

beforeEach(() => {
sendStateEventMock = jest.fn();
sendEventMock = jest.fn();
client.sendStateEvent = sendStateEventMock;
client.sendEvent = sendEventMock;

mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
});
Expand All @@ -205,8 +212,6 @@ describe("MatrixRTCSession", () => {
});

it("sends a membership event when joining a call", () => {
client.sendStateEvent = jest.fn();

sess!.joinRoomSession([mockFocus]);

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

it("does nothing if join called when already joined", () => {
const sendStateEventMock = jest.fn();
client.sendStateEvent = sendStateEventMock;

sess!.joinRoomSession([mockFocus]);

expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -299,6 +301,188 @@ describe("MatrixRTCSession", () => {
jest.useRealTimers();
}
});

it("creates a key when joining", () => {
sess!.joinRoomSession([mockFocus], true);
const keys = sess?.getKeysForParticipant("@alice:example.org", "AAAAAAA");
expect(keys).toHaveLength(1);

const allKeys = sess!.getEncryptionKeys();
expect(allKeys).toBeTruthy();
expect(Array.from(allKeys)).toHaveLength(1);
});

it("sends keys when joining", async () => {
const eventSentPromise = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
});

sess!.joinRoomSession([mockFocus], true);

await eventSentPromise;

expect(sendEventMock).toHaveBeenCalledWith(expect.stringMatching(".*"), "io.element.call.encryption_keys", {
call_id: "",
device_id: "AAAAAAA",
keys: [
{
index: 0,
key: expect.stringMatching(".*"),
},
],
});
});

it("retries key sends", async () => {
jest.useFakeTimers();
let firstEventSent = false;

try {
const eventSentPromise = new Promise<void>((resolve) => {
sendEventMock.mockImplementation(() => {
if (!firstEventSent) {
jest.advanceTimersByTime(10000);

firstEventSent = true;
const e = new Error() as MatrixError;
e.data = {};
throw e;
} else {
resolve();
}
});
});

sess!.joinRoomSession([mockFocus], true);
jest.advanceTimersByTime(10000);

await eventSentPromise;

expect(sendEventMock).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
});

it("cancels key send event that fail", async () => {
const eventSentinel = {} as unknown as MatrixEvent;

client.cancelPendingEvent = jest.fn();
sendEventMock.mockImplementation(() => {
const e = new Error() as MatrixError;
e.data = {};
e.event = eventSentinel;
throw e;
});

sess!.joinRoomSession([mockFocus], true);

expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel);
});

it("Re-sends key if a new member joins", async () => {
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);

const keysSentPromise1 = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
});

sess.joinRoomSession([mockFocus], true);
await keysSentPromise1;

sendEventMock.mockClear();
jest.advanceTimersByTime(10000);

const keysSentPromise2 = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
});

const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);

const member2 = Object.assign({}, membershipTemplate, {
device_id: "BBBBBBB",
});

mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId, undefined));
sess.onMembershipUpdate();

await keysSentPromise2;

expect(sendEventMock).toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});

it("Doesn't re-send key immediately", async () => {
const realSetImmediate = setImmediate;
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);

const keysSentPromise1 = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
});

sess.joinRoomSession([mockFocus], true);
await keysSentPromise1;

sendEventMock.mockClear();

const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);

const member2 = Object.assign({}, membershipTemplate, {
device_id: "BBBBBBB",
});

mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId, undefined));
sess.onMembershipUpdate();

await new Promise((resolve) => {
realSetImmediate(resolve);
});

expect(sendEventMock).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
});

it("Does not emits if no membership changes", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);

const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
sess.onMembershipUpdate();

expect(onMembershipsChanged).not.toHaveBeenCalled();
});

it("Emits on membership changes", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);

const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);

mockRoom.getLiveTimeline().getState = jest
.fn()
.mockReturnValue(makeMockRoomState([], mockRoom.roomId, undefined));
sess.onMembershipUpdate();

expect(onMembershipsChanged).toHaveBeenCalled();
});

it("emits an event at the time a membership event expires", () => {
Expand Down Expand Up @@ -409,4 +593,54 @@ describe("MatrixRTCSession", () => {
"@alice:example.org",
);
});

it("collects keys from encryption events", () => {
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"),
} as unknown as MatrixEvent);

const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
expect(bobKeys).toHaveLength(1);
expect(bobKeys[0]).toEqual(Buffer.from("this is the key", "utf-8"));
});

it("collects keys at non-zero indices", () => {
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: 4,
key: "dGhpcyBpcyB0aGUga2V5",
},
],
}),
getSender: jest.fn().mockReturnValue("@bob:example.org"),
} as unknown as MatrixEvent);

const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
expect(bobKeys).toHaveLength(5);
expect(bobKeys[0]).toBeFalsy();
expect(bobKeys[1]).toBeFalsy();
expect(bobKeys[2]).toBeFalsy();
expect(bobKeys[3]).toBeFalsy();
expect(bobKeys[4]).toEqual(Buffer.from("this is the key", "utf-8"));
});
});
32 changes: 31 additions & 1 deletion spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
import {
ClientEvent,
EventTimeline,
EventType,
IRoomTimelineData,
MatrixClient,
MatrixEvent,
RoomEvent,
} from "../../../src";
import { RoomStateEvent } from "../../../src/models/room-state";
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
Expand Down Expand Up @@ -78,4 +86,26 @@ describe("MatrixRTCSessionManager", () => {

expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});

it("Calls onCallEncryption on encryption keys event", () => {
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;

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"),
sender: {
userId: "@mock:user.example",
},
} as unknown as MatrixEvent;
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
expect(onCallEncryptionMock).toHaveBeenCalled();
});
});
6 changes: 5 additions & 1 deletion spec/unit/matrixrtc/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ export function makeMockRoom(
} as unknown as Room;
}

function makeMockRoomState(memberships: CallMembershipData[], roomId: string, getLocalAge: (() => number) | undefined) {
export function makeMockRoomState(
memberships: CallMembershipData[],
roomId: string,
getLocalAge: (() => number) | undefined,
) {
return {
getStateEvents: (_: string, stateKey: string) => {
const event = mockRTCEvent(memberships, roomId, getLocalAge);
Expand Down
Loading

0 comments on commit bf81c4b

Please sign in to comment.