Skip to content

Commit 9c17eb6

Browse files
authored
Begin factoring out a CryptoBackend interface (#2955)
Part of element-hq/element-web#21972. Eventually I want to replace the whole of the current `Crypto` implementation with an alternative implementation, but in order to get from here to there, I'm factoring out a common interface which will be implemented by both implementations. I'm also determined to fix the problem where the innards of the crypto implementation are exposed to applications via the `MatrixClient.crypto` property. It's not (yet) entirely clear what shape this interface should be, so I'm going with a minimal approach and adding things as we know we need them. This means that we need to keep the old `client.crypto` property around as well as a new `client.cryptoBackend` property. Eventually `client.crypto` will go away, but that will be a breaking change in the js-sdk.
1 parent 8293011 commit 9c17eb6

File tree

11 files changed

+255
-56
lines changed

11 files changed

+255
-56
lines changed

spec/integ/megolm-integ.spec.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,131 @@ describe("megolm", () => {
650650
]);
651651
});
652652

653+
describe("get|setGlobalErrorOnUnknownDevices", () => {
654+
it("should raise an error if crypto is disabled", () => {
655+
aliceTestClient.client["cryptoBackend"] = undefined;
656+
expect(() => aliceTestClient.client.setGlobalErrorOnUnknownDevices(true)).toThrowError(
657+
"encryption disabled",
658+
);
659+
expect(() => aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toThrowError("encryption disabled");
660+
});
661+
662+
it("should permit sending to unknown devices", async () => {
663+
expect(aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toBeTruthy();
664+
665+
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
666+
await aliceTestClient.start();
667+
const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount);
668+
669+
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"]));
670+
await aliceTestClient.flushSync();
671+
672+
// start out with the device unknown - the send should be rejected.
673+
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
674+
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
675+
676+
await Promise.all([
677+
aliceTestClient.client.sendTextMessage(ROOM_ID, "test").then(
678+
() => {
679+
throw new Error("sendTextMessage failed on an unknown device");
680+
},
681+
(e) => {
682+
expect(e.name).toEqual("UnknownDeviceError");
683+
},
684+
),
685+
aliceTestClient.httpBackend.flushAllExpected(),
686+
]);
687+
688+
// enable sending to unknown devices, and resend
689+
aliceTestClient.client.setGlobalErrorOnUnknownDevices(false);
690+
expect(aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toBeFalsy();
691+
692+
const room = aliceTestClient.client.getRoom(ROOM_ID)!;
693+
const pendingMsg = room.getPendingEvents()[0];
694+
695+
const inboundGroupSessionPromise = expectSendRoomKey(
696+
aliceTestClient.httpBackend,
697+
"@bob:xyz",
698+
testOlmAccount,
699+
p2pSession,
700+
);
701+
702+
await Promise.all([
703+
aliceTestClient.client.resendEvent(pendingMsg, room),
704+
expectSendMegolmMessage(aliceTestClient.httpBackend, inboundGroupSessionPromise),
705+
]);
706+
});
707+
});
708+
709+
describe("get|setGlobalBlacklistUnverifiedDevices", () => {
710+
it("should raise an error if crypto is disabled", () => {
711+
aliceTestClient.client["cryptoBackend"] = undefined;
712+
expect(() => aliceTestClient.client.setGlobalBlacklistUnverifiedDevices(true)).toThrowError(
713+
"encryption disabled",
714+
);
715+
expect(() => aliceTestClient.client.getGlobalBlacklistUnverifiedDevices()).toThrowError(
716+
"encryption disabled",
717+
);
718+
});
719+
720+
it("should disable sending to unverified devices", async () => {
721+
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
722+
await aliceTestClient.start();
723+
const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount);
724+
725+
// tell alice we share a room with bob
726+
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"]));
727+
await aliceTestClient.flushSync();
728+
729+
logger.log("Forcing alice to download our device keys");
730+
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
731+
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
732+
733+
await Promise.all([
734+
aliceTestClient.client.downloadKeys(["@bob:xyz"]),
735+
aliceTestClient.httpBackend.flush("/keys/query", 2),
736+
]);
737+
738+
logger.log("Telling alice to block messages to unverified devices");
739+
expect(aliceTestClient.client.getGlobalBlacklistUnverifiedDevices()).toBeFalsy();
740+
aliceTestClient.client.setGlobalBlacklistUnverifiedDevices(true);
741+
expect(aliceTestClient.client.getGlobalBlacklistUnverifiedDevices()).toBeTruthy();
742+
743+
logger.log("Telling alice to send a megolm message");
744+
aliceTestClient.httpBackend.when("PUT", "/send/").respond(200, { event_id: "$event_id" });
745+
aliceTestClient.httpBackend.when("PUT", "/sendToDevice/m.room_key.withheld/").respond(200, {});
746+
747+
await Promise.all([
748+
aliceTestClient.client.sendTextMessage(ROOM_ID, "test"),
749+
aliceTestClient.httpBackend.flushAllExpected({ timeout: 1000 }),
750+
]);
751+
752+
// Now, let's mark the device as verified, and check that keys are sent to it.
753+
754+
logger.log("Marking the device as verified");
755+
// XXX: this is an integration test; we really ought to do this via the cross-signing dance
756+
const d = aliceTestClient.client.crypto!.deviceList.getStoredDevice("@bob:xyz", "DEVICE_ID")!;
757+
d.verified = DeviceInfo.DeviceVerification.VERIFIED;
758+
aliceTestClient.client.crypto?.deviceList.storeDevicesForUser("@bob:xyz", { DEVICE_ID: d });
759+
760+
const inboundGroupSessionPromise = expectSendRoomKey(
761+
aliceTestClient.httpBackend,
762+
"@bob:xyz",
763+
testOlmAccount,
764+
p2pSession,
765+
);
766+
767+
logger.log("Asking alice to re-send");
768+
await Promise.all([
769+
expectSendMegolmMessage(aliceTestClient.httpBackend, inboundGroupSessionPromise).then((decrypted) => {
770+
expect(decrypted.type).toEqual("m.room.message");
771+
expect(decrypted.content!.body).toEqual("test");
772+
}),
773+
aliceTestClient.client.sendTextMessage(ROOM_ID, "test"),
774+
]);
775+
});
776+
});
777+
653778
it("We should start a new megolm session when a device is blocked", async () => {
654779
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
655780
await aliceTestClient.start();

spec/unit/crypto/cross-signing.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1147,7 +1147,7 @@ describe("userHasCrossSigningKeys", function () {
11471147
});
11481148

11491149
it("throws an error if crypto is disabled", () => {
1150-
aliceClient.crypto = undefined;
1150+
aliceClient["cryptoBackend"] = undefined;
11511151
expect(() => aliceClient.userHasCrossSigningKeys()).toThrowError("encryption disabled");
11521152
});
11531153
});

spec/unit/room.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3131,7 +3131,7 @@ describe("Room", function () {
31313131

31323132
it("should load pending events from from the store and decrypt if needed", async () => {
31333133
const client = new TestClient(userA).client;
3134-
client.crypto = {
3134+
client.crypto = client["cryptoBackend"] = {
31353135
decryptEvent: jest.fn().mockResolvedValue({ clearEvent: { body: "enc" } }),
31363136
} as unknown as Crypto;
31373137
client.store.getPendingEvents = jest.fn(async (roomId) => [

src/@types/crypto.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,33 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17+
import type { IClearEvent } from "../models/event";
18+
1719
export type OlmGroupSessionExtraData = {
1820
untrusted?: boolean;
1921
sharedHistory?: boolean;
2022
};
23+
24+
/**
25+
* The result of a (successful) call to {@link Crypto.decryptEvent}
26+
*/
27+
export interface IEventDecryptionResult {
28+
/**
29+
* The plaintext payload for the event (typically containing <tt>type</tt> and <tt>content</tt> fields).
30+
*/
31+
clearEvent: IClearEvent;
32+
/**
33+
* List of curve25519 keys involved in telling us about the senderCurve25519Key and claimedEd25519Key.
34+
* See {@link MatrixEvent#getForwardingCurve25519KeyChain}.
35+
*/
36+
forwardingCurve25519KeyChain?: string[];
37+
/**
38+
* Key owned by the sender of this event. See {@link MatrixEvent#getSenderKey}.
39+
*/
40+
senderCurve25519Key?: string;
41+
/**
42+
* ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}.
43+
*/
44+
claimedEd25519Key?: string;
45+
untrusted?: boolean;
46+
}

src/client.ts

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ import { UIARequest, UIAResponse } from "./@types/uia";
210210
import { LocalNotificationSettings } from "./@types/local_notifications";
211211
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync";
212212
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
213+
import { CryptoBackend } from "./common-crypto/CryptoBackend";
213214

214215
export type Store = IStore;
215216

@@ -1147,7 +1148,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
11471148
public urlPreviewCache: { [key: string]: Promise<IPreviewUrlResponse> } = {};
11481149
public identityServer?: IIdentityServerProvider;
11491150
public http: MatrixHttpApi<IHttpOpts & { onlyData: true }>; // XXX: Intended private, used in code.
1150-
public crypto?: Crypto; // XXX: Intended private, used in code.
1151+
public crypto?: Crypto; // libolm crypto implementation. XXX: Intended private, used in code. Being replaced by cryptoBackend
1152+
private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto
11511153
public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code.
11521154
public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code.
11531155
public groupCallEventHandler?: GroupCallEventHandler;
@@ -1455,7 +1457,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
14551457
* clean shutdown.
14561458
*/
14571459
public stopClient(): void {
1458-
this.crypto?.stop(); // crypto might have been initialised even if the client wasn't fully started
1460+
this.cryptoBackend?.stop(); // crypto might have been initialised even if the client wasn't fully started
14591461

14601462
if (!this.clientRunning) return; // already stopped
14611463

@@ -1959,7 +1961,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
19591961
}
19601962

19611963
/**
1962-
* Initialise support for end-to-end encryption in this client
1964+
* Initialise support for end-to-end encryption in this client, using libolm.
19631965
*
19641966
* You should call this method after creating the matrixclient, but *before*
19651967
* calling `startClient`, if you want to support end-to-end encryption.
@@ -1975,7 +1977,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
19751977
);
19761978
}
19771979

1978-
if (this.crypto) {
1980+
if (this.cryptoBackend) {
19791981
logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
19801982
return;
19811983
}
@@ -2040,7 +2042,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
20402042

20412043
// if crypto initialisation was successful, tell it to attach its event handlers.
20422044
crypto.registerEventHandlers(this as Parameters<Crypto["registerEventHandlers"]>[0]);
2043-
this.crypto = crypto;
2045+
this.cryptoBackend = this.crypto = crypto;
20442046

20452047
// upload our keys in the background
20462048
this.crypto.uploadDeviceKeys().catch((e) => {
@@ -2054,7 +2056,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
20542056
* @returns True if end-to-end is enabled.
20552057
*/
20562058
public isCryptoEnabled(): boolean {
2057-
return !!this.crypto;
2059+
return !!this.cryptoBackend;
20582060
}
20592061

20602062
/**
@@ -2299,21 +2301,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
22992301
* @param value - whether to blacklist all unverified devices by default
23002302
*/
23012303
public setGlobalBlacklistUnverifiedDevices(value: boolean): boolean {
2302-
if (!this.crypto) {
2304+
if (!this.cryptoBackend) {
23032305
throw new Error("End-to-end encryption disabled");
23042306
}
2305-
this.crypto.globalBlacklistUnverifiedDevices = value;
2307+
this.cryptoBackend.globalBlacklistUnverifiedDevices = value;
23062308
return value;
23072309
}
23082310

23092311
/**
23102312
* @returns whether to blacklist all unverified devices by default
23112313
*/
23122314
public getGlobalBlacklistUnverifiedDevices(): boolean {
2313-
if (!this.crypto) {
2315+
if (!this.cryptoBackend) {
23142316
throw new Error("End-to-end encryption disabled");
23152317
}
2316-
return this.crypto.globalBlacklistUnverifiedDevices;
2318+
return this.cryptoBackend.globalBlacklistUnverifiedDevices;
23172319
}
23182320

23192321
/**
@@ -2327,10 +2329,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
23272329
* @param value - whether error on unknown devices
23282330
*/
23292331
public setGlobalErrorOnUnknownDevices(value: boolean): void {
2330-
if (!this.crypto) {
2332+
if (!this.cryptoBackend) {
23312333
throw new Error("End-to-end encryption disabled");
23322334
}
2333-
this.crypto.globalErrorOnUnknownDevices = value;
2335+
this.cryptoBackend.globalErrorOnUnknownDevices = value;
23342336
}
23352337

23362338
/**
@@ -2339,10 +2341,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
23392341
* This API is currently UNSTABLE and may change or be removed without notice.
23402342
*/
23412343
public getGlobalErrorOnUnknownDevices(): boolean {
2342-
if (!this.crypto) {
2344+
if (!this.cryptoBackend) {
23432345
throw new Error("End-to-end encryption disabled");
23442346
}
2345-
return this.crypto.globalErrorOnUnknownDevices;
2347+
return this.cryptoBackend.globalErrorOnUnknownDevices;
23462348
}
23472349

23482350
/**
@@ -2482,10 +2484,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
24822484
* the cross-signing pseudo-device.
24832485
*/
24842486
public userHasCrossSigningKeys(): Promise<boolean> {
2485-
if (!this.crypto) {
2487+
if (!this.cryptoBackend) {
24862488
throw new Error("End-to-end encryption disabled");
24872489
}
2488-
return this.crypto.userHasCrossSigningKeys();
2490+
return this.cryptoBackend.userHasCrossSigningKeys();
24892491
}
24902492

24912493
/**
@@ -7162,7 +7164,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
71627164
*/
71637165
public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise<void> {
71647166
if (event.shouldAttemptDecryption() && this.isCryptoEnabled()) {
7165-
event.attemptDecryption(this.crypto!, options);
7167+
event.attemptDecryption(this.cryptoBackend!, options);
71667168
}
71677169

71687170
if (event.isBeingDecrypted()) {

src/common-crypto/CryptoBackend.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
Copyright 2022 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 type { IEventDecryptionResult } from "../@types/crypto";
18+
import { MatrixEvent } from "../models/event";
19+
20+
/**
21+
* Common interface for the crypto implementations
22+
*/
23+
export interface CryptoBackend {
24+
/**
25+
* Global override for whether the client should ever send encrypted
26+
* messages to unverified devices. This provides the default for rooms which
27+
* do not specify a value.
28+
*
29+
* If true, all unverified devices will be blacklisted by default
30+
*/
31+
globalBlacklistUnverifiedDevices: boolean;
32+
33+
/**
34+
* Whether sendMessage in a room with unknown and unverified devices
35+
* should throw an error and not send the message. This has 'Global' for
36+
* symmetry with setGlobalBlacklistUnverifiedDevices but there is currently
37+
* no room-level equivalent for this setting.
38+
*/
39+
globalErrorOnUnknownDevices: boolean;
40+
41+
/**
42+
* Shut down any background processes related to crypto
43+
*/
44+
stop(): void;
45+
46+
/**
47+
* Checks if the user has previously published cross-signing keys
48+
*
49+
* This means downloading the devicelist for the user and checking if the list includes
50+
* the cross-signing pseudo-device.
51+
52+
* @returns true if the user has previously published cross-signing keys
53+
*/
54+
userHasCrossSigningKeys(): Promise<boolean>;
55+
56+
/**
57+
* Decrypt a received event
58+
*
59+
* @returns a promise which resolves once we have finished decrypting.
60+
* Rejects with an error if there is a problem decrypting the event.
61+
*/
62+
decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>;
63+
}

src/common-crypto/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
This directory contains functionality which is common to both the legacy (libolm-based) crypto implementation,
2+
and the new rust-based implementation.
3+
4+
It is an internal module, and is _not_ directly exposed to applications.

0 commit comments

Comments
 (0)