diff --git a/spec/integ/megolm-integ.spec.ts b/spec/integ/megolm-integ.spec.ts index fcdfa8694fa..334800333a6 100644 --- a/spec/integ/megolm-integ.spec.ts +++ b/spec/integ/megolm-integ.spec.ts @@ -650,6 +650,131 @@ describe("megolm", () => { ]); }); + describe("get|setGlobalErrorOnUnknownDevices", () => { + it("should raise an error if crypto is disabled", () => { + aliceTestClient.client["cryptoBackend"] = undefined; + expect(() => aliceTestClient.client.setGlobalErrorOnUnknownDevices(true)).toThrowError( + "encryption disabled", + ); + expect(() => aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toThrowError("encryption disabled"); + }); + + it("should permit sending to unknown devices", async () => { + expect(aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toBeTruthy(); + + aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await aliceTestClient.start(); + const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount); + + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"])); + await aliceTestClient.flushSync(); + + // start out with the device unknown - the send should be rejected. + aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz")); + aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz")); + + await Promise.all([ + aliceTestClient.client.sendTextMessage(ROOM_ID, "test").then( + () => { + throw new Error("sendTextMessage failed on an unknown device"); + }, + (e) => { + expect(e.name).toEqual("UnknownDeviceError"); + }, + ), + aliceTestClient.httpBackend.flushAllExpected(), + ]); + + // enable sending to unknown devices, and resend + aliceTestClient.client.setGlobalErrorOnUnknownDevices(false); + expect(aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toBeFalsy(); + + const room = aliceTestClient.client.getRoom(ROOM_ID)!; + const pendingMsg = room.getPendingEvents()[0]; + + const inboundGroupSessionPromise = expectSendRoomKey( + aliceTestClient.httpBackend, + "@bob:xyz", + testOlmAccount, + p2pSession, + ); + + await Promise.all([ + aliceTestClient.client.resendEvent(pendingMsg, room), + expectSendMegolmMessage(aliceTestClient.httpBackend, inboundGroupSessionPromise), + ]); + }); + }); + + describe("get|setGlobalBlacklistUnverifiedDevices", () => { + it("should raise an error if crypto is disabled", () => { + aliceTestClient.client["cryptoBackend"] = undefined; + expect(() => aliceTestClient.client.setGlobalBlacklistUnverifiedDevices(true)).toThrowError( + "encryption disabled", + ); + expect(() => aliceTestClient.client.getGlobalBlacklistUnverifiedDevices()).toThrowError( + "encryption disabled", + ); + }); + + it("should disable sending to unverified devices", async () => { + aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await aliceTestClient.start(); + const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount); + + // tell alice we share a room with bob + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"])); + await aliceTestClient.flushSync(); + + logger.log("Forcing alice to download our device keys"); + aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz")); + aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz")); + + await Promise.all([ + aliceTestClient.client.downloadKeys(["@bob:xyz"]), + aliceTestClient.httpBackend.flush("/keys/query", 2), + ]); + + logger.log("Telling alice to block messages to unverified devices"); + expect(aliceTestClient.client.getGlobalBlacklistUnverifiedDevices()).toBeFalsy(); + aliceTestClient.client.setGlobalBlacklistUnverifiedDevices(true); + expect(aliceTestClient.client.getGlobalBlacklistUnverifiedDevices()).toBeTruthy(); + + logger.log("Telling alice to send a megolm message"); + aliceTestClient.httpBackend.when("PUT", "/send/").respond(200, { event_id: "$event_id" }); + aliceTestClient.httpBackend.when("PUT", "/sendToDevice/m.room_key.withheld/").respond(200, {}); + + await Promise.all([ + aliceTestClient.client.sendTextMessage(ROOM_ID, "test"), + aliceTestClient.httpBackend.flushAllExpected({ timeout: 1000 }), + ]); + + // Now, let's mark the device as verified, and check that keys are sent to it. + + logger.log("Marking the device as verified"); + // XXX: this is an integration test; we really ought to do this via the cross-signing dance + const d = aliceTestClient.client.crypto!.deviceList.getStoredDevice("@bob:xyz", "DEVICE_ID")!; + d.verified = DeviceInfo.DeviceVerification.VERIFIED; + aliceTestClient.client.crypto?.deviceList.storeDevicesForUser("@bob:xyz", { DEVICE_ID: d }); + + const inboundGroupSessionPromise = expectSendRoomKey( + aliceTestClient.httpBackend, + "@bob:xyz", + testOlmAccount, + p2pSession, + ); + + logger.log("Asking alice to re-send"); + await Promise.all([ + expectSendMegolmMessage(aliceTestClient.httpBackend, inboundGroupSessionPromise).then((decrypted) => { + expect(decrypted.type).toEqual("m.room.message"); + expect(decrypted.content!.body).toEqual("test"); + }), + aliceTestClient.client.sendTextMessage(ROOM_ID, "test"), + ]); + }); + }); + it("We should start a new megolm session when a device is blocked", async () => { aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await aliceTestClient.start(); diff --git a/spec/unit/crypto/cross-signing.spec.ts b/spec/unit/crypto/cross-signing.spec.ts index 6f15bdd09ab..eddaf43ba4a 100644 --- a/spec/unit/crypto/cross-signing.spec.ts +++ b/spec/unit/crypto/cross-signing.spec.ts @@ -1147,7 +1147,7 @@ describe("userHasCrossSigningKeys", function () { }); it("throws an error if crypto is disabled", () => { - aliceClient.crypto = undefined; + aliceClient["cryptoBackend"] = undefined; expect(() => aliceClient.userHasCrossSigningKeys()).toThrowError("encryption disabled"); }); }); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 8fd3c22b936..6c96b838ee3 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -3130,7 +3130,7 @@ describe("Room", function () { it("should load pending events from from the store and decrypt if needed", async () => { const client = new TestClient(userA).client; - client.crypto = { + client.crypto = client["cryptoBackend"] = { decryptEvent: jest.fn().mockResolvedValue({ clearEvent: { body: "enc" } }), } as unknown as Crypto; client.store.getPendingEvents = jest.fn(async (roomId) => [ diff --git a/src/@types/crypto.ts b/src/@types/crypto.ts index 3c46d993910..91e4d161c7b 100644 --- a/src/@types/crypto.ts +++ b/src/@types/crypto.ts @@ -14,7 +14,33 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { IClearEvent } from "../models/event"; + export type OlmGroupSessionExtraData = { untrusted?: boolean; sharedHistory?: boolean; }; + +/** + * The result of a (successful) call to {@link Crypto.decryptEvent} + */ +export interface IEventDecryptionResult { + /** + * The plaintext payload for the event (typically containing type and content fields). + */ + clearEvent: IClearEvent; + /** + * List of curve25519 keys involved in telling us about the senderCurve25519Key and claimedEd25519Key. + * See {@link MatrixEvent#getForwardingCurve25519KeyChain}. + */ + forwardingCurve25519KeyChain?: string[]; + /** + * Key owned by the sender of this event. See {@link MatrixEvent#getSenderKey}. + */ + senderCurve25519Key?: string; + /** + * ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}. + */ + claimedEd25519Key?: string; + untrusted?: boolean; +} diff --git a/src/client.ts b/src/client.ts index 66b16903039..83c4ac2cd12 100644 --- a/src/client.ts +++ b/src/client.ts @@ -210,6 +210,7 @@ import { UIARequest, UIAResponse } from "./@types/uia"; import { LocalNotificationSettings } from "./@types/local_notifications"; import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync"; import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature"; +import { CryptoBackend } from "./common-crypto/CryptoBackend"; export type Store = IStore; @@ -1147,7 +1148,8 @@ export class MatrixClient extends TypedEventEmitter } = {}; public identityServer?: IIdentityServerProvider; public http: MatrixHttpApi; // XXX: Intended private, used in code. - public crypto?: Crypto; // XXX: Intended private, used in code. + public crypto?: Crypto; // libolm crypto implementation. XXX: Intended private, used in code. Being replaced by cryptoBackend + private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code. public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code. public groupCallEventHandler?: GroupCallEventHandler; @@ -1455,7 +1457,7 @@ export class MatrixClient extends TypedEventEmitter[0]); - this.crypto = crypto; + this.cryptoBackend = this.crypto = crypto; // upload our keys in the background this.crypto.uploadDeviceKeys().catch((e) => { @@ -2054,7 +2056,7 @@ export class MatrixClient extends TypedEventEmitter { - if (!this.crypto) { + if (!this.cryptoBackend) { throw new Error("End-to-end encryption disabled"); } - return this.crypto.userHasCrossSigningKeys(); + return this.cryptoBackend.userHasCrossSigningKeys(); } /** @@ -7162,7 +7164,7 @@ export class MatrixClient extends TypedEventEmitter { if (event.shouldAttemptDecryption() && this.isCryptoEnabled()) { - event.attemptDecryption(this.crypto!, options); + event.attemptDecryption(this.cryptoBackend!, options); } if (event.isBeingDecrypted()) { diff --git a/src/common-crypto/CryptoBackend.ts b/src/common-crypto/CryptoBackend.ts new file mode 100644 index 00000000000..77748cb5050 --- /dev/null +++ b/src/common-crypto/CryptoBackend.ts @@ -0,0 +1,63 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { IEventDecryptionResult } from "../@types/crypto"; +import { MatrixEvent } from "../models/event"; + +/** + * Common interface for the crypto implementations + */ +export interface CryptoBackend { + /** + * Global override for whether the client should ever send encrypted + * messages to unverified devices. This provides the default for rooms which + * do not specify a value. + * + * If true, all unverified devices will be blacklisted by default + */ + globalBlacklistUnverifiedDevices: boolean; + + /** + * Whether sendMessage in a room with unknown and unverified devices + * should throw an error and not send the message. This has 'Global' for + * symmetry with setGlobalBlacklistUnverifiedDevices but there is currently + * no room-level equivalent for this setting. + */ + globalErrorOnUnknownDevices: boolean; + + /** + * Shut down any background processes related to crypto + */ + stop(): void; + + /** + * Checks if the user has previously published cross-signing keys + * + * This means downloading the devicelist for the user and checking if the list includes + * the cross-signing pseudo-device. + + * @returns true if the user has previously published cross-signing keys + */ + userHasCrossSigningKeys(): Promise; + + /** + * Decrypt a received event + * + * @returns a promise which resolves once we have finished decrypting. + * Rejects with an error if there is a problem decrypting the event. + */ + decryptEvent(event: MatrixEvent): Promise; +} diff --git a/src/common-crypto/README.md b/src/common-crypto/README.md new file mode 100644 index 00000000000..7af3298af48 --- /dev/null +++ b/src/common-crypto/README.md @@ -0,0 +1,4 @@ +This directory contains functionality which is common to both the legacy (libolm-based) crypto implementation, +and the new rust-based implementation. + +It is an internal module, and is _not_ directly exposed to applications. diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index e8816569339..ef04e8f4e0b 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -20,6 +20,7 @@ limitations under the License. import { v4 as uuidv4 } from "uuid"; +import type { IEventDecryptionResult } from "../../@types/crypto"; import { logger } from "../../logger"; import * as olmlib from "../olmlib"; import { @@ -38,13 +39,7 @@ import { IOlmSessionResult } from "../olmlib"; import { DeviceInfoMap } from "../DeviceList"; import { IContent, MatrixEvent } from "../../models/event"; import { EventType, MsgType, ToDeviceMessageId } from "../../@types/event"; -import { - IMegolmEncryptedContent, - IEventDecryptionResult, - IMegolmSessionData, - IncomingRoomKeyRequest, - IEncryptedContent, -} from "../index"; +import { IMegolmEncryptedContent, IMegolmSessionData, IncomingRoomKeyRequest, IEncryptedContent } from "../index"; import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager"; import { OlmGroupSessionExtraData } from "../../@types/crypto"; import { MatrixError } from "../../http-api"; diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts index 90e724d3804..1a795545916 100644 --- a/src/crypto/algorithms/olm.ts +++ b/src/crypto/algorithms/olm.ts @@ -18,13 +18,14 @@ limitations under the License. * Defines m.olm encryption/decryption */ +import type { IEventDecryptionResult } from "../../@types/crypto"; import { logger } from "../../logger"; import * as olmlib from "../olmlib"; import { DeviceInfo } from "../deviceinfo"; import { DecryptionAlgorithm, DecryptionError, EncryptionAlgorithm, registerAlgorithm } from "./base"; import { Room } from "../../models/room"; import { IContent, MatrixEvent } from "../../models/event"; -import { IEncryptedContent, IEventDecryptionResult, IOlmEncryptedContent } from "../index"; +import { IEncryptedContent, IOlmEncryptedContent } from "../index"; import { IInboundSession } from "../OlmDevice"; const DeviceVerification = DeviceInfo.DeviceVerification; diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 391e6975e7d..9349550130d 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -20,6 +20,7 @@ limitations under the License. import anotherjson from "another-json"; import { v4 as uuidv4 } from "uuid"; +import type { IEventDecryptionResult } from "../@types/crypto"; import type { PkDecryption, PkSigning } from "@matrix-org/olm"; import { EventType, ToDeviceMessageId } from "../@types/event"; import { TypedReEmitter } from "../ReEmitter"; @@ -67,7 +68,7 @@ import { BackupManager } from "./backup"; import { IStore } from "../store"; import { Room, RoomEvent } from "../models/room"; import { RoomMember, RoomMemberEvent } from "../models/room-member"; -import { EventStatus, IClearEvent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event"; +import { EventStatus, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event"; import { ToDeviceBatch } from "../models/ToDeviceMessage"; import { ClientEvent, @@ -87,6 +88,7 @@ import { IContent } from "../models/event"; import { ISyncResponse } from "../sync-accumulator"; import { ISignatures } from "../@types/signed"; import { IMessage } from "./algorithms/olm"; +import { CryptoBackend } from "../common-crypto/CryptoBackend"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -218,30 +220,6 @@ interface ISignableObject { unsigned?: object; } -/** - * The result of a (successful) call to decryptEvent. - */ -export interface IEventDecryptionResult { - /** - * The plaintext payload for the event (typically containing type and content fields). - */ - clearEvent: IClearEvent; - /** - * List of curve25519 keys involved in telling us about the senderCurve25519Key and claimedEd25519Key. - * See {@link MatrixEvent#getForwardingCurve25519KeyChain}. - */ - forwardingCurve25519KeyChain?: string[]; - /** - * Key owned by the sender of this event. See {@link MatrixEvent#getSenderKey}. - */ - senderCurve25519Key?: string; - /** - * ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}. - */ - claimedEd25519Key?: string; - untrusted?: boolean; -} - export interface IRequestsMap { getRequest(event: MatrixEvent): VerificationRequest | undefined; getRequestByChannel(channel: IVerificationChannel): VerificationRequest | undefined; @@ -380,7 +358,7 @@ export type CryptoEventHandlerMap = { [CryptoEvent.UserCrossSigningUpdated]: (userId: string) => void; }; -export class Crypto extends TypedEventEmitter { +export class Crypto extends TypedEventEmitter implements CryptoBackend { /** * @returns The version of Olm. */ @@ -3915,3 +3893,6 @@ class IncomingRoomKeyRequestCancellation { this.requestId = content.request_id; } } + +// IEventDecryptionResult is re-exported for backwards compatibility, in case any applications are referencing it. +export type { IEventDecryptionResult } from "../@types/crypto"; diff --git a/src/models/event.ts b/src/models/event.ts index c4910e752c1..1f1c3cfa366 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -21,10 +21,11 @@ limitations under the License. import { ExtensibleEvent, ExtensibleEvents, Optional } from "matrix-events-sdk"; +import type { IEventDecryptionResult } from "../@types/crypto"; import { logger } from "../logger"; import { VerificationRequest } from "../crypto/verification/request/VerificationRequest"; import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event"; -import { Crypto, IEventDecryptionResult } from "../crypto"; +import { Crypto } from "../crypto"; import { deepSortedObjectEntries, internaliseString } from "../utils"; import { RoomMember } from "./room-member"; import { Thread, ThreadEvent, EventHandlerMap as ThreadEventHandlerMap, THREAD_RELATION_TYPE } from "./thread"; @@ -34,6 +35,7 @@ import { MatrixError } from "../http-api"; import { TypedEventEmitter } from "./typed-event-emitter"; import { EventStatus } from "./event-status"; import { DecryptionError } from "../crypto/algorithms"; +import { CryptoBackend } from "../common-crypto/CryptoBackend"; export { EventStatus } from "./event-status"; @@ -725,7 +727,7 @@ export class MatrixEvent extends TypedEventEmitter { + public async attemptDecryption(crypto: CryptoBackend, options: IDecryptOptions = {}): Promise { // start with a couple of sanity checks. if (!this.isEncrypted()) { throw new Error("Attempt to decrypt event which isn't encrypted"); @@ -803,7 +805,7 @@ export class MatrixEvent extends TypedEventEmitter { + private async decryptionLoop(crypto: CryptoBackend, options: IDecryptOptions = {}): Promise { // make sure that this method never runs completely synchronously. // (doing so would mean that we would clear decryptionPromise *before* // it is set in attemptDecryption - and hence end up with a stuck