Skip to content

Commit 05bf642

Browse files
authored
Element-R: implement encryption of outgoing events (#3122)
This PR wires up the Rust-SDK into the event encryption path
1 parent e492a44 commit 05bf642

14 files changed

+598
-25
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
"eslint-plugin-unicorn": "^45.0.0",
110110
"exorcist": "^2.0.0",
111111
"fake-indexeddb": "^4.0.0",
112+
"fetch-mock-jest": "^1.5.1",
112113
"jest": "^29.0.0",
113114
"jest-environment-jsdom": "^29.0.0",
114115
"jest-localstorage-mock": "^2.4.6",

spec/integ/crypto.spec.ts

+29
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,35 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm (%s)", (backend: string,
633633
expect(event.getContent().body).toEqual("42");
634634
});
635635

636+
oldBackendOnly("prepareToEncrypt", async () => {
637+
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
638+
await aliceTestClient.start();
639+
aliceTestClient.client.setGlobalErrorOnUnknownDevices(false);
640+
641+
// tell alice she is sharing a room with bob
642+
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"]));
643+
await aliceTestClient.flushSync();
644+
645+
// we expect alice first to query bob's keys...
646+
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
647+
aliceTestClient.httpBackend.flush("/keys/query", 1);
648+
649+
// ... and then claim one of his OTKs
650+
aliceTestClient.httpBackend.when("POST", "/keys/claim").respond(200, getTestKeysClaimResponse("@bob:xyz"));
651+
aliceTestClient.httpBackend.flush("/keys/claim", 1);
652+
653+
// fire off the prepare request
654+
const room = aliceTestClient.client.getRoom(ROOM_ID);
655+
expect(room).toBeTruthy();
656+
const p = aliceTestClient.client.prepareToEncrypt(room!);
657+
658+
// we expect to get a room key message
659+
await expectSendRoomKey(aliceTestClient.httpBackend, "@bob:xyz", testOlmAccount);
660+
661+
// the prepare request should complete successfully.
662+
await p;
663+
});
664+
636665
oldBackendOnly("Alice sends a megolm message", async () => {
637666
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
638667
await aliceTestClient.start();

spec/unit/matrix-client.spec.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1415,7 +1415,7 @@ describe("MatrixClient", function () {
14151415
expect(getRoomId).toEqual(roomId);
14161416
return mockRoom;
14171417
};
1418-
client.crypto = {
1418+
client.crypto = client["cryptoBackend"] = {
14191419
// mock crypto
14201420
encryptEvent: () => new Promise(() => {}),
14211421
stop: jest.fn(),
@@ -1437,8 +1437,9 @@ describe("MatrixClient", function () {
14371437

14381438
it("should cancel an event which is encrypting", async () => {
14391439
// @ts-ignore protected method access
1440-
client.encryptAndSendEvent(null, event);
1440+
client.encryptAndSendEvent(mockRoom, event);
14411441
await testUtils.emitPromise(event, "Event.status");
1442+
expect(event.status).toBe(EventStatus.ENCRYPTING);
14421443
client.cancelPendingEvent(event);
14431444
assertCancelled();
14441445
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
18+
import fetchMock from "fetch-mock-jest";
19+
import { Mocked } from "jest-mock";
20+
import { KeysClaimRequest, UserId } from "@matrix-org/matrix-sdk-crypto-js";
21+
22+
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
23+
import { KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
24+
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
25+
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi } from "../../../src";
26+
27+
afterEach(() => {
28+
fetchMock.mockReset();
29+
});
30+
31+
describe("KeyClaimManager", () => {
32+
/* for these tests, we connect a KeyClaimManager to a mock OlmMachine, and a real OutgoingRequestProcessor
33+
* (which is connected to a mock fetch implementation)
34+
*/
35+
36+
/** the KeyClaimManager implementation under test */
37+
let keyClaimManager: KeyClaimManager;
38+
39+
/** a mocked-up OlmMachine which the OutgoingRequestProcessor and KeyClaimManager are connected to */
40+
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
41+
42+
beforeEach(async () => {
43+
const dummyEventEmitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
44+
const httpApi = new MatrixHttpApi(dummyEventEmitter, {
45+
baseUrl: "https://example.com",
46+
prefix: "/_matrix",
47+
onlyData: true,
48+
});
49+
50+
olmMachine = {
51+
getMissingSessions: jest.fn(),
52+
markRequestAsSent: jest.fn(),
53+
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
54+
55+
const outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, httpApi);
56+
57+
keyClaimManager = new KeyClaimManager(olmMachine, outgoingRequestProcessor);
58+
});
59+
60+
/**
61+
* Returns a promise which resolve once olmMachine.markRequestAsSent is called.
62+
*
63+
* The call itself will block initially.
64+
*
65+
* The promise returned by this function yields a callback function, which should be called to unblock the
66+
* markRequestAsSent call.
67+
*/
68+
function awaitCallToMarkRequestAsSent(): Promise<() => void> {
69+
return new Promise<() => void>((resolveCalledPromise, _reject) => {
70+
olmMachine.markRequestAsSent.mockImplementationOnce(async () => {
71+
// the mock implementation returns a promise...
72+
const completePromise = new Promise<void>((resolveCompletePromise, _reject) => {
73+
// ... and we now resolve the original promise with the resolver for that second promise.
74+
resolveCalledPromise(resolveCompletePromise);
75+
});
76+
return completePromise;
77+
});
78+
});
79+
}
80+
81+
it("should claim missing keys", async () => {
82+
const u1 = new UserId("@alice:example.com");
83+
const u2 = new UserId("@bob:example.com");
84+
85+
// stub out olmMachine.getMissingSessions(), with a result indicating that it needs a keyclaim
86+
const keysClaimRequest = new KeysClaimRequest("1234", '{ "k1": "v1" }');
87+
olmMachine.getMissingSessions.mockResolvedValueOnce(keysClaimRequest);
88+
89+
// have the claim request return a 200
90+
fetchMock.postOnce("https://example.com/_matrix/client/v3/keys/claim", '{ "k": "v" }');
91+
92+
// also stub out olmMachine.markRequestAsSent
93+
olmMachine.markRequestAsSent.mockResolvedValueOnce(undefined);
94+
95+
// fire off the request
96+
await keyClaimManager.ensureSessionsForUsers([u1, u2]);
97+
98+
// check that all the calls were made
99+
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u1, u2]);
100+
expect(fetchMock).toHaveFetched("https://example.com/_matrix/client/v3/keys/claim", {
101+
method: "POST",
102+
body: { k1: "v1" },
103+
});
104+
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", keysClaimRequest.type, '{ "k": "v" }');
105+
});
106+
107+
it("should wait for previous claims to complete before making another", async () => {
108+
const u1 = new UserId("@alice:example.com");
109+
const u2 = new UserId("@bob:example.com");
110+
111+
// stub out olmMachine.getMissingSessions(), with a result indicating that it needs a keyclaim
112+
const keysClaimRequest = new KeysClaimRequest("1234", '{ "k1": "v1" }');
113+
olmMachine.getMissingSessions.mockResolvedValue(keysClaimRequest);
114+
115+
// have the claim request return a 200
116+
fetchMock.post("https://example.com/_matrix/client/v3/keys/claim", '{ "k": "v" }');
117+
118+
// stub out olmMachine.markRequestAsSent, and have it block
119+
let markRequestAsSentPromise = awaitCallToMarkRequestAsSent();
120+
121+
// fire off two requests, and keep track of whether their promises resolve
122+
let req1Resolved = false;
123+
keyClaimManager.ensureSessionsForUsers([u1]).then(() => {
124+
req1Resolved = true;
125+
});
126+
let req2Resolved = false;
127+
const req2 = keyClaimManager.ensureSessionsForUsers([u2]).then(() => {
128+
req2Resolved = true;
129+
});
130+
131+
// now: wait for the (first) call to OlmMachine.markRequestAsSent
132+
let resolveMarkRequestAsSentCallback = await markRequestAsSentPromise;
133+
134+
// at this point, there should have been a single call to getMissingSessions, and a single fetch; and neither
135+
// call to ensureSessionsAsUsers should have completed
136+
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u1]);
137+
expect(olmMachine.getMissingSessions).toHaveBeenCalledTimes(1);
138+
expect(fetchMock).toHaveBeenCalledTimes(1);
139+
expect(req1Resolved).toBe(false);
140+
expect(req2Resolved).toBe(false);
141+
142+
// await the next call to markRequestAsSent, and release the first one
143+
markRequestAsSentPromise = awaitCallToMarkRequestAsSent();
144+
resolveMarkRequestAsSentCallback();
145+
resolveMarkRequestAsSentCallback = await markRequestAsSentPromise;
146+
147+
// the first request should now have completed, and we should have more calls and fetches
148+
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u2]);
149+
expect(olmMachine.getMissingSessions).toHaveBeenCalledTimes(2);
150+
expect(fetchMock).toHaveBeenCalledTimes(2);
151+
expect(req1Resolved).toBe(true);
152+
expect(req2Resolved).toBe(false);
153+
154+
// finally, release the second call to markRequestAsSent and check that the second request completes
155+
resolveMarkRequestAsSentCallback();
156+
await req2;
157+
});
158+
});

src/client.ts

+12-10
Original file line numberDiff line numberDiff line change
@@ -2185,7 +2185,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
21852185
// importing rust-crypto will download the webassembly, so we delay it until we know it will be
21862186
// needed.
21872187
const RustCrypto = await import("./rust-crypto");
2188-
this.cryptoBackend = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
2188+
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
2189+
this.cryptoBackend = rustCrypto;
2190+
2191+
// attach the event listeners needed by RustCrypto
2192+
this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
21892193
}
21902194

21912195
/**
@@ -2608,10 +2612,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
26082612
* @param room - the room the event is in
26092613
*/
26102614
public prepareToEncrypt(room: Room): void {
2611-
if (!this.crypto) {
2615+
if (!this.cryptoBackend) {
26122616
throw new Error("End-to-end encryption disabled");
26132617
}
2614-
this.crypto.prepareToEncrypt(room);
2618+
this.cryptoBackend.prepareToEncrypt(room);
26152619
}
26162620

26172621
/**
@@ -4392,11 +4396,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
43924396
return null;
43934397
}
43944398

4395-
if (!this.isRoomEncrypted(event.getRoomId()!)) {
4399+
if (!room || !this.isRoomEncrypted(event.getRoomId()!)) {
43964400
return null;
43974401
}
43984402

4399-
if (!this.crypto && this.usingExternalCrypto) {
4403+
if (!this.cryptoBackend && this.usingExternalCrypto) {
44004404
// The client has opted to allow sending messages to encrypted
44014405
// rooms even if the room is encrypted, and we haven't setup
44024406
// crypto. This is useful for users of matrix-org/pantalaimon
@@ -4417,13 +4421,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
44174421
return null;
44184422
}
44194423

4420-
if (!this.crypto) {
4421-
throw new Error(
4422-
"This room is configured to use encryption, but your client does " + "not support encryption.",
4423-
);
4424+
if (!this.cryptoBackend) {
4425+
throw new Error("This room is configured to use encryption, but your client does not support encryption.");
44244426
}
44254427

4426-
return this.crypto.encryptEvent(event, room);
4428+
return this.cryptoBackend.encryptEvent(event, room);
44274429
}
44284430

44294431
/**

src/common-crypto/CryptoBackend.ts

+35
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypt
1818
import type { IToDeviceEvent } from "../sync-accumulator";
1919
import type { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
2020
import { MatrixEvent } from "../models/event";
21+
import { Room } from "../models/room";
2122
import { IEncryptedEventInfo } from "../crypto/api";
2223

2324
/**
@@ -75,6 +76,26 @@ export interface CryptoBackend extends SyncCryptoCallbacks {
7576
*/
7677
checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel;
7778

79+
/**
80+
* Perform any background tasks that can be done before a message is ready to
81+
* send, in order to speed up sending of the message.
82+
*
83+
* @param room - the room the event is in
84+
*/
85+
prepareToEncrypt(room: Room): void;
86+
87+
/**
88+
* Encrypt an event according to the configuration of the room.
89+
*
90+
* @param event - event to be sent
91+
*
92+
* @param room - destination room.
93+
*
94+
* @returns Promise which resolves when the event has been
95+
* encrypted, or null if nothing was needed
96+
*/
97+
encryptEvent(event: MatrixEvent, room: Room): Promise<void>;
98+
7899
/**
79100
* Decrypt a received event
80101
*
@@ -117,6 +138,20 @@ export interface SyncCryptoCallbacks {
117138
*/
118139
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
119140

141+
/**
142+
* Called by the /sync loop whenever an m.room.encryption event is received.
143+
*
144+
* This is called before RoomStateEvents are emitted for any of the events in the /sync
145+
* response (even if the other events technically happened first). This works around a problem
146+
* if the client uses a RoomStateEvent (typically a membership event) as a trigger to send a message
147+
* in a new room (or one where encryption has been newly enabled): that would otherwise leave the
148+
* crypto layer confused because it expects crypto to be set up, but it has not yet been.
149+
*
150+
* @param room - in which the event was received
151+
* @param event - encryption event to be processed
152+
*/
153+
onCryptoEvent(room: Room, event: MatrixEvent): Promise<void>;
154+
120155
/**
121156
* Called by the /sync loop after each /sync response is processed.
122157
*

src/crypto/index.ts

+1-5
Original file line numberDiff line numberDiff line change
@@ -2808,11 +2808,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
28082808
* @returns Promise which resolves when the event has been
28092809
* encrypted, or null if nothing was needed
28102810
*/
2811-
public async encryptEvent(event: MatrixEvent, room?: Room): Promise<void> {
2812-
if (!room) {
2813-
throw new Error("Cannot send encrypted messages in unknown rooms");
2814-
}
2815-
2811+
public async encryptEvent(event: MatrixEvent, room: Room): Promise<void> {
28162812
const roomId = event.getRoomId()!;
28172813

28182814
const alg = this.roomEncryptors.get(roomId);

0 commit comments

Comments
 (0)