From 810f7142e6de6c56976dc365029a09a94920dfa0 Mon Sep 17 00:00:00 2001 From: Florian D Date: Fri, 7 Feb 2025 13:31:40 +0100 Subject: [PATCH] Remove legacy crypto (#4653) * Remove deprecated calls in `webrtc/call.ts` * Throw error when legacy call was used * Remove `MatrixClient.initLegacyCrypto` (#4620) * Remove `MatrixClient.initLegacyCrypto` * Remove `MatrixClient.initLegacyCrypto` in README.md * Remove tests using `MatrixClient.initLegacyCrypto` * Remove legacy crypto support in `sync` api (#4622) * Remove deprecated `DeviceInfo` in `webrtc/call.ts` (#4654) * chore(legacy call): Remove `DeviceInfo` usage * refactor(legacy call): throw `GroupCallUnknownDeviceError` at the end of `initOpponentCrypto` * Remove deprecated methods and attributes of `MatrixClient` (#4659) * feat(legacy crypto)!: remove deprecated methods of `MatrixClient` * test(legacy crypto): update existing tests to not use legacy crypto - `Embedded.spec.ts`: casting since `encryptAndSendToDevices` is removed from `MatrixClient`. - `room.spec.ts`: remove deprecated usage of `MatrixClient.crypto` - `matrix-client.spec.ts` & `matrix-client-methods.spec.ts`: remove calls of deprecated methods of `MatrixClient` * test(legacy crypto): remove test files using `MatrixClient` deprecated methods * test(legacy crypto): update existing integ tests to run successfully * feat(legacy crypto!): remove `ICreateClientOpts.deviceToImport`. `ICreateClientOpts.deviceToImport` was used in the legacy cryto. The rust crypto doesn't support to import devices in this way. * feat(legacy crypto!): remove `{get,set}GlobalErrorOnUnknownDevices` `globalErrorOnUnknownDevices` is not used in the rust-crypto. The API is marked as unstable, we can remove it. * Remove usage of legacy crypto in `event.ts` (#4666) * feat(legacy crypto!): remove legacy crypto usage in `event.ts` * test(legacy crypto): update event.spec.ts to not use legacy crypto types * Remove legacy crypto export in `matrix.ts` (#4667) * feat(legacy crypto!): remove legacy crypto export in `matrix.ts` * test(legacy crypto): update `megolm-backup.spec.ts` to import directly `CryptoApi` * Remove usage of legacy crypto in integ tests (#4669) * Clean up legacy stores (#4663) * feat(legacy crypto!): keep legacy methods used in lib olm migration The rust cryto needs these legacy stores in order to do the migration from the legacy crypto to the rust crypto. We keep the following methods of the stores: - Used in `libolm_migration.ts`. - Needed in the legacy store tests. - Needed in the rust crypto test migration. * feat(legacy crypto): extract legacy crypto types in legacy stores In order to be able to delete the legacy crypto, these stores shouldn't rely on the legacy crypto. We need to extract the used types. * feat(crypto store): remove `CryptoStore` functions used only by tests * test(crypto store): use legacy `MemoryStore` type * Remove deprecated methods of `CryptoBackend` (#4671) * feat(CryptoBackend)!: remove deprecated methods * feat(rust-crypto)!: remove deprecated methods of `CryptoBackend` * test(rust-crypto): remove tests of deprecated methods of `CryptoBackend` * Remove usage of legacy crypto in `embedded.ts` (#4668) The interface of `encryptAndSendToDevices` changes because `DeviceInfo` is from the legacy crypto. In fact `encryptAndSendToDevices` only need pairs of userId and deviceId. * Remove legacy crypto files (#4672) * fix(legacy store): fix legacy store typing In https://github.com/matrix-org/matrix-js-sdk/pull/4663, the storeXXX methods were removed of the CryptoStore interface but they are used internally by IndexedDBCryptoStore. * feat(legacy crypto)!: remove content of `crypto/*` except legacy stores * test(legacy crypto): remove `spec/unit/crypto/*` except legacy store tests * refactor: remove unused types * doc: fix broken link * doc: remove link tag when typedoc is unable to find the CryptoApi * Clean up integ test after legacy crypto removal (#4682) * test(crypto): remove `newBackendOnly` test closure * test(crypto): fix duplicate test name * test(crypto): remove `oldBackendOnly` test closure * test(crypto): remove `rust-sdk` comparison * test(crypto): remove iteration on `CRYPTO_BACKEND` * test(crypto): remove old legacy comments and tests * test(crypto): fix documentations and removed unused expect * Restore broken link to `CryptoApi` (#4692) * chore: fix linting and formatting due to merge * Remove unused crypto type and missing doc (#4696) * chore(crypto): remove unused types * doc(crypto): add missing link * test(call): add test when crypto is enabled --- .eslintrc.cjs | 3 +- README.md | 2 - spec/TestClient.ts | 10 - spec/integ/crypto/cross-signing.spec.ts | 23 +- spec/integ/crypto/crypto.spec.ts | 1404 +----- spec/integ/crypto/device-dehydration.spec.ts | 4 +- spec/integ/crypto/megolm-backup.spec.ts | 184 +- spec/integ/crypto/olm-encryption-spec.ts | 705 --- spec/integ/crypto/rust-crypto.spec.ts | 3 +- spec/integ/crypto/to-device-messages.spec.ts | 6 +- spec/integ/crypto/verification.spec.ts | 116 +- spec/integ/devicelist-integ.spec.ts | 406 -- spec/integ/matrix-client-methods.spec.ts | 175 +- spec/integ/matrix-client-syncing.spec.ts | 6 +- spec/test-utils/test-utils.ts | 12 - spec/unit/crypto.spec.ts | 1467 ------ spec/unit/crypto/CrossSigningInfo.spec.ts | 243 - spec/unit/crypto/DeviceList.spec.ts | 215 - spec/unit/crypto/algorithms/megolm.spec.ts | 1109 ---- spec/unit/crypto/algorithms/olm.spec.ts | 257 - spec/unit/crypto/backup.spec.ts | 791 --- spec/unit/crypto/cross-signing.spec.ts | 1152 ----- spec/unit/crypto/crypto-utils.ts | 42 - spec/unit/crypto/dehydration.spec.ts | 138 - spec/unit/crypto/device-converter.spec.ts | 58 - .../crypto/outgoing-room-key-requests.spec.ts | 91 - spec/unit/crypto/secrets.spec.ts | 697 --- .../crypto/verification/InRoomChannel.spec.ts | 91 - spec/unit/crypto/verification/qr_code.spec.ts | 41 - spec/unit/crypto/verification/request.spec.ts | 80 - spec/unit/crypto/verification/sas.spec.ts | 581 --- .../verification/secret_request.spec.ts | 122 - spec/unit/crypto/verification/util.ts | 134 - .../verification/verification_request.spec.ts | 331 -- spec/unit/embedded.spec.ts | 8 +- spec/unit/matrix-client.spec.ts | 102 +- spec/unit/models/event.spec.ts | 19 +- spec/unit/room.spec.ts | 6 +- spec/unit/rust-crypto/rust-crypto.spec.ts | 39 +- spec/unit/webrtc/call.spec.ts | 29 + src/@types/crypto.ts | 7 +- src/client.ts | 2208 +------- src/common-crypto/CryptoBackend.ts | 47 - src/crypto-api/index.ts | 6 +- src/crypto-api/keybackup.ts | 3 +- src/crypto/CrossSigning.ts | 773 --- src/crypto/DeviceList.ts | 989 ---- src/crypto/EncryptionSetup.ts | 358 -- src/crypto/OlmDevice.ts | 1506 ------ src/crypto/OutgoingRoomKeyRequestManager.ts | 486 -- src/crypto/RoomList.ts | 70 - src/crypto/SecretSharing.ts | 240 - src/crypto/SecretStorage.ts | 136 - src/crypto/aes.ts | 23 - src/crypto/algorithms/base.ts | 241 - src/crypto/algorithms/index.ts | 20 - src/crypto/algorithms/megolm.ts | 2216 -------- src/crypto/algorithms/olm.ts | 381 -- src/crypto/api.ts | 70 - src/crypto/backup.ts | 922 ---- src/crypto/crypto.ts | 18 - src/crypto/dehydration.ts | 272 - src/crypto/device-converter.ts | 45 - src/crypto/deviceinfo.ts | 158 - src/crypto/index.ts | 4438 ----------------- src/crypto/key_passphrase.ts | 42 - src/crypto/keybackup.ts | 47 - src/crypto/olmlib.ts | 539 -- src/crypto/recoverykey.ts | 18 - src/crypto/store/base.ts | 196 +- .../store/indexeddb-crypto-store-backend.ts | 599 +-- src/crypto/store/indexeddb-crypto-store.ts | 297 +- src/crypto/store/localStorage-crypto-store.ts | 174 +- src/crypto/store/memory-crypto-store.ts | 360 +- src/crypto/verification/Base.ts | 409 -- src/crypto/verification/Error.ts | 76 - src/crypto/verification/IllegalMethod.ts | 50 - src/crypto/verification/QRCode.ts | 310 -- src/crypto/verification/SAS.ts | 499 -- src/crypto/verification/SASDecimal.ts | 37 - src/crypto/verification/request/Channel.ts | 34 - .../verification/request/InRoomChannel.ts | 371 -- .../verification/request/ToDeviceChannel.ts | 354 -- .../request/VerificationRequest.ts | 977 ---- src/embedded.ts | 21 +- src/matrix.ts | 8 - src/models/device.ts | 2 +- src/models/event.ts | 44 +- src/rust-crypto/backup.ts | 2 +- src/rust-crypto/rust-crypto.ts | 59 - src/sync.ts | 47 - src/webrtc/call.ts | 35 +- 92 files changed, 491 insertions(+), 31651 deletions(-) delete mode 100644 spec/integ/crypto/olm-encryption-spec.ts delete mode 100644 spec/integ/devicelist-integ.spec.ts delete mode 100644 spec/unit/crypto.spec.ts delete mode 100644 spec/unit/crypto/CrossSigningInfo.spec.ts delete mode 100644 spec/unit/crypto/DeviceList.spec.ts delete mode 100644 spec/unit/crypto/algorithms/megolm.spec.ts delete mode 100644 spec/unit/crypto/algorithms/olm.spec.ts delete mode 100644 spec/unit/crypto/backup.spec.ts delete mode 100644 spec/unit/crypto/cross-signing.spec.ts delete mode 100644 spec/unit/crypto/crypto-utils.ts delete mode 100644 spec/unit/crypto/dehydration.spec.ts delete mode 100644 spec/unit/crypto/device-converter.spec.ts delete mode 100644 spec/unit/crypto/outgoing-room-key-requests.spec.ts delete mode 100644 spec/unit/crypto/secrets.spec.ts delete mode 100644 spec/unit/crypto/verification/InRoomChannel.spec.ts delete mode 100644 spec/unit/crypto/verification/qr_code.spec.ts delete mode 100644 spec/unit/crypto/verification/request.spec.ts delete mode 100644 spec/unit/crypto/verification/sas.spec.ts delete mode 100644 spec/unit/crypto/verification/secret_request.spec.ts delete mode 100644 spec/unit/crypto/verification/util.ts delete mode 100644 spec/unit/crypto/verification/verification_request.spec.ts delete mode 100644 src/crypto/CrossSigning.ts delete mode 100644 src/crypto/DeviceList.ts delete mode 100644 src/crypto/EncryptionSetup.ts delete mode 100644 src/crypto/OlmDevice.ts delete mode 100644 src/crypto/OutgoingRoomKeyRequestManager.ts delete mode 100644 src/crypto/RoomList.ts delete mode 100644 src/crypto/SecretSharing.ts delete mode 100644 src/crypto/SecretStorage.ts delete mode 100644 src/crypto/aes.ts delete mode 100644 src/crypto/algorithms/base.ts delete mode 100644 src/crypto/algorithms/index.ts delete mode 100644 src/crypto/algorithms/megolm.ts delete mode 100644 src/crypto/algorithms/olm.ts delete mode 100644 src/crypto/api.ts delete mode 100644 src/crypto/backup.ts delete mode 100644 src/crypto/crypto.ts delete mode 100644 src/crypto/dehydration.ts delete mode 100644 src/crypto/device-converter.ts delete mode 100644 src/crypto/deviceinfo.ts delete mode 100644 src/crypto/index.ts delete mode 100644 src/crypto/key_passphrase.ts delete mode 100644 src/crypto/keybackup.ts delete mode 100644 src/crypto/olmlib.ts delete mode 100644 src/crypto/recoverykey.ts delete mode 100644 src/crypto/verification/Base.ts delete mode 100644 src/crypto/verification/Error.ts delete mode 100644 src/crypto/verification/IllegalMethod.ts delete mode 100644 src/crypto/verification/QRCode.ts delete mode 100644 src/crypto/verification/SAS.ts delete mode 100644 src/crypto/verification/SASDecimal.ts delete mode 100644 src/crypto/verification/request/Channel.ts delete mode 100644 src/crypto/verification/request/InRoomChannel.ts delete mode 100644 src/crypto/verification/request/ToDeviceChannel.ts delete mode 100644 src/crypto/verification/request/VerificationRequest.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0f781215c..1ab3fe44b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -86,12 +86,11 @@ module.exports = { // Disabled tests are a reality for now but as soon as all of the xits are // eliminated, we should enforce this. "jest/no-disabled-tests": "off", - // Also treat "oldBackendOnly" as a test function. // Used in some crypto tests. "jest/no-standalone-expect": [ "error", { - additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly", "newBackendOnly"], + additionalTestBlockFunctions: ["beforeAll", "beforeEach"], }, ], }, diff --git a/README.md b/README.md index e3bf79204..274a7f9de 100644 --- a/README.md +++ b/README.md @@ -307,8 +307,6 @@ Then visit `http://localhost:8005` to see the API docs. ## Initialization -**Do not use `matrixClient.initLegacyCrypto()`. This method is deprecated and no longer maintained.** - To initialize the end-to-end encryption support in the matrix client: ```javascript diff --git a/spec/TestClient.ts b/spec/TestClient.ts index 1d242a027..7662b57c6 100644 --- a/spec/TestClient.ts +++ b/spec/TestClient.ts @@ -26,7 +26,6 @@ import MockHttpBackend from "matrix-mock-request"; import type { IDeviceKeys, IOneTimeKey } from "../src/@types/crypto"; import type { IE2EKeyReceiver } from "./test-utils/E2EKeyReceiver"; -import { LocalStorageCryptoStore } from "../src/crypto/store/localStorage-crypto-store"; import { logger } from "../src/logger"; import { syncPromise } from "./test-utils/test-utils"; import { createClient, type IStartClientOpts } from "../src/matrix"; @@ -36,7 +35,6 @@ import { type MatrixClient, PendingEventOrdering, } from "../src/client"; -import { MockStorageApi } from "./MockStorageApi"; import { type IKeysUploadResponse, type IUploadKeysRequest } from "../src/client"; import { type ISyncResponder } from "./test-utils/SyncResponder"; @@ -60,10 +58,6 @@ export class TestClient implements IE2EKeyReceiver, ISyncResponder { sessionStoreBackend?: Storage, options?: Partial, ) { - if (sessionStoreBackend === undefined) { - sessionStoreBackend = new MockStorageApi() as unknown as Storage; - } - this.httpBackend = new MockHttpBackend(); const fullOptions: ICreateClientOpts = { @@ -74,10 +68,6 @@ export class TestClient implements IE2EKeyReceiver, ISyncResponder { fetchFn: this.httpBackend.fetchFn as typeof globalThis.fetch, ...options, }; - if (!fullOptions.cryptoStore) { - // expose this so the tests can get to it - fullOptions.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend); - } this.client = createClient(fullOptions); this.deviceKeys = null; diff --git a/spec/integ/crypto/cross-signing.spec.ts b/spec/integ/crypto/cross-signing.spec.ts index 8797f6a3a..84210901c 100644 --- a/spec/integ/crypto/cross-signing.spec.ts +++ b/spec/integ/crypto/cross-signing.spec.ts @@ -18,8 +18,8 @@ import fetchMock from "fetch-mock-jest"; import "fake-indexeddb/auto"; import { IDBFactory } from "fake-indexeddb"; -import { CRYPTO_BACKENDS, type InitCrypto, syncPromise } from "../../test-utils/test-utils"; -import { type AuthDict, createClient, CryptoEvent, type MatrixClient } from "../../../src"; +import { syncPromise } from "../../test-utils/test-utils"; +import { type AuthDict, createClient, type MatrixClient } from "../../../src"; import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints"; import encryptAESSecretStorageItem from "../../../src/utils/encryptAESSecretStorageItem.ts"; import { type CryptoCallbacks, CrossSigningKey } from "../../../src/crypto-api"; @@ -37,6 +37,7 @@ import { import * as testData from "../../test-utils/test-data"; import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator"; +import { CryptoEvent } from "../../../src/crypto-api"; afterEach(() => { // reset fake-indexeddb after each test, to make sure we don't leak connections @@ -54,11 +55,7 @@ const TEST_DEVICE_ID = "xzcvb"; * These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as * to provide the most effective integration tests possible. */ -describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => { - // newBackendOnly is the opposite to `oldBackendOnly`: it will skip the test if we are running against the legacy - // backend. Once we drop support for legacy crypto, it will go away. - const newBackendOnly = backend === "rust-sdk" ? test : test.skip; - +describe("cross-signing", () => { let aliceClient: MatrixClient; /** an object which intercepts `/sync` requests from {@link #aliceClient} */ @@ -107,7 +104,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s body: { errcode: "M_NOT_FOUND" }, }); - await initCrypto(aliceClient); + await aliceClient.initRustCrypto(); }, /* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */ 10000, @@ -162,7 +159,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s ); }); - newBackendOnly("get cross signing keys from secret storage and import them", async () => { + it("get cross signing keys from secret storage and import them", async () => { // Return public cross signing keys e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); @@ -263,7 +260,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s expect(calls.length).toEqual(0); }); - newBackendOnly("will upload existing cross-signing keys to an established secret storage", async () => { + it("will upload existing cross-signing keys to an established secret storage", async () => { // This rather obscure codepath covers the case that: // - 4S is set up and working // - our device has private cross-signing keys, but has not published them to 4S @@ -420,9 +417,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s function awaitCrossSigningKeysUpload() { return new Promise((resolve) => { fetchMock.post( - // legacy crypto uses /unstable/; /v3/ is correct { - url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"), + url: new RegExp("/_matrix/client/v3/keys/device_signing/upload"), name: "upload-keys", }, (url, options) => { @@ -475,9 +471,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s await aliceClient.startClient(); await syncPromise(aliceClient); - // Wait for legacy crypto to find the device - await jest.advanceTimersByTimeAsync(10); - const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([aliceClient.getSafeUserId()]); expect(devices.get(aliceClient.getSafeUserId())!.has(testData.TEST_DEVICE_ID)).toBeTruthy(); }); diff --git a/spec/integ/crypto/crypto.spec.ts b/spec/integ/crypto/crypto.spec.ts index 102bb2b16..24d03321a 100644 --- a/spec/integ/crypto/crypto.spec.ts +++ b/spec/integ/crypto/crypto.spec.ts @@ -24,11 +24,8 @@ import type FetchMock from "fetch-mock"; import type Olm from "@matrix-org/olm"; import * as testUtils from "../../test-utils/test-utils"; import { - advanceTimersUntil, - CRYPTO_BACKENDS, emitPromise, getSyncResponse, - type InitCrypto, mkEventCustom, mkMembershipCustom, syncPromise, @@ -44,30 +41,22 @@ import { TEST_ROOM_ID as ROOM_ID, TEST_USER_ID, } from "../../test-utils/test-data"; -import { TestClient } from "../../TestClient"; import { logger } from "../../../src/logger"; import { Category, ClientEvent, createClient, - CryptoEvent, HistoryVisibility, type IClaimOTKsResult, type IContent, type IDownloadKeyResult, type IEvent, - IndexedDBCryptoStore, type IStartClientOpts, type MatrixClient, - MatrixEvent, + type MatrixEvent, MatrixEventEvent, - MsgType, PendingEventOrdering, - Room, - type RoomMember, - RoomStateEvent, } from "../../../src/matrix"; -import { DeviceInfo } from "../../../src/crypto/deviceinfo"; import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder"; import { defer, escapeRegExp } from "../../../src/utils"; @@ -97,15 +86,14 @@ import { encryptGroupSessionKey, encryptMegolmEvent, encryptMegolmEventRawPlainText, - encryptOlmEvent, establishOlmSession, getTestOlmAccountKeys, } from "./olm-utils"; -import { type ToDevicePayload } from "../../../src/models/ToDeviceMessage"; import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator"; import { UNSIGNED_MEMBERSHIP_FIELD } from "../../../src/@types/event"; import { KnownMembership } from "../../../src/@types/membership"; import { type KeyBackup } from "../../../src/rust-crypto/backup.ts"; +import { CryptoEvent } from "../../../src/crypto-api"; afterEach(() => { // reset fake-indexeddb after each test, to make sure we don't leak connections @@ -218,18 +206,13 @@ async function expectSendMegolmMessage( return JSON.parse(r.plaintext); } -describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, initCrypto: InitCrypto) => { +describe("crypto", () => { if (!globalThis.Olm) { // currently we use libolm to implement the crypto in the tests, so need it to be present. logger.warn("not running megolm tests: Olm not present"); return; } - // oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the - // Rust backend. Once we have full support in the rust sdk, it will go away. - const oldBackendOnly = backend === "rust-sdk" ? test.skip : test; - const newBackendOnly = backend !== "rust-sdk" ? test.skip : test; - const Olm = globalThis.Olm; let testOlmAccount = {} as unknown as Olm.Account; @@ -383,7 +366,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, keyReceiver = new E2EKeyReceiver(homeserverUrl); syncResponder = new SyncResponder(homeserverUrl); - await initCrypto(aliceClient); + await aliceClient.initRustCrypto(); // create a test olm device which we will use to communicate with alice. We use libolm to implement this. testOlmAccount = await createOlmAccount(); @@ -418,13 +401,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); - // if we're using the old crypto impl, stub out some methods in the device manager. - // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. - if (aliceClient.crypto) { - aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map()); - aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - } - const p2pSession = await createOlmSession(testOlmAccount, keyReceiver); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); @@ -561,7 +537,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, return await awaitDecryption; } - newBackendOnly("fails with HISTORICAL_MESSAGE_BACKUP_NO_BACKUP when there is no backup", async () => { + it("fails with HISTORICAL_MESSAGE_BACKUP_NO_BACKUP when there is no backup", async () => { fetchMock.get("path:/_matrix/client/v3/room_keys/version", { status: 404, body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, @@ -573,7 +549,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP); }); - newBackendOnly("fails with HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED when the backup is broken", async () => { + it("fails with HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED when the backup is broken", async () => { fetchMock.get("path:/_matrix/client/v3/room_keys/version", {}); expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); @@ -584,7 +560,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, ); }); - newBackendOnly("fails with HISTORICAL_MESSAGE_WORKING_BACKUP when backup is working", async () => { + it("fails with HISTORICAL_MESSAGE_WORKING_BACKUP when backup is working", async () => { // The test backup data is signed by a dummy device. We'll need to tell Alice about the device, and // later, tell her to trust it, so that she trusts the backup. const e2eResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl()); @@ -617,7 +593,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP); }); - newBackendOnly("fails with NOT_JOINED if user is not member of room", async () => { + it("fails with NOT_JOINED if user is not member of room", async () => { fetchMock.get("path:/_matrix/client/v3/room_keys/version", { status: 404, body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, @@ -633,148 +609,125 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED); }); - newBackendOnly( - "fails with NOT_JOINED if user is not member of room (MSC4115 unstable prefix)", - async () => { - fetchMock.get("path:/_matrix/client/v3/room_keys/version", { - status: 404, - body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, - }); - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); + it("fails with NOT_JOINED if user is not member of room (MSC4115 unstable prefix)", async () => { + fetchMock.get("path:/_matrix/client/v3/room_keys/version", { + status: 404, + body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, + }); + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); - const ev = await sendEventAndAwaitDecryption({ - unsigned: { - [UNSIGNED_MEMBERSHIP_FIELD.altName!]: "leave", - }, - }); - expect(ev.decryptionFailureReason).toEqual( - DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED, - ); - }, - ); + const ev = await sendEventAndAwaitDecryption({ + unsigned: { + [UNSIGNED_MEMBERSHIP_FIELD.altName!]: "leave", + }, + }); + expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED); + }); - newBackendOnly( - "fails with another error when the server reports user was a member of the room", - async () => { - // This tests that when the server reports that the user - // was invited at the time the event was sent, then we - // don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, - // and instead get some other error, since the user should - // have gotten the key for the event. - fetchMock.get("path:/_matrix/client/v3/room_keys/version", { - status: 404, - body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, - }); - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); + it("fails with another error when the server reports user was invited in the room", async () => { + // This tests that when the server reports that the user + // was invited at the time the event was sent, then we + // don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, + // and instead get some other error, since the user should + // have gotten the key for the event. + fetchMock.get("path:/_matrix/client/v3/room_keys/version", { + status: 404, + body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, + }); + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); - const ev = await sendEventAndAwaitDecryption({ - unsigned: { - [UNSIGNED_MEMBERSHIP_FIELD.name]: "invite", - }, - }); - expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP); - }, - ); + const ev = await sendEventAndAwaitDecryption({ + unsigned: { + [UNSIGNED_MEMBERSHIP_FIELD.name]: "invite", + }, + }); + expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP); + }); - newBackendOnly( - "fails with another error when the server reports user was a member of the room (MSC4115 unstable prefix)", - async () => { - // This tests that when the server reports that the user - // was invited at the time the event was sent, then we - // don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, - // and instead get some other error, since the user should - // have gotten the key for the event. - fetchMock.get("path:/_matrix/client/v3/room_keys/version", { - status: 404, - body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, - }); - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); + it("fails with another error when the server reports user was invited in the room (MSC4115 unstable prefix)", async () => { + // This tests that when the server reports that the user + // was invited at the time the event was sent, then we + // don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, + // and instead get some other error, since the user should + // have gotten the key for the event. + fetchMock.get("path:/_matrix/client/v3/room_keys/version", { + status: 404, + body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, + }); + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); - const ev = await sendEventAndAwaitDecryption({ - unsigned: { - [UNSIGNED_MEMBERSHIP_FIELD.altName!]: "invite", - }, - }); - expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP); - }, - ); + const ev = await sendEventAndAwaitDecryption({ + unsigned: { + [UNSIGNED_MEMBERSHIP_FIELD.altName!]: "invite", + }, + }); + expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP); + }); - newBackendOnly( - "fails with another error when the server reports user was a member of the room", - async () => { - // This tests that when the server reports the user's - // membership, and reports that the user was joined, then we - // don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, and - // instead get some other error. - fetchMock.get("path:/_matrix/client/v3/room_keys/version", { - status: 404, - body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, - }); - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); + it("fails with another error when the server reports user was a member of the room", async () => { + // This tests that when the server reports the user's + // membership, and reports that the user was joined, then we + // don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, and + // instead get some other error. + fetchMock.get("path:/_matrix/client/v3/room_keys/version", { + status: 404, + body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, + }); + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); - const ev = await sendEventAndAwaitDecryption({ - unsigned: { - [UNSIGNED_MEMBERSHIP_FIELD.name]: "join", - }, - }); - expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP); - }, - ); + const ev = await sendEventAndAwaitDecryption({ + unsigned: { + [UNSIGNED_MEMBERSHIP_FIELD.name]: "join", + }, + }); + expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP); + }); - newBackendOnly( - "fails with another error when the server reports user was a member of the room (MSC4115 unstable prefix)", - async () => { - // This tests that when the server reports the user's - // membership, and reports that the user was joined, then we - // don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, and - // instead get some other error. - fetchMock.get("path:/_matrix/client/v3/room_keys/version", { - status: 404, - body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, - }); - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); + it("fails with another error when the server reports user was a member of the room (MSC4115 unstable prefix)", async () => { + // This tests that when the server reports the user's + // membership, and reports that the user was joined, then we + // don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, and + // instead get some other error. + fetchMock.get("path:/_matrix/client/v3/room_keys/version", { + status: 404, + body: { errcode: "M_NOT_FOUND", error: "No current backup version." }, + }); + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); - const ev = await sendEventAndAwaitDecryption({ - unsigned: { - [UNSIGNED_MEMBERSHIP_FIELD.altName!]: "join", - }, - }); - expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP); - }, - ); + const ev = await sendEventAndAwaitDecryption({ + unsigned: { + [UNSIGNED_MEMBERSHIP_FIELD.altName!]: "join", + }, + }); + expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP); + }); }); describe("IsolationMode decryption tests", () => { - newBackendOnly( - "OnlySigned mode - fails with an error when cross-signed sender is required but sender is not cross-signed", - async () => { - const decryptedEvent = await setUpTestAndDecrypt(new OnlySignedDevicesIsolationMode()); + it("OnlySigned mode - fails with an error when cross-signed sender is required but sender is not cross-signed", async () => { + const decryptedEvent = await setUpTestAndDecrypt(new OnlySignedDevicesIsolationMode()); - // It will error as an unknown device because we haven't fetched - // the sender's device keys. - expect(decryptedEvent.isDecryptionFailure()).toBe(true); - expect(decryptedEvent.decryptionFailureReason).toEqual(DecryptionFailureCode.UNKNOWN_SENDER_DEVICE); - }, - ); + // It will error as an unknown device because we haven't fetched + // the sender's device keys. + expect(decryptedEvent.isDecryptionFailure()).toBe(true); + expect(decryptedEvent.decryptionFailureReason).toEqual(DecryptionFailureCode.UNKNOWN_SENDER_DEVICE); + }); - newBackendOnly( - "NoIsolation mode - Decrypts with warning when cross-signed sender is required but sender is not cross-signed", - async () => { - const decryptedEvent = await setUpTestAndDecrypt(new AllDevicesIsolationMode(false)); + it("NoIsolation mode - Decrypts with warning when cross-signed sender is required but sender is not cross-signed", async () => { + const decryptedEvent = await setUpTestAndDecrypt(new AllDevicesIsolationMode(false)); - expect(decryptedEvent.isDecryptionFailure()).toBe(false); + expect(decryptedEvent.isDecryptionFailure()).toBe(false); - expect(await aliceClient.getCrypto()!.getEncryptionInfoForEvent(decryptedEvent)).toEqual({ - shieldColour: EventShieldColour.RED, - shieldReason: EventShieldReason.UNKNOWN_DEVICE, - }); - }, - ); + expect(await aliceClient.getCrypto()!.getEncryptionInfoForEvent(decryptedEvent)).toEqual({ + shieldColour: EventShieldColour.RED, + shieldReason: EventShieldReason.UNKNOWN_DEVICE, + }); + }); async function setUpTestAndDecrypt(isolationMode: DeviceIsolationMode): Promise { // This tests that a message will not be decrypted if the sender @@ -881,13 +834,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); - // if we're using the old crypto impl, stub out some methods in the device manager. - // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. - if (aliceClient.crypto) { - aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map()); - aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - } - const p2pSession = await createOlmSession(testOlmAccount, keyReceiver); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); @@ -942,13 +888,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); - // if we're using the old crypto impl, stub out some methods in the device manager. - // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. - if (aliceClient.crypto) { - aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map()); - aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - } - const p2pSession = await createOlmSession(testOlmAccount, keyReceiver); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); @@ -1021,7 +960,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, keyResponder.addDeviceKeys(testDeviceKeys); await startClientAndAwaitFirstSync(); - aliceClient.setGlobalErrorOnUnknownDevices(false); // tell alice she is sharing a room with bob syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"])); @@ -1033,17 +971,13 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // fire off the prepare request const room = aliceClient.getRoom(ROOM_ID); expect(room).toBeTruthy(); - const p = aliceClient.prepareToEncrypt(room!); + aliceClient.getCrypto()?.prepareToEncrypt(room!); // we expect to get a room key message await expectSendRoomKey("@bob:xyz", testOlmAccount); - - // the prepare request should complete successfully. - await p; }); - it("Alice sends a megolm message with GlobalErrorOnUnknownDevices=false", async () => { - aliceClient.setGlobalErrorOnUnknownDevices(false); + it("Alice sends a megolm message", async () => { const homeserverUrl = aliceClient.getHomeserverUrl(); const keyResponder = new E2EKeyResponder(homeserverUrl); keyResponder.addKeyReceiver("@alice:localhost", keyReceiver); @@ -1071,7 +1005,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }); it("We should start a new megolm session after forceDiscardSession", async () => { - aliceClient.setGlobalErrorOnUnknownDevices(false); const homeserverUrl = aliceClient.getHomeserverUrl(); const keyResponder = new E2EKeyResponder(homeserverUrl); keyResponder.addKeyReceiver("@alice:localhost", keyReceiver); @@ -1098,7 +1031,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, ]); // Finally the interesting part: discard the session. - aliceClient.forceDiscardSession(ROOM_ID); + aliceClient.getCrypto()!.forceDiscardSession(ROOM_ID); // Now when we send the next message, we should get a *new* megolm session. const inboundGroupSessionPromise2 = expectSendRoomKey("@bob:xyz", testOlmAccount); @@ -1106,207 +1039,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, await Promise.all([aliceClient.sendTextMessage(ROOM_ID, "test2"), p2]); }); - oldBackendOnly("Alice sends a megolm message", async () => { - // TODO: do something about this for the rust backend. - // Currently it fails because we don't respect the default GlobalErrorOnUnknownDevices and - // send messages to unknown devices. - - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount); - - syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"])); - await syncPromise(aliceClient); - - // start out with the device unknown - the send should be rejected. - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - - await aliceClient.sendTextMessage(ROOM_ID, "test").then( - () => { - throw new Error("sendTextMessage failed on an unknown device"); - }, - (e) => { - expect(e.name).toEqual("UnknownDeviceError"); - }, - ); - - // mark the device as known, and resend. - aliceClient.setDeviceKnown("@bob:xyz", "DEVICE_ID"); - - const room = aliceClient.getRoom(ROOM_ID)!; - const pendingMsg = room.getPendingEvents()[0]; - - const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession); - - await Promise.all([ - aliceClient.resendEvent(pendingMsg, room), - expectSendMegolmMessage(inboundGroupSessionPromise), - ]); - }); - - oldBackendOnly("We shouldn't attempt to send to blocked devices", async () => { - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount); - - syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"])); - await syncPromise(aliceClient); - - logger.log("Forcing alice to download our device keys"); - - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - - await aliceClient.downloadKeys(["@bob:xyz"]); - - logger.log("Telling alice to block our device"); - aliceClient.setDeviceBlocked("@bob:xyz", "DEVICE_ID"); - - logger.log("Telling alice to send a megolm message"); - fetchMock.putOnce({ url: new RegExp("/send/"), name: "send-event" }, { event_id: "$event_id" }); - fetchMock.putOnce({ url: new RegExp("/sendToDevice/m.room_key.withheld/"), name: "send-withheld" }, {}); - - await aliceClient.sendTextMessage(ROOM_ID, "test"); - - // check that the event and withheld notifications were both sent - expect(fetchMock.done("send-event")).toBeTruthy(); - expect(fetchMock.done("send-withheld")).toBeTruthy(); - }); - - describe("get|setGlobalErrorOnUnknownDevices", () => { - it("should raise an error if crypto is disabled", () => { - aliceClient["cryptoBackend"] = undefined; - expect(() => aliceClient.setGlobalErrorOnUnknownDevices(true)).toThrow("encryption disabled"); - expect(() => aliceClient.getGlobalErrorOnUnknownDevices()).toThrow("encryption disabled"); - }); - - oldBackendOnly("should permit sending to unknown devices", async () => { - expect(aliceClient.getGlobalErrorOnUnknownDevices()).toBeTruthy(); - - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount); - - syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"])); - await syncPromise(aliceClient); - - // start out with the device unknown - the send should be rejected. - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - - await aliceClient.sendTextMessage(ROOM_ID, "test").then( - () => { - throw new Error("sendTextMessage failed on an unknown device"); - }, - (e) => { - expect(e.name).toEqual("UnknownDeviceError"); - }, - ); - - // enable sending to unknown devices, and resend - aliceClient.setGlobalErrorOnUnknownDevices(false); - expect(aliceClient.getGlobalErrorOnUnknownDevices()).toBeFalsy(); - - const room = aliceClient.getRoom(ROOM_ID)!; - const pendingMsg = room.getPendingEvents()[0]; - - const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession); - - await Promise.all([ - aliceClient.resendEvent(pendingMsg, room), - expectSendMegolmMessage(inboundGroupSessionPromise), - ]); - }); - }); - - describe("get|setGlobalBlacklistUnverifiedDevices", () => { - it("should raise an error if crypto is disabled", () => { - aliceClient["cryptoBackend"] = undefined; - expect(() => aliceClient.setGlobalBlacklistUnverifiedDevices(true)).toThrow("encryption disabled"); - expect(() => aliceClient.getGlobalBlacklistUnverifiedDevices()).toThrow("encryption disabled"); - }); - - oldBackendOnly("should disable sending to unverified devices", async () => { - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount); - - // tell alice we share a room with bob - syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"])); - await syncPromise(aliceClient); - - logger.log("Forcing alice to download our device keys"); - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - - await aliceClient.downloadKeys(["@bob:xyz"]); - - logger.log("Telling alice to block messages to unverified devices"); - expect(aliceClient.getGlobalBlacklistUnverifiedDevices()).toBeFalsy(); - aliceClient.setGlobalBlacklistUnverifiedDevices(true); - expect(aliceClient.getGlobalBlacklistUnverifiedDevices()).toBeTruthy(); - - logger.log("Telling alice to send a megolm message"); - fetchMock.putOnce(new RegExp("/send/"), { event_id: "$event_id" }); - fetchMock.putOnce(new RegExp("/sendToDevice/m.room_key.withheld/"), {}); - - await aliceClient.sendTextMessage(ROOM_ID, "test"); - - // 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 = aliceClient.crypto!.deviceList.getStoredDevice("@bob:xyz", "DEVICE_ID")!; - d.verified = DeviceInfo.DeviceVerification.VERIFIED; - aliceClient.crypto?.deviceList.storeDevicesForUser("@bob:xyz", { DEVICE_ID: d }); - - const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession); - - logger.log("Asking alice to re-send"); - await Promise.all([ - expectSendMegolmMessage(inboundGroupSessionPromise).then((decrypted) => { - expect(decrypted.type).toEqual("m.room.message"); - expect(decrypted.content!.body).toEqual("test"); - }), - aliceClient.sendTextMessage(ROOM_ID, "test"), - ]); - }); - - it("should send a m.unverified code in toDevice messages to an unverified device when globalBlacklistUnverifiedDevices=true", async () => { - aliceClient.getCrypto()!.globalBlacklistUnverifiedDevices = true; - - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount); - - // Tell alice we share a room with bob - syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"])); - await syncPromise(aliceClient); - - // Force alice to download bob keys - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - - // Wait to receive the toDevice message and return bob device content - const toDevicePromise = new Promise((resolve) => { - fetchMock.putOnce(new RegExp("/sendToDevice/m.room_key.withheld/"), (url, request) => { - const content = JSON.parse(request.body as string); - resolve(content.messages["@bob:xyz"]["DEVICE_ID"]); - return {}; - }); - }); - - // Mock endpoint of message sending - fetchMock.put(new RegExp("/send/"), { event_id: "$event_id" }); - - await aliceClient.sendTextMessage(ROOM_ID, "test"); - - // Finally, check that the toDevice message has the m.unverified code - const toDeviceContent = await toDevicePromise; - expect(toDeviceContent.code).toBe("m.unverified"); - }); - }); - describe("Session should rotate according to encryption settings", () => { /** * Send a message to bob and get the encrypted message @@ -1320,7 +1052,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, return encryptedMessage; } - newBackendOnly("should rotate the session after 2 messages", async () => { + it("should rotate the session after 2 messages", async () => { expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount); @@ -1367,7 +1099,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(thirdSessionId).not.toBe(sessionId); }); - newBackendOnly("should rotate the session after 1h", async () => { + it("should rotate the session after 1h", async () => { expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount); @@ -1420,7 +1152,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }); }); - newBackendOnly("should rotate the session when the history visibility changes", async () => { + it("should rotate the session when the history visibility changes", async () => { expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount); @@ -1475,330 +1207,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(sessionId).not.toEqual(newSessionId); }); - oldBackendOnly("We should start a new megolm session when a device is blocked", async () => { - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount); - - syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"])); - await syncPromise(aliceClient); - - logger.log("Fetching bob's devices and marking known"); - - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - - await aliceClient.downloadKeys(["@bob:xyz"]); - await aliceClient.setDeviceKnown("@bob:xyz", "DEVICE_ID"); - - logger.log("Telling alice to send a megolm message"); - - let megolmSessionId: string; - const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession); - inboundGroupSessionPromise.then((igs) => { - megolmSessionId = igs.session_id(); - }); - - await Promise.all([ - aliceClient.sendTextMessage(ROOM_ID, "test"), - expectSendMegolmMessage(inboundGroupSessionPromise), - ]); - - logger.log("Telling alice to block our device"); - aliceClient.setDeviceBlocked("@bob:xyz", "DEVICE_ID"); - - logger.log("Telling alice to send another megolm message"); - - fetchMock.putOnce( - { url: new RegExp("/send/"), name: "send-event" }, - (url: string, opts: RequestInit): FetchMock.MockResponse => { - const content = JSON.parse(opts.body as string); - logger.log("/send:", content); - // make sure that a new session is used - expect(content.session_id).not.toEqual(megolmSessionId); - return { - event_id: "$event_id", - }; - }, - ); - fetchMock.putOnce({ url: new RegExp("/sendToDevice/m.room_key.withheld/"), name: "send-withheld" }, {}); - - await aliceClient.sendTextMessage(ROOM_ID, "test2"); - - // check that the event and withheld notifications were both sent - expect(fetchMock.done("send-event")).toBeTruthy(); - expect(fetchMock.done("send-withheld")).toBeTruthy(); - }); - - // https://github.com/vector-im/element-web/issues/2676 - oldBackendOnly("Alice should send to her other devices", async () => { - // for this test, we make the testOlmAccount be another of Alice's devices. - // it ought to get included in messages Alice sends. - expectAliceKeyQuery(getTestKeysQueryResponse(aliceClient.getUserId()!)); - - await startClientAndAwaitFirstSync(); - // an encrypted room with just alice - const syncResponse = { - next_batch: 1, - rooms: { - join: { - [ROOM_ID]: { - state: { - events: [ - testUtils.mkEvent({ - type: "m.room.encryption", - skey: "", - content: { algorithm: "m.megolm.v1.aes-sha2" }, - }), - testUtils.mkMembership({ - mship: KnownMembership.Join, - sender: aliceClient.getUserId()!, - }), - ], - }, - }, - }, - }, - }; - syncResponder.sendOrQueueSyncResponse(syncResponse); - - await syncPromise(aliceClient); - - // start out with the device unknown - the send should be rejected. - try { - await aliceClient.sendTextMessage(ROOM_ID, "test"); - throw new Error("sendTextMessage succeeded on an unknown device"); - } catch (e) { - expect((e as any).name).toEqual("UnknownDeviceError"); - expect([...(e as any).devices.keys()]).toEqual([aliceClient.getUserId()!]); - expect((e as any).devices.get(aliceClient.getUserId()!).has("DEVICE_ID")).toBeTruthy(); - } - - // mark the device as known, and resend. - aliceClient.setDeviceKnown(aliceClient.getUserId()!, "DEVICE_ID"); - expectAliceKeyClaim((url: string, opts: RequestInit): FetchMock.MockResponse => { - const content = JSON.parse(opts.body as string); - expect(content.one_time_keys[aliceClient.getUserId()!].DEVICE_ID).toEqual("signed_curve25519"); - return getTestKeysClaimResponse(aliceClient.getUserId()!); - }); - - const inboundGroupSessionPromise = expectSendRoomKey(aliceClient.getUserId()!, testOlmAccount); - - let decrypted: Partial = {}; - - // Grab the event that we'll need to resend - const room = aliceClient.getRoom(ROOM_ID)!; - const pendingEvents = room.getPendingEvents(); - expect(pendingEvents.length).toEqual(1); - const unsentEvent = pendingEvents[0]; - - await Promise.all([ - expectSendMegolmMessage(inboundGroupSessionPromise).then((d) => { - decrypted = d; - }), - aliceClient.resendEvent(unsentEvent, room), - ]); - - expect(decrypted.type).toEqual("m.room.message"); - expect(decrypted.content?.body).toEqual("test"); - }); - - oldBackendOnly("Alice should wait for device list to complete when sending a megolm message", async () => { - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount); - - syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"])); - await syncPromise(aliceClient); - - // this will block - logger.log("Forcing alice to download our device keys"); - const downloadPromise = aliceClient.downloadKeys(["@bob:xyz"]); - - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - - // so will this. - const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test").then( - () => { - throw new Error("sendTextMessage failed on an unknown device"); - }, - (e) => { - expect(e.name).toEqual("UnknownDeviceError"); - }, - ); - - expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); - - await Promise.all([downloadPromise, sendPromise]); - }); - - oldBackendOnly("Alice exports megolm keys and imports them to a new device", async () => { - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - - // if we're using the old crypto impl, stub out some methods in the device manager. - // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. - if (aliceClient.crypto) { - aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map()); - aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - } - - // establish an olm session with alice - const p2pSession = await createOlmSession(testOlmAccount, keyReceiver); - - const groupSession = new Olm.OutboundGroupSession(); - groupSession.create(); - - // make the room_key event - const roomKeyEncrypted = encryptGroupSessionKey({ - recipient: aliceClient.getUserId()!, - recipientCurve25519Key: keyReceiver.getDeviceKey(), - recipientEd25519Key: keyReceiver.getSigningKey(), - olmAccount: testOlmAccount, - p2pSession: p2pSession, - groupSession: groupSession, - room_id: ROOM_ID, - }); - - // encrypt a message with the group session - const messageEncrypted = encryptMegolmEvent({ - senderKey: testSenderKey, - groupSession: groupSession, - room_id: ROOM_ID, - }); - - // Alice gets both the events in a single sync - syncResponder.sendOrQueueSyncResponse({ - next_batch: 1, - to_device: { - events: [roomKeyEncrypted], - }, - rooms: { - join: { [ROOM_ID]: { timeline: { events: [messageEncrypted] } } }, - }, - }); - await syncPromise(aliceClient); - - const room = aliceClient.getRoom(ROOM_ID)!; - await room.decryptCriticalEvents(); - - // it probably won't be decrypted yet, because it takes a while to process the olm keys - const decryptedEvent = await testUtils.awaitDecryption(room.getLiveTimeline().getEvents()[0], { - waitOnDecryptionFailure: true, - }); - expect(decryptedEvent.getContent().body).toEqual("42"); - - const exported = await aliceClient.getCrypto()!.exportRoomKeysAsJson(); - - // start a new client - await aliceClient.stopClient(); - - const homeserverUrl = "https://alice-server2.com"; - aliceClient = createClient({ - baseUrl: homeserverUrl, - userId: "@alice:localhost", - accessToken: "akjgkrgjs", - deviceId: "xzcvb", - }); - - keyReceiver = new E2EKeyReceiver(homeserverUrl); - syncResponder = new SyncResponder(homeserverUrl); - await initCrypto(aliceClient); - await aliceClient.getCrypto()!.importRoomKeysAsJson(exported); - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - - aliceClient.startClient(); - - // if we're using the old crypto impl, stub out some methods in the device manager. - // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. - if (aliceClient.crypto) { - aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - } - - const syncResponse = { - next_batch: 1, - rooms: { - join: { [ROOM_ID]: { timeline: { events: [messageEncrypted] } } }, - }, - }; - - syncResponder.sendOrQueueSyncResponse(syncResponse); - await syncPromise(aliceClient); - - const event = room.getLiveTimeline().getEvents()[0]; - expect(event.getContent().body).toEqual("42"); - }); - - it("Alice receives an untrusted megolm key, only to receive the trusted one shortly after", async () => { - const testClient = new TestClient("@alice:localhost", "device2", "access_token2"); - const groupSession = new Olm.OutboundGroupSession(); - groupSession.create(); - const inboundGroupSession = new Olm.InboundGroupSession(); - inboundGroupSession.create(groupSession.session_key()); - const rawEvent = encryptMegolmEvent({ - senderKey: testSenderKey, - groupSession: groupSession, - room_id: ROOM_ID, - }); - await testClient.client.initLegacyCrypto(); - const keys = [ - { - room_id: ROOM_ID, - algorithm: "m.megolm.v1.aes-sha2", - session_id: groupSession.session_id(), - session_key: inboundGroupSession.export_session(0), - sender_key: testSenderKey, - forwarding_curve25519_key_chain: [], - sender_claimed_keys: {}, - }, - ]; - await testClient.client.importRoomKeys(keys, { untrusted: true }); - - const event1 = testUtils.mkEvent({ - event: true, - ...rawEvent, - room: ROOM_ID, - }); - await event1.attemptDecryption(testClient.client.crypto!, { isRetry: true }); - expect(event1.isKeySourceUntrusted()).toBeTruthy(); - - const event2 = testUtils.mkEvent({ - type: "m.room_key", - content: { - room_id: ROOM_ID, - algorithm: "m.megolm.v1.aes-sha2", - session_id: groupSession.session_id(), - session_key: groupSession.session_key(), - }, - event: true, - }); - // @ts-ignore - private - event2.senderCurve25519Key = testSenderKey; - // @ts-ignore - private - testClient.client.crypto!.onRoomKeyEvent(event2); - - const event3 = testUtils.mkEvent({ - event: true, - ...rawEvent, - room: ROOM_ID, - }); - await event3.attemptDecryption(testClient.client.crypto!, { isRetry: true }); - expect(event3.isKeySourceUntrusted()).toBeFalsy(); - testClient.stop(); - }); - it("Alice can decrypt a message with falsey content", async () => { expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); - // if we're using the old crypto impl, stub out some methods in the device manager. - // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. - if (aliceClient.crypto) { - aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map()); - aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - } - const p2pSession = await createOlmSession(testOlmAccount, keyReceiver); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); @@ -1851,409 +1263,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(decryptedEvent.getClearContent()).toBeUndefined(); }); - oldBackendOnly("Alice receives shared history before being invited to a room by the sharer", async () => { - const beccaTestClient = new TestClient("@becca:localhost", "foobar", "bazquux"); - await beccaTestClient.client.initLegacyCrypto(); - - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - await beccaTestClient.start(); - - // if we're using the old crypto impl, stub out some methods in the device manager. - // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. - if (aliceClient.crypto) { - aliceClient.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map()); - aliceClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; - aliceClient.crypto!.deviceList.getUserByIdentityKey = () => beccaTestClient.client.getUserId()!; - } - - const beccaRoom = new Room(ROOM_ID, beccaTestClient.client, "@becca:localhost", {}); - beccaTestClient.client.store.storeRoom(beccaRoom); - await beccaTestClient.client.setRoomEncryption(ROOM_ID, { algorithm: "m.megolm.v1.aes-sha2" }); - - const event = new MatrixEvent({ - type: "m.room.message", - sender: "@becca:localhost", - room_id: ROOM_ID, - event_id: "$1", - content: { - msgtype: "m.text", - body: "test message", - }, - }); - - await beccaTestClient.client.crypto!.encryptEvent(event, beccaRoom); - // remove keys from the event - // @ts-ignore private properties - event.clearEvent = undefined; - // @ts-ignore private properties - event.senderCurve25519Key = null; - // @ts-ignore private properties - event.claimedEd25519Key = null; - - const device = new DeviceInfo(beccaTestClient.client.deviceId!); - - // Create an olm session for Becca and Alice's devices - const aliceOtks = await keyReceiver.awaitOneTimeKeyUpload(); - const aliceOtkId = Object.keys(aliceOtks)[0]; - const aliceOtk = aliceOtks[aliceOtkId]; - const p2pSession = new globalThis.Olm.Session(); - await beccaTestClient.client.crypto!.cryptoStore.doTxn( - "readonly", - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - beccaTestClient.client.crypto!.cryptoStore.getAccount(txn, (pickledAccount: string | null) => { - const account = new globalThis.Olm.Account(); - try { - account.unpickle(beccaTestClient.client.crypto!.olmDevice.pickleKey, pickledAccount!); - p2pSession.create_outbound(account, keyReceiver.getDeviceKey(), aliceOtk.key); - } finally { - account.free(); - } - }); - }, - ); - - const content = event.getWireContent(); - const groupSessionKey = await beccaTestClient.client.crypto!.olmDevice.getInboundGroupSessionKey( - ROOM_ID, - content.sender_key, - content.session_id, - ); - const encryptedForwardedKey = encryptOlmEvent({ - sender: "@becca:localhost", - senderSigningKey: beccaTestClient.getSigningKey(), - senderKey: beccaTestClient.getDeviceKey(), - recipient: aliceClient.getUserId()!, - recipientCurve25519Key: keyReceiver.getDeviceKey(), - recipientEd25519Key: keyReceiver.getSigningKey(), - p2pSession: p2pSession, - plaincontent: { - "algorithm": "m.megolm.v1.aes-sha2", - "room_id": ROOM_ID, - "sender_key": content.sender_key, - "sender_claimed_ed25519_key": groupSessionKey!.sender_claimed_ed25519_key, - "session_id": content.session_id, - "session_key": groupSessionKey!.key, - "chain_index": groupSessionKey!.chain_index, - "forwarding_curve25519_key_chain": groupSessionKey!.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": true, - }, - plaintype: "m.forwarded_room_key", - }); - - // Alice receives shared history - syncResponder.sendOrQueueSyncResponse({ - next_batch: 1, - to_device: { events: [encryptedForwardedKey] }, - }); - await syncPromise(aliceClient); - - // Alice is invited to the room by Becca - syncResponder.sendOrQueueSyncResponse({ - next_batch: 2, - rooms: { - invite: { - [ROOM_ID]: { - invite_state: { - events: [ - { - sender: "@becca:localhost", - type: "m.room.encryption", - state_key: "", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - { - sender: "@becca:localhost", - type: "m.room.member", - state_key: "@alice:localhost", - content: { - membership: KnownMembership.Invite, - }, - }, - ], - }, - }, - }, - }, - }); - await syncPromise(aliceClient); - - // Alice has joined the room - expectAliceKeyQuery({ device_keys: { "@becca:localhost": {} }, failures: {} }); - syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@alice:localhost", "@becca:localhost"])); - await syncPromise(aliceClient); - - syncResponder.sendOrQueueSyncResponse({ - next_batch: 4, - rooms: { - join: { - [ROOM_ID]: { timeline: { events: [event.event] } }, - }, - }, - }); - await syncPromise(aliceClient); - - const room = aliceClient.getRoom(ROOM_ID)!; - const roomEvent = room.getLiveTimeline().getEvents()[0]; - expect(roomEvent.isEncrypted()).toBe(true); - const decryptedEvent = await testUtils.awaitDecryption(roomEvent); - expect(decryptedEvent.getContent().body).toEqual("test message"); - - await beccaTestClient.stop(); - }); - - oldBackendOnly("Alice receives shared history before being invited to a room by someone else", async () => { - const beccaTestClient = new TestClient("@becca:localhost", "foobar", "bazquux"); - await beccaTestClient.client.initLegacyCrypto(); - - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - - await beccaTestClient.start(); - - const beccaRoom = new Room(ROOM_ID, beccaTestClient.client, "@becca:localhost", {}); - beccaTestClient.client.store.storeRoom(beccaRoom); - await beccaTestClient.client.setRoomEncryption(ROOM_ID, { algorithm: "m.megolm.v1.aes-sha2" }); - - const event = new MatrixEvent({ - type: "m.room.message", - sender: "@becca:localhost", - room_id: ROOM_ID, - event_id: "$1", - content: { - msgtype: "m.text", - body: "test message", - }, - }); - - await beccaTestClient.client.crypto!.encryptEvent(event, beccaRoom); - // remove keys from the event - // @ts-ignore private properties - event.clearEvent = undefined; - // @ts-ignore private properties - event.senderCurve25519Key = null; - // @ts-ignore private properties - event.claimedEd25519Key = null; - - const device = new DeviceInfo(beccaTestClient.client.deviceId!); - aliceClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; - - // Create an olm session for Becca and Alice's devices - const aliceOtks = await keyReceiver.awaitOneTimeKeyUpload(); - const aliceOtkId = Object.keys(aliceOtks)[0]; - const aliceOtk = aliceOtks[aliceOtkId]; - const p2pSession = new globalThis.Olm.Session(); - await beccaTestClient.client.crypto!.cryptoStore.doTxn( - "readonly", - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - beccaTestClient.client.crypto!.cryptoStore.getAccount(txn, (pickledAccount: string | null) => { - const account = new globalThis.Olm.Account(); - try { - account.unpickle(beccaTestClient.client.crypto!.olmDevice.pickleKey, pickledAccount!); - p2pSession.create_outbound(account, keyReceiver.getDeviceKey(), aliceOtk.key); - } finally { - account.free(); - } - }); - }, - ); - - const content = event.getWireContent(); - const groupSessionKey = await beccaTestClient.client.crypto!.olmDevice.getInboundGroupSessionKey( - ROOM_ID, - content.sender_key, - content.session_id, - ); - const encryptedForwardedKey = encryptOlmEvent({ - sender: "@becca:localhost", - senderKey: beccaTestClient.getDeviceKey(), - senderSigningKey: beccaTestClient.getSigningKey(), - recipient: aliceClient.getUserId()!, - recipientCurve25519Key: keyReceiver.getDeviceKey(), - recipientEd25519Key: keyReceiver.getSigningKey(), - p2pSession: p2pSession, - plaincontent: { - "algorithm": "m.megolm.v1.aes-sha2", - "room_id": ROOM_ID, - "sender_key": content.sender_key, - "sender_claimed_ed25519_key": groupSessionKey!.sender_claimed_ed25519_key, - "session_id": content.session_id, - "session_key": groupSessionKey!.key, - "chain_index": groupSessionKey!.chain_index, - "forwarding_curve25519_key_chain": groupSessionKey!.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": true, - }, - plaintype: "m.forwarded_room_key", - }); - - // Alice receives forwarded history from Becca - expectAliceKeyQuery({ device_keys: { "@becca:localhost": {} }, failures: {} }); - syncResponder.sendOrQueueSyncResponse({ - next_batch: 1, - to_device: { events: [encryptedForwardedKey] }, - }); - await syncPromise(aliceClient); - - // Alice is invited to the room by Charlie - syncResponder.sendOrQueueSyncResponse({ - next_batch: 2, - rooms: { - invite: { - [ROOM_ID]: { - invite_state: { - events: [ - { - sender: "@becca:localhost", - type: "m.room.encryption", - state_key: "", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - { - sender: "@charlie:localhost", - type: "m.room.member", - state_key: "@alice:localhost", - content: { - membership: KnownMembership.Invite, - }, - }, - ], - }, - }, - }, - }, - }); - await syncPromise(aliceClient); - - // Alice has joined the room - expectAliceKeyQuery({ device_keys: { "@becca:localhost": {}, "@charlie:localhost": {} }, failures: {} }); - syncResponder.sendOrQueueSyncResponse( - getSyncResponse(["@alice:localhost", "@becca:localhost", "@charlie:localhost"]), - ); - await syncPromise(aliceClient); - - // wait for the key/device downloads for becca and charlie to complete - await aliceClient.downloadKeys(["@becca:localhost", "@charlie:localhost"]); - - syncResponder.sendOrQueueSyncResponse({ - next_batch: 4, - rooms: { - join: { - [ROOM_ID]: { timeline: { events: [event.event] } }, - }, - }, - }); - await syncPromise(aliceClient); - - // Decryption should fail, because Alice hasn't received any keys she can trust - const room = aliceClient.getRoom(ROOM_ID)!; - const roomEvent = room.getLiveTimeline().getEvents()[0]; - expect(roomEvent.isEncrypted()).toBe(true); - const decryptedEvent = await testUtils.awaitDecryption(roomEvent); - expect(decryptedEvent.isDecryptionFailure()).toBe(true); - - await beccaTestClient.stop(); - }); - - oldBackendOnly("allows sending an encrypted event as soon as room state arrives", async () => { - /* Empirically, clients expect to be able to send encrypted events as soon as the - * RoomStateEvent.NewMember notification is emitted, so test that works correctly. - */ - const testRoomId = "!testRoom:id"; - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - - /* Alice makes the /createRoom call */ - fetchMock.postOnce(new RegExp("/createRoom"), { room_id: testRoomId }); - await aliceClient.createRoom({ - initial_state: [ - { - type: "m.room.encryption", - state_key: "", - content: { algorithm: "m.megolm.v1.aes-sha2" }, - }, - ], - }); - - /* The sync arrives in two parts; first the m.room.create... */ - syncResponder.sendOrQueueSyncResponse({ - rooms: { - join: { - [testRoomId]: { - timeline: { - events: [ - { - type: "m.room.create", - state_key: "", - event_id: "$create", - }, - { - type: "m.room.member", - state_key: aliceClient.getUserId(), - content: { membership: KnownMembership.Join }, - event_id: "$alijoin", - }, - ], - }, - }, - }, - }, - }); - await syncPromise(aliceClient); - - // ... and then the e2e event and an invite ... - syncResponder.sendOrQueueSyncResponse({ - rooms: { - join: { - [testRoomId]: { - timeline: { - events: [ - { - type: "m.room.encryption", - state_key: "", - content: { algorithm: "m.megolm.v1.aes-sha2" }, - event_id: "$e2e", - }, - { - type: "m.room.member", - state_key: "@other:user", - content: { membership: KnownMembership.Invite }, - event_id: "$otherinvite", - }, - ], - }, - }, - }, - }, - }); - - // as soon as the roomMember arrives, try to send a message - expectAliceKeyQuery({ device_keys: { "@other:user": {} }, failures: {} }); - aliceClient.on(RoomStateEvent.NewMember, (_e, _s, member: RoomMember) => { - if (member.userId == "@other:user") { - aliceClient.sendMessage(testRoomId, { msgtype: MsgType.Text, body: "Hello, World" }); - } - }); - - // flush the sync and wait for the /send/ request. - const sendEventPromise = new Promise((resolve) => { - fetchMock.putOnce(new RegExp("/send/m.room.encrypted/"), () => { - resolve(undefined); - return { event_id: "asdfgh" }; - }); - }); - await syncPromise(aliceClient); - await sendEventPromise; - }); - describe("getEncryptionInfoForEvent", () => { it("handles outgoing events", async () => { - aliceClient.setGlobalErrorOnUnknownDevices(false); expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); @@ -2333,19 +1344,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // now check getEncryptionInfoForEvent again const encInfo2 = await aliceClient.getCrypto()!.getEncryptionInfoForEvent(lastEvent); - let expectedEncryptionInfo; - if (backend === "rust-sdk") { - // rust crypto does not trust its own device until it is cross-signed. - expectedEncryptionInfo = { - shieldColour: EventShieldColour.RED, - shieldReason: EventShieldReason.UNSIGNED_DEVICE, - }; - } else { - expectedEncryptionInfo = { - shieldColour: EventShieldColour.NONE, - shieldReason: null, - }; - } + // rust crypto does not trust its own device until it is cross-signed. + const expectedEncryptionInfo = { + shieldColour: EventShieldColour.RED, + shieldReason: EventShieldReason.UNSIGNED_DEVICE, + }; expect(encInfo2).toEqual(expectedEncryptionInfo); }); }); @@ -2357,7 +1360,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // set up the aliceTestClient so that it is a room with no known members expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync({ lazyLoadMembers: true }); - aliceClient.setGlobalErrorOnUnknownDevices(false); syncResponder.sendOrQueueSyncResponse(getSyncResponse([])); await syncPromise(aliceClient); @@ -2503,74 +1505,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }); }, ); - - oldBackendOnly("does not block decryption on an 'm.unavailable' report", async function () { - // there may be a key downloads for alice - expectAliceKeyQuery({ device_keys: {}, failures: {} }); - - await startClientAndAwaitFirstSync(); - - // encrypt a message with a group session. - const groupSession = new Olm.OutboundGroupSession(); - groupSession.create(); - const messageEncryptedEvent = encryptMegolmEvent({ - senderKey: testSenderKey, - groupSession: groupSession, - room_id: ROOM_ID, - }); - - // Alice gets the room message, but not the key - syncResponder.sendOrQueueSyncResponse({ - next_batch: 1, - rooms: { - join: { [ROOM_ID]: { timeline: { events: [messageEncryptedEvent] } } }, - }, - }); - await syncPromise(aliceClient); - - // alice will (eventually) send a room-key request - fetchMock.putOnce(new RegExp("/sendToDevice/m.room_key_request/"), {}); - - // at this point, the message should be a decryption failure - const room = aliceClient.getRoom(ROOM_ID)!; - const event = room.getLiveTimeline().getEvents()[0]; - expect(event.isDecryptionFailure()).toBeTruthy(); - - // we want to wait for the message to be updated, so create a promise for it - const retryPromise = new Promise((resolve) => { - event.once(MatrixEventEvent.Decrypted, (ev) => { - resolve(ev); - }); - }); - - // alice gets back a room-key-withheld notification - syncResponder.sendOrQueueSyncResponse({ - next_batch: 2, - to_device: { - events: [ - { - type: "m.room_key.withheld", - sender: "@bob:example.com", - content: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: ROOM_ID, - session_id: groupSession.session_id(), - sender_key: testSenderKey, - code: "m.unavailable", - reason: "", - }, - }, - ], - }, - }); - await syncPromise(aliceClient); - - // the withheld notification should trigger a retry; wait for it - await retryPromise; - - // finally: the message should still be a regular decryption failure, not a withheld notification. - expect(event.getContent().body).not.toContain("withheld"); - }); }); describe("key upload request", () => { @@ -2966,17 +1900,13 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }); describe("bootstrapSecretStorage", () => { - // Doesn't work with legacy crypto, which will try to bootstrap even without private key, which is buggy. - newBackendOnly( - "should throw an error if we are unable to create a key because createSecretStorageKey is not set", - async () => { - await expect( - aliceClient.getCrypto()!.bootstrapSecretStorage({ setupNewSecretStorage: true }), - ).rejects.toThrow("unable to create a new secret storage key, createSecretStorageKey is not set"); + it("should throw an error if we are unable to create a key because createSecretStorageKey is not set", async () => { + await expect( + aliceClient.getCrypto()!.bootstrapSecretStorage({ setupNewSecretStorage: true }), + ).rejects.toThrow("unable to create a new secret storage key, createSecretStorageKey is not set"); - expect(await aliceClient.getCrypto()!.isSecretStorageReady()).toStrictEqual(false); - }, - ); + expect(await aliceClient.getCrypto()!.isSecretStorageReady()).toStrictEqual(false); + }); it("Should create a 4S key", async () => { accountDataAccumulator.interceptGetAccountData(); @@ -3015,8 +1945,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }); it("should do nothing if an AES key is already in the secret storage and setupNewSecretStorage is not set", async () => { - const awaitAccountDataClientUpdate = awaitAccountDataUpdate("m.secret_storage.default_key"); - const bootstrapPromise = aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey }); // Wait for the key to be uploaded in the account data @@ -3028,9 +1956,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // Wait for bootstrapSecretStorage to finished await bootstrapPromise; - // On legacy crypto we need to wait for ClientEvent.AccountData before calling bootstrap again. - await awaitAccountDataClientUpdate; - // Call again bootstrapSecretStorage await aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey }); @@ -3122,7 +2047,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(signatures![aliceClient.getUserId()!][`ed25519:${mskId}`]).toBeDefined(); }); - newBackendOnly("should upload existing megolm backup key to a new 4S store", async () => { + it("should upload existing megolm backup key to a new 4S store", async () => { const backupKeyTo4SPromise = awaitMegolmBackupKeyUpload(); // we need these to set up the mocks but we don't actually care whether they @@ -3160,6 +2085,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, await bootstrapSecurity(backupVersion); const check = await aliceClient.getCrypto()!.checkKeyBackupAndEnable(); + fetchMock.get( + `path:/_matrix/client/v3/room_keys/version/${check!.backupInfo.version}`, + check!.backupInfo!, + ); // Import a new key that should be uploaded const newKey = testData.MEGOLM_SESSION_DATA; @@ -3194,9 +2123,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, fetchMock.get("express:/_matrix/client/v3/room_keys/keys", keyBackupData); // should be able to restore from 4S - const importResult = await advanceTimersUntil( - aliceClient.restoreKeyBackupWithSecretStorage(check!.backupInfo!), - ); + await aliceClient.getCrypto()!.loadSessionBackupPrivateKeyFromSecretStorage(); + const importResult = await aliceClient.getCrypto()!.restoreKeyBackup(); expect(importResult.imported).toStrictEqual(1); }); @@ -3261,19 +2189,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, const newBackupUploadPromise = awaitMegolmBackupKeyUpload(); - // Track calls to scheduleAllGroupSessionsForBackup. This is - // only relevant on legacy encryption. - const scheduleAllGroupSessionsForBackup = jest.fn(); - if (backend === "libolm") { - aliceClient.crypto!.backupManager.scheduleAllGroupSessionsForBackup = - scheduleAllGroupSessionsForBackup; - } else { - // With Rust crypto, we don't need to call this function, so - // we call the dummy value here so we pass our later - // expectation. - scheduleAllGroupSessionsForBackup(); - } - await aliceClient.getCrypto()!.resetKeyBackup(); await awaitDeleteCalled; await newBackupStatusUpdate; @@ -3285,13 +2200,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(nextVersion).toBeDefined(); expect(nextVersion).not.toEqual(currentVersion); expect(nextKey).not.toEqual(currentBackupKey); - expect(scheduleAllGroupSessionsForBackup).toHaveBeenCalled(); - // The `deleteKeyBackupVersion` API is deprecated but has been modified to work with both crypto backend - // ensure that it works anyhow - await aliceClient.deleteKeyBackupVersion(nextVersion!); + await aliceClient.getCrypto()!.deleteKeyBackupVersion(nextVersion!); await aliceClient.getCrypto()!.checkKeyBackupAndEnable(); - // XXX Legacy crypto does not update 4S when doing that; should ensure that rust implem does it. expect(await aliceClient.getCrypto()!.getActiveSessionBackupVersion()).toBeNull(); }); }); @@ -3354,7 +2265,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(verificationStatus.needsUserApproval).toBe(false); }); - newBackendOnly("An unverified user changes identity", async () => { + it("An unverified user changes identity", async () => { // We have to be tracking Bob's keys, which means we need to share a room with him syncResponder.sendOrQueueSyncResponse({ ...getSyncResponse([BOB_TEST_USER_ID]), @@ -3394,7 +2305,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, /** Guards against downgrade attacks from servers hiding or manipulating the crypto settings. */ describe("Persistent encryption settings", () => { - let persistentStoreClient: MatrixClient; + let client1: MatrixClient; let client2: MatrixClient; beforeEach(async () => { @@ -3407,12 +2318,13 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // For legacy crypto, these tests only work properly with a proper (indexeddb-based) CryptoStore, so // rather than using the existing `aliceClient`, create a new client. Once we drop legacy crypto, we can // just use `aliceClient` here. - persistentStoreClient = await makeNewClient(homeserverurl, userId, "persistentStoreClient"); - await persistentStoreClient.startClient({}); + // XXX: Even with the rust-crypto, we need to create a new client. The tests fail with a timeout error. + client1 = await makeNewClient(homeserverurl, userId, "client1"); + await client1.startClient({}); }); afterEach(async () => { - persistentStoreClient.stopClient(); + client1.stopClient(); client2?.stopClient(); }); @@ -3420,13 +2332,13 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // Alice is in an encrypted room const encryptionState = mkEncryptionEvent({ algorithm: "m.megolm.v1.aes-sha2" }); syncResponder.sendOrQueueSyncResponse(getSyncResponseWithState([encryptionState])); - await syncPromise(persistentStoreClient); + await syncPromise(client1); // Send a message, and expect to get an `m.room.encrypted` event. - await Promise.all([persistentStoreClient.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessage()]); + await Promise.all([client1.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessage()]); // We now replace the client, and allow the new one to resync, *without* the encryption event. - client2 = await replaceClient(persistentStoreClient); + client2 = await replaceClient(client1); syncResponder.sendOrQueueSyncResponse(getSyncResponseWithState([])); await client2.startClient({}); await syncPromise(client2); @@ -3439,11 +2351,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // Alice is in an encrypted room, where the rotation period is set to 2 messages const encryptionState = mkEncryptionEvent({ algorithm: "m.megolm.v1.aes-sha2", rotation_period_msgs: 2 }); syncResponder.sendOrQueueSyncResponse(getSyncResponseWithState([encryptionState])); - await syncPromise(persistentStoreClient); + await syncPromise(client1); // Send a message, and expect to get an `m.room.encrypted` event. const [, msg1Content] = await Promise.all([ - persistentStoreClient.sendTextMessage(ROOM_ID, "test1"), + client1.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessage(), ]); @@ -3457,17 +2369,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, next_batch: "1", rooms: { join: { [TEST_ROOM_ID]: { timeline: { events: [encryptionState2], prev_batch: "" } } } }, }); - await syncPromise(persistentStoreClient); + await syncPromise(client1); // Send two more messages. The first should use the same megolm session as the first; the second should // use a different one. const [, msg2Content] = await Promise.all([ - persistentStoreClient.sendTextMessage(ROOM_ID, "test2"), + client1.sendTextMessage(ROOM_ID, "test2"), expectEncryptedSendMessage(), ]); expect(msg2Content.session_id).toEqual(msg1Content.session_id); const [, msg3Content] = await Promise.all([ - persistentStoreClient.sendTextMessage(ROOM_ID, "test3"), + client1.sendTextMessage(ROOM_ID, "test3"), expectEncryptedSendMessage(), ]); expect(msg3Content.session_id).not.toEqual(msg1Content.session_id); @@ -3477,13 +2389,13 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // Alice is in an encrypted room, where the rotation period is set to 2 messages const encryptionState = mkEncryptionEvent({ algorithm: "m.megolm.v1.aes-sha2", rotation_period_msgs: 2 }); syncResponder.sendOrQueueSyncResponse(getSyncResponseWithState([encryptionState])); - await syncPromise(persistentStoreClient); + await syncPromise(client1); // Send a message, and expect to get an `m.room.encrypted` event. - await Promise.all([persistentStoreClient.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessage()]); + await Promise.all([client1.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessage()]); // We now replace the client, and allow the new one to resync with a *different* encryption event. - client2 = await replaceClient(persistentStoreClient); + client2 = await replaceClient(client1); const encryptionState2 = mkEncryptionEvent({ algorithm: "m.megolm.v1.aes-sha2", rotation_period_msgs: 100, @@ -3514,20 +2426,16 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, userId: userId, accessToken: "akjgkrgjs", deviceId: "xzcvb", - cryptoCallbacks: createCryptoCallbacks(), logger: logger.getChild(loggerPrefix), - - // For legacy crypto, these tests only work with a proper persistent cryptoStore. - cryptoStore: new IndexedDBCryptoStore(indexedDB, "test"), }); - await initCrypto(client); + await client.initRustCrypto(); mockInitialApiRequests(client.getHomeserverUrl()); return client; } function mkEncryptionEvent(content: object) { return mkEventCustom({ - sender: persistentStoreClient.getSafeUserId(), + sender: client1.getSafeUserId(), type: "m.room.encryption", state_key: "", content: content, @@ -3544,7 +2452,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, events: [ mkMembershipCustom({ membership: KnownMembership.Join, - sender: persistentStoreClient.getSafeUserId(), + sender: client1.getSafeUserId(), }), ...stateEvents, ], diff --git a/spec/integ/crypto/device-dehydration.spec.ts b/spec/integ/crypto/device-dehydration.spec.ts index 4236ae297..68428db94 100644 --- a/spec/integ/crypto/device-dehydration.spec.ts +++ b/spec/integ/crypto/device-dehydration.spec.ts @@ -218,8 +218,8 @@ async function initializeSecretStorage( privateKey: new Uint8Array(32), }; } - await matrixClient.bootstrapCrossSigning({ setupNewCrossSigning: true }); - await matrixClient.bootstrapSecretStorage({ + await matrixClient.getCrypto()!.bootstrapCrossSigning({ setupNewCrossSigning: true }); + await matrixClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey, setupNewSecretStorage: true, setupNewKeyBackup: false, diff --git a/spec/integ/crypto/megolm-backup.spec.ts b/spec/integ/crypto/megolm-backup.spec.ts index b209cb291..e0a2c790d 100644 --- a/spec/integ/crypto/megolm-backup.spec.ts +++ b/spec/integ/crypto/megolm-backup.spec.ts @@ -21,7 +21,6 @@ import { type Mocked } from "jest-mock"; import { createClient, - type Crypto, encodeBase64, type ICreateClientOpts, type IEvent, @@ -33,18 +32,12 @@ import { SyncResponder } from "../../test-utils/SyncResponder"; import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; import { mockInitialApiRequests } from "../../test-utils/mockEndpoints"; -import { - advanceTimersUntil, - awaitDecryption, - CRYPTO_BACKENDS, - type InitCrypto, - syncPromise, -} from "../../test-utils/test-utils"; +import { advanceTimersUntil, awaitDecryption, syncPromise } from "../../test-utils/test-utils"; import * as testData from "../../test-utils/test-data"; import { type KeyBackupInfo, type KeyBackupSession } from "../../../src/crypto-api/keybackup"; import { flushPromises } from "../../test-utils/flushPromises"; import { defer, type IDeferred } from "../../../src/utils"; -import { decodeRecoveryKey, DecryptionFailureCode, CryptoEvent } from "../../../src/crypto-api"; +import { decodeRecoveryKey, DecryptionFailureCode, CryptoEvent, type CryptoApi } from "../../../src/crypto-api"; import { type KeyBackup } from "../../../src/rust-crypto/backup.ts"; const ROOM_ID = testData.TEST_ROOM_ID; @@ -114,14 +107,7 @@ function mockUploadEmitter( return emitter; } -describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backend: string, initCrypto: InitCrypto) => { - // oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the - // Rust backend. Once we have full support in the rust sdk, it will go away. - const oldBackendOnly = backend === "rust-sdk" ? test.skip : test; - const newBackendOnly = backend === "libolm" ? test.skip : test; - - const isNewBackend = backend === "rust-sdk"; - +describe("megolm-keys backup", () => { let aliceClient: MatrixClient; /** an object which intercepts `/sync` requests on the test homeserver */ let syncResponder: SyncResponder; @@ -167,7 +153,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe deviceId: TEST_DEVICE_ID, ...opts, }); - await initCrypto(client); + await client.initRustCrypto(); return client; } @@ -248,11 +234,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe // On the first decryption attempt, decryption fails. await awaitDecryption(event); - expect(event.decryptionFailureReason).toEqual( - isNewBackend - ? DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP - : DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, - ); + expect(event.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP); // Eventually, decryption succeeds. await awaitDecryption(event, { waitOnDecryptionFailure: true }); @@ -312,7 +294,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe }); describe("recover from backup", () => { - let aliceCrypto: Crypto.CryptoApi; + let aliceCrypto: CryptoApi; beforeEach(async () => { fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA); @@ -344,43 +326,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup); const check = await aliceCrypto.checkKeyBackupAndEnable(); - - let onKeyCached: () => void; - const awaitKeyCached = new Promise((resolve) => { - onKeyCached = resolve; - }); - await aliceCrypto.storeSessionBackupPrivateKey( decodeRecoveryKey(testData.BACKUP_DECRYPTION_KEY_BASE58), check!.backupInfo!.version!, ); - const result = await advanceTimersUntil( - isNewBackend - ? aliceCrypto.restoreKeyBackup() - : aliceClient.restoreKeyBackupWithRecoveryKey( - testData.BACKUP_DECRYPTION_KEY_BASE58, - undefined, - undefined, - check!.backupInfo!, - { - cacheCompleteCallback: () => onKeyCached(), - }, - ), - ); + const result = await advanceTimersUntil(aliceCrypto.restoreKeyBackup()); expect(result.imported).toStrictEqual(1); - - if (isNewBackend) return; - - await awaitKeyCached; - - // The key should be now cached - const afterCache = await advanceTimersUntil( - aliceClient.restoreKeyBackupWithCache(undefined, undefined, check!.backupInfo!), - ); - - expect(afterCache.imported).toStrictEqual(1); }); /** @@ -413,13 +366,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe it("Should import full backup in chunks", async function () { const importMockImpl = jest.fn(); - if (isNewBackend) { - // @ts-ignore - mock a private method for testing purpose - jest.spyOn(aliceCrypto.backupManager, "importBackedUpRoomKeys").mockImplementation(importMockImpl); - } else { - // @ts-ignore - mock a private method for testing purpose - jest.spyOn(aliceCrypto, "importBackedUpRoomKeys").mockImplementation(importMockImpl); - } + // @ts-ignore - mock a private method for testing purpose + jest.spyOn(aliceCrypto.backupManager, "importBackedUpRoomKeys").mockImplementation(importMockImpl); // We need several rooms with several sessions to test chunking const { response, expectedTotal } = createBackupDownloadResponse([45, 300, 345, 12, 130]); @@ -434,19 +382,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe ); const progressCallback = jest.fn(); - const result = await (isNewBackend - ? aliceCrypto.restoreKeyBackup({ - progressCallback, - }) - : aliceClient.restoreKeyBackupWithRecoveryKey( - testData.BACKUP_DECRYPTION_KEY_BASE58, - undefined, - undefined, - check!.backupInfo!, - { - progressCallback, - }, - )); + const result = await aliceCrypto.restoreKeyBackup({ + progressCallback, + }); expect(result.imported).toStrictEqual(expectedTotal); // Should be called 5 times: 200*4 plus one chunk with the remaining 32 @@ -489,13 +427,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe // Ok for other chunks .mockResolvedValue(undefined); - if (isNewBackend) { - // @ts-ignore - mock a private method for testing purpose - jest.spyOn(aliceCrypto.backupManager, "importBackedUpRoomKeys").mockImplementation(importMockImpl); - } else { - // @ts-ignore - mock a private method for testing purpose - jest.spyOn(aliceCrypto, "importBackedUpRoomKeys").mockImplementation(importMockImpl); - } + // @ts-ignore - mock a private method for testing purpose + jest.spyOn(aliceCrypto.backupManager, "importBackedUpRoomKeys").mockImplementation(importMockImpl); const { response, expectedTotal } = createBackupDownloadResponse([100, 300]); @@ -508,17 +441,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe ); const progressCallback = jest.fn(); - const result = await (isNewBackend - ? aliceCrypto.restoreKeyBackup({ progressCallback }) - : aliceClient.restoreKeyBackupWithRecoveryKey( - testData.BACKUP_DECRYPTION_KEY_BASE58, - undefined, - undefined, - check!.backupInfo!, - { - progressCallback, - }, - )); + const result = await aliceCrypto.restoreKeyBackup({ progressCallback }); expect(result.total).toStrictEqual(expectedTotal); // A chunk failed to import @@ -574,67 +497,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe check!.backupInfo!.version!, ); - const result = await (isNewBackend - ? aliceCrypto.restoreKeyBackup() - : aliceClient.restoreKeyBackupWithRecoveryKey( - testData.BACKUP_DECRYPTION_KEY_BASE58, - undefined, - undefined, - check!.backupInfo!, - )); + const result = await aliceCrypto.restoreKeyBackup(); expect(result.total).toStrictEqual(expectedTotal); // A chunk failed to import expect(result.imported).toStrictEqual(expectedTotal - decryptionFailureCount); }); - oldBackendOnly("recover specific session from backup", async function () { - fetchMock.get( - "express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", - testData.CURVE25519_KEY_BACKUP_DATA, - ); + it("Should get the decryption key from the secret storage and restore the key backup", async function () { + // @ts-ignore - mock a private method for testing purpose + jest.spyOn(aliceCrypto.secretStorage, "get").mockResolvedValue(testData.BACKUP_DECRYPTION_KEY_BASE64); - const check = await aliceCrypto.checkKeyBackupAndEnable(); - - const result = await advanceTimersUntil( - aliceClient.restoreKeyBackupWithRecoveryKey( - testData.BACKUP_DECRYPTION_KEY_BASE58, - ROOM_ID, - testData.MEGOLM_SESSION_DATA.session_id, - check!.backupInfo!, - ), - ); - - expect(result.imported).toStrictEqual(1); - }); - - newBackendOnly( - "Should get the decryption key from the secret storage and restore the key backup", - async function () { - // @ts-ignore - mock a private method for testing purpose - jest.spyOn(aliceCrypto.secretStorage, "get").mockResolvedValue(testData.BACKUP_DECRYPTION_KEY_BASE64); - - const fullBackup = { - rooms: { - [ROOM_ID]: { - sessions: { - [testData.MEGOLM_SESSION_DATA.session_id]: testData.CURVE25519_KEY_BACKUP_DATA, - }, - }, - }, - }; - fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup); - - await aliceCrypto.loadSessionBackupPrivateKeyFromSecretStorage(); - const decryptionKey = await aliceCrypto.getSessionBackupPrivateKey(); - expect(encodeBase64(decryptionKey!)).toStrictEqual(testData.BACKUP_DECRYPTION_KEY_BASE64); - - const result = await aliceCrypto.restoreKeyBackup(); - expect(result.imported).toStrictEqual(1); - }, - ); - - oldBackendOnly("Fails on bad recovery key", async function () { const fullBackup = { rooms: { [ROOM_ID]: { @@ -644,22 +517,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe }, }, }; - fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup); - const check = await aliceCrypto.checkKeyBackupAndEnable(); + await aliceCrypto.loadSessionBackupPrivateKeyFromSecretStorage(); + const decryptionKey = await aliceCrypto.getSessionBackupPrivateKey(); + expect(encodeBase64(decryptionKey!)).toStrictEqual(testData.BACKUP_DECRYPTION_KEY_BASE64); - await expect( - aliceClient.restoreKeyBackupWithRecoveryKey( - "EsTx A7Xn aNFF k3jH zpV3 MQoN LJEg mscC HecF 982L wC77 mYQD", - undefined, - undefined, - check!.backupInfo!, - ), - ).rejects.toThrow(); + const result = await aliceCrypto.restoreKeyBackup(); + expect(result.imported).toStrictEqual(1); }); - newBackendOnly("Should throw an error if the decryption key is not found in cache", async () => { + it("Should throw an error if the decryption key is not found in cache", async () => { await expect(aliceCrypto.restoreKeyBackup()).rejects.toThrow("No decryption key found in crypto store"); }); }); @@ -968,7 +836,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe expect(backupStatus).toStrictEqual(testData.SIGNED_BACKUP_DATA.version); }); - newBackendOnly("getKeyBackupInfo() should not return a backup if the active backup has been deleted", async () => { + it("getKeyBackupInfo() should not return a backup if the active backup has been deleted", async () => { // 404 means that there is no active backup fetchMock.get("express:/_matrix/client/v3/room_keys/version", 404); fetchMock.delete(`express:/_matrix/client/v3/room_keys/version/${testData.SIGNED_BACKUP_DATA.version}`, {}); diff --git a/spec/integ/crypto/olm-encryption-spec.ts b/spec/integ/crypto/olm-encryption-spec.ts deleted file mode 100644 index 419a19e1c..000000000 --- a/spec/integ/crypto/olm-encryption-spec.ts +++ /dev/null @@ -1,705 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 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. -*/ - -/* This file consists of a set of integration tests which try to simulate - * communication via an Olm-encrypted room between two users, Alice and Bob. - * - * Note that megolm (group) conversation is not tested here. - * - * See also `crypto.spec.js`. - */ - -// load olm before the sdk if possible -import "../../olm-loader"; - -import type { Session } from "@matrix-org/olm"; -import type { IDeviceKeys, IOneTimeKey } from "../../../src/@types/crypto"; -import { logger } from "../../../src/logger"; -import * as testUtils from "../../test-utils/test-utils"; -import { TestClient } from "../../TestClient"; -import { - CRYPTO_ENABLED, - type IClaimKeysRequest, - type IQueryKeysRequest, - type IUploadKeysRequest, -} from "../../../src/client"; -import { - ClientEvent, - type IContent, - type ISendEventResponse, - type MatrixClient, - MatrixEvent, - MsgType, -} from "../../../src/matrix"; -import { DeviceInfo } from "../../../src/crypto/deviceinfo"; -import { KnownMembership } from "../../../src/@types/membership"; - -let aliTestClient: TestClient; -const roomId = "!room:localhost"; -const aliUserId = "@ali:localhost"; -const aliDeviceId = "zxcvb"; -const aliAccessToken = "aseukfgwef"; -let bobTestClient: TestClient; -const bobUserId = "@bob:localhost"; -const bobDeviceId = "bvcxz"; -const bobAccessToken = "fewgfkuesa"; -let aliMessages: IContent[]; -let bobMessages: IContent[]; - -type OlmPayload = ReturnType; - -async function bobUploadsDeviceKeys(): Promise { - bobTestClient.expectDeviceKeyUpload(); - await bobTestClient.httpBackend.flushAllExpected(); - expect(Object.keys(bobTestClient.deviceKeys!).length).not.toEqual(0); -} - -/** - * Set an expectation that querier will query uploader's keys; then flush the http request. - * - * @returns resolves once the http request has completed. - */ -function expectQueryKeys(querier: TestClient, uploader: TestClient): Promise { - // can't query keys before bob has uploaded them - expect(uploader.deviceKeys).toBeTruthy(); - - const uploaderKeys: Record = {}; - uploaderKeys[uploader.deviceId!] = uploader.deviceKeys!; - querier.httpBackend.when("POST", "/keys/query").respond(200, function (_path, content: IQueryKeysRequest) { - expect(content.device_keys![uploader.userId!]).toEqual([]); - const result: Record> = {}; - result[uploader.userId!] = uploaderKeys; - return { device_keys: result }; - }); - return querier.httpBackend.flush("/keys/query", 1); -} -const expectAliQueryKeys = () => expectQueryKeys(aliTestClient, bobTestClient); -const expectBobQueryKeys = () => expectQueryKeys(bobTestClient, aliTestClient); - -/** - * Set an expectation that ali will claim one of bob's keys; then flush the http request. - * - * @returns resolves once the http request has completed. - */ -async function expectAliClaimKeys(): Promise { - const keys = await bobTestClient.awaitOneTimeKeyUpload(); - aliTestClient.httpBackend.when("POST", "/keys/claim").respond(200, function (_path, content: IClaimKeysRequest) { - const claimType = content.one_time_keys![bobUserId][bobDeviceId]; - expect(claimType).toEqual("signed_curve25519"); - let keyId = ""; - for (keyId in keys) { - if (bobTestClient.oneTimeKeys!.hasOwnProperty(keyId)) { - if (keyId.indexOf(claimType + ":") === 0) { - break; - } - } - } - const result: Record>> = {}; - result[bobUserId] = {}; - result[bobUserId][bobDeviceId] = {}; - result[bobUserId][bobDeviceId][keyId] = keys[keyId]; - return { one_time_keys: result }; - }); - // it can take a while to process the key query, so give it some extra - // time, and make sure the claim actually happens rather than ploughing on - // confusingly. - const r = await aliTestClient.httpBackend.flush("/keys/claim", 1, 500); - expect(r).toEqual(1); -} - -async function aliDownloadsKeys(): Promise { - // can't query keys before bob has uploaded them - expect(bobTestClient.getSigningKey()).toBeTruthy(); - - const p1 = async () => { - await aliTestClient.client.downloadKeys([bobUserId]); - const devices = aliTestClient.client.getStoredDevicesForUser(bobUserId); - expect(devices.length).toEqual(1); - expect(devices[0].deviceId).toEqual("bvcxz"); - }; - const p2 = expectAliQueryKeys; - - // check that the localStorage is updated as we expect (not sure this is - // an integration test, but meh) - await Promise.all([p1(), p2()]); - await aliTestClient.client.crypto!.deviceList.saveIfDirty(); - // @ts-ignore - protected - aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - const devices = data!.devices[bobUserId]!; - expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys!.keys); - expect(devices[bobDeviceId].verified).toBe(DeviceInfo.DeviceVerification.UNVERIFIED); - }); -} - -async function clientEnablesEncryption(client: MatrixClient): Promise { - await client.setRoomEncryption(roomId, { - algorithm: "m.olm.v1.curve25519-aes-sha2", - }); - expect(client.isRoomEncrypted(roomId)).toBeTruthy(); -} -const aliEnablesEncryption = () => clientEnablesEncryption(aliTestClient.client); -const bobEnablesEncryption = () => clientEnablesEncryption(bobTestClient.client); - -/** - * Ali sends a message, first claiming e2e keys. Set the expectations and - * check the results. - * - * @returns which resolves to the ciphertext for Bob's device. - */ -async function aliSendsFirstMessage(): Promise { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, ciphertext] = await Promise.all([ - sendMessage(aliTestClient.client), - expectAliQueryKeys().then(expectAliClaimKeys).then(expectAliSendMessageRequest), - ]); - return ciphertext; -} - -/** - * Ali sends a message without first claiming e2e keys. Set the expectations - * and check the results. - * - * @returns which resolves to the ciphertext for Bob's device. - */ -async function aliSendsMessage(): Promise { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, ciphertext] = await Promise.all([sendMessage(aliTestClient.client), expectAliSendMessageRequest()]); - return ciphertext; -} - -/** - * Bob sends a message, first querying (but not claiming) e2e keys. Set the - * expectations and check the results. - * - * @returns which resolves to the ciphertext for Ali's device. - */ -async function bobSendsReplyMessage(): Promise { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, ciphertext] = await Promise.all([ - sendMessage(bobTestClient.client), - expectBobQueryKeys().then(expectBobSendMessageRequest), - ]); - return ciphertext; -} - -/** - * Set an expectation that Ali will send a message, and flush the request - * - * @returns which resolves to the ciphertext for Bob's device. - */ -async function expectAliSendMessageRequest(): Promise { - const content = await expectSendMessageRequest(aliTestClient.httpBackend); - aliMessages.push(content); - expect(Object.keys(content.ciphertext)).toEqual([bobTestClient.getDeviceKey()]); - const ciphertext = content.ciphertext[bobTestClient.getDeviceKey()]; - expect(ciphertext).toBeTruthy(); - return ciphertext; -} - -/** - * Set an expectation that Bob will send a message, and flush the request - * - * @returns which resolves to the ciphertext for Bob's device. - */ -async function expectBobSendMessageRequest(): Promise { - const content = await expectSendMessageRequest(bobTestClient.httpBackend); - bobMessages.push(content); - const aliKeyId = "curve25519:" + aliDeviceId; - const aliDeviceCurve25519Key = aliTestClient.deviceKeys!.keys[aliKeyId]; - expect(Object.keys(content.ciphertext)).toEqual([aliDeviceCurve25519Key]); - const ciphertext = content.ciphertext[aliDeviceCurve25519Key]; - expect(ciphertext).toBeTruthy(); - return ciphertext; -} - -function sendMessage(client: MatrixClient): Promise { - return client.sendMessage(roomId, { msgtype: MsgType.Text, body: "Hello, World" }); -} - -async function expectSendMessageRequest(httpBackend: TestClient["httpBackend"]): Promise { - const path = "/send/m.room.encrypted/"; - const prom = new Promise((resolve) => { - httpBackend.when("PUT", path).respond(200, function (_path, content) { - resolve(content); - return { - event_id: "asdfgh", - }; - }); - }); - - // it can take a while to process the key query - await httpBackend.flush(path, 1); - return prom; -} - -function aliRecvMessage(): Promise { - const message = bobMessages.shift()!; - return recvMessage(aliTestClient.httpBackend, aliTestClient.client, bobUserId, message); -} - -function bobRecvMessage(): Promise { - const message = aliMessages.shift()!; - return recvMessage(bobTestClient.httpBackend, bobTestClient.client, aliUserId, message); -} - -async function recvMessage( - httpBackend: TestClient["httpBackend"], - client: MatrixClient, - sender: string, - message: IContent, -): Promise { - const syncData = { - next_batch: "x", - rooms: { - join: { - [roomId]: { - timeline: { - events: [ - testUtils.mkEvent({ - type: "m.room.encrypted", - room: roomId, - content: message, - sender: sender, - }), - ], - }, - }, - }, - }, - }; - httpBackend.when("GET", "/sync").respond(200, syncData); - - const eventPromise = new Promise((resolve) => { - const onEvent = function (event: MatrixEvent) { - // ignore the m.room.member events - if (event.getType() == "m.room.member") { - return; - } - logger.log(client.credentials.userId + " received event", event); - - client.removeListener(ClientEvent.Event, onEvent); - resolve(event); - }; - client.on(ClientEvent.Event, onEvent); - }); - - await httpBackend.flushAllExpected(); - - const preDecryptionEvent = await eventPromise; - expect(preDecryptionEvent.isEncrypted()).toBeTruthy(); - // it may still be being decrypted - const event = await testUtils.awaitDecryption(preDecryptionEvent); - expect(event.getType()).toEqual("m.room.message"); - expect(event.getContent()).toMatchObject({ - msgtype: "m.text", - body: "Hello, World", - }); - expect(event.isEncrypted()).toBeTruthy(); -} - -/** - * Send an initial sync response to the client (which just includes the member - * list for our test room). - * - * @returns which resolves when the sync has been flushed. - */ -function firstSync(testClient: TestClient): Promise { - // send a sync response including our test room. - const syncData = { - next_batch: "x", - rooms: { - join: { - [roomId]: { - state: { - events: [ - testUtils.mkMembership({ - mship: KnownMembership.Join, - user: aliUserId, - }), - testUtils.mkMembership({ - mship: KnownMembership.Join, - user: bobUserId, - }), - ], - }, - timeline: { - events: [], - }, - }, - }, - }, - }; - - testClient.httpBackend.when("GET", "/sync").respond(200, syncData); - return testClient.flushSync(); -} - -describe("MatrixClient crypto", () => { - if (!CRYPTO_ENABLED) { - return; - } - - beforeEach(async () => { - aliTestClient = new TestClient(aliUserId, aliDeviceId, aliAccessToken); - await aliTestClient.client.initLegacyCrypto(); - - bobTestClient = new TestClient(bobUserId, bobDeviceId, bobAccessToken); - await bobTestClient.client.initLegacyCrypto(); - - aliMessages = []; - bobMessages = []; - }); - - afterEach(() => { - aliTestClient.httpBackend.verifyNoOutstandingExpectation(); - bobTestClient.httpBackend.verifyNoOutstandingExpectation(); - - return Promise.all([aliTestClient.stop(), bobTestClient.stop()]); - }); - - it("Bob uploads device keys", bobUploadsDeviceKeys); - - it("handles failures to upload device keys", async () => { - // since device keys are uploaded asynchronously, there's not really much to do here other than fail the - // upload. - bobTestClient.httpBackend.when("POST", "/keys/upload").fail(0, new Error("bleh")); - await bobTestClient.httpBackend.flushAllExpected(); - }); - - it("Ali downloads Bobs device keys", async () => { - await bobUploadsDeviceKeys(); - await aliDownloadsKeys(); - }); - - it("Ali gets keys with an invalid signature", async () => { - await bobUploadsDeviceKeys(); - // tamper bob's keys - const bobDeviceKeys = bobTestClient.deviceKeys!; - expect(bobDeviceKeys.keys["curve25519:" + bobDeviceId]).toBeTruthy(); - bobDeviceKeys.keys["curve25519:" + bobDeviceId] += "abc"; - await Promise.all([aliTestClient.client.downloadKeys([bobUserId]), expectAliQueryKeys()]); - const devices = aliTestClient.client.getStoredDevicesForUser(bobUserId); - // should get an empty list - expect(devices).toEqual([]); - }); - - it("Ali gets keys with an incorrect userId", async () => { - const eveUserId = "@eve:localhost"; - - const bobDeviceKeys = { - algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], - device_id: "bvcxz", - keys: { - "ed25519:bvcxz": "pYuWKMCVuaDLRTM/eWuB8OlXEb61gZhfLVJ+Y54tl0Q", - "curve25519:bvcxz": "7Gni0loo/nzF0nFp9847RbhElGewzwUXHPrljjBGPTQ", - }, - user_id: "@eve:localhost", - signatures: { - "@eve:localhost": { - "ed25519:bvcxz": - "CliUPZ7dyVPBxvhSA1d+X+LYa5b2AYdjcTwG" + "0stXcIxjaJNemQqtdgwKDtBFl3pN2I13SEijRDCf1A8bYiQMDg", - }, - }, - }; - - const bobKeys: Record = {}; - bobKeys[bobDeviceId] = bobDeviceKeys; - aliTestClient.httpBackend.when("POST", "/keys/query").respond(200, { device_keys: { [bobUserId]: bobKeys } }); - - await Promise.all([ - aliTestClient.client.downloadKeys([bobUserId, eveUserId]), - aliTestClient.httpBackend.flush("/keys/query", 1), - ]); - const [bobDevices, eveDevices] = await Promise.all([ - aliTestClient.client.getStoredDevicesForUser(bobUserId), - aliTestClient.client.getStoredDevicesForUser(eveUserId), - ]); - // should get an empty list - expect(bobDevices).toEqual([]); - expect(eveDevices).toEqual([]); - }); - - it("Ali gets keys with an incorrect deviceId", async () => { - const bobDeviceKeys = { - algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], - device_id: "bad_device", - keys: { - "ed25519:bad_device": "e8XlY5V8x2yJcwa5xpSzeC/QVOrU+D5qBgyTK0ko+f0", - "curve25519:bad_device": "YxuuLG/4L5xGeP8XPl5h0d7DzyYVcof7J7do+OXz0xc", - }, - user_id: "@bob:localhost", - signatures: { - "@bob:localhost": { - "ed25519:bad_device": - "fEFTq67RaSoIEVBJ8DtmRovbwUBKJ0A" + "me9m9PDzM9azPUwZ38Xvf6vv1A7W1PSafH4z3Y2ORIyEnZgHaNby3CQ", - }, - }, - }; - - const bobKeys: Record = {}; - bobKeys[bobDeviceId] = bobDeviceKeys; - aliTestClient.httpBackend.when("POST", "/keys/query").respond(200, { device_keys: { [bobUserId]: bobKeys } }); - - await Promise.all([ - aliTestClient.client.downloadKeys([bobUserId]), - aliTestClient.httpBackend.flush("/keys/query", 1), - ]); - const devices = aliTestClient.client.getStoredDevicesForUser(bobUserId); - // should get an empty list - expect(devices).toEqual([]); - }); - - it("Bob starts his client and uploads device keys and one-time keys", async () => { - await bobTestClient.start(); - const keys = await bobTestClient.awaitOneTimeKeyUpload(); - expect(Object.keys(keys).length).toEqual(5); - expect(Object.keys(bobTestClient.deviceKeys!).length).not.toEqual(0); - }); - - it("Ali sends a message", async () => { - aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); - await aliTestClient.start(); - await bobTestClient.start(); - await firstSync(aliTestClient); - await aliEnablesEncryption(); - await aliSendsFirstMessage(); - }); - - it("Bob receives a message", async () => { - aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); - await aliTestClient.start(); - await bobTestClient.start(); - bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map()); - await firstSync(aliTestClient); - await aliEnablesEncryption(); - await aliSendsFirstMessage(); - await bobRecvMessage(); - }); - - it("Bob receives a message with a bogus sender", async () => { - aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); - await aliTestClient.start(); - await bobTestClient.start(); - bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map()); - await firstSync(aliTestClient); - await aliEnablesEncryption(); - await aliSendsFirstMessage(); - const message = aliMessages.shift()!; - const syncData = { - next_batch: "x", - rooms: { - join: { - [roomId]: { - timeline: { - events: [ - testUtils.mkEvent({ - type: "m.room.encrypted", - room: roomId, - content: message, - sender: "@bogus:sender", - }), - ], - }, - }, - }, - }, - }; - bobTestClient.httpBackend.when("GET", "/sync").respond(200, syncData); - - const eventPromise = new Promise((resolve) => { - const onEvent = function (event: MatrixEvent) { - logger.log(bobUserId + " received event", event); - resolve(event); - }; - bobTestClient.client.once(ClientEvent.Event, onEvent); - }); - await bobTestClient.httpBackend.flushAllExpected(); - const preDecryptionEvent = await eventPromise; - expect(preDecryptionEvent.isEncrypted()).toBeTruthy(); - // it may still be being decrypted - const event = await testUtils.awaitDecryption(preDecryptionEvent); - expect(event.getType()).toEqual("m.room.message"); - expect(event.getContent().msgtype).toEqual("m.bad.encrypted"); - }); - - it("Ali blocks Bob's device", async () => { - aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); - await aliTestClient.start(); - await bobTestClient.start(); - await firstSync(aliTestClient); - await aliEnablesEncryption(); - await aliDownloadsKeys(); - aliTestClient.client.setDeviceBlocked(bobUserId, bobDeviceId, true); - const p1 = sendMessage(aliTestClient.client); - const p2 = expectSendMessageRequest(aliTestClient.httpBackend).then(function (sentContent) { - // no unblocked devices, so the ciphertext should be empty - expect(sentContent.ciphertext).toEqual({}); - }); - await Promise.all([p1, p2]); - }); - - it("Bob receives two pre-key messages", async () => { - aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); - await aliTestClient.start(); - await bobTestClient.start(); - bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map()); - await firstSync(aliTestClient); - await aliEnablesEncryption(); - await aliSendsFirstMessage(); - await bobRecvMessage(); - await aliSendsMessage(); - await bobRecvMessage(); - }); - - it("Bob replies to the message", async () => { - aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); - bobTestClient.expectKeyQuery({ device_keys: { [bobUserId]: {} }, failures: {} }); - await aliTestClient.start(); - await bobTestClient.start(); - await firstSync(aliTestClient); - await firstSync(bobTestClient); - await aliEnablesEncryption(); - await aliSendsFirstMessage(); - bobTestClient.httpBackend.when("POST", "/keys/query").respond(200, {}); - await bobRecvMessage(); - await bobEnablesEncryption(); - const ciphertext = await bobSendsReplyMessage(); - expect(ciphertext.type).toEqual(1); - await aliRecvMessage(); - }); - - it("Ali does a key query when encryption is enabled", async () => { - // enabling encryption in the room should make alice download devices - // for both members. - aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} }); - await aliTestClient.start(); - await firstSync(aliTestClient); - const syncData = { - next_batch: "2", - rooms: { - join: { - [roomId]: { - state: { - events: [ - testUtils.mkEvent({ - type: "m.room.encryption", - skey: "", - content: { - algorithm: "m.olm.v1.curve25519-aes-sha2", - }, - }), - ], - }, - }, - }, - }, - }; - - aliTestClient.httpBackend.when("GET", "/sync").respond(200, syncData); - await aliTestClient.httpBackend.flush("/sync", 1); - aliTestClient.expectKeyQuery({ - device_keys: { - [bobUserId]: {}, - }, - failures: {}, - }); - await aliTestClient.httpBackend.flushAllExpected(); - }); - - it("Upload new oneTimeKeys based on a /sync request - no count-asking", async () => { - // Send a response which causes a key upload - const httpBackend = aliTestClient.httpBackend; - const syncDataEmpty = { - next_batch: "a", - device_one_time_keys_count: { - signed_curve25519: 0, - }, - }; - - // enqueue expectations: - // * Sync with empty one_time_keys => upload keys - - logger.log(aliTestClient + ": starting"); - httpBackend.when("GET", "/versions").respond(200, {}); - httpBackend.when("GET", "/pushrules").respond(200, {}); - httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); - aliTestClient.expectDeviceKeyUpload(); - - // we let the client do a very basic initial sync, which it needs before - // it will upload one-time keys. - httpBackend.when("GET", "/sync").respond(200, syncDataEmpty); - - await Promise.all([aliTestClient.client.startClient({}), httpBackend.flushAllExpected()]); - logger.log(aliTestClient + ": started"); - httpBackend.when("POST", "/keys/upload").respond(200, (_path, content: IUploadKeysRequest) => { - expect(content.one_time_keys).toBeTruthy(); - expect(content.one_time_keys).not.toEqual({}); - expect(Object.keys(content.one_time_keys!).length).toBeGreaterThanOrEqual(1); - // cancel futher calls by telling the client - // we have more than we need - return { - one_time_key_counts: { - signed_curve25519: 70, - }, - }; - }); - await httpBackend.flushAllExpected(); - }); - - it("Checks for outgoing room key requests for a given event's session", async () => { - const eventA0 = new MatrixEvent({ - sender: "@bob:example.com", - room_id: "!someroom", - content: { - algorithm: "m.megolm.v1.aes-sha2", - session_id: "sessionid", - sender_key: "senderkey", - }, - }); - const eventA1 = new MatrixEvent({ - sender: "@bob:example.com", - room_id: "!someroom", - content: { - algorithm: "m.megolm.v1.aes-sha2", - session_id: "sessionid", - sender_key: "senderkey", - }, - }); - const eventB = new MatrixEvent({ - sender: "@bob:example.com", - room_id: "!someroom", - content: { - algorithm: "m.megolm.v1.aes-sha2", - session_id: "othersessionid", - sender_key: "senderkey", - }, - }); - const nonEncryptedEvent = new MatrixEvent({ - sender: "@bob:example.com", - room_id: "!someroom", - content: {}, - }); - - aliTestClient.client.crypto?.onSyncCompleted({}); - await aliTestClient.client.cancelAndResendEventRoomKeyRequest(eventA0); - expect(await aliTestClient.client.getOutgoingRoomKeyRequest(eventA1)).not.toBeNull(); - expect(await aliTestClient.client.getOutgoingRoomKeyRequest(eventB)).toBeNull(); - expect(await aliTestClient.client.getOutgoingRoomKeyRequest(nonEncryptedEvent)).toBeNull(); - }); -}); diff --git a/spec/integ/crypto/rust-crypto.spec.ts b/spec/integ/crypto/rust-crypto.spec.ts index 5aee7e835..2394d9a3e 100644 --- a/spec/integ/crypto/rust-crypto.spec.ts +++ b/spec/integ/crypto/rust-crypto.spec.ts @@ -18,12 +18,13 @@ import "fake-indexeddb/auto"; import { IDBFactory } from "fake-indexeddb"; import fetchMock from "fetch-mock-jest"; -import { createClient, CryptoEvent, IndexedDBCryptoStore } from "../../../src"; +import { createClient, IndexedDBCryptoStore } from "../../../src"; import { populateStore } from "../../test-utils/test_indexeddb_cryptostore_dump"; import { MSK_NOT_CACHED_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/no_cached_msk_dump"; import { IDENTITY_NOT_TRUSTED_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/unverified"; import { FULL_ACCOUNT_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/full_account"; import { EMPTY_ACCOUNT_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/empty_account"; +import { CryptoEvent } from "../../../src/crypto-api"; jest.setTimeout(15000); diff --git a/spec/integ/crypto/to-device-messages.spec.ts b/spec/integ/crypto/to-device-messages.spec.ts index 3801e934f..90ce5edc6 100644 --- a/spec/integ/crypto/to-device-messages.spec.ts +++ b/spec/integ/crypto/to-device-messages.spec.ts @@ -18,7 +18,7 @@ import fetchMock from "fetch-mock-jest"; import "fake-indexeddb/auto"; import { IDBFactory } from "fake-indexeddb"; -import { CRYPTO_BACKENDS, getSyncResponse, type InitCrypto, syncPromise } from "../../test-utils/test-utils"; +import { getSyncResponse, syncPromise } from "../../test-utils/test-utils"; import { createClient, type MatrixClient } from "../../../src"; import * as testData from "../../test-utils/test-data"; import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; @@ -38,7 +38,7 @@ afterEach(() => { * These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as * to provide the most effective integration tests possible. */ -describe.each(Object.entries(CRYPTO_BACKENDS))("to-device-messages (%s)", (backend: string, initCrypto: InitCrypto) => { +describe("to-device-messages", () => { let aliceClient: MatrixClient; /** an object which intercepts `/keys/query` requests on the test homeserver */ @@ -81,7 +81,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("to-device-messages (%s)", (backe { filter_id: "fid" }, ); - await initCrypto(aliceClient); + await aliceClient.initRustCrypto(); }, /* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */ 10000, diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index b2c001938..426a9c3ce 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -25,7 +25,6 @@ import Olm from "@matrix-org/olm"; import type FetchMock from "fetch-mock"; import { createClient, - CryptoEvent, DeviceVerification, type IContent, type ICreateClientOpts, @@ -45,14 +44,7 @@ import { VerifierEvent, } from "../../../src/crypto-api/verification"; import { defer, escapeRegExp } from "../../../src/utils"; -import { - awaitDecryption, - CRYPTO_BACKENDS, - emitPromise, - getSyncResponse, - type InitCrypto, - syncPromise, -} from "../../test-utils/test-utils"; +import { awaitDecryption, emitPromise, getSyncResponse, syncPromise } from "../../test-utils/test-utils"; import { SyncResponder } from "../../test-utils/SyncResponder"; import { BACKUP_DECRYPTION_KEY_BASE64, @@ -81,7 +73,7 @@ import { getTestOlmAccountKeys, type ToDeviceEvent, } from "./olm-utils"; -import { type KeyBackupInfo } from "../../../src/crypto-api"; +import { type KeyBackupInfo, CryptoEvent } from "../../../src/crypto-api"; import { encodeBase64 } from "../../../src/base64"; // The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations @@ -118,11 +110,7 @@ const TEST_HOMESERVER_URL = "https://alice-server.com"; * to provide the most effective integration tests possible. */ // we test with both crypto stacks... -describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: string, initCrypto: InitCrypto) => { - // newBackendOnly is the opposite to `oldBackendOnly`: it will skip the test if we are running against the legacy - // backend. Once we drop support for legacy crypto, it will go away. - const newBackendOnly = backend === "rust-sdk" ? test : test.skip; - +describe("verification", () => { /** the client under test */ let aliceClient: MatrixClient; @@ -432,9 +420,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st expect(requests[0].transactionId).toEqual(transactionId); } - // legacy crypto picks devices individually; rust crypto uses a broadcast message - const toDeviceMessage = - requestBody.messages[TEST_USER_ID]["*"] ?? requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + // rust crypto uses a broadcast message + const toDeviceMessage = requestBody.messages[TEST_USER_ID]["*"]; expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); expect(toDeviceMessage.transaction_id).toEqual(transactionId); }); @@ -522,18 +509,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st reciprocateQRCodeCallbacks.confirm(); await sendToDevicePromise; - // at this point, on legacy crypto, the master key is already marked as trusted, and the request is "Done". - // Rust crypto, on the other hand, waits for the 'done' to arrive from the other side. + // Rust crypto waits for the 'done' to arrive from the other side. if (request.phase === VerificationPhase.Done) { - // legacy crypto: we're all done const userVerificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID); // eslint-disable-next-line jest/no-conditional-expect expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy(); await verificationPromise; - } else { - // rust crypto: still in flight - // eslint-disable-next-line jest/no-conditional-expect - expect(request.phase).toEqual(VerificationPhase.Started); } // the dummy device replies with its own 'done' @@ -569,7 +550,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st expect(qrCodeBuffer).toBeUndefined(); }); - newBackendOnly("can verify another by scanning their QR code", async () => { + it("can verify another by scanning their QR code", async () => { aliceClient = await startTestClient(); // we need cross-signing keys for a QR code verification e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); @@ -907,7 +888,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st describe("Send verification request in DM", () => { beforeEach(async () => { aliceClient = await startTestClient(); - aliceClient.setGlobalErrorOnUnknownDevices(false); e2eKeyResponder.addCrossSigningData(BOB_SIGNED_CROSS_SIGNING_KEYS_DATA); e2eKeyResponder.addDeviceKeys(BOB_SIGNED_TEST_DEVICE_DATA); @@ -990,21 +970,18 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st testOlmAccount.create(); aliceClient = await startTestClient(); - aliceClient.setGlobalErrorOnUnknownDevices(false); syncResponder.sendOrQueueSyncResponse(getSyncResponse([BOB_TEST_USER_ID])); await syncPromise(aliceClient); // Rust crypto requires the sender's device keys before it accepts a // verification request. - if (backend === "rust-sdk") { - const crypto = aliceClient.getCrypto()!; + const crypto = aliceClient.getCrypto()!; - const bobDeviceKeys = getTestOlmAccountKeys(testOlmAccount, BOB_TEST_USER_ID, "BobDevice"); - e2eKeyResponder.addDeviceKeys(bobDeviceKeys); - syncResponder.sendOrQueueSyncResponse({ device_lists: { changed: [BOB_TEST_USER_ID] } }); - await syncPromise(aliceClient); - await crypto.getUserDeviceInfo([BOB_TEST_USER_ID]); - } + const bobDeviceKeys = getTestOlmAccountKeys(testOlmAccount, BOB_TEST_USER_ID, "BobDevice"); + e2eKeyResponder.addDeviceKeys(bobDeviceKeys); + syncResponder.sendOrQueueSyncResponse({ device_lists: { changed: [BOB_TEST_USER_ID] } }); + await syncPromise(aliceClient); + await crypto.getUserDeviceInfo([BOB_TEST_USER_ID]); }); /** @@ -1152,43 +1129,40 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st expect(request?.otherUserId).toBe("@bob:xyz"); }); - newBackendOnly( - "If the verification request is not decrypted within 5 minutes, the request is ignored", - async () => { - const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver); - const groupSession = new Olm.OutboundGroupSession(); - groupSession.create(); + it("If the verification request is not decrypted within 5 minutes, the request is ignored", async () => { + const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver); + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); - // make the room_key event, but don't send it yet - const toDeviceEvent = encryptGroupSessionKeyForAlice(groupSession, p2pSession); + // make the room_key event, but don't send it yet + const toDeviceEvent = encryptGroupSessionKeyForAlice(groupSession, p2pSession); - // Add verification request from Bob to Alice in the DM between them - returnRoomMessageFromSync(TEST_ROOM_ID, createEncryptedVerificationRequest(groupSession)); + // Add verification request from Bob to Alice in the DM between them + returnRoomMessageFromSync(TEST_ROOM_ID, createEncryptedVerificationRequest(groupSession)); - // Wait for the sync response to be processed - await syncPromise(aliceClient); + // Wait for the sync response to be processed + await syncPromise(aliceClient); - const room = aliceClient.getRoom(TEST_ROOM_ID)!; - const matrixEvent = room.getLiveTimeline().getEvents()[0]; + const room = aliceClient.getRoom(TEST_ROOM_ID)!; + const matrixEvent = room.getLiveTimeline().getEvents()[0]; - // wait for a first attempt at decryption: should fail - await awaitDecryption(matrixEvent); - expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted"); + // wait for a first attempt at decryption: should fail + await awaitDecryption(matrixEvent); + expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted"); - // Advance time by 5mins, the verification request should be ignored after that - jest.advanceTimersByTime(5 * 60 * 1000); + // Advance time by 5mins, the verification request should be ignored after that + jest.advanceTimersByTime(5 * 60 * 1000); - // Send Bob the room keys - returnToDeviceMessageFromSync(toDeviceEvent); + // Send Bob the room keys + returnToDeviceMessageFromSync(toDeviceEvent); - // Wait for the message to be decrypted - await awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true }); + // Wait for the message to be decrypted + await awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true }); - const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"); - // the request should not be present - expect(request).not.toBeDefined(); - }, - ); + const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"); + // the request should not be present + expect(request).not.toBeDefined(); + }); }); describe("Secrets are gossiped after verification", () => { @@ -1260,7 +1234,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st fetchMock.mockReset(); }); - newBackendOnly("Should request cross signing keys after verification", async () => { + it("Should request cross signing keys after verification", async () => { const requestPromises = mockSecretRequestAndGetPromises(); await doInteractiveVerification(); @@ -1271,7 +1245,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st await requestPromises.get("m.cross_signing.self_signing"); }); - newBackendOnly("Should accept the backup decryption key gossip if valid", async () => { + it("Should accept the backup decryption key gossip if valid", async () => { const requestPromises = mockSecretRequestAndGetPromises(); await doInteractiveVerification(); @@ -1290,7 +1264,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st expect(encodeBase64(cachedKey!)).toEqual(BACKUP_DECRYPTION_KEY_BASE64); }); - newBackendOnly("Should not accept the backup decryption key gossip if private key do not match", async () => { + it("Should not accept the backup decryption key gossip if private key do not match", async () => { const requestPromises = mockSecretRequestAndGetPromises(); await doInteractiveVerification(); @@ -1311,7 +1285,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st expect(cachedKey).toBeNull(); }); - newBackendOnly("Should not accept the backup decryption key gossip if backup not trusted", async () => { + it("Should not accept the backup decryption key gossip if backup not trusted", async () => { const requestPromises = mockSecretRequestAndGetPromises(); await doInteractiveVerification(); @@ -1335,7 +1309,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st expect(cachedKey).toBeNull(); }); - newBackendOnly("Should not accept the backup decryption key gossip if backup algorithm unknown", async () => { + it("Should not accept the backup decryption key gossip if backup algorithm unknown", async () => { const requestPromises = mockSecretRequestAndGetPromises(); await doInteractiveVerification(); @@ -1360,7 +1334,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st expect(cachedKey).toBeNull(); }); - newBackendOnly("Should not accept an invalid backup decryption key", async () => { + it("Should not accept an invalid backup decryption key", async () => { const requestPromises = mockSecretRequestAndGetPromises(); await doInteractiveVerification(); @@ -1482,7 +1456,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st deviceId: "device_under_test", ...opts, }); - await initCrypto(client); + await client.initRustCrypto(); await client.startClient(); return client; } diff --git a/spec/integ/devicelist-integ.spec.ts b/spec/integ/devicelist-integ.spec.ts deleted file mode 100644 index ce741d8dc..000000000 --- a/spec/integ/devicelist-integ.spec.ts +++ /dev/null @@ -1,406 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 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 { TestClient } from "../TestClient"; -import * as testUtils from "../test-utils/test-utils"; -import { logger } from "../../src/logger"; -import { KnownMembership } from "../../src/@types/membership"; - -const ROOM_ID = "!room:id"; - -/** - * get a /sync response which contains a single e2e room (ROOM_ID), with the - * members given - * - * @returns sync response - */ -function getSyncResponse(roomMembers: string[]) { - const stateEvents = [ - testUtils.mkEvent({ - type: "m.room.encryption", - skey: "", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }), - ]; - - Array.prototype.push.apply( - stateEvents, - roomMembers.map((m) => - testUtils.mkMembership({ - mship: KnownMembership.Join, - sender: m, - }), - ), - ); - - const syncResponse = { - next_batch: 1, - rooms: { - join: { - [ROOM_ID]: { - state: { - events: stateEvents, - }, - }, - }, - }, - }; - - return syncResponse; -} - -describe("DeviceList management:", function () { - if (!globalThis.Olm) { - logger.warn("not running deviceList tests: Olm not present"); - return; - } - - let aliceTestClient: TestClient; - let sessionStoreBackend: Storage; - - async function createTestClient() { - const testClient = new TestClient("@alice:localhost", "xzcvb", "akjgkrgjs", sessionStoreBackend); - await testClient.client.initLegacyCrypto(); - return testClient; - } - - beforeEach(async function () { - // we create our own sessionStoreBackend so that we can use it for - // another TestClient. - sessionStoreBackend = new testUtils.MockStorageApi(); - - aliceTestClient = await createTestClient(); - }); - - afterEach(function () { - return aliceTestClient.stop(); - }); - - it("Alice shouldn't do a second /query for non-e2e-capable devices", function () { - aliceTestClient.expectKeyQuery({ - device_keys: { "@alice:localhost": {} }, - failures: {}, - }); - return aliceTestClient - .start() - .then(function () { - const syncResponse = getSyncResponse(["@bob:xyz"]); - aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); - - return aliceTestClient.flushSync(); - }) - .then(function () { - logger.log("Forcing alice to download our device keys"); - - aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, { - device_keys: { - "@bob:xyz": {}, - }, - }); - - return Promise.all([ - aliceTestClient.client.downloadKeys(["@bob:xyz"]), - aliceTestClient.httpBackend.flush("/keys/query", 1), - ]); - }) - .then(function () { - logger.log("Telling alice to send a megolm message"); - - aliceTestClient.httpBackend.when("PUT", "/send/").respond(200, { - event_id: "$event_id", - }); - - return Promise.all([ - aliceTestClient.client.sendTextMessage(ROOM_ID, "test"), - - // the crypto stuff can take a while, so give the requests a whole second. - aliceTestClient.httpBackend.flushAllExpected({ - timeout: 1000, - }), - ]); - }); - }); - - it.skip("We should not get confused by out-of-order device query responses", () => { - // https://github.com/vector-im/element-web/issues/3126 - aliceTestClient.expectKeyQuery({ - device_keys: { "@alice:localhost": {} }, - failures: {}, - }); - return aliceTestClient - .start() - .then(() => { - aliceTestClient.httpBackend - .when("GET", "/sync") - .respond(200, getSyncResponse(["@bob:xyz", "@chris:abc"])); - return aliceTestClient.flushSync(); - }) - .then(() => { - // to make sure the initial device queries are flushed out, we - // attempt to send a message. - - aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, { - device_keys: { - "@bob:xyz": {}, - "@chris:abc": {}, - }, - }); - - aliceTestClient.httpBackend.when("PUT", "/send/").respond(200, { event_id: "$event1" }); - - return Promise.all([ - aliceTestClient.client.sendTextMessage(ROOM_ID, "test"), - aliceTestClient.httpBackend - .flush("/keys/query", 1) - .then(() => aliceTestClient.httpBackend.flush("/send/", 1)), - aliceTestClient.client.crypto!.deviceList.saveIfDirty(), - ]); - }) - .then(() => { - // @ts-ignore accessing a protected field - aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => { - expect(data!.syncToken).toEqual(1); - }); - - // invalidate bob's and chris's device lists in separate syncs - aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { - next_batch: "2", - device_lists: { - changed: ["@bob:xyz"], - }, - }); - aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { - next_batch: "3", - device_lists: { - changed: ["@chris:abc"], - }, - }); - // flush both syncs - return aliceTestClient.flushSync().then(() => { - return aliceTestClient.flushSync(); - }); - }) - .then(() => { - // check that we don't yet have a request for chris's devices. - aliceTestClient.httpBackend - .when("POST", "/keys/query", { - device_keys: { - "@chris:abc": {}, - }, - token: "3", - }) - .respond(200, { - device_keys: { "@chris:abc": {} }, - }); - return aliceTestClient.httpBackend.flush("/keys/query", 1); - }) - .then((flushed) => { - expect(flushed).toEqual(0); - return aliceTestClient.client.crypto!.deviceList.saveIfDirty(); - }) - .then(() => { - // @ts-ignore accessing a protected field - aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => { - const bobStat = data!.trackingStatus["@bob:xyz"]; - if (bobStat != 1 && bobStat != 2) { - throw new Error("Unexpected status for bob: wanted 1 or 2, got " + bobStat); - } - const chrisStat = data!.trackingStatus["@chris:abc"]; - if (chrisStat != 1 && chrisStat != 2) { - throw new Error("Unexpected status for chris: wanted 1 or 2, got " + chrisStat); - } - }); - - // now add an expectation for a query for bob's devices, and let - // it complete. - aliceTestClient.httpBackend - .when("POST", "/keys/query", { - device_keys: { - "@bob:xyz": {}, - }, - token: "2", - }) - .respond(200, { - device_keys: { "@bob:xyz": {} }, - }); - return aliceTestClient.httpBackend.flush("/keys/query", 1); - }) - .then((flushed) => { - expect(flushed).toEqual(1); - - // wait for the client to stop processing the response - return aliceTestClient.client.downloadKeys(["@bob:xyz"]); - }) - .then(() => { - return aliceTestClient.client.crypto!.deviceList.saveIfDirty(); - }) - .then(() => { - // @ts-ignore accessing a protected field - aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => { - const bobStat = data!.trackingStatus["@bob:xyz"]; - expect(bobStat).toEqual(3); - const chrisStat = data!.trackingStatus["@chris:abc"]; - if (chrisStat != 1 && chrisStat != 2) { - throw new Error("Unexpected status for chris: wanted 1 or 2, got " + bobStat); - } - }); - - // now let the query for chris's devices complete. - return aliceTestClient.httpBackend.flush("/keys/query", 1); - }) - .then((flushed) => { - expect(flushed).toEqual(1); - - // wait for the client to stop processing the response - return aliceTestClient.client.downloadKeys(["@chris:abc"]); - }) - .then(() => { - return aliceTestClient.client.crypto!.deviceList.saveIfDirty(); - }) - .then(() => { - // @ts-ignore accessing a protected field - aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => { - const bobStat = data!.trackingStatus["@bob:xyz"]; - const chrisStat = data!.trackingStatus["@bob:xyz"]; - - expect(bobStat).toEqual(3); - expect(chrisStat).toEqual(3); - expect(data!.syncToken).toEqual(3); - }); - }); - }); - - // https://github.com/vector-im/element-web/issues/4983 - describe("Alice should know she has stale device lists", () => { - beforeEach(async function () { - await aliceTestClient.start(); - - aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"])); - await aliceTestClient.flushSync(); - - aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, { - device_keys: { - "@bob:xyz": {}, - }, - }); - await aliceTestClient.httpBackend.flush("/keys/query", 1); - await aliceTestClient.client.crypto!.deviceList.saveIfDirty(); - - // @ts-ignore accessing a protected field - aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => { - const bobStat = data!.trackingStatus["@bob:xyz"]; - - // Alice should be tracking bob's device list - expect(bobStat).toBeGreaterThan(0); - }); - }); - - it("when Bob leaves", async function () { - aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { - next_batch: 2, - device_lists: { - left: ["@bob:xyz"], - }, - rooms: { - join: { - [ROOM_ID]: { - timeline: { - events: [ - testUtils.mkMembership({ - mship: KnownMembership.Leave, - sender: "@bob:xyz", - }), - ], - }, - }, - }, - }, - }); - - await aliceTestClient.flushSync(); - await aliceTestClient.client.crypto!.deviceList.saveIfDirty(); - - // @ts-ignore accessing a protected field - aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => { - const bobStat = data!.trackingStatus["@bob:xyz"]; - - // Alice should have marked bob's device list as untracked - expect(bobStat).toEqual(0); - }); - }); - - it("when Alice leaves", async function () { - aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { - next_batch: 2, - device_lists: { - left: ["@bob:xyz"], - }, - rooms: { - leave: { - [ROOM_ID]: { - timeline: { - events: [ - testUtils.mkMembership({ - mship: KnownMembership.Leave, - sender: "@bob:xyz", - }), - ], - }, - }, - }, - }, - }); - - await aliceTestClient.flushSync(); - await aliceTestClient.client.crypto!.deviceList.saveIfDirty(); - - // @ts-ignore accessing a protected field - aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => { - const bobStat = data!.trackingStatus["@bob:xyz"]; - - // Alice should have marked bob's device list as untracked - expect(bobStat).toEqual(0); - }); - }); - - it("when Bob leaves whilst Alice is offline", async function () { - aliceTestClient.stop(); - - const anotherTestClient = await createTestClient(); - - try { - await anotherTestClient.start(); - anotherTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse([])); - await anotherTestClient.flushSync(); - await anotherTestClient.client?.crypto?.deviceList?.saveIfDirty(); - - // @ts-ignore accessing private property - anotherTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { - const bobStat = data!.trackingStatus["@bob:xyz"]; - - // Alice should have marked bob's device list as untracked - expect(bobStat).toEqual(0); - }); - } finally { - anotherTestClient.stop(); - } - }); - }); -}); diff --git a/spec/integ/matrix-client-methods.spec.ts b/spec/integ/matrix-client-methods.spec.ts index abd55185a..a28ec25d2 100644 --- a/spec/integ/matrix-client-methods.spec.ts +++ b/spec/integ/matrix-client-methods.spec.ts @@ -13,11 +13,10 @@ 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 Mocked } from "jest-mock"; import type HttpBackend from "matrix-mock-request"; import * as utils from "../test-utils/test-utils"; -import { CRYPTO_ENABLED, type IStoredClientOpts, MatrixClient } from "../../src/client"; +import { type IStoredClientOpts, MatrixClient } from "../../src/client"; import { MatrixEvent } from "../../src/models/event"; import { Filter, @@ -34,7 +33,6 @@ import { THREAD_RELATION_TYPE } from "../../src/models/thread"; import { type IFilterDefinition } from "../../src/filter"; import { type ISearchResults } from "../../src/@types/search"; import { type IStore } from "../../src/store"; -import { type CryptoBackend } from "../../src/common-crypto/CryptoBackend"; import { SetPresence } from "../../src/sync"; import { KnownMembership } from "../../src/@types/membership"; @@ -644,126 +642,6 @@ describe("MatrixClient", function () { }); }); - describe("downloadKeys", function () { - if (!CRYPTO_ENABLED) { - return; - } - - beforeEach(function () { - // running initLegacyCrypto should trigger a key upload - httpBackend.when("POST", "/keys/upload").respond(200, {}); - return Promise.all([client.initLegacyCrypto(), httpBackend.flush("/keys/upload", 1)]); - }); - - afterEach(() => { - client.stopClient(); - }); - - it("should do an HTTP request and then store the keys", function () { - const ed25519key = "7wG2lzAqbjcyEkOP7O4gU7ItYcn+chKzh5sT/5r2l78"; - // ed25519key = client.getDeviceEd25519Key(); - const borisKeys = { - dev1: { - algorithms: ["1"], - device_id: "dev1", - keys: { "ed25519:dev1": ed25519key }, - signatures: { - boris: { - "ed25519:dev1": - "RAhmbNDq1efK3hCpBzZDsKoGSsrHUxb25NW5/WbEV9R" + - "JVwLdP032mg5QsKt/pBDUGtggBcnk43n3nBWlA88WAw", - }, - }, - unsigned: { abc: "def" }, - user_id: "boris", - }, - }; - const chazKeys = { - dev2: { - algorithms: ["2"], - device_id: "dev2", - keys: { "ed25519:dev2": ed25519key }, - signatures: { - chaz: { - "ed25519:dev2": - "FwslH/Q7EYSb7swDJbNB5PSzcbEO1xRRBF1riuijqvL" + - "EkrK9/XVN8jl4h7thGuRITQ01siBQnNmMK9t45QfcCQ", - }, - }, - unsigned: { ghi: "def" }, - user_id: "chaz", - }, - }; - - /* - function sign(o) { - var anotherjson = require('another-json'); - var b = JSON.parse(JSON.stringify(o)); - delete(b.signatures); - delete(b.unsigned); - return client.crypto.olmDevice.sign(anotherjson.stringify(b)); - }; - - logger.log("Ed25519: " + ed25519key); - logger.log("boris:", sign(borisKeys.dev1)); - logger.log("chaz:", sign(chazKeys.dev2)); - */ - - httpBackend - .when("POST", "/keys/query") - .check(function (req) { - expect(req.data).toEqual({ - device_keys: { - boris: [], - chaz: [], - }, - }); - }) - .respond(200, { - device_keys: { - boris: borisKeys, - chaz: chazKeys, - }, - }); - - const prom = client.downloadKeys(["boris", "chaz"]).then(function (res) { - assertObjectContains(res.get("boris")!.get("dev1")!, { - verified: 0, // DeviceVerification.UNVERIFIED - keys: { "ed25519:dev1": ed25519key }, - algorithms: ["1"], - unsigned: { abc: "def" }, - }); - - assertObjectContains(res.get("chaz")!.get("dev2")!, { - verified: 0, // DeviceVerification.UNVERIFIED - keys: { "ed25519:dev2": ed25519key }, - algorithms: ["2"], - unsigned: { ghi: "def" }, - }); - }); - - httpBackend.flush(""); - return prom; - }); - }); - - describe("deleteDevice", function () { - const auth = { identifier: 1 }; - it("should pass through an auth dict", function () { - httpBackend - .when("DELETE", "/_matrix/client/v3/devices/my_device") - .check(function (req) { - expect(req.data).toEqual({ auth: auth }); - }) - .respond(200); - - const prom = client.deleteDevice("my_device", auth); - - httpBackend.flush(""); - return prom; - }); - }); - describe("partitionThreadedEvents", function () { let room: Room; beforeEach(() => { @@ -1628,49 +1506,6 @@ describe("MatrixClient", function () { }); }); - describe("uploadKeys", () => { - // uploadKeys() is a no-op nowadays, so there's not much to test here. - it("should complete successfully", async () => { - await client.uploadKeys(); - }); - }); - - describe("getCryptoTrustCrossSignedDevices", () => { - it("should throw if e2e is disabled", () => { - expect(() => client.getCryptoTrustCrossSignedDevices()).toThrow("End-to-end encryption disabled"); - }); - - it("should proxy to the crypto backend", async () => { - const mockBackend = { - getTrustCrossSignedDevices: jest.fn().mockReturnValue(true), - } as unknown as Mocked; - client["cryptoBackend"] = mockBackend; - - expect(client.getCryptoTrustCrossSignedDevices()).toBe(true); - mockBackend.getTrustCrossSignedDevices.mockReturnValue(false); - expect(client.getCryptoTrustCrossSignedDevices()).toBe(false); - }); - }); - - describe("setCryptoTrustCrossSignedDevices", () => { - it("should throw if e2e is disabled", () => { - expect(() => client.setCryptoTrustCrossSignedDevices(false)).toThrow("End-to-end encryption disabled"); - }); - - it("should proxy to the crypto backend", async () => { - const mockBackend = { - setTrustCrossSignedDevices: jest.fn(), - } as unknown as Mocked; - client["cryptoBackend"] = mockBackend; - - client.setCryptoTrustCrossSignedDevices(true); - expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(true); - - client.setCryptoTrustCrossSignedDevices(false); - expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(false); - }); - }); - describe("setSyncPresence", () => { it("should pass calls through to the underlying sync api", () => { const setPresence = jest.fn(); @@ -2197,11 +2032,3 @@ const buildEventCreate = () => type: "m.room.create", unsigned: { age: 80126105 }, }); - -function assertObjectContains(obj: Record, expected: any): void { - for (const k in expected) { - if (expected.hasOwnProperty(k)) { - expect(obj[k]).toEqual(expected[k]); - } - } -} diff --git a/spec/integ/matrix-client-syncing.spec.ts b/spec/integ/matrix-client-syncing.spec.ts index 78738b46a..816472aa9 100644 --- a/spec/integ/matrix-client-syncing.spec.ts +++ b/spec/integ/matrix-client-syncing.spec.ts @@ -26,7 +26,6 @@ import { UNSTABLE_MSC2716_MARKER, type MatrixClient, ClientEvent, - IndexedDBCryptoStore, type ISyncResponse, type IRoomEvent, type IJoinedRoom, @@ -2570,9 +2569,8 @@ describe("MatrixClient syncing (IndexedDB version)", () => { }; it("should emit ClientEvent.Room when invited while using indexeddb crypto store", async () => { - const idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, { - cryptoStore: new IndexedDBCryptoStore(globalThis.indexedDB, "tests"), - }); + // rust crypto uses by default indexeddb + const idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken); const idbHttpBackend = idbTestClient.httpBackend; const idbClient = idbTestClient.client; idbHttpBackend.when("GET", "/versions").respond(200, {}); diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index a60107d11..de45eeef8 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -558,18 +558,6 @@ export const mkPusher = (extra: Partial = {}): IPusher => ({ ...extra, }); -/** - * a list of the supported crypto implementations, each with a callback to initialise that implementation - * for the given client - */ -export const CRYPTO_BACKENDS: Record = {}; -export type InitCrypto = (_: MatrixClient) => Promise; - -CRYPTO_BACKENDS["rust-sdk"] = (client: MatrixClient) => client.initRustCrypto(); -if (globalThis.Olm) { - CRYPTO_BACKENDS["libolm"] = (client: MatrixClient) => client.initLegacyCrypto(); -} - export const emitPromise = (e: EventEmitter, k: string): Promise => new Promise((r) => e.once(k, r)); /** diff --git a/spec/unit/crypto.spec.ts b/spec/unit/crypto.spec.ts deleted file mode 100644 index 15abab550..000000000 --- a/spec/unit/crypto.spec.ts +++ /dev/null @@ -1,1467 +0,0 @@ -import "../olm-loader"; -// eslint-disable-next-line no-restricted-imports -import { EventEmitter } from "events"; - -import type { PkDecryption, PkSigning } from "@matrix-org/olm"; -import { type IClaimOTKsResult, type MatrixClient } from "../../src/client"; -import { Crypto } from "../../src/crypto"; -import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store"; -import { MockStorageApi } from "../MockStorageApi"; -import { TestClient } from "../TestClient"; -import { MatrixEvent } from "../../src/models/event"; -import { Room } from "../../src/models/room"; -import * as olmlib from "../../src/crypto/olmlib"; -import { sleep } from "../../src/utils"; -import { CRYPTO_ENABLED } from "../../src/client"; -import { DeviceInfo } from "../../src/crypto/deviceinfo"; -import { logger } from "../../src/logger"; -import { DeviceVerification, MemoryStore } from "../../src"; -import { RoomKeyRequestState } from "../../src/crypto/OutgoingRoomKeyRequestManager"; -import { RoomMember } from "../../src/models/room-member"; -import { type IStore } from "../../src/store"; -import { type IRoomEncryption, type RoomList } from "../../src/crypto/RoomList"; -import { EventShieldColour, EventShieldReason } from "../../src/crypto-api"; -import { UserTrustLevel } from "../../src/crypto/CrossSigning"; -import { type CryptoBackend } from "../../src/common-crypto/CryptoBackend"; -import { type EventDecryptionResult } from "../../src/common-crypto/CryptoBackend"; -import * as testData from "../test-utils/test-data"; -import { KnownMembership } from "../../src/@types/membership"; -import type { DeviceInfoMap } from "../../src/crypto/DeviceList"; - -const Olm = globalThis.Olm; - -function awaitEvent(emitter: EventEmitter, event: string): Promise { - return new Promise((resolve) => { - emitter.once(event, (result) => { - resolve(result); - }); - }); -} - -async function keyshareEventForEvent(client: MatrixClient, event: MatrixEvent, index?: number): Promise { - const roomId = event.getRoomId()!; - const eventContent = event.getWireContent(); - const key = await client.crypto!.olmDevice.getInboundGroupSessionKey( - roomId, - eventContent.sender_key, - eventContent.session_id, - index, - ); - const ksEvent = new MatrixEvent({ - type: "m.forwarded_room_key", - sender: client.getUserId()!, - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": roomId, - "sender_key": eventContent.sender_key, - "sender_claimed_ed25519_key": key?.sender_claimed_ed25519_key, - "session_id": eventContent.session_id, - "session_key": key?.key, - "chain_index": key?.chain_index, - "forwarding_curve25519_key_chain": key?.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": true, - }, - }); - // make onRoomKeyEvent think this was an encrypted event - // @ts-ignore private property - ksEvent.senderCurve25519Key = "akey"; - ksEvent.getWireType = () => "m.room.encrypted"; - ksEvent.getWireContent = () => { - return { - algorithm: "m.olm.v1.curve25519-aes-sha2", - }; - }; - return ksEvent; -} - -function roomKeyEventForEvent(client: MatrixClient, event: MatrixEvent): MatrixEvent { - const roomId = event.getRoomId(); - const eventContent = event.getWireContent(); - const key = client.crypto!.olmDevice.getOutboundGroupSessionKey(eventContent.session_id); - const ksEvent = new MatrixEvent({ - type: "m.room_key", - sender: client.getUserId()!, - content: { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: roomId, - session_id: eventContent.session_id, - session_key: key.key, - }, - }); - // make onRoomKeyEvent think this was an encrypted event - // @ts-ignore private property - ksEvent.senderCurve25519Key = event.getSenderKey(); - ksEvent.getWireType = () => "m.room.encrypted"; - ksEvent.getWireContent = () => { - return { - algorithm: "m.olm.v1.curve25519-aes-sha2", - }; - }; - return ksEvent; -} - -describe("Crypto", function () { - if (!CRYPTO_ENABLED) { - return; - } - - beforeAll(function () { - return Olm.init(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it("Crypto exposes the correct olm library version", function () { - expect(Crypto.getOlmVersion()[0]).toEqual(3); - }); - - it("getVersion() should return the current version of the olm library", async () => { - const client = new TestClient("@alice:example.com", "deviceid").client; - await client.initLegacyCrypto(); - - const olmVersionTuple = Crypto.getOlmVersion(); - expect(client.getCrypto()?.getVersion()).toBe( - `Olm ${olmVersionTuple[0]}.${olmVersionTuple[1]}.${olmVersionTuple[2]}`, - ); - }); - - describe("encrypted events", function () { - it("provides encryption information for events from unverified senders", async function () { - const client = new TestClient("@alice:example.com", "deviceid").client; - await client.initLegacyCrypto(); - - // unencrypted event - const event = { - getId: () => "$event_id", - getSender: () => "@bob:example.com", - getSenderKey: () => null, - getWireContent: () => { - return {}; - }, - } as unknown as MatrixEvent; - - let encryptionInfo = client.getEventEncryptionInfo(event); - expect(encryptionInfo.encrypted).toBeFalsy(); - - expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toBe(null); - - // unknown sender (e.g. deleted device), forwarded megolm key (untrusted) - event.getSenderKey = () => "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI"; - event.getWireContent = () => { - return { algorithm: olmlib.MEGOLM_ALGORITHM }; - }; - event.getForwardingCurve25519KeyChain = () => ["not empty"]; - event.isKeySourceUntrusted = () => true; - event.getClaimedEd25519Key = () => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - - encryptionInfo = client.getEventEncryptionInfo(event); - expect(encryptionInfo.encrypted).toBeTruthy(); - expect(encryptionInfo.authenticated).toBeFalsy(); - expect(encryptionInfo.sender).toBeFalsy(); - - expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ - shieldColour: EventShieldColour.GREY, - shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, - }); - - // known sender, megolm key from backup - event.getForwardingCurve25519KeyChain = () => []; - event.isKeySourceUntrusted = () => true; - const device = new DeviceInfo("FLIBBLE"); - device.keys["curve25519:FLIBBLE"] = "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI"; - device.keys["ed25519:FLIBBLE"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - client.crypto!.deviceList.getDeviceByIdentityKey = () => device; - - encryptionInfo = client.getEventEncryptionInfo(event); - expect(encryptionInfo.encrypted).toBeTruthy(); - expect(encryptionInfo.authenticated).toBeFalsy(); - expect(encryptionInfo.sender).toBeTruthy(); - expect(encryptionInfo.mismatchedSender).toBeFalsy(); - - expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ - shieldColour: EventShieldColour.GREY, - shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, - }); - - // known sender, trusted megolm key, but bad ed25519key - event.isKeySourceUntrusted = () => false; - device.keys["ed25519:FLIBBLE"] = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; - - encryptionInfo = client.getEventEncryptionInfo(event); - expect(encryptionInfo.encrypted).toBeTruthy(); - expect(encryptionInfo.authenticated).toBeTruthy(); - expect(encryptionInfo.sender).toBeTruthy(); - expect(encryptionInfo.mismatchedSender).toBeTruthy(); - - expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ - shieldColour: EventShieldColour.RED, - shieldReason: EventShieldReason.MISMATCHED_SENDER_KEY, - }); - - client.stopClient(); - }); - - describe("provides encryption information for events from verified senders", function () { - const testDeviceId = testData.BOB_TEST_DEVICE_ID; - const testDevice = testData.BOB_SIGNED_TEST_DEVICE_DATA; - - let client: MatrixClient; - beforeEach(async () => { - client = new TestClient("@alice:example.com", "deviceid").client; - await client.initLegacyCrypto(); - - // mock out the verification check - client.crypto!.checkUserTrust = (userId) => new UserTrustLevel(true, false, false); - }); - - afterEach(() => { - client.stopClient(); - }); - - async function buildEncryptedEvent( - decryptionResult: Partial = {}, - ): Promise { - const mockCryptoBackend = { - decryptEvent: async (event: MatrixEvent): Promise => { - return { - claimedEd25519Key: testDevice.keys["ed25519:" + testDeviceId], - clearEvent: { - room_id: "!room_id", - type: "m.room.message", - content: { body: "test" }, - }, - forwardingCurve25519KeyChain: [], - senderCurve25519Key: testDevice.keys["curve25519:" + testDeviceId], - ...decryptionResult, - }; - }, - } as unknown as CryptoBackend; - - const event = new MatrixEvent({ - event_id: "$event_id", - sender: testData.BOB_TEST_USER_ID, - type: "m.room.encrypted", - content: { algorithm: "m.megolm.v1.aes-sha2" }, - }); - await event.attemptDecryption(mockCryptoBackend); - return event; - } - - it("unknown device", async () => { - const event = await buildEncryptedEvent(); - expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ - shieldColour: EventShieldColour.GREY, - shieldReason: EventShieldReason.UNKNOWN_DEVICE, - }); - }); - - it("known but unsigned device", async () => { - client.crypto!.deviceList.storeDevicesForUser(testData.BOB_TEST_USER_ID, { - [testDeviceId]: { - keys: testDevice.keys, - algorithms: testDevice.algorithms, - verified: DeviceVerification.Unverified, - known: true, - }, - }); - - const event = await buildEncryptedEvent(); - expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ - shieldColour: EventShieldColour.RED, - shieldReason: EventShieldReason.UNSIGNED_DEVICE, - }); - }); - - describe("known and verified device", () => { - beforeEach(() => { - client.crypto!.deviceList.storeDevicesForUser(testData.BOB_TEST_USER_ID, { - [testDeviceId]: { - keys: testDevice.keys, - algorithms: testDevice.algorithms, - verified: DeviceVerification.Verified, - known: true, - }, - }); - }); - - it("regular key", async () => { - const event = await buildEncryptedEvent(); - expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ - shieldColour: EventShieldColour.NONE, - shieldReason: null, - }); - }); - - it("unauthenticated key", async () => { - const event = await buildEncryptedEvent({ untrusted: true }); - expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ - shieldColour: EventShieldColour.GREY, - shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, - }); - }); - }); - }); - - it("doesn't throw an error when attempting to decrypt a redacted event", async () => { - const client = new TestClient("@alice:example.com", "deviceid").client; - await client.initLegacyCrypto(); - - const event = new MatrixEvent({ - content: {}, - event_id: "$event_id", - room_id: "!room_id", - sender: "@bob:example.com", - type: "m.room.encrypted", - unsigned: { - redacted_because: { - content: {}, - event_id: "$redaction_event_id", - redacts: "$event_id", - room_id: "!room_id", - origin_server_ts: 1234567890, - sender: "@bob:example.com", - type: "m.room.redaction", - unsigned: {}, - }, - }, - }); - await event.attemptDecryption(client.crypto!); - expect(event.isDecryptionFailure()).toBeFalsy(); - // since the redaction event isn't encrypted, the redacted_because - // should be the same as in the original event - expect(event.getRedactionEvent()).toEqual(event.getUnsigned().redacted_because); - - client.stopClient(); - }); - }); - - describe("Session management", function () { - const otkResponse: IClaimOTKsResult = { - failures: {}, - one_time_keys: { - "@alice:home.server": { - aliceDevice: { - "signed_curve25519:FLIBBLE": { - key: "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI", - signatures: { - "@alice:home.server": { - "ed25519:aliceDevice": "totally a valid signature", - }, - }, - }, - }, - }, - }, - }; - - let crypto: Crypto; - let mockBaseApis: MatrixClient; - - let fakeEmitter: EventEmitter; - - beforeEach(async function () { - const mockStorage = new MockStorageApi() as unknown as Storage; - const clientStore = new MemoryStore({ localStorage: mockStorage }) as unknown as IStore; - const cryptoStore = new MemoryCryptoStore(); - - cryptoStore.storeEndToEndDeviceData( - { - devices: { - "@bob:home.server": { - BOBDEVICE: { - algorithms: [], - verified: 1, - known: false, - keys: { - "curve25519:BOBDEVICE": "this is a key", - }, - }, - }, - }, - trackingStatus: {}, - }, - {}, - ); - - mockBaseApis = { - sendToDevice: jest.fn(), - getKeyBackupVersion: jest.fn(), - isGuest: jest.fn(), - emit: jest.fn(), - } as unknown as MatrixClient; - - fakeEmitter = new EventEmitter(); - - crypto = new Crypto(mockBaseApis, "@alice:home.server", "FLIBBLE", clientStore, cryptoStore, []); - crypto.registerEventHandlers(fakeEmitter as any); - await crypto.init(); - }); - - afterEach(async function () { - await crypto.stop(); - }); - - it("restarts wedged Olm sessions", async function () { - const prom = new Promise((resolve) => { - mockBaseApis.claimOneTimeKeys = function () { - resolve(); - return Promise.resolve(otkResponse); - }; - }); - - fakeEmitter.emit("toDeviceEvent", { - getId: jest.fn().mockReturnValue("$wedged"), - getType: jest.fn().mockReturnValue("m.room.message"), - getContent: jest.fn().mockReturnValue({ - msgtype: "m.bad.encrypted", - }), - getWireContent: jest.fn().mockReturnValue({ - algorithm: "m.olm.v1.curve25519-aes-sha2", - sender_key: "this is a key", - }), - getSender: jest.fn().mockReturnValue("@bob:home.server"), - }); - - await prom; - }); - }); - - describe("Key requests", function () { - let aliceClient: MatrixClient; - let secondAliceClient: MatrixClient; - let bobClient: MatrixClient; - let claraClient: MatrixClient; - - beforeEach(async function () { - aliceClient = new TestClient("@alice:example.com", "alicedevice").client; - secondAliceClient = new TestClient("@alice:example.com", "secondAliceDevice").client; - bobClient = new TestClient("@bob:example.com", "bobdevice").client; - claraClient = new TestClient("@clara:example.com", "claradevice").client; - await aliceClient.initLegacyCrypto(); - await secondAliceClient.initLegacyCrypto(); - await bobClient.initLegacyCrypto(); - await claraClient.initLegacyCrypto(); - }); - - afterEach(async function () { - aliceClient.stopClient(); - secondAliceClient.stopClient(); - bobClient.stopClient(); - claraClient.stopClient(); - }); - - it("does not cancel keyshare requests until all messages are decrypted with trusted keys", async function () { - const encryptionCfg = { - algorithm: "m.megolm.v1.aes-sha2", - }; - const roomId = "!someroom"; - const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); - const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); - // Make Bob invited by Alice so Bob will accept Alice's forwarded keys - bobRoom.currentState.setStateEvents([ - new MatrixEvent({ - type: "m.room.member", - sender: "@alice:example.com", - room_id: roomId, - content: { membership: KnownMembership.Invite }, - state_key: "@bob:example.com", - }), - ]); - aliceClient.store.storeRoom(aliceRoom); - bobClient.store.storeRoom(bobRoom); - await aliceClient.setRoomEncryption(roomId, encryptionCfg); - await bobClient.setRoomEncryption(roomId, encryptionCfg); - const events = [ - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$1", - content: { - msgtype: "m.text", - body: "1", - }, - }), - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$2", - content: { - msgtype: "m.text", - body: "2", - }, - }), - ]; - await Promise.all( - events.map(async (event) => { - // alice encrypts each event, and then bob tries to decrypt - // them without any keys, so that they'll be in pending - await aliceClient.crypto!.encryptEvent(event, aliceRoom); - // remove keys from the event - // @ts-ignore private properties - event.clearEvent = undefined; - // @ts-ignore private properties - event.senderCurve25519Key = null; - // @ts-ignore private properties - event.claimedEd25519Key = null; - await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy(); - }), - ); - - const device = new DeviceInfo(aliceClient.deviceId!); - bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; - - const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); - - const decryptEventsPromise = Promise.all( - events.map((ev) => { - return awaitEvent(ev, "Event.decrypted"); - }), - ); - - // keyshare the session key starting at the second message, so - // the first message can't be decrypted yet, but the second one - // can - let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1); - bobClient.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map()); - bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com"; - await bobDecryptor.onRoomKeyEvent(ksEvent); - await decryptEventsPromise; - expect(events[0].getContent().msgtype).toBe("m.bad.encrypted"); - expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); - - const cryptoStore = bobClient.crypto!.cryptoStore; - const eventContent = events[0].getWireContent(); - const senderKey = eventContent.sender_key; - const sessionId = eventContent.session_id; - const roomKeyRequestBody = { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: roomId, - sender_key: senderKey, - session_id: sessionId, - }; - // the room key request should still be there, since we haven't - // decrypted everything - expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined(); - - // keyshare the session key starting at the first message, so - // that it can now be decrypted - const decryptEventPromise = awaitEvent(events[0], "Event.decrypted"); - ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); - await bobDecryptor.onRoomKeyEvent(ksEvent); - await decryptEventPromise; - expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); - expect(events[0].isKeySourceUntrusted()).toBeTruthy(); - await sleep(1); - // the room key request should still be there, since we've - // decrypted everything with an untrusted key - expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined(); - - // Now share a trusted room key event so Bob will re-decrypt the messages. - // Bob will backfill trust when they receive a trusted session with a higher - // index that connects to an untrusted session with a lower index. - const roomKeyEvent = roomKeyEventForEvent(aliceClient, events[1]); - const trustedDecryptEventPromise = awaitEvent(events[0], "Event.decrypted"); - await bobDecryptor.onRoomKeyEvent(roomKeyEvent); - await trustedDecryptEventPromise; - expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); - expect(events[0].isKeySourceUntrusted()).toBeFalsy(); - await sleep(1); - // now the room key request should be gone, since there's - // no better key to wait for - expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeFalsy(); - }); - - it("should error if a forwarded room key lacks a content.sender_key", async function () { - const encryptionCfg = { - algorithm: "m.megolm.v1.aes-sha2", - }; - const roomId = "!someroom"; - const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); - const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); - aliceClient.store.storeRoom(aliceRoom); - bobClient.store.storeRoom(bobRoom); - await aliceClient.setRoomEncryption(roomId, encryptionCfg); - await bobClient.setRoomEncryption(roomId, encryptionCfg); - const event = new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$1", - content: { - msgtype: "m.text", - body: "1", - }, - }); - // alice encrypts each event, and then bob tries to decrypt - // them without any keys, so that they'll be in pending - await aliceClient.crypto!.encryptEvent(event, aliceRoom); - // remove keys from the event - // @ts-ignore private property - event.clearEvent = undefined; - // @ts-ignore private property - event.senderCurve25519Key = null; - // @ts-ignore private property - event.claimedEd25519Key = null; - try { - await bobClient.crypto!.decryptEvent(event); - } catch { - // we expect this to fail because we don't have the - // decryption keys yet - } - - const device = new DeviceInfo(aliceClient.deviceId!); - bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; - - const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); - - const ksEvent = await keyshareEventForEvent(aliceClient, event, 1); - ksEvent.getContent().sender_key = undefined; // test - bobClient.crypto!.olmDevice.addInboundGroupSession = jest.fn(); - await bobDecryptor.onRoomKeyEvent(ksEvent); - expect(bobClient.crypto!.olmDevice.addInboundGroupSession).not.toHaveBeenCalled(); - }); - - it("creates a new keyshare request if we request a keyshare", async function () { - // make sure that cancelAndResend... creates a new keyshare request - // if there wasn't an already-existing one - const event = new MatrixEvent({ - sender: "@bob:example.com", - room_id: "!someroom", - content: { - algorithm: olmlib.MEGOLM_ALGORITHM, - session_id: "sessionid", - sender_key: "senderkey", - }, - }); - await aliceClient.cancelAndResendEventRoomKeyRequest(event); - const cryptoStore = aliceClient.crypto!.cryptoStore; - const roomKeyRequestBody = { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: "!someroom", - session_id: "sessionid", - sender_key: "senderkey", - }; - expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined(); - }); - - it("uses a new txnid for re-requesting keys", async function () { - jest.useFakeTimers(); - - const event = new MatrixEvent({ - sender: "@bob:example.com", - room_id: "!someroom", - content: { - algorithm: olmlib.MEGOLM_ALGORITHM, - session_id: "sessionid", - sender_key: "senderkey", - }, - }); - // replace Alice's sendToDevice function with a mock - const aliceSendToDevice = jest.fn().mockResolvedValue(undefined); - aliceClient.sendToDevice = aliceSendToDevice; - aliceClient.startClient(); - - // make a room key request, and record the transaction ID for the - // sendToDevice call - await aliceClient.cancelAndResendEventRoomKeyRequest(event); - // key requests get queued until the sync has finished, but we don't - // let the client set up enough for that to happen, so gut-wrench a bit - // to force it to send now. - // @ts-ignore - aliceClient.crypto!.outgoingRoomKeyRequestManager.sendQueuedRequests(); - jest.runAllTimers(); - await Promise.resolve(); - expect(aliceSendToDevice).toHaveBeenCalledTimes(1); - const txnId = aliceSendToDevice.mock.calls[0][2]; - - // give the room key request manager time to update the state - // of the request - await Promise.resolve(); - - // cancel and resend the room key request - await aliceClient.cancelAndResendEventRoomKeyRequest(event); - jest.runAllTimers(); - await Promise.resolve(); - // cancelAndResend will call sendToDevice twice: - // the first call to sendToDevice will be the cancellation - // the second call to sendToDevice will be the key request - expect(aliceSendToDevice).toHaveBeenCalledTimes(3); - expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId); - }); - - it("should accept forwarded keys it requested from one of its own user's other devices", async function () { - const encryptionCfg = { - algorithm: "m.megolm.v1.aes-sha2", - }; - const roomId = "!someroom"; - const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); - const bobRoom = new Room(roomId, secondAliceClient, "@alice:example.com", {}); - aliceClient.store.storeRoom(aliceRoom); - secondAliceClient.store.storeRoom(bobRoom); - await aliceClient.setRoomEncryption(roomId, encryptionCfg); - await secondAliceClient.setRoomEncryption(roomId, encryptionCfg); - const events = [ - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$1", - content: { - msgtype: "m.text", - body: "1", - }, - }), - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$2", - content: { - msgtype: "m.text", - body: "2", - }, - }), - ]; - await Promise.all( - events.map(async (event) => { - // alice encrypts each event, and then bob tries to decrypt - // them without any keys, so that they'll be in pending - await aliceClient.crypto!.encryptEvent(event, aliceRoom); - // remove keys from the event - // @ts-ignore private properties - event.clearEvent = undefined; - // @ts-ignore private properties - event.senderCurve25519Key = null; - // @ts-ignore private properties - event.claimedEd25519Key = null; - await expect(secondAliceClient.crypto!.decryptEvent(event)).rejects.toBeTruthy(); - }), - ); - - const device = new DeviceInfo(aliceClient.deviceId!); - device.verified = DeviceInfo.DeviceVerification.VERIFIED; - secondAliceClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; - secondAliceClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com"; - - const cryptoStore = secondAliceClient.crypto!.cryptoStore; - const eventContent = events[0].getWireContent(); - const senderKey = eventContent.sender_key; - const sessionId = eventContent.session_id; - const roomKeyRequestBody = { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: roomId, - sender_key: senderKey, - session_id: sessionId, - }; - const outgoingReq = await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody); - expect(outgoingReq).toBeDefined(); - await cryptoStore.updateOutgoingRoomKeyRequest(outgoingReq!.requestId, RoomKeyRequestState.Unsent, { - state: RoomKeyRequestState.Sent, - }); - - const bobDecryptor = secondAliceClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); - - const decryptEventsPromise = Promise.all( - events.map((ev) => { - return awaitEvent(ev, "Event.decrypted"); - }), - ); - const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); - await bobDecryptor.onRoomKeyEvent(ksEvent); - const key = await secondAliceClient.crypto!.olmDevice.getInboundGroupSessionKey( - roomId, - events[0].getWireContent().sender_key, - events[0].getWireContent().session_id, - ); - expect(key).not.toBeNull(); - await decryptEventsPromise; - expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); - expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); - }); - - it("should accept forwarded keys from the user who invited it to the room", async function () { - const encryptionCfg = { - algorithm: "m.megolm.v1.aes-sha2", - }; - const roomId = "!someroom"; - const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); - const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); - const claraRoom = new Room(roomId, claraClient, "@clara:example.com", {}); - // Make Bob invited by Clara - bobRoom.currentState.setStateEvents([ - new MatrixEvent({ - type: "m.room.member", - sender: "@clara:example.com", - room_id: roomId, - content: { membership: KnownMembership.Invite }, - state_key: "@bob:example.com", - }), - ]); - aliceClient.store.storeRoom(aliceRoom); - bobClient.store.storeRoom(bobRoom); - claraClient.store.storeRoom(claraRoom); - await aliceClient.setRoomEncryption(roomId, encryptionCfg); - await bobClient.setRoomEncryption(roomId, encryptionCfg); - await claraClient.setRoomEncryption(roomId, encryptionCfg); - const events = [ - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$1", - content: { - msgtype: "m.text", - body: "1", - }, - }), - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$2", - content: { - msgtype: "m.text", - body: "2", - }, - }), - ]; - await Promise.all( - events.map(async (event) => { - // alice encrypts each event, and then bob tries to decrypt - // them without any keys, so that they'll be in pending - await aliceClient.crypto!.encryptEvent(event, aliceRoom); - // remove keys from the event - // @ts-ignore private properties - event.clearEvent = undefined; - // @ts-ignore private properties - event.senderCurve25519Key = null; - // @ts-ignore private properties - event.claimedEd25519Key = null; - await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy(); - }), - ); - - const device = new DeviceInfo(claraClient.deviceId!); - bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; - bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@clara:example.com"; - - const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); - - const decryptEventsPromise = Promise.all( - events.map((ev) => { - return awaitEvent(ev, "Event.decrypted"); - }), - ); - const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); - ksEvent.event.sender = claraClient.getUserId()!; - ksEvent.sender = new RoomMember(roomId, claraClient.getUserId()!); - await bobDecryptor.onRoomKeyEvent(ksEvent); - const key = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey( - roomId, - events[0].getWireContent().sender_key, - events[0].getWireContent().session_id, - ); - expect(key).not.toBeNull(); - await decryptEventsPromise; - expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); - expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); - }); - - it("should not accept requested forwarded keys from other users", async function () { - const encryptionCfg = { - algorithm: "m.megolm.v1.aes-sha2", - }; - const roomId = "!someroom"; - const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); - const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); - aliceClient.store.storeRoom(aliceRoom); - bobClient.store.storeRoom(bobRoom); - await aliceClient.setRoomEncryption(roomId, encryptionCfg); - await bobClient.setRoomEncryption(roomId, encryptionCfg); - const events = [ - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$1", - content: { - msgtype: "m.text", - body: "1", - }, - }), - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$2", - content: { - msgtype: "m.text", - body: "2", - }, - }), - ]; - await Promise.all( - events.map(async (event) => { - // alice encrypts each event, and then bob tries to decrypt - // them without any keys, so that they'll be in pending - await aliceClient.crypto!.encryptEvent(event, aliceRoom); - // remove keys from the event - // @ts-ignore private properties - event.clearEvent = undefined; - // @ts-ignore private properties - event.senderCurve25519Key = null; - // @ts-ignore private properties - event.claimedEd25519Key = null; - await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy(); - }), - ); - - const cryptoStore = bobClient.crypto!.cryptoStore; - const eventContent = events[0].getWireContent(); - const senderKey = eventContent.sender_key; - const sessionId = eventContent.session_id; - const roomKeyRequestBody = { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: roomId, - sender_key: senderKey, - session_id: sessionId, - }; - const outgoingReq = await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody); - expect(outgoingReq).toBeDefined(); - await cryptoStore.updateOutgoingRoomKeyRequest(outgoingReq!.requestId, RoomKeyRequestState.Unsent, { - state: RoomKeyRequestState.Sent, - }); - - const device = new DeviceInfo(aliceClient.deviceId!); - device.verified = DeviceInfo.DeviceVerification.VERIFIED; - bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; - bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com"; - - const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); - - const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); - ksEvent.event.sender = aliceClient.getUserId()!; - ksEvent.sender = new RoomMember(roomId, aliceClient.getUserId()!); - await bobDecryptor.onRoomKeyEvent(ksEvent); - const key = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey( - roomId, - events[0].getWireContent().sender_key, - events[0].getWireContent().session_id, - ); - expect(key).toBeNull(); - }); - - it("should not accept unexpected forwarded keys for a room it's in", async function () { - const encryptionCfg = { - algorithm: "m.megolm.v1.aes-sha2", - }; - const roomId = "!someroom"; - const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); - const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); - const claraRoom = new Room(roomId, claraClient, "@clara:example.com", {}); - aliceClient.store.storeRoom(aliceRoom); - bobClient.store.storeRoom(bobRoom); - claraClient.store.storeRoom(claraRoom); - await aliceClient.setRoomEncryption(roomId, encryptionCfg); - await bobClient.setRoomEncryption(roomId, encryptionCfg); - await claraClient.setRoomEncryption(roomId, encryptionCfg); - const events = [ - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$1", - content: { - msgtype: "m.text", - body: "1", - }, - }), - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$2", - content: { - msgtype: "m.text", - body: "2", - }, - }), - ]; - await Promise.all( - events.map(async (event) => { - // alice encrypts each event, and then bob tries to decrypt - // them without any keys, so that they'll be in pending - await aliceClient.crypto!.encryptEvent(event, aliceRoom); - // remove keys from the event - // @ts-ignore private properties - event.clearEvent = undefined; - // @ts-ignore private properties - event.senderCurve25519Key = null; - // @ts-ignore private properties - event.claimedEd25519Key = null; - await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy(); - }), - ); - - const device = new DeviceInfo(claraClient.deviceId!); - bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; - bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com"; - - const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); - - const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); - ksEvent.event.sender = claraClient.getUserId()!; - ksEvent.sender = new RoomMember(roomId, claraClient.getUserId()!); - await bobDecryptor.onRoomKeyEvent(ksEvent); - const key = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey( - roomId, - events[0].getWireContent().sender_key, - events[0].getWireContent().session_id, - ); - expect(key).toBeNull(); - }); - - it("should park forwarded keys for a room it's not in", async function () { - const encryptionCfg = { - algorithm: "m.megolm.v1.aes-sha2", - }; - const roomId = "!someroom"; - const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); - aliceClient.store.storeRoom(aliceRoom); - await aliceClient.setRoomEncryption(roomId, encryptionCfg); - const events = [ - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$1", - content: { - msgtype: "m.text", - body: "1", - }, - }), - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$2", - content: { - msgtype: "m.text", - body: "2", - }, - }), - ]; - await Promise.all( - events.map(async (event) => { - // alice encrypts each event, and then bob tries to decrypt - // them without any keys, so that they'll be in pending - await aliceClient.crypto!.encryptEvent(event, aliceRoom); - // remove keys from the event - // @ts-ignore private properties - event.clearEvent = undefined; - // @ts-ignore private properties - event.senderCurve25519Key = null; - // @ts-ignore private properties - event.claimedEd25519Key = null; - }), - ); - - const device = new DeviceInfo(aliceClient.deviceId!); - bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; - bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com"; - - const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); - - const content = events[0].getWireContent(); - - const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); - await bobDecryptor.onRoomKeyEvent(ksEvent); - const bobKey = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey( - roomId, - content.sender_key, - content.session_id, - ); - expect(bobKey).toBeNull(); - - const aliceKey = await aliceClient.crypto!.olmDevice.getInboundGroupSessionKey( - roomId, - content.sender_key, - content.session_id, - ); - const parked = await bobClient.crypto!.cryptoStore.takeParkedSharedHistory(roomId); - expect(parked).toEqual([ - { - senderId: aliceClient.getUserId(), - senderKey: content.sender_key, - sessionId: content.session_id, - sessionKey: aliceKey!.key, - keysClaimed: { ed25519: aliceKey!.sender_claimed_ed25519_key }, - forwardingCurve25519KeyChain: ["akey"], - }, - ]); - }); - }); - - describe("Secret storage", function () { - it("creates secret storage even if there is no keyInfo", async function () { - jest.spyOn(logger, "debug").mockImplementation(() => {}); - jest.setTimeout(10000); - const client = new TestClient("@a:example.com", "dev").client; - await client.initLegacyCrypto(); - client.crypto!.isCrossSigningReady = async () => false; - client.crypto!.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null); - client.crypto!.baseApis.setAccountData = jest.fn().mockResolvedValue(null); - client.crypto!.baseApis.uploadKeySignatures = jest.fn(); - client.crypto!.baseApis.http.authedRequest = jest.fn(); - const createSecretStorageKey = async () => { - return { - keyInfo: undefined, // Returning undefined here used to cause a crash - privateKey: Uint8Array.of(32, 33), - }; - }; - await client.crypto!.bootstrapSecretStorage({ - createSecretStorageKey, - }); - client.stopClient(); - }); - }); - - describe("encryptAndSendToDevices", () => { - let client: TestClient; - let ensureOlmSessionsForDevices: jest.SpiedFunction; - let encryptMessageForDevice: jest.SpiedFunction; - const payload = { hello: "world" }; - let encryptedPayload: object; - - beforeEach(async () => { - ensureOlmSessionsForDevices = jest.spyOn(olmlib, "ensureOlmSessionsForDevices"); - ensureOlmSessionsForDevices.mockResolvedValue(new Map()); - encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice"); - encryptMessageForDevice.mockImplementation(async (...[result, , , , , , payload]) => { - result.plaintext = { type: 0, body: JSON.stringify(payload) }; - }); - - client = new TestClient("@alice:example.org", "aliceweb"); - - // running initLegacyCrypto should trigger a key upload - client.httpBackend.when("POST", "/keys/upload").respond(200, {}); - await Promise.all([client.client.initLegacyCrypto(), client.httpBackend.flush("/keys/upload", 1)]); - - encryptedPayload = { - algorithm: "m.olm.v1.curve25519-aes-sha2", - sender_key: client.client.crypto!.olmDevice.deviceCurve25519Key, - ciphertext: { plaintext: { type: 0, body: JSON.stringify(payload) } }, - }; - }); - - afterEach(async () => { - ensureOlmSessionsForDevices.mockRestore(); - encryptMessageForDevice.mockRestore(); - await client.stop(); - }); - - it("encrypts and sends to devices", async () => { - client.httpBackend - .when("PUT", "/sendToDevice/m.room.encrypted") - .check((request) => { - const data = request.data; - delete data.messages["@bob:example.org"]["bobweb"]["org.matrix.msgid"]; - delete data.messages["@bob:example.org"]["bobmobile"]["org.matrix.msgid"]; - delete data.messages["@carol:example.org"]["caroldesktop"]["org.matrix.msgid"]; - expect(data).toStrictEqual({ - messages: { - "@bob:example.org": { - bobweb: encryptedPayload, - bobmobile: encryptedPayload, - }, - "@carol:example.org": { - caroldesktop: encryptedPayload, - }, - }, - }); - }) - .respond(200, {}); - - await Promise.all([ - client.client.encryptAndSendToDevices( - [ - { userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") }, - { userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobmobile") }, - { userId: "@carol:example.org", deviceInfo: new DeviceInfo("caroldesktop") }, - ], - payload, - ), - client.httpBackend.flushAllExpected(), - ]); - }); - - it("sends nothing to devices that couldn't be encrypted to", async () => { - encryptMessageForDevice.mockImplementation(async (...[result, , , , userId, device, payload]) => { - // Refuse to encrypt to Carol's desktop device - if (userId === "@carol:example.org" && device.deviceId === "caroldesktop") return; - result.plaintext = { type: 0, body: JSON.stringify(payload) }; - }); - - client.httpBackend - .when("PUT", "/sendToDevice/m.room.encrypted") - .check((req) => { - const data = req.data; - delete data.messages["@bob:example.org"]["bobweb"]["org.matrix.msgid"]; - // Carol is nowhere to be seen - expect(data).toStrictEqual({ - messages: { "@bob:example.org": { bobweb: encryptedPayload } }, - }); - }) - .respond(200, {}); - - await Promise.all([ - client.client.encryptAndSendToDevices( - [ - { userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") }, - { userId: "@carol:example.org", deviceInfo: new DeviceInfo("caroldesktop") }, - ], - payload, - ), - client.httpBackend.flushAllExpected(), - ]); - }); - - it("no-ops if no devices can be encrypted to", async () => { - // Refuse to encrypt to anybody - encryptMessageForDevice.mockResolvedValue(undefined); - - // Get the room keys version request out of the way - client.httpBackend.when("GET", "/room_keys/version").respond(404, {}); - await client.httpBackend.flush("/room_keys/version", 1); - - await client.client.encryptAndSendToDevices( - [{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") }], - payload, - ); - client.httpBackend.verifyNoOutstandingRequests(); - }); - }); - - describe("encryptToDeviceMessages", () => { - let client: TestClient; - let ensureOlmSessionsForDevices: jest.SpiedFunction; - let encryptMessageForDevice: jest.SpiedFunction; - const payload = { hello: "world" }; - let encryptedPayload: object; - let crypto: Crypto; - - beforeEach(async () => { - ensureOlmSessionsForDevices = jest.spyOn(olmlib, "ensureOlmSessionsForDevices"); - ensureOlmSessionsForDevices.mockResolvedValue(new Map()); - encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice"); - encryptMessageForDevice.mockImplementation(async (...[result, , , , , , payload]) => { - result.plaintext = { type: 0, body: JSON.stringify(payload) }; - }); - - client = new TestClient("@alice:example.org", "aliceweb"); - - // running initLegacyCrypto should trigger a key upload - client.httpBackend.when("POST", "/keys/upload").respond(200, {}); - await Promise.all([client.client.initLegacyCrypto(), client.httpBackend.flush("/keys/upload", 1)]); - - encryptedPayload = { - algorithm: "m.olm.v1.curve25519-aes-sha2", - sender_key: client.client.crypto!.olmDevice.deviceCurve25519Key, - ciphertext: { plaintext: { type: 0, body: JSON.stringify(payload) } }, - }; - - crypto = client.client.getCrypto() as Crypto; - }); - - afterEach(async () => { - ensureOlmSessionsForDevices.mockRestore(); - encryptMessageForDevice.mockRestore(); - await client.stop(); - }); - - it("returns encrypted batch where devices known", async () => { - const deviceInfoMap: DeviceInfoMap = new Map([ - [ - "@bob:example.org", - new Map([ - ["bobweb", new DeviceInfo("bobweb")], - ["bobmobile", new DeviceInfo("bobmobile")], - ]), - ], - ["@carol:example.org", new Map([["caroldesktop", new DeviceInfo("caroldesktop")]])], - ]); - jest.spyOn(crypto.deviceList, "downloadKeys").mockResolvedValue(deviceInfoMap); - // const deviceInfoMap = await this.downloadKeys(Array.from(userIds), false); - - const batch = await client.client.getCrypto()?.encryptToDeviceMessages( - "m.test.type", - [ - { userId: "@bob:example.org", deviceId: "bobweb" }, - { userId: "@bob:example.org", deviceId: "bobmobile" }, - { userId: "@carol:example.org", deviceId: "caroldesktop" }, - { userId: "@carol:example.org", deviceId: "carolmobile" }, // not known - ], - payload, - ); - expect(crypto.deviceList.downloadKeys).toHaveBeenCalledWith( - ["@bob:example.org", "@carol:example.org"], - false, - ); - expect(encryptMessageForDevice).toHaveBeenCalledTimes(3); - const expectedPayload = expect.objectContaining({ - ...encryptedPayload, - "org.matrix.msgid": expect.any(String), - "sender_key": expect.any(String), - }); - expect(batch?.eventType).toEqual("m.room.encrypted"); - expect(batch?.batch.length).toEqual(3); - expect(batch).toEqual({ - eventType: "m.room.encrypted", - batch: expect.arrayContaining([ - { - userId: "@bob:example.org", - deviceId: "bobweb", - payload: expectedPayload, - }, - { - userId: "@bob:example.org", - deviceId: "bobmobile", - payload: expectedPayload, - }, - { - userId: "@carol:example.org", - deviceId: "caroldesktop", - payload: expectedPayload, - }, - ]), - }); - }); - - it("returns empty batch if no devices known", async () => { - jest.spyOn(crypto.deviceList, "downloadKeys").mockResolvedValue(new Map()); - const batch = await crypto.encryptToDeviceMessages( - "m.test.type", - [ - { deviceId: "AAA", userId: "@user1:domain" }, - { deviceId: "BBB", userId: "@user1:domain" }, - { deviceId: "CCC", userId: "@user2:domain" }, - ], - payload, - ); - expect(batch?.eventType).toEqual("m.room.encrypted"); - expect(batch?.batch).toEqual([]); - }); - }); - - describe("checkSecretStoragePrivateKey", () => { - let client: TestClient; - - beforeEach(async () => { - client = new TestClient("@alice:example.org", "aliceweb"); - await client.client.initLegacyCrypto(); - }); - - afterEach(async () => { - await client.stop(); - }); - - it("should free PkDecryption", () => { - const free = jest.fn(); - jest.spyOn(Olm, "PkDecryption").mockImplementation( - () => - ({ - init_with_private_key: jest.fn(), - free, - }) as unknown as PkDecryption, - ); - client.client.checkSecretStoragePrivateKey(new Uint8Array(), ""); - expect(free).toHaveBeenCalled(); - }); - }); - - describe("checkCrossSigningPrivateKey", () => { - let client: TestClient; - - beforeEach(async () => { - client = new TestClient("@alice:example.org", "aliceweb"); - await client.client.initLegacyCrypto(); - }); - - afterEach(async () => { - await client.stop(); - }); - - it("should free PkSigning", () => { - const free = jest.fn(); - jest.spyOn(Olm, "PkSigning").mockImplementation( - () => - ({ - init_with_seed: jest.fn(), - free, - }) as unknown as PkSigning, - ); - client.client.checkCrossSigningPrivateKey(new Uint8Array(), ""); - expect(free).toHaveBeenCalled(); - }); - }); - - describe("start", () => { - let client: TestClient; - - beforeEach(async () => { - client = new TestClient("@alice:example.org", "aliceweb"); - await client.client.initLegacyCrypto(); - }); - - afterEach(async function () { - await client!.stop(); - }); - - // start() is a no-op nowadays, so there's not much to test here. - it("should complete successfully", async () => { - await client!.client.crypto!.start(); - }); - }); - - describe("setRoomEncryption", () => { - let mockClient: MatrixClient; - let mockRoomList: RoomList; - let clientStore: IStore; - let crypto: Crypto; - - beforeEach(async function () { - mockClient = {} as MatrixClient; - const mockStorage = new MockStorageApi() as unknown as Storage; - clientStore = new MemoryStore({ localStorage: mockStorage }) as unknown as IStore; - const cryptoStore = new MemoryCryptoStore(); - - mockRoomList = { - getRoomEncryption: jest.fn().mockReturnValue(null), - setRoomEncryption: jest.fn().mockResolvedValue(undefined), - } as unknown as RoomList; - - crypto = new Crypto(mockClient, "@alice:home.server", "FLIBBLE", clientStore, cryptoStore, []); - // @ts-ignore we are injecting a mock into a private property - crypto.roomList = mockRoomList; - }); - - it("should set the algorithm if called for a known room", async () => { - const room = new Room("!room:id", mockClient, "@my.user:id"); - await clientStore.storeRoom(room); - await crypto.setRoomEncryption("!room:id", { algorithm: "m.megolm.v1.aes-sha2" } as IRoomEncryption); - expect(mockRoomList!.setRoomEncryption).toHaveBeenCalledTimes(1); - expect(jest.mocked(mockRoomList!.setRoomEncryption).mock.calls[0][0]).toEqual("!room:id"); - }); - - it("should raise if called for an unknown room", async () => { - await expect(async () => { - await crypto.setRoomEncryption("!room:id", { algorithm: "m.megolm.v1.aes-sha2" } as IRoomEncryption); - }).rejects.toThrow(/unknown room/); - expect(mockRoomList!.setRoomEncryption).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/unit/crypto/CrossSigningInfo.spec.ts b/spec/unit/crypto/CrossSigningInfo.spec.ts deleted file mode 100644 index e99ceb273..000000000 --- a/spec/unit/crypto/CrossSigningInfo.spec.ts +++ /dev/null @@ -1,243 +0,0 @@ -/* -Copyright 2020 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 "../../olm-loader"; -import { CrossSigningInfo, createCryptoStoreCacheCallbacks } from "../../../src/crypto/CrossSigning"; -import { IndexedDBCryptoStore } from "../../../src/crypto/store/indexeddb-crypto-store"; -import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store"; -import "fake-indexeddb/auto"; -import "jest-localstorage-mock"; -import { OlmDevice } from "../../../src/crypto/OlmDevice"; -import { logger } from "../../../src/logger"; - -const userId = "@alice:example.com"; - -// Private key for tests only -const testKey = new Uint8Array([ - 0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, 0xae, 0xb1, 0x05, - 0xc1, 0xe7, 0x62, 0x78, 0xa6, 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d, -]); - -const types = [ - { type: "master", shouldCache: true }, - { type: "self_signing", shouldCache: true }, - { type: "user_signing", shouldCache: true }, - { type: "invalid", shouldCache: false }, -]; - -const badKey = Uint8Array.from(testKey); -badKey[0] ^= 1; - -const masterKeyPub = "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"; - -describe("CrossSigningInfo.getCrossSigningKey", function () { - if (!globalThis.Olm) { - logger.warn("Not running megolm backup unit tests: libolm not present"); - return; - } - - beforeAll(function () { - return globalThis.Olm.init(); - }); - - it("should throw if no callback is provided", async () => { - const info = new CrossSigningInfo(userId); - await expect(info.getCrossSigningKey("master")).rejects.toThrow(); - }); - - it.each(types)("should throw if the callback returns falsey", async ({ type, shouldCache }) => { - const info = new CrossSigningInfo(userId, { - getCrossSigningKey: async () => false as unknown as Uint8Array, - }); - await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey"); - }); - - it("should throw if the expected key doesn't come back", async () => { - const info = new CrossSigningInfo(userId, { - getCrossSigningKey: async () => masterKeyPub as unknown as Uint8Array, - }); - await expect(info.getCrossSigningKey("master", "")).rejects.toThrow(); - }); - - it("should return a key from its callback", async () => { - const info = new CrossSigningInfo(userId, { - getCrossSigningKey: async () => testKey, - }); - const [pubKey, pkSigning] = await info.getCrossSigningKey("master", masterKeyPub); - expect(pubKey).toEqual(masterKeyPub); - // check that the pkSigning object corresponds to the pubKey - const signature = pkSigning.sign("message"); - const util = new globalThis.Olm.Utility(); - try { - util.ed25519_verify(pubKey, "message", signature); - } finally { - util.free(); - } - }); - - it.each(types)( - "should request a key from the cache callback (if set)" + " and does not call app if one is found" + " %o", - async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockImplementation(() => { - if (shouldCache) { - return Promise.reject(new Error("Regular callback called")); - } else { - return Promise.resolve(testKey); - } - }); - const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey); - const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { getCrossSigningKeyCache }); - const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); - expect(pubKey).toEqual(masterKeyPub); - expect(getCrossSigningKeyCache).toHaveBeenCalledTimes(shouldCache ? 1 : 0); - if (shouldCache) { - // eslint-disable-next-line jest/no-conditional-expect - expect(getCrossSigningKeyCache).toHaveBeenLastCalledWith(type, expect.any(String)); - } - }, - ); - - it.each(types)("should store a key with the cache callback (if set)", async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); - const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); - const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { storeCrossSigningKeyCache }); - const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); - expect(pubKey).toEqual(masterKeyPub); - expect(storeCrossSigningKeyCache).toHaveBeenCalledTimes(shouldCache ? 1 : 0); - if (shouldCache) { - // eslint-disable-next-line jest/no-conditional-expect - expect(storeCrossSigningKeyCache).toHaveBeenLastCalledWith(type, testKey); - } - }); - - it.each(types)("does not store a bad key to the cache", async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockResolvedValue(badKey); - const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); - const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { storeCrossSigningKeyCache }); - await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow(); - expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0); - }); - - it.each(types)("does not store a value to the cache if it came from the cache", async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockImplementation(() => { - if (shouldCache) { - return Promise.reject(new Error("Regular callback called")); - } else { - return Promise.resolve(testKey); - } - }); - const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey); - const storeCrossSigningKeyCache = jest.fn().mockRejectedValue(new Error("Tried to store a value from cache")); - const info = new CrossSigningInfo( - userId, - { getCrossSigningKey }, - { getCrossSigningKeyCache, storeCrossSigningKeyCache }, - ); - expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0); - const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); - expect(pubKey).toEqual(masterKeyPub); - }); - - it.each(types)( - "requests a key from the cache callback (if set) and then calls app" + " if one is not found", - async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); - const getCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); - const storeCrossSigningKeyCache = jest.fn(); - const info = new CrossSigningInfo( - userId, - { getCrossSigningKey }, - { getCrossSigningKeyCache, storeCrossSigningKeyCache }, - ); - const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); - expect(pubKey).toEqual(masterKeyPub); - expect(getCrossSigningKey.mock.calls.length).toBe(1); - expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); - - /* Also expect that the cache gets updated */ - expect(storeCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); - }, - ); - - it.each(types)( - "requests a key from the cache callback (if set) and then" + " calls app if that key doesn't match", - async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); - const getCrossSigningKeyCache = jest.fn().mockResolvedValue(badKey); - const storeCrossSigningKeyCache = jest.fn(); - const info = new CrossSigningInfo( - userId, - { getCrossSigningKey }, - { getCrossSigningKeyCache, storeCrossSigningKeyCache }, - ); - const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); - expect(pubKey).toEqual(masterKeyPub); - expect(getCrossSigningKey.mock.calls.length).toBe(1); - expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); - - /* Also expect that the cache gets updated */ - expect(storeCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); - }, - ); -}); - -/* - * Note that MemoryStore is weird. It's only used for testing - as far as I can tell, - * it's not possible to get one in normal execution unless you hack as we do here. - */ -describe.each([ - ["IndexedDBCryptoStore", () => new IndexedDBCryptoStore(globalThis.indexedDB, "tests")], - ["LocalStorageCryptoStore", () => new IndexedDBCryptoStore(undefined!, "tests")], - [ - "MemoryCryptoStore", - () => { - const store = new IndexedDBCryptoStore(undefined!, "tests"); - // @ts-ignore set private properties - store._backend = new MemoryCryptoStore(); - // @ts-ignore - store._backendPromise = Promise.resolve(store._backend); - return store; - }, - ], -])("CrossSigning > createCryptoStoreCacheCallbacks [%s]", function (name, dbFactory) { - let store: IndexedDBCryptoStore; - - beforeAll(() => { - store = dbFactory(); - }); - - beforeEach(async () => { - await store.deleteAllData(); - }); - - it("should cache data to the store and retrieve it", async () => { - await store.startup(); - const olmDevice = new OlmDevice(store); - const { getCrossSigningKeyCache, storeCrossSigningKeyCache } = createCryptoStoreCacheCallbacks( - store, - olmDevice, - ); - await storeCrossSigningKeyCache!("self_signing", testKey); - - // If we've not saved anything, don't expect anything - // Definitely don't accidentally return the wrong key for the type - const nokey = await getCrossSigningKeyCache!("self", ""); - expect(nokey).toBeNull(); - - const key = await getCrossSigningKeyCache!("self_signing", ""); - expect(new Uint8Array(key!)).toEqual(testKey); - }); -}); diff --git a/spec/unit/crypto/DeviceList.spec.ts b/spec/unit/crypto/DeviceList.spec.ts deleted file mode 100644 index 429f15ebe..000000000 --- a/spec/unit/crypto/DeviceList.spec.ts +++ /dev/null @@ -1,215 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd -Copyright 2019, 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 { logger } from "../../../src/logger"; -import * as utils from "../../../src/utils"; -import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store"; -import { DeviceList } from "../../../src/crypto/DeviceList"; -import { type IDownloadKeyResult, type MatrixClient } from "../../../src"; -import { type OlmDevice } from "../../../src/crypto/OlmDevice"; -import { type CryptoStore } from "../../../src/crypto/store/base"; - -const signedDeviceList: IDownloadKeyResult = { - failures: {}, - device_keys: { - "@test1:sw1v.org": { - HGKAWHRVJQ: { - signatures: { - "@test1:sw1v.org": { - "ed25519:HGKAWHRVJQ": - "8PB450fxKDn5s8IiRZ2N2t6MiueQYVRLHFEzqIi1eLdxx1w" + - "XEPC1/1Uz9T4gwnKlMVAKkhB5hXQA/3kjaeLABw", - }, - }, - user_id: "@test1:sw1v.org", - keys: { - "ed25519:HGKAWHRVJQ": "0gI/T6C+mn1pjtvnnW2yB2l1IIBb/5ULlBXi/LXFSEQ", - "curve25519:HGKAWHRVJQ": "mbIZED1dBsgIgkgzxDpxKkJmsr4hiWlGzQTvUnQe3RY", - }, - algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], - device_id: "HGKAWHRVJQ", - unsigned: { - device_display_name: "", - }, - }, - }, - }, -}; - -const signedDeviceList2: IDownloadKeyResult = { - failures: {}, - device_keys: { - "@test2:sw1v.org": { - QJVRHWAKGH: { - signatures: { - "@test2:sw1v.org": { - "ed25519:QJVRHWAKGH": - "w1xxdLe1iIqzEFHLRVYQeuiM6t2N2ZRiI8s5nDKxf054BP8" + - "1CPEX/AQXh5BhkKAVMlKnwg4T9zU1/wBALeajk3", - }, - }, - user_id: "@test2:sw1v.org", - keys: { - "ed25519:QJVRHWAKGH": "Ig0/C6T+bBII1l2By2Wnnvtjp1nm/iXBlLU5/QESFXL", - "curve25519:QJVRHWAKGH": "YR3eQnUvTQzGlWih4rsmJkKxpDxzgkgIgsBd1DEZIbm", - }, - algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], - device_id: "QJVRHWAKGH", - unsigned: { - device_display_name: "", - }, - }, - }, - }, -}; - -describe("DeviceList", function () { - let downloadSpy: jest.Mock; - let cryptoStore: CryptoStore; - let deviceLists: DeviceList[] = []; - - beforeEach(function () { - deviceLists = []; - - downloadSpy = jest.fn(); - cryptoStore = new MemoryCryptoStore(); - }); - - afterEach(function () { - for (const dl of deviceLists) { - dl.stop(); - } - }); - - function createTestDeviceList(keyDownloadChunkSize = 250) { - const baseApis = { - downloadKeysForUsers: downloadSpy, - getUserId: () => "@test1:sw1v.org", - deviceId: "HGKAWHRVJQ", - } as unknown as MatrixClient; - const mockOlm = { - verifySignature: function (key: string, message: string, signature: string) {}, - } as unknown as OlmDevice; - const dl = new DeviceList(baseApis, cryptoStore, mockOlm, keyDownloadChunkSize); - deviceLists.push(dl); - return dl; - } - - it("should successfully download and store device keys", function () { - const dl = createTestDeviceList(); - - dl.startTrackingDeviceList("@test1:sw1v.org"); - - const queryDefer1 = utils.defer(); - downloadSpy.mockReturnValue(queryDefer1.promise); - - const prom1 = dl.refreshOutdatedDeviceLists(); - expect(downloadSpy).toHaveBeenCalledWith(["@test1:sw1v.org"], {}); - queryDefer1.resolve(utils.deepCopy(signedDeviceList)); - - return prom1.then(() => { - const storedKeys = dl.getRawStoredDevicesForUser("@test1:sw1v.org"); - expect(Object.keys(storedKeys)).toEqual(["HGKAWHRVJQ"]); - dl.stop(); - }); - }); - - it("should have an outdated devicelist on an invalidation while an update is in progress", async function () { - const dl = createTestDeviceList(); - - dl.startTrackingDeviceList("@test1:sw1v.org"); - - const queryDefer1 = utils.defer(); - downloadSpy.mockReturnValue(queryDefer1.promise); - - const prom1 = dl.refreshOutdatedDeviceLists(); - expect(downloadSpy).toHaveBeenCalledWith(["@test1:sw1v.org"], {}); - downloadSpy.mockReset(); - - // outdated notif arrives while the request is in flight. - const queryDefer2 = utils.defer(); - downloadSpy.mockReturnValue(queryDefer2.promise); - - dl.invalidateUserDeviceList("@test1:sw1v.org"); - dl.refreshOutdatedDeviceLists(); - - await dl - .saveIfDirty() - .then(() => { - // the first request completes - queryDefer1.resolve({ - failures: {}, - device_keys: { - "@test1:sw1v.org": {}, - }, - }); - return prom1; - }) - .then(async () => { - // uh-oh; user restarts before second request completes. The new instance - // should know we never got a complete device list. - logger.log("Creating new devicelist to simulate app reload"); - downloadSpy.mockReset(); - const dl2 = createTestDeviceList(); - await dl2.load(); - const queryDefer3 = utils.defer(); - downloadSpy.mockReturnValue(queryDefer3.promise); - - const prom3 = dl2.refreshOutdatedDeviceLists(); - expect(downloadSpy).toHaveBeenCalledWith(["@test1:sw1v.org"], {}); - dl2.stop(); - - queryDefer3.resolve(utils.deepCopy(signedDeviceList)); - - // allow promise chain to complete - return prom3; - }) - .then(() => { - const storedKeys = dl.getRawStoredDevicesForUser("@test1:sw1v.org"); - expect(Object.keys(storedKeys)).toEqual(["HGKAWHRVJQ"]); - dl.stop(); - }); - }); - - it("should download device keys in batches", function () { - const dl = createTestDeviceList(1); - - dl.startTrackingDeviceList("@test1:sw1v.org"); - dl.startTrackingDeviceList("@test2:sw1v.org"); - - const queryDefer1 = utils.defer(); - downloadSpy.mockReturnValueOnce(queryDefer1.promise); - const queryDefer2 = utils.defer(); - downloadSpy.mockReturnValueOnce(queryDefer2.promise); - - const prom1 = dl.refreshOutdatedDeviceLists(); - expect(downloadSpy).toHaveBeenCalledTimes(2); - expect(downloadSpy).toHaveBeenNthCalledWith(1, ["@test1:sw1v.org"], {}); - expect(downloadSpy).toHaveBeenNthCalledWith(2, ["@test2:sw1v.org"], {}); - queryDefer1.resolve(utils.deepCopy(signedDeviceList)); - queryDefer2.resolve(utils.deepCopy(signedDeviceList2)); - - return prom1.then(() => { - const storedKeys1 = dl.getRawStoredDevicesForUser("@test1:sw1v.org"); - expect(Object.keys(storedKeys1)).toEqual(["HGKAWHRVJQ"]); - const storedKeys2 = dl.getRawStoredDevicesForUser("@test2:sw1v.org"); - expect(Object.keys(storedKeys2)).toEqual(["QJVRHWAKGH"]); - dl.stop(); - }); - }); -}); diff --git a/spec/unit/crypto/algorithms/megolm.spec.ts b/spec/unit/crypto/algorithms/megolm.spec.ts deleted file mode 100644 index 4a2035383..000000000 --- a/spec/unit/crypto/algorithms/megolm.spec.ts +++ /dev/null @@ -1,1109 +0,0 @@ -/* -Copyright 2022 - 2023 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 { mocked, type MockedObject } from "jest-mock"; - -import type { DeviceInfoMap } from "../../../../src/crypto/DeviceList"; -import "../../../olm-loader"; -import type { OutboundGroupSession } from "@matrix-org/olm"; -import * as algorithms from "../../../../src/crypto/algorithms"; -import { MemoryCryptoStore } from "../../../../src/crypto/store/memory-crypto-store"; -import * as testUtils from "../../../test-utils/test-utils"; -import { OlmDevice } from "../../../../src/crypto/OlmDevice"; -import { Crypto, type IncomingRoomKeyRequest } from "../../../../src/crypto"; -import { logger } from "../../../../src/logger"; -import { MatrixEvent } from "../../../../src/models/event"; -import { TestClient } from "../../../TestClient"; -import { Room } from "../../../../src/models/room"; -import * as olmlib from "../../../../src/crypto/olmlib"; -import { TypedEventEmitter } from "../../../../src/models/typed-event-emitter"; -import { ClientEvent, type MatrixClient, RoomMember } from "../../../../src"; -import { DeviceInfo, type IDevice } from "../../../../src/crypto/deviceinfo"; -import { DeviceTrustLevel } from "../../../../src/crypto/CrossSigning"; -import { MegolmEncryption as MegolmEncryptionClass } from "../../../../src/crypto/algorithms/megolm"; -import { recursiveMapToObject } from "../../../../src/utils"; -import { sleep } from "../../../../src/utils"; -import { KnownMembership } from "../../../../src/@types/membership"; - -const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!; -const MegolmEncryption = algorithms.ENCRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!; - -const ROOM_ID = "!ROOM:ID"; - -const Olm = globalThis.Olm; - -describe("MegolmDecryption", function () { - if (!globalThis.Olm) { - logger.warn("Not running megolm unit tests: libolm not present"); - return; - } - - beforeAll(function () { - return Olm.init(); - }); - - let megolmDecryption: algorithms.DecryptionAlgorithm; - let mockOlmLib: MockedObject; - let mockCrypto: MockedObject; - let mockBaseApis: MockedObject; - - beforeEach(async function () { - mockCrypto = testUtils.mock(Crypto, "Crypto") as MockedObject; - - // @ts-ignore assigning to readonly prop - mockCrypto.backupManager = { - backupGroupSession: () => {}, - }; - - mockBaseApis = { - claimOneTimeKeys: jest.fn(), - sendToDevice: jest.fn(), - queueToDevice: jest.fn(), - } as unknown as MockedObject; - - const cryptoStore = new MemoryCryptoStore(); - - const olmDevice = new OlmDevice(cryptoStore); - - megolmDecryption = new MegolmDecryption({ - userId: "@user:id", - crypto: mockCrypto, - olmDevice: olmDevice, - baseApis: mockBaseApis, - roomId: ROOM_ID, - }); - - // we stub out the olm encryption bits - mockOlmLib = { - encryptMessageForDevice: jest.fn().mockResolvedValue(undefined), - ensureOlmSessionsForDevices: jest.fn(), - } as unknown as MockedObject; - - // @ts-ignore illegal assignment that makes these tests work :/ - megolmDecryption.olmlib = mockOlmLib; - - jest.clearAllMocks(); - }); - - describe("receives some keys:", function () { - let groupSession: OutboundGroupSession; - beforeEach(async function () { - groupSession = new globalThis.Olm.OutboundGroupSession(); - groupSession.create(); - - // construct a fake decrypted key event via the use of a mocked - // 'crypto' implementation. - const event = new MatrixEvent({ - type: "m.room.encrypted", - }); - const decryptedData = { - clearEvent: { - type: "m.room_key", - content: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: ROOM_ID, - session_id: groupSession.session_id(), - session_key: groupSession.session_key(), - }, - }, - senderCurve25519Key: "SENDER_CURVE25519", - claimedEd25519Key: "SENDER_ED25519", - }; - event.getWireType = () => "m.room.encrypted"; - event.getWireContent = () => { - return { - algorithm: "m.olm.v1.curve25519-aes-sha2", - }; - }; - - const mockCrypto = { - decryptEvent: function () { - return Promise.resolve(decryptedData); - }, - } as unknown as Crypto; - - await event.attemptDecryption(mockCrypto).then(() => { - megolmDecryption.onRoomKeyEvent(event); - }); - }); - - it("can decrypt an event", function () { - const event = new MatrixEvent({ - type: "m.room.encrypted", - room_id: ROOM_ID, - content: { - algorithm: "m.megolm.v1.aes-sha2", - sender_key: "SENDER_CURVE25519", - session_id: groupSession.session_id(), - ciphertext: groupSession.encrypt( - JSON.stringify({ - room_id: ROOM_ID, - content: "testytest", - }), - ), - }, - }); - - return megolmDecryption.decryptEvent(event).then((res) => { - expect(res.clearEvent.content).toEqual("testytest"); - }); - }); - - it("can respond to a key request event", function () { - const keyRequest: IncomingRoomKeyRequest = { - requestId: "123", - share: jest.fn(), - userId: "@alice:foo", - deviceId: "alidevice", - requestBody: { - algorithm: "", - room_id: ROOM_ID, - sender_key: "SENDER_CURVE25519", - session_id: groupSession.session_id(), - }, - }; - - return megolmDecryption - .hasKeysForKeyRequest(keyRequest) - .then((hasKeys) => { - expect(hasKeys).toBe(true); - - // set up some pre-conditions for the share call - const deviceInfo = {} as DeviceInfo; - mockCrypto.getStoredDevice.mockReturnValue(deviceInfo); - - mockOlmLib.ensureOlmSessionsForDevices.mockResolvedValue( - new Map([ - [ - "@alice:foo", - new Map([ - [ - "alidevice", - { - sessionId: "alisession", - device: new DeviceInfo("alidevice"), - }, - ], - ]), - ], - ]), - ); - - const awaitEncryptForDevice = new Promise((res, rej) => { - mockOlmLib.encryptMessageForDevice.mockImplementation(() => { - res(); - return Promise.resolve(); - }); - }); - - mockBaseApis.sendToDevice.mockReset(); - mockBaseApis.queueToDevice.mockReset(); - - // do the share - megolmDecryption.shareKeysWithDevice(keyRequest); - - // it's asynchronous, so we have to wait a bit - return awaitEncryptForDevice; - }) - .then(() => { - // check that it called encryptMessageForDevice with - // appropriate args. - expect(mockOlmLib.encryptMessageForDevice).toHaveBeenCalledTimes(1); - - const call = mockOlmLib.encryptMessageForDevice.mock.calls[0]; - const payload = call[6]; - - expect(payload.type).toEqual("m.forwarded_room_key"); - expect(payload.content).toMatchObject({ - sender_key: "SENDER_CURVE25519", - sender_claimed_ed25519_key: "SENDER_ED25519", - session_id: groupSession.session_id(), - chain_index: 0, - forwarding_curve25519_key_chain: [], - }); - expect(payload.content.session_key).toBeDefined(); - }); - }); - - it("can detect replay attacks", function () { - // trying to decrypt two different messages (marked by different - // event IDs or timestamps) using the same (sender key, session id, - // message index) triple should result in an exception being thrown - // as it should be detected as a replay attack. - const sessionId = groupSession.session_id(); - const cipherText = groupSession.encrypt( - JSON.stringify({ - room_id: ROOM_ID, - content: "testytest", - }), - ); - const event1 = new MatrixEvent({ - type: "m.room.encrypted", - room_id: ROOM_ID, - content: { - algorithm: "m.megolm.v1.aes-sha2", - sender_key: "SENDER_CURVE25519", - session_id: sessionId, - ciphertext: cipherText, - }, - event_id: "$event1", - origin_server_ts: 1507753886000, - }); - - const successHandler = jest.fn(); - const failureHandler = jest.fn((err) => { - expect(err.toString()).toMatch(/Duplicate message index, possible replay attack/); - }); - - return megolmDecryption - .decryptEvent(event1) - .then((res) => { - const event2 = new MatrixEvent({ - type: "m.room.encrypted", - room_id: ROOM_ID, - content: { - algorithm: "m.megolm.v1.aes-sha2", - sender_key: "SENDER_CURVE25519", - session_id: sessionId, - ciphertext: cipherText, - }, - event_id: "$event2", - origin_server_ts: 1507754149000, - }); - - return megolmDecryption.decryptEvent(event2); - }) - .then(successHandler, failureHandler) - .then(() => { - expect(successHandler).not.toHaveBeenCalled(); - expect(failureHandler).toHaveBeenCalled(); - }); - }); - - it("allows re-decryption of the same event", function () { - // in contrast with the previous test, if the event ID and - // timestamp are the same, then it should not be considered a - // replay attack - const sessionId = groupSession.session_id(); - const cipherText = groupSession.encrypt( - JSON.stringify({ - room_id: ROOM_ID, - content: "testytest", - }), - ); - const event = new MatrixEvent({ - type: "m.room.encrypted", - room_id: ROOM_ID, - content: { - algorithm: "m.megolm.v1.aes-sha2", - sender_key: "SENDER_CURVE25519", - session_id: sessionId, - ciphertext: cipherText, - }, - event_id: "$event1", - origin_server_ts: 1507753886000, - }); - - return megolmDecryption.decryptEvent(event).then((res) => { - return megolmDecryption.decryptEvent(event); - // test is successful if no exception is thrown - }); - }); - - describe("session reuse and key reshares", () => { - const rotationPeriodMs = 999 * 24 * 60 * 60 * 1000; // 999 days, so we don't have to deal with it - - let megolmEncryption: MegolmEncryptionClass; - let aliceDeviceInfo: DeviceInfo; - let mockRoom: Room; - let olmDevice: OlmDevice; - - beforeEach(async () => { - const cryptoStore = new MemoryCryptoStore(); - - olmDevice = new OlmDevice(cryptoStore); - olmDevice.verifySignature = jest.fn(); - await olmDevice.init(); - - mockBaseApis.claimOneTimeKeys.mockResolvedValue({ - failures: {}, - one_time_keys: { - "@alice:home.server": { - aliceDevice: { - "signed_curve25519:flooble": { - key: "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI", - signatures: { - "@alice:home.server": { - "ed25519:aliceDevice": "totally valid", - }, - }, - }, - }, - }, - }, - }); - mockBaseApis.sendToDevice.mockResolvedValue({}); - mockBaseApis.queueToDevice.mockResolvedValue(undefined); - - aliceDeviceInfo = { - deviceId: "aliceDevice", - isBlocked: jest.fn().mockReturnValue(false), - isUnverified: jest.fn().mockReturnValue(false), - getIdentityKey: jest.fn().mockReturnValue("YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE"), - getFingerprint: jest.fn().mockReturnValue(""), - } as unknown as DeviceInfo; - - mockCrypto.downloadKeys.mockReturnValue( - Promise.resolve(new Map([["@alice:home.server", new Map([["aliceDevice", aliceDeviceInfo]])]])), - ); - - mockCrypto.checkDeviceTrust.mockReturnValue({ - isVerified: () => false, - } as DeviceTrustLevel); - - megolmEncryption = new MegolmEncryption({ - userId: "@user:id", - deviceId: "12345", - crypto: mockCrypto, - olmDevice: olmDevice, - baseApis: mockBaseApis, - roomId: ROOM_ID, - config: { - algorithm: "m.megolm.v1.aes-sha2", - rotation_period_ms: rotationPeriodMs, - }, - }) as MegolmEncryptionClass; - - // Splice the real method onto the mock object as megolm uses this method - // on the crypto class in order to encrypt / start sessions - // @ts-ignore Mock - mockCrypto.encryptAndSendToDevices = Crypto.prototype.encryptAndSendToDevices; - // @ts-ignore Mock - mockCrypto.olmDevice = olmDevice; - // @ts-ignore Mock - mockCrypto.baseApis = mockBaseApis; - - mockRoom = { - roomId: ROOM_ID, - getEncryptionTargetMembers: jest.fn().mockReturnValue([{ userId: "@alice:home.server" }]), - getBlacklistUnverifiedDevices: jest.fn().mockReturnValue(false), - shouldEncryptForInvitedMembers: jest.fn().mockReturnValue(false), - } as unknown as Room; - }); - - it("should use larger otkTimeout when preparing to encrypt room", async () => { - megolmEncryption.prepareToEncrypt(mockRoom); - await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { - body: "Some text", - }); - expect(mockRoom.getEncryptionTargetMembers).toHaveBeenCalled(); - - expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith( - [["@alice:home.server", "aliceDevice"]], - "signed_curve25519", - 10000, - ); - }); - - it("should generate a new session if this one needs rotation", async () => { - // @ts-ignore - private method access - const session = await megolmEncryption.prepareNewSession(false); - session.creationTime -= rotationPeriodMs + 10000; // a smidge over the rotation time - // Inject expired session which needs rotation - // @ts-ignore - private field access - megolmEncryption.setupPromise = Promise.resolve(session); - - // @ts-ignore - private method access - const prepareNewSessionSpy = jest.spyOn(megolmEncryption, "prepareNewSession"); - await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { - body: "Some text", - }); - expect(prepareNewSessionSpy).toHaveBeenCalledTimes(1); - }); - - it("re-uses sessions for sequential messages", async function () { - const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { - body: "Some text", - }); - expect(mockRoom.getEncryptionTargetMembers).toHaveBeenCalled(); - - // this should have claimed a key for alice as it's starting a new session - expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith( - [["@alice:home.server", "aliceDevice"]], - "signed_curve25519", - 2000, - ); - expect(mockCrypto.downloadKeys).toHaveBeenCalledWith(["@alice:home.server"], false); - expect(mockBaseApis.queueToDevice).toHaveBeenCalled(); - expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith( - [["@alice:home.server", "aliceDevice"]], - "signed_curve25519", - 2000, - ); - - mockBaseApis.claimOneTimeKeys.mockReset(); - - const ct2 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { - body: "Some more text", - }); - - // this should *not* have claimed a key as it should be using the same session - expect(mockBaseApis.claimOneTimeKeys).not.toHaveBeenCalled(); - - // likewise they should show the same session ID - expect(ct2.session_id).toEqual(ct1.session_id); - }); - - it("re-shares keys to devices it's already sent to", async function () { - const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { - body: "Some text", - }); - - mockBaseApis.sendToDevice.mockClear(); - await megolmEncryption.reshareKeyWithDevice!( - olmDevice.deviceCurve25519Key!, - ct1.session_id, - "@alice:home.server", - aliceDeviceInfo, - ); - - expect(mockBaseApis.sendToDevice).toHaveBeenCalled(); - }); - - it("does not re-share keys to devices whose keys have changed", async function () { - const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { - body: "Some text", - }); - - aliceDeviceInfo.getIdentityKey = jest - .fn() - .mockReturnValue("YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWI"); - - mockBaseApis.queueToDevice.mockClear(); - await megolmEncryption.reshareKeyWithDevice!( - olmDevice.deviceCurve25519Key!, - ct1.session_id, - "@alice:home.server", - aliceDeviceInfo, - ); - - expect(mockBaseApis.queueToDevice).not.toHaveBeenCalled(); - }); - - it("shouldn't wedge the setup promise if sharing a room key fails", async () => { - // @ts-ignore - private field access - const initialSetupPromise = await megolmEncryption.setupPromise; - expect(initialSetupPromise).toBe(null); - - // @ts-ignore - private field access - megolmEncryption.prepareSession = () => { - throw new Error("Can't prepare session"); - }; - - await expect(() => - // @ts-ignore - private field access - megolmEncryption.ensureOutboundSession(mockRoom, {}, {}, true), - ).rejects.toThrow(); - - // @ts-ignore - private field access - const finalSetupPromise = await megolmEncryption.setupPromise; - expect(finalSetupPromise).toBe(null); - }); - }); - }); - - describe("prepareToEncrypt", () => { - let megolm: MegolmEncryptionClass; - let room: jest.Mocked; - - const deviceMap: DeviceInfoMap = new Map([ - [ - "user-a", - new Map([ - ["device-a", new DeviceInfo("device-a")], - ["device-b", new DeviceInfo("device-b")], - ["device-c", new DeviceInfo("device-c")], - ]), - ], - [ - "user-b", - new Map([ - ["device-d", new DeviceInfo("device-d")], - ["device-e", new DeviceInfo("device-e")], - ["device-f", new DeviceInfo("device-f")], - ]), - ], - [ - "user-c", - new Map([ - ["device-g", new DeviceInfo("device-g")], - ["device-h", new DeviceInfo("device-h")], - ["device-i", new DeviceInfo("device-i")], - ]), - ], - ]); - - beforeEach(() => { - room = testUtils.mock(Room, "Room") as jest.Mocked; - room.getEncryptionTargetMembers.mockImplementation(async () => [ - new RoomMember(room.roomId, "@user:example.org"), - ]); - room.getBlacklistUnverifiedDevices.mockReturnValue(false); - - mockCrypto.downloadKeys.mockImplementation(async () => deviceMap); - - mockCrypto.checkDeviceTrust.mockImplementation(() => new DeviceTrustLevel(true, true, true, true)); - - const olmDevice = new OlmDevice(new MemoryCryptoStore()); - megolm = new MegolmEncryptionClass({ - userId: "@user:id", - deviceId: "12345", - crypto: mockCrypto, - olmDevice, - baseApis: mockBaseApis, - roomId: room.roomId, - config: { - algorithm: "m.megolm.v1.aes-sha2", - rotation_period_ms: 9_999_999, - }, - }); - }); - - it("checks each device", async () => { - megolm.prepareToEncrypt(room); - //@ts-ignore private member access, gross - await megolm.encryptionPreparation?.promise; - - for (const [userId, devices] of deviceMap) { - for (const deviceId of devices.keys()) { - expect(mockCrypto.checkDeviceTrust).toHaveBeenCalledWith(userId, deviceId); - } - } - }); - - it("is cancellable", async () => { - const stop = megolm.prepareToEncrypt(room); - - const before = mockCrypto.checkDeviceTrust.mock.calls.length; - stop(); - - // Ensure that no more devices were checked after cancellation. - await sleep(10); - expect(mockCrypto.checkDeviceTrust).toHaveBeenCalledTimes(before); - }); - }); - - it("notifies devices that have been blocked", async function () { - const aliceClient = new TestClient("@alice:example.com", "alicedevice").client; - const bobClient1 = new TestClient("@bob:example.com", "bobdevice1").client; - const bobClient2 = new TestClient("@bob:example.com", "bobdevice2").client; - await Promise.all([ - aliceClient.initLegacyCrypto(), - bobClient1.initLegacyCrypto(), - bobClient2.initLegacyCrypto(), - ]); - const aliceDevice = aliceClient.crypto!.olmDevice; - const bobDevice1 = bobClient1.crypto!.olmDevice; - const bobDevice2 = bobClient2.crypto!.olmDevice; - - const encryptionCfg = { - algorithm: "m.megolm.v1.aes-sha2", - }; - const roomId = "!someroom"; - const room = new Room(roomId, aliceClient, "@alice:example.com", {}); - - const bobMember = new RoomMember(roomId, "@bob:example.com"); - room.getEncryptionTargetMembers = async function () { - return [bobMember]; - }; - room.setBlacklistUnverifiedDevices(true); - aliceClient.store.storeRoom(room); - await aliceClient.setRoomEncryption(roomId, encryptionCfg); - - const BOB_DEVICES: Record = { - bobdevice1: { - algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], - keys: { - "ed25519:Dynabook": bobDevice1.deviceEd25519Key!, - "curve25519:Dynabook": bobDevice1.deviceCurve25519Key!, - }, - verified: 0, - known: false, - }, - bobdevice2: { - algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], - keys: { - "ed25519:Dynabook": bobDevice2.deviceEd25519Key!, - "curve25519:Dynabook": bobDevice2.deviceCurve25519Key!, - }, - verified: -1, - known: false, - }, - }; - - aliceClient.crypto!.deviceList.storeDevicesForUser("@bob:example.com", BOB_DEVICES); - aliceClient.crypto!.deviceList.downloadKeys = async function (userIds) { - // @ts-ignore short-circuiting private method - return this.getDevicesFromStore(userIds); - }; - - aliceClient.sendToDevice = jest.fn().mockResolvedValue({}); - - const event = new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$event", - content: { - msgtype: "m.text", - body: "secret", - }, - }); - await aliceClient.crypto!.encryptEvent(event, room); - - expect(aliceClient.sendToDevice).toHaveBeenCalled(); - const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0]; - expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/); - delete contentMap.get("@bob:example.com")?.get("bobdevice1")?.["session_id"]; - delete contentMap.get("@bob:example.com")?.get("bobdevice1")?.["org.matrix.msgid"]; - delete contentMap.get("@bob:example.com")?.get("bobdevice2")?.["session_id"]; - delete contentMap.get("@bob:example.com")?.get("bobdevice2")?.["org.matrix.msgid"]; - expect(recursiveMapToObject(contentMap)).toStrictEqual({ - ["@bob:example.com"]: { - ["bobdevice1"]: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: roomId, - code: "m.unverified", - reason: "The sender has disabled encrypting to unverified devices.", - sender_key: aliceDevice.deviceCurve25519Key, - }, - ["bobdevice2"]: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: roomId, - code: "m.blacklisted", - reason: "The sender has blocked you.", - sender_key: aliceDevice.deviceCurve25519Key, - }, - }, - }); - - aliceClient.stopClient(); - bobClient1.stopClient(); - bobClient2.stopClient(); - }); - - it("does not block unverified devices when sending verification events", async function () { - const aliceClient = new TestClient("@alice:example.com", "alicedevice").client; - const bobClient = new TestClient("@bob:example.com", "bobdevice").client; - await Promise.all([aliceClient.initLegacyCrypto(), bobClient.initLegacyCrypto()]); - const bobDevice = bobClient.crypto!.olmDevice; - - const encryptionCfg = { - algorithm: "m.megolm.v1.aes-sha2", - }; - const roomId = "!someroom"; - const room = new Room(roomId, aliceClient, "@alice:example.com", {}); - - const bobMember = new RoomMember(roomId, "@bob:example.com"); - room.getEncryptionTargetMembers = async function () { - return [bobMember]; - }; - room.setBlacklistUnverifiedDevices(true); - aliceClient.store.storeRoom(room); - await aliceClient.setRoomEncryption(roomId, encryptionCfg); - - const BOB_DEVICES: Record = { - bobdevice: { - algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], - keys: { - "ed25519:bobdevice": bobDevice.deviceEd25519Key!, - "curve25519:bobdevice": bobDevice.deviceCurve25519Key!, - }, - verified: 0, - known: true, - }, - }; - - aliceClient.crypto!.deviceList.storeDevicesForUser("@bob:example.com", BOB_DEVICES); - aliceClient.crypto!.deviceList.downloadKeys = async function (userIds) { - // @ts-ignore private - return this.getDevicesFromStore(userIds); - }; - - await bobDevice.generateOneTimeKeys(1); - const oneTimeKeys = await bobDevice.getOneTimeKeys(); - const signedOneTimeKeys: Record = {}; - for (const keyId in oneTimeKeys.curve25519) { - if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) { - const k = { - key: oneTimeKeys.curve25519[keyId], - signatures: {}, - }; - signedOneTimeKeys["signed_curve25519:" + keyId] = k; - await bobClient.crypto!.signObject(k); - break; - } - } - - aliceClient.claimOneTimeKeys = jest.fn().mockResolvedValue({ - one_time_keys: { - "@bob:example.com": { - bobdevice: signedOneTimeKeys, - }, - }, - failures: {}, - }); - - aliceClient.sendToDevice = jest.fn().mockResolvedValue({}); - - const event = new MatrixEvent({ - type: "m.key.verification.start", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$event", - content: { - from_device: "alicedevice", - method: "m.sas.v1", - transaction_id: "transactionid", - }, - }); - await aliceClient.crypto!.encryptEvent(event, room); - - expect(aliceClient.sendToDevice).toHaveBeenCalled(); - const [msgtype] = mocked(aliceClient.sendToDevice).mock.calls[0]; - expect(msgtype).toEqual("m.room.encrypted"); - - aliceClient.stopClient(); - bobClient.stopClient(); - }); - - it("notifies devices when unable to create olm session", async function () { - const aliceClient = new TestClient("@alice:example.com", "alicedevice").client; - const bobClient = new TestClient("@bob:example.com", "bobdevice").client; - await Promise.all([aliceClient.initLegacyCrypto(), bobClient.initLegacyCrypto()]); - const aliceDevice = aliceClient.crypto!.olmDevice; - const bobDevice = bobClient.crypto!.olmDevice; - - const encryptionCfg = { - algorithm: "m.megolm.v1.aes-sha2", - }; - const roomId = "!someroom"; - const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); - const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); - aliceClient.store.storeRoom(aliceRoom); - bobClient.store.storeRoom(bobRoom); - await aliceClient.setRoomEncryption(roomId, encryptionCfg); - await bobClient.setRoomEncryption(roomId, encryptionCfg); - - aliceRoom.getEncryptionTargetMembers = jest.fn().mockResolvedValue([ - { - userId: "@alice:example.com", - membership: KnownMembership.Join, - }, - { - userId: "@bob:example.com", - membership: KnownMembership.Join, - }, - ]); - const BOB_DEVICES = { - bobdevice: { - user_id: "@bob:example.com", - device_id: "bobdevice", - algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], - keys: { - "ed25519:bobdevice": bobDevice.deviceEd25519Key!, - "curve25519:bobdevice": bobDevice.deviceCurve25519Key!, - }, - known: true, - verified: 1, - }, - }; - - aliceClient.crypto!.deviceList.storeDevicesForUser("@bob:example.com", BOB_DEVICES); - aliceClient.crypto!.deviceList.downloadKeys = async function (userIds) { - // @ts-ignore private - return this.getDevicesFromStore(userIds); - }; - - aliceClient.claimOneTimeKeys = jest.fn().mockResolvedValue({ - // Bob has no one-time keys - one_time_keys: {}, - failures: {}, - }); - - aliceClient.sendToDevice = jest.fn().mockResolvedValue({}); - - const event = new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$event", - content: {}, - }); - await aliceClient.crypto!.encryptEvent(event, aliceRoom); - - expect(aliceClient.sendToDevice).toHaveBeenCalled(); - const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0]; - expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/); - delete contentMap.get("@bob:example.com")?.get("bobdevice")?.["org.matrix.msgid"]; - expect(recursiveMapToObject(contentMap)).toStrictEqual({ - ["@bob:example.com"]: { - ["bobdevice"]: { - algorithm: "m.megolm.v1.aes-sha2", - code: "m.no_olm", - reason: "Unable to establish a secure channel.", - sender_key: aliceDevice.deviceCurve25519Key, - }, - }, - }); - - aliceClient.stopClient(); - bobClient.stopClient(); - }); - - it("throws an error describing why it doesn't have a key", async function () { - const aliceClient = new TestClient("@alice:example.com", "alicedevice").client; - const bobClient = new TestClient("@bob:example.com", "bobdevice").client; - await Promise.all([aliceClient.initLegacyCrypto(), bobClient.initLegacyCrypto()]); - const bobDevice = bobClient.crypto!.olmDevice; - - const aliceEventEmitter = new TypedEventEmitter(); - aliceClient.crypto!.registerEventHandlers(aliceEventEmitter); - - const roomId = "!someroom"; - - aliceEventEmitter.emit( - ClientEvent.ToDeviceEvent, - new MatrixEvent({ - type: "m.room_key.withheld", - sender: "@bob:example.com", - content: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: roomId, - session_id: "session_id1", - sender_key: bobDevice.deviceCurve25519Key, - code: "m.blacklisted", - reason: "You have been blocked", - }, - }), - ); - - await expect( - aliceClient.crypto!.decryptEvent( - new MatrixEvent({ - type: "m.room.encrypted", - sender: "@bob:example.com", - event_id: "$event", - room_id: roomId, - content: { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "blablabla", - device_id: "bobdevice", - sender_key: bobDevice.deviceCurve25519Key, - session_id: "session_id1", - }, - }), - ), - ).rejects.toThrow("The sender has blocked you."); - - aliceEventEmitter.emit( - ClientEvent.ToDeviceEvent, - new MatrixEvent({ - type: "m.room_key.withheld", - sender: "@bob:example.com", - content: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: roomId, - session_id: "session_id2", - sender_key: bobDevice.deviceCurve25519Key, - code: "m.blacklisted", - reason: "You have been blocked", - }, - }), - ); - - await expect( - aliceClient.crypto!.decryptEvent( - new MatrixEvent({ - type: "m.room.encrypted", - sender: "@bob:example.com", - event_id: "$event", - room_id: roomId, - content: { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "blablabla", - device_id: "bobdevice", - sender_key: bobDevice.deviceCurve25519Key, - session_id: "session_id2", - }, - }), - ), - ).rejects.toThrow("The sender has blocked you."); - aliceClient.stopClient(); - bobClient.stopClient(); - }); - - it("throws an error describing the lack of an olm session", async function () { - const aliceClient = new TestClient("@alice:example.com", "alicedevice").client; - const bobClient = new TestClient("@bob:example.com", "bobdevice").client; - await Promise.all([aliceClient.initLegacyCrypto(), bobClient.initLegacyCrypto()]); - - const aliceEventEmitter = new TypedEventEmitter(); - aliceClient.crypto!.registerEventHandlers(aliceEventEmitter); - - aliceClient.crypto!.downloadKeys = jest.fn(); - const bobDevice = bobClient.crypto!.olmDevice; - - const roomId = "!someroom"; - - const now = Date.now(); - - aliceEventEmitter.emit( - ClientEvent.ToDeviceEvent, - new MatrixEvent({ - type: "m.room_key.withheld", - sender: "@bob:example.com", - content: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: roomId, - session_id: "session_id1", - sender_key: bobDevice.deviceCurve25519Key, - code: "m.no_olm", - reason: "Unable to establish a secure channel.", - }, - }), - ); - - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - - await expect( - aliceClient.crypto!.decryptEvent( - new MatrixEvent({ - type: "m.room.encrypted", - sender: "@bob:example.com", - event_id: "$event", - room_id: roomId, - content: { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "blablabla", - device_id: "bobdevice", - sender_key: bobDevice.deviceCurve25519Key, - session_id: "session_id1", - }, - origin_server_ts: now, - }), - ), - ).rejects.toThrow("The sender was unable to establish a secure channel."); - - aliceEventEmitter.emit( - ClientEvent.ToDeviceEvent, - new MatrixEvent({ - type: "m.room_key.withheld", - sender: "@bob:example.com", - content: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: roomId, - session_id: "session_id2", - sender_key: bobDevice.deviceCurve25519Key, - code: "m.no_olm", - reason: "Unable to establish a secure channel.", - }, - }), - ); - - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - - await expect( - aliceClient.crypto!.decryptEvent( - new MatrixEvent({ - type: "m.room.encrypted", - sender: "@bob:example.com", - event_id: "$event", - room_id: roomId, - content: { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "blablabla", - device_id: "bobdevice", - sender_key: bobDevice.deviceCurve25519Key, - session_id: "session_id2", - }, - origin_server_ts: now, - }), - ), - ).rejects.toThrow("The sender was unable to establish a secure channel."); - aliceClient.stopClient(); - bobClient.stopClient(); - }); - - it("throws an error to indicate a wedged olm session", async function () { - const aliceClient = new TestClient("@alice:example.com", "alicedevice").client; - const bobClient = new TestClient("@bob:example.com", "bobdevice").client; - await Promise.all([aliceClient.initLegacyCrypto(), bobClient.initLegacyCrypto()]); - const aliceEventEmitter = new TypedEventEmitter(); - aliceClient.crypto!.registerEventHandlers(aliceEventEmitter); - - const bobDevice = bobClient.crypto!.olmDevice; - aliceClient.crypto!.downloadKeys = jest.fn(); - - const roomId = "!someroom"; - - const now = Date.now(); - - // pretend we got an event that we can't decrypt - aliceEventEmitter.emit( - ClientEvent.ToDeviceEvent, - new MatrixEvent({ - type: "m.room.encrypted", - sender: "@bob:example.com", - content: { - msgtype: "m.bad.encrypted", - algorithm: "m.megolm.v1.aes-sha2", - session_id: "session_id", - sender_key: bobDevice.deviceCurve25519Key, - }, - }), - ); - - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - - await expect( - aliceClient.crypto!.decryptEvent( - new MatrixEvent({ - type: "m.room.encrypted", - sender: "@bob:example.com", - event_id: "$event", - room_id: roomId, - content: { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "blablabla", - device_id: "bobdevice", - sender_key: bobDevice.deviceCurve25519Key, - session_id: "session_id", - }, - origin_server_ts: now, - }), - ), - ).rejects.toThrow("The secure channel with the sender was corrupted."); - aliceClient.stopClient(); - bobClient.stopClient(); - }); -}); diff --git a/spec/unit/crypto/algorithms/olm.spec.ts b/spec/unit/crypto/algorithms/olm.spec.ts deleted file mode 100644 index a06175649..000000000 --- a/spec/unit/crypto/algorithms/olm.spec.ts +++ /dev/null @@ -1,257 +0,0 @@ -/* -Copyright 2018,2019 New Vector Ltd -Copyright 2019, 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 MockedObject } from "jest-mock"; - -import "../../../olm-loader"; -import { MemoryCryptoStore } from "../../../../src/crypto/store/memory-crypto-store"; -import { logger } from "../../../../src/logger"; -import { OlmDevice } from "../../../../src/crypto/OlmDevice"; -import * as olmlib from "../../../../src/crypto/olmlib"; -import { DeviceInfo } from "../../../../src/crypto/deviceinfo"; -import { type MatrixClient } from "../../../../src"; - -function makeOlmDevice() { - const cryptoStore = new MemoryCryptoStore(); - const olmDevice = new OlmDevice(cryptoStore); - return olmDevice; -} - -async function setupSession(initiator: OlmDevice, opponent: OlmDevice) { - await opponent.generateOneTimeKeys(1); - const keys = await opponent.getOneTimeKeys(); - const firstKey = Object.values(keys["curve25519"])[0]; - - const sid = await initiator.createOutboundSession(opponent.deviceCurve25519Key!, firstKey); - return sid; -} - -function alwaysSucceed(promise: Promise): Promise { - // swallow any exception thrown by a promise, so that - // Promise.all doesn't abort - return promise.catch(() => {}); -} - -describe("OlmDevice", function () { - if (!globalThis.Olm) { - logger.warn("Not running megolm unit tests: libolm not present"); - return; - } - - beforeAll(function () { - return globalThis.Olm.init(); - }); - - let aliceOlmDevice: OlmDevice; - let bobOlmDevice: OlmDevice; - - beforeEach(async function () { - aliceOlmDevice = makeOlmDevice(); - bobOlmDevice = makeOlmDevice(); - await aliceOlmDevice.init(); - await bobOlmDevice.init(); - }); - - describe("olm", function () { - it("can decrypt messages", async function () { - const sid = await setupSession(aliceOlmDevice, bobOlmDevice); - - const ciphertext = (await aliceOlmDevice.encryptMessage( - bobOlmDevice.deviceCurve25519Key!, - sid, - "The olm or proteus is an aquatic salamander in the family Proteidae", - )) as any; // OlmDevice.encryptMessage has incorrect return type - - const result = await bobOlmDevice.createInboundSession( - aliceOlmDevice.deviceCurve25519Key!, - ciphertext.type, - ciphertext.body, - ); - expect(result.payload).toEqual("The olm or proteus is an aquatic salamander in the family Proteidae"); - }); - - it("exports picked account and olm sessions", async function () { - const sessionId = await setupSession(aliceOlmDevice, bobOlmDevice); - - const exported = await bobOlmDevice.export(); - // At this moment only Alice (the “initiator” in setupSession) has a session - expect(exported.sessions).toEqual([]); - - const MESSAGE = "The olm or proteus is an aquatic salamander" + " in the family Proteidae"; - const ciphertext = (await aliceOlmDevice.encryptMessage( - bobOlmDevice.deviceCurve25519Key!, - sessionId, - MESSAGE, - )) as any; // OlmDevice.encryptMessage has incorrect return type - - const bobRecreatedOlmDevice = makeOlmDevice(); - bobRecreatedOlmDevice.init({ fromExportedDevice: exported }); - - const decrypted = await bobRecreatedOlmDevice.createInboundSession( - aliceOlmDevice.deviceCurve25519Key!, - ciphertext.type, - ciphertext.body, - ); - expect(decrypted.payload).toEqual(MESSAGE); - - const exportedAgain = await bobRecreatedOlmDevice.export(); - // this time we expect Bob to have a session to export - expect(exportedAgain.sessions).toHaveLength(1); - - const MESSAGE_2 = "In contrast to most amphibians," + " the olm is entirely aquatic"; - const ciphertext2 = (await aliceOlmDevice.encryptMessage( - bobOlmDevice.deviceCurve25519Key!, - sessionId, - MESSAGE_2, - )) as any; // OlmDevice.encryptMessage has incorrect return type - - const bobRecreatedAgainOlmDevice = makeOlmDevice(); - bobRecreatedAgainOlmDevice.init({ fromExportedDevice: exportedAgain }); - - // Note: "decrypted_2" does not have the same structure as "decrypted" - const decrypted2 = await bobRecreatedAgainOlmDevice.decryptMessage( - aliceOlmDevice.deviceCurve25519Key!, - decrypted.session_id, - ciphertext2.type, - ciphertext2.body, - ); - expect(decrypted2).toEqual(MESSAGE_2); - }); - - it("creates only one session at a time", async function () { - // if we call ensureOlmSessionsForDevices multiple times, it should - // only try to create one session at a time, even if the server is - // slow - let count = 0; - const baseApis = { - claimOneTimeKeys: () => { - // simulate a very slow server (.5 seconds to respond) - count++; - return new Promise((resolve, reject) => { - setTimeout(reject, 500); - }); - }, - } as unknown as MockedObject; - const devicesByUser = new Map([ - [ - "@bob:example.com", - [ - DeviceInfo.fromStorage( - { - keys: { - "curve25519:ABCDEFG": "akey", - }, - }, - "ABCDEFG", - ), - ], - ], - ]); - - // start two tasks that try to ensure that there's an olm session - const promises = Promise.all([ - alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUser)), - alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUser)), - ]); - - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - - // after .2s, both tasks should have started, but one should be - // waiting on the other before trying to create a session, so - // claimOneTimeKeys should have only been called once - expect(count).toBe(1); - - await promises; - - // after waiting for both tasks to complete, the first task should - // have failed, so the second task should have tried to create a - // new session and will have called claimOneTimeKeys - expect(count).toBe(2); - }); - - it("avoids deadlocks when two tasks are ensuring the same devices", async function () { - // This test checks whether `ensureOlmSessionsForDevices` properly - // handles multiple tasks in flight ensuring some set of devices in - // common without deadlocks. - - let claimRequestCount = 0; - const baseApis = { - claimOneTimeKeys: () => { - // simulate a very slow server (.5 seconds to respond) - claimRequestCount++; - return new Promise((resolve, reject) => { - setTimeout(reject, 500); - }); - }, - } as unknown as MockedObject; - - const deviceBobA = DeviceInfo.fromStorage( - { - keys: { - "curve25519:BOB-A": "akey", - }, - }, - "BOB-A", - ); - const deviceBobB = DeviceInfo.fromStorage( - { - keys: { - "curve25519:BOB-B": "bkey", - }, - }, - "BOB-B", - ); - - // There's no required ordering of devices per user, so here we - // create two different orderings so that each task reserves a - // device the other task needs before continuing. - const devicesByUserAB = new Map([["@bob:example.com", [deviceBobA, deviceBobB]]]); - const devicesByUserBA = new Map([["@bob:example.com", [deviceBobB, deviceBobA]]]); - - const task1 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUserAB)); - - // After a single tick through the first task, it should have - // claimed ownership of all devices to avoid deadlocking others. - expect(Object.keys(aliceOlmDevice.sessionsInProgress).length).toBe(2); - - const task2 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUserBA)); - - // The second task should not have changed the ownership count, as - // it's waiting on the first task. - expect(Object.keys(aliceOlmDevice.sessionsInProgress).length).toBe(2); - - // Track the tasks, but don't await them yet. - const promises = Promise.all([task1, task2]); - - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - - // After .2s, the first task should have made an initial claim request. - expect(claimRequestCount).toBe(1); - - await promises; - - // After waiting for both tasks to complete, the first task should - // have failed, so the second task should have tried to create a - // new session and will have called claimOneTimeKeys - expect(claimRequestCount).toBe(2); - }); - }); -}); diff --git a/spec/unit/crypto/backup.spec.ts b/spec/unit/crypto/backup.spec.ts deleted file mode 100644 index a11f73dfd..000000000 --- a/spec/unit/crypto/backup.spec.ts +++ /dev/null @@ -1,791 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 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 "../../olm-loader"; -import { logger } from "../../../src/logger"; -import * as olmlib from "../../../src/crypto/olmlib"; -import { MatrixClient } from "../../../src/client"; -import { MatrixEvent } from "../../../src/models/event"; -import * as algorithms from "../../../src/crypto/algorithms"; -import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store"; -import * as testUtils from "../../test-utils/test-utils"; -import { OlmDevice } from "../../../src/crypto/OlmDevice"; -import { Crypto } from "../../../src/crypto"; -import { resetCrossSigningKeys } from "./crypto-utils"; -import { BackupManager } from "../../../src/crypto/backup"; -import { StubStore } from "../../../src/store/stub"; -import { IndexedDBCryptoStore, type MatrixScheduler } from "../../../src"; -import { type CryptoStore } from "../../../src/crypto/store/base"; -import { type MegolmDecryption as MegolmDecryptionClass } from "../../../src/crypto/algorithms/megolm"; -import { type IKeyBackupInfo } from "../../../src/crypto/keybackup"; - -const Olm = globalThis.Olm; - -const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!; - -const ROOM_ID = "!ROOM:ID"; - -const SESSION_ID = "o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc"; -const ENCRYPTED_EVENT = new MatrixEvent({ - type: "m.room.encrypted", - room_id: "!ROOM:ID", - content: { - algorithm: "m.megolm.v1.aes-sha2", - sender_key: "SENDER_CURVE25519", - session_id: SESSION_ID, - ciphertext: - "AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N" + - "CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl" + - "mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs", - }, - event_id: "$event1", - origin_server_ts: 1507753886000, -}); - -const CURVE25519_KEY_BACKUP_DATA = { - first_message_index: 0, - forwarded_count: 0, - is_verified: false, - session_data: { - ciphertext: - "2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw" + - "6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ" + - "Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9" + - "SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy" + - "Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF" + - "ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV" + - "4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv" + - "C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe" + - "Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf" + - "QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy" + - "iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg", - mac: "5lxYBHQU80M", - ephemeral: "/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14", - }, -}; - -const AES256_KEY_BACKUP_DATA = { - first_message_index: 0, - forwarded_count: 0, - is_verified: false, - session_data: { - iv: "b3Jqqvm5S9QdmXrzssspLQ", - ciphertext: - "GOOASO3E9ThogkG0zMjEduGLM3u9jHZTkS7AvNNbNj3q1znwk4OlaVKXce" + - "7ynofiiYIiS865VlOqrKEEXv96XzRyUpgn68e3WsicwYl96EtjIEh/iY003PG2Qd" + - "EluT899Ax7PydpUHxEktbWckMppYomUR5q8x1KI1SsOQIiJaIGThmIMPANRCFiK0" + - "WQj+q+dnhzx4lt9AFqU5bKov8qKnw2qGYP7/+6RmJ0Kpvs8tG6lrcNDEHtFc2r0r" + - "KKubDypo0Vc8EWSwsAHdKa36ewRavpreOuE8Z9RLfY0QIR1ecXrMqW0CdGFr7H3P" + - "vcjF8sjwvQAavzxEKT1WMGizSMLeKWo2mgZ5cKnwV5HGUAw596JQvKs9laG2U89K" + - "YrT0sH30vi62HKzcBLcDkWkUSNYPz7UiZ1MM0L380UA+1ZOXSOmtBA9xxzzbc8Xd" + - "fRimVgklGdxrxjzuNLYhL2BvVH4oPWonD9j0bvRwE6XkimdbGQA8HB7UmXXjE8WA" + - "RgaDHkfzoA3g3aeQ", - mac: "uR988UYgGL99jrvLLPX3V1ows+UYbktTmMxPAo2kxnU", - }, -}; - -const CURVE25519_BACKUP_INFO = { - algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, - version: "1", - auth_data: { - public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", - }, -}; - -const AES256_BACKUP_INFO: IKeyBackupInfo = { - algorithm: "org.matrix.msc3270.v1.aes-hmac-sha2", - version: "1", - auth_data: {} as IKeyBackupInfo["auth_data"], -}; - -const keys: Record = {}; - -function getCrossSigningKey(type: string) { - return Promise.resolve(keys[type]); -} - -function saveCrossSigningKeys(k: Record) { - Object.assign(keys, k); -} - -function makeTestScheduler(): MatrixScheduler { - return (["getQueueForEvent", "queueEvent", "removeEventFromQueue", "setProcessFunction"] as const).reduce( - (r, k) => { - r[k] = jest.fn(); - return r; - }, - {} as MatrixScheduler, - ); -} - -function makeTestClient(cryptoStore: CryptoStore) { - const scheduler = makeTestScheduler(); - const store = new StubStore(); - - const client = new MatrixClient({ - baseUrl: "https://my.home.server", - idBaseUrl: "https://identity.server", - accessToken: "my.access.token", - fetchFn: jest.fn(), // NOP - store: store, - scheduler: scheduler, - userId: "@alice:bar", - deviceId: "device", - cryptoStore: cryptoStore, - cryptoCallbacks: { getCrossSigningKey, saveCrossSigningKeys }, - }); - - // initialising the crypto library will trigger a key upload request, which we can stub out - client.uploadKeysRequest = jest.fn(); - return client; -} - -describe("MegolmBackup", function () { - if (!globalThis.Olm) { - logger.warn("Not running megolm backup unit tests: libolm not present"); - return; - } - - beforeAll(function () { - return Olm.init(); - }); - - let olmDevice: OlmDevice; - let mockOlmLib: typeof olmlib; - let mockCrypto: Crypto; - let cryptoStore: CryptoStore; - let megolmDecryption: MegolmDecryptionClass; - beforeEach(async function () { - mockCrypto = testUtils.mock(Crypto, "Crypto"); - // @ts-ignore making mock - mockCrypto.backupManager = testUtils.mock(BackupManager, "BackupManager"); - mockCrypto.backupManager.backupInfo = CURVE25519_BACKUP_INFO; - - cryptoStore = new MemoryCryptoStore(); - - olmDevice = new OlmDevice(cryptoStore); - - // we stub out the olm encryption bits - mockOlmLib = {} as unknown as typeof olmlib; - mockOlmLib.ensureOlmSessionsForDevices = jest.fn(); - mockOlmLib.encryptMessageForDevice = jest.fn().mockResolvedValue(undefined); - }); - - describe("backup", function () { - let mockBaseApis: MatrixClient; - - beforeEach(function () { - mockBaseApis = {} as unknown as MatrixClient; - - megolmDecryption = new MegolmDecryption({ - userId: "@user:id", - crypto: mockCrypto, - olmDevice: olmDevice, - baseApis: mockBaseApis, - roomId: ROOM_ID, - }) as MegolmDecryptionClass; - - // @ts-ignore private field access - megolmDecryption.olmlib = mockOlmLib; - - // clobber the setTimeout function to run 100x faster. - // ideally we would use lolex, but we have no oportunity - // to tick the clock between the first try and the retry. - const realSetTimeout = globalThis.setTimeout; - jest.spyOn(globalThis, "setTimeout").mockImplementation(function (f, n) { - return realSetTimeout(f!, n! / 100); - }); - }); - - afterEach(function () { - jest.spyOn(globalThis, "setTimeout").mockRestore(); - }); - - test("fail if crypto not enabled", async () => { - const client = makeTestClient(cryptoStore); - const data = { - algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, - version: "1", - auth_data: { - public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", - }, - }; - await expect(client.restoreKeyBackupWithSecretStorage(data)).rejects.toThrow( - "End-to-end encryption disabled", - ); - }); - - test("fail if given backup has no version", async () => { - const client = makeTestClient(cryptoStore); - await client.initLegacyCrypto(); - const data = { - algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, - auth_data: { - public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", - }, - }; - const key = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]); - await client.getCrypto()!.storeSessionBackupPrivateKey(key, "1"); - await expect(client.restoreKeyBackupWithCache(undefined, undefined, data)).rejects.toThrow( - "Backup version must be defined", - ); - }); - - it("automatically calls the key back up", function () { - const groupSession = new Olm.OutboundGroupSession(); - groupSession.create(); - - // construct a fake decrypted key event via the use of a mocked - // 'crypto' implementation. - const event = new MatrixEvent({ - type: "m.room.encrypted", - }); - event.getWireType = () => "m.room.encrypted"; - event.getWireContent = () => { - return { - algorithm: "m.olm.v1.curve25519-aes-sha2", - }; - }; - const decryptedData = { - clearEvent: { - type: "m.room_key", - content: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: ROOM_ID, - session_id: groupSession.session_id(), - session_key: groupSession.session_key(), - }, - }, - senderCurve25519Key: "SENDER_CURVE25519", - claimedEd25519Key: "SENDER_ED25519", - }; - - mockCrypto.decryptEvent = function () { - return Promise.resolve(decryptedData); - }; - mockCrypto.cancelRoomKeyRequest = function () {}; - - // @ts-ignore readonly field write - mockCrypto.backupManager = { - backupGroupSession: jest.fn(), - }; - - return event - .attemptDecryption(mockCrypto) - .then(() => { - return megolmDecryption.onRoomKeyEvent(event); - }) - .then(() => { - expect(mockCrypto.backupManager.backupGroupSession).toHaveBeenCalled(); - }); - }); - - it("sends backups to the server (Curve25519 version)", function () { - const groupSession = new Olm.OutboundGroupSession(); - groupSession.create(); - const ibGroupSession = new Olm.InboundGroupSession(); - ibGroupSession.create(groupSession.session_key()); - - const client = makeTestClient(cryptoStore); - - megolmDecryption = new MegolmDecryption({ - userId: "@user:id", - crypto: mockCrypto, - olmDevice: olmDevice, - baseApis: client, - roomId: ROOM_ID, - }) as MegolmDecryptionClass; - - // @ts-ignore private field access - megolmDecryption.olmlib = mockOlmLib; - - return client - .initLegacyCrypto() - .then(() => { - return cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { - cryptoStore.addEndToEndInboundGroupSession( - "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - groupSession.session_id(), - { - forwardingCurve25519KeyChain: undefined!, - keysClaimed: { - ed25519: "SENDER_ED25519", - }, - room_id: ROOM_ID, - session: ibGroupSession.pickle(olmDevice.pickleKey), - }, - txn, - ); - }); - }) - .then(async () => { - await client.enableKeyBackup({ - algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, - version: "1", - auth_data: { - public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", - }, - }); - let numCalls = 0; - return new Promise((resolve, reject) => { - client.http.authedRequest = function (method, path, queryParams, data, opts): any { - ++numCalls; - expect(numCalls).toBeLessThanOrEqual(1); - if (numCalls >= 2) { - // exit out of retry loop if there's something wrong - reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({}); - } - expect(method).toBe("PUT"); - expect(path).toBe("/room_keys/keys"); - expect(queryParams?.version).toBe("1"); - expect((data as Record).rooms[ROOM_ID].sessions).toBeDefined(); - expect((data as Record).rooms[ROOM_ID].sessions).toHaveProperty( - groupSession.session_id(), - ); - resolve(); - return Promise.resolve({}); - }; - client.crypto!.backupManager.backupGroupSession( - "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - groupSession.session_id(), - ); - }).then(() => { - expect(numCalls).toBe(1); - client.stopClient(); - }); - }); - }); - - it("sends backups to the server (AES-256 version)", function () { - const groupSession = new Olm.OutboundGroupSession(); - groupSession.create(); - const ibGroupSession = new Olm.InboundGroupSession(); - ibGroupSession.create(groupSession.session_key()); - - const client = makeTestClient(cryptoStore); - - megolmDecryption = new MegolmDecryption({ - userId: "@user:id", - crypto: mockCrypto, - olmDevice: olmDevice, - baseApis: client, - roomId: ROOM_ID, - }) as MegolmDecryptionClass; - - // @ts-ignore private field access - megolmDecryption.olmlib = mockOlmLib; - - return client - .initLegacyCrypto() - .then(() => { - return client.crypto!.storeSessionBackupPrivateKey(new Uint8Array(32)); - }) - .then(() => { - return cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { - cryptoStore.addEndToEndInboundGroupSession( - "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - groupSession.session_id(), - { - forwardingCurve25519KeyChain: undefined!, - keysClaimed: { - ed25519: "SENDER_ED25519", - }, - room_id: ROOM_ID, - session: ibGroupSession.pickle(olmDevice.pickleKey), - }, - txn, - ); - }); - }) - .then(async () => { - await client.enableKeyBackup({ - algorithm: "org.matrix.msc3270.v1.aes-hmac-sha2", - version: "1", - auth_data: { - iv: "PsCAtR7gMc4xBd9YS3A9Ow", - mac: "ZSDsTFEZK7QzlauCLMleUcX96GQZZM7UNtk4sripSqQ", - }, - }); - let numCalls = 0; - return new Promise((resolve, reject) => { - client.http.authedRequest = function (method, path, queryParams, data, opts): any { - ++numCalls; - expect(numCalls).toBeLessThanOrEqual(1); - if (numCalls >= 2) { - // exit out of retry loop if there's something wrong - reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({}); - } - expect(method).toBe("PUT"); - expect(path).toBe("/room_keys/keys"); - expect(queryParams?.version).toBe("1"); - expect((data as Record).rooms[ROOM_ID].sessions).toBeDefined(); - expect((data as Record).rooms[ROOM_ID].sessions).toHaveProperty( - groupSession.session_id(), - ); - resolve(); - return Promise.resolve({}); - }; - client.crypto!.backupManager.backupGroupSession( - "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - groupSession.session_id(), - ); - }).then(() => { - expect(numCalls).toBe(1); - client.stopClient(); - }); - }); - }); - - it("signs backups with the cross-signing master key", async function () { - const groupSession = new Olm.OutboundGroupSession(); - groupSession.create(); - const ibGroupSession = new Olm.InboundGroupSession(); - ibGroupSession.create(groupSession.session_key()); - - const client = makeTestClient(cryptoStore); - - megolmDecryption = new MegolmDecryption({ - userId: "@user:id", - crypto: mockCrypto, - olmDevice: olmDevice, - baseApis: client, - roomId: ROOM_ID, - }) as MegolmDecryptionClass; - - // @ts-ignore private field access - megolmDecryption.olmlib = mockOlmLib; - - await client.initLegacyCrypto(); - client.uploadDeviceSigningKeys = async function (e) { - return {}; - }; - client.uploadKeySignatures = async function (e) { - return { failures: {} }; - }; - await resetCrossSigningKeys(client); - let numCalls = 0; - await Promise.all([ - new Promise((resolve, reject) => { - let backupInfo: Record | BodyInit | undefined; - client.http.authedRequest = function (method, path, queryParams, data, opts): any { - ++numCalls; - expect(numCalls).toBeLessThanOrEqual(2); - /* eslint-disable jest/no-conditional-expect */ - if (numCalls === 1) { - expect(method).toBe("POST"); - expect(path).toBe("/room_keys/version"); - try { - // make sure auth_data is signed by the master key - olmlib.pkVerify( - (data as Record).auth_data, - client.getCrossSigningId()!, - "@alice:bar", - ); - } catch (e) { - reject(e); - return Promise.resolve({}); - } - backupInfo = data; - return Promise.resolve({}); - } else if (numCalls === 2) { - expect(method).toBe("GET"); - expect(path).toBe("/room_keys/version"); - resolve(); - return Promise.resolve(backupInfo); - } else { - // exit out of retry loop if there's something wrong - reject(new Error("authedRequest called too many times")); - return Promise.resolve({}); - } - /* eslint-enable jest/no-conditional-expect */ - }; - }), - client.createKeyBackupVersion({ - algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, - auth_data: { - public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", - }, - }), - ]); - expect(numCalls).toBe(2); - client.stopClient(); - }); - - it("retries when a backup fails", async function () { - const groupSession = new Olm.OutboundGroupSession(); - groupSession.create(); - const ibGroupSession = new Olm.InboundGroupSession(); - ibGroupSession.create(groupSession.session_key()); - - const scheduler = makeTestScheduler(); - const store = new StubStore(); - const client = new MatrixClient({ - baseUrl: "https://my.home.server", - idBaseUrl: "https://identity.server", - accessToken: "my.access.token", - fetchFn: jest.fn(), // NOP - store: store, - scheduler: scheduler, - userId: "@alice:bar", - deviceId: "device", - cryptoStore: cryptoStore, - }); - // initialising the crypto library will trigger a key upload request, which we can stub out - client.uploadKeysRequest = jest.fn(); - - megolmDecryption = new MegolmDecryption({ - userId: "@user:id", - crypto: mockCrypto, - olmDevice: olmDevice, - baseApis: client, - roomId: ROOM_ID, - }) as MegolmDecryptionClass; - - // @ts-ignore private field access - megolmDecryption.olmlib = mockOlmLib; - - await client.initLegacyCrypto(); - await cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { - cryptoStore.addEndToEndInboundGroupSession( - "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - groupSession.session_id(), - { - forwardingCurve25519KeyChain: undefined!, - keysClaimed: { - ed25519: "SENDER_ED25519", - }, - room_id: ROOM_ID, - session: ibGroupSession.pickle(olmDevice.pickleKey), - }, - txn, - ); - }); - - await client.enableKeyBackup({ - algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, - version: "1", - auth_data: { - public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", - }, - }); - let numCalls = 0; - - await new Promise((resolve, reject) => { - client.http.authedRequest = function (method, path, queryParams, data, opts): any { - ++numCalls; - expect(numCalls).toBeLessThanOrEqual(2); - if (numCalls >= 3) { - // exit out of retry loop if there's something wrong - reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({}); - } - expect(method).toBe("PUT"); - expect(path).toBe("/room_keys/keys"); - expect(queryParams?.version).toBe("1"); - expect((data as Record).rooms[ROOM_ID].sessions).toBeDefined(); - expect((data as Record).rooms[ROOM_ID].sessions).toHaveProperty( - groupSession.session_id(), - ); - if (numCalls > 1) { - resolve(); - return Promise.resolve({}); - } else { - return Promise.reject(new Error("this is an expected failure")); - } - }; - return client.crypto!.backupManager.backupGroupSession( - "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - groupSession.session_id(), - ); - }); - expect(numCalls).toBe(2); - client.stopClient(); - }); - }); - - describe("restore", function () { - let client: MatrixClient; - - beforeEach(function () { - client = makeTestClient(cryptoStore); - - megolmDecryption = new MegolmDecryption({ - userId: "@user:id", - crypto: mockCrypto, - olmDevice: olmDevice, - baseApis: client, - roomId: ROOM_ID, - }) as MegolmDecryptionClass; - - // @ts-ignore private field access - megolmDecryption.olmlib = mockOlmLib; - - return client.initLegacyCrypto(); - }); - - afterEach(function () { - client.stopClient(); - }); - - it("can restore from backup (Curve25519 version)", function () { - client.http.authedRequest = function () { - return Promise.resolve(CURVE25519_KEY_BACKUP_DATA); - }; - return client - .restoreKeyBackupWithRecoveryKey( - "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", - ROOM_ID, - SESSION_ID, - CURVE25519_BACKUP_INFO, - ) - .then(() => { - return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); - }) - .then((res) => { - expect(res.clearEvent.content).toEqual("testytest"); - expect(res.untrusted).toBeTruthy(); // keys from Curve25519 backup are untrusted - }); - }); - - it("can restore from backup (AES-256 version)", function () { - client.http.authedRequest = function () { - return Promise.resolve(AES256_KEY_BACKUP_DATA); - }; - return client - .restoreKeyBackupWithRecoveryKey( - "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", - ROOM_ID, - SESSION_ID, - AES256_BACKUP_INFO, - ) - .then(() => { - return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); - }) - .then((res) => { - expect(res.clearEvent.content).toEqual("testytest"); - expect(res.untrusted).toBeFalsy(); // keys from AES backup are trusted - }); - }); - - it("can restore backup by room (Curve25519 version)", function () { - client.http.authedRequest = function () { - return Promise.resolve({ - rooms: { - [ROOM_ID]: { - sessions: { - [SESSION_ID]: CURVE25519_KEY_BACKUP_DATA, - }, - }, - }, - }); - }; - return client - .restoreKeyBackupWithRecoveryKey( - "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", - null!, - null!, - CURVE25519_BACKUP_INFO, - ) - .then(() => { - return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); - }) - .then((res) => { - expect(res.clearEvent.content).toEqual("testytest"); - }); - }); - - it("has working cache functions", async function () { - const key = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]); - await client.crypto!.storeSessionBackupPrivateKey(key); - const result = await client.crypto!.getSessionBackupPrivateKey(); - expect(new Uint8Array(result!)).toEqual(key); - }); - - it("caches session backup keys as it encounters them", async function () { - const cachedNull = await client.crypto!.getSessionBackupPrivateKey(); - expect(cachedNull).toBeNull(); - client.http.authedRequest = function () { - return Promise.resolve(CURVE25519_KEY_BACKUP_DATA); - }; - await new Promise((resolve) => { - client.restoreKeyBackupWithRecoveryKey( - "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", - ROOM_ID, - SESSION_ID, - CURVE25519_BACKUP_INFO, - { cacheCompleteCallback: resolve }, - ); - }); - const cachedKey = await client.crypto!.getSessionBackupPrivateKey(); - expect(cachedKey).not.toBeNull(); - }); - - it("fails if an known algorithm is used", async function () { - const BAD_BACKUP_INFO = Object.assign({}, CURVE25519_BACKUP_INFO, { - algorithm: "this.algorithm.does.not.exist", - }); - client.http.authedRequest = function () { - return Promise.resolve(CURVE25519_KEY_BACKUP_DATA); - }; - - await expect( - client.restoreKeyBackupWithRecoveryKey( - "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", - ROOM_ID, - SESSION_ID, - BAD_BACKUP_INFO, - ), - ).rejects.toThrow(); - }); - }); - - describe("flagAllGroupSessionsForBackup", () => { - it("should return number of sesions needing backup", async () => { - const scheduler = makeTestScheduler(); - const store = new StubStore(); - const client = new MatrixClient({ - baseUrl: "https://my.home.server", - idBaseUrl: "https://identity.server", - accessToken: "my.access.token", - fetchFn: jest.fn(), // NOP - store, - scheduler, - userId: "@alice:bar", - deviceId: "device", - cryptoStore, - }); - // initialising the crypto library will trigger a key upload request, which we can stub out - client.uploadKeysRequest = jest.fn(); - - await client.initLegacyCrypto(); - - cryptoStore.countSessionsNeedingBackup = jest.fn().mockReturnValue(6); - await expect(client.flagAllGroupSessionsForBackup()).resolves.toBe(6); - client.stopClient(); - }); - }); - - describe("getKeyBackupInfo", () => { - it("should return throw an `Not implemented`", async () => { - const client = makeTestClient(cryptoStore); - await client.initLegacyCrypto(); - await expect(client.getCrypto()?.getKeyBackupInfo()).rejects.toThrow("Not implemented"); - }); - }); -}); diff --git a/spec/unit/crypto/cross-signing.spec.ts b/spec/unit/crypto/cross-signing.spec.ts deleted file mode 100644 index 011c0d5e3..000000000 --- a/spec/unit/crypto/cross-signing.spec.ts +++ /dev/null @@ -1,1152 +0,0 @@ -/* -Copyright 2019 New Vector Ltd -Copyright 2019 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 "../../olm-loader"; -import anotherjson from "another-json"; -import { type PkSigning } from "@matrix-org/olm"; - -import type HttpBackend from "matrix-mock-request"; -import * as olmlib from "../../../src/crypto/olmlib"; -import { MatrixError } from "../../../src/http-api"; -import { logger } from "../../../src/logger"; -import { type ICreateClientOpts, type ISignedKey, type MatrixClient } from "../../../src/client"; -import { CryptoEvent } from "../../../src/crypto"; -import { type IDevice } from "../../../src/crypto/deviceinfo"; -import { TestClient } from "../../TestClient"; -import { resetCrossSigningKeys } from "./crypto-utils"; -import { type BootstrapCrossSigningOpts, type CrossSigningKeyInfo } from "../../../src/crypto-api"; - -const PUSH_RULES_RESPONSE: Response = { - method: "GET", - path: "/pushrules/", - data: {}, -}; - -const filterResponse = function (userId: string): Response { - const filterPath = "/user/" + encodeURIComponent(userId) + "/filter"; - return { - method: "POST", - path: filterPath, - data: { filter_id: "f1lt3r" }, - }; -}; - -interface Response { - method: "GET" | "PUT" | "POST" | "DELETE"; - path: string; - data: object; -} - -function setHttpResponses(httpBackend: HttpBackend, responses: Response[]) { - responses.forEach((response) => { - httpBackend.when(response.method, response.path).respond(200, response.data); - }); -} - -async function makeTestClient( - userInfo: { userId: string; deviceId: string }, - options: Partial = {}, - keys: Record = {}, -) { - function getCrossSigningKey(type: string) { - return keys[type] ?? null; - } - - function saveCrossSigningKeys(k: Record) { - Object.assign(keys, k); - } - - options.cryptoCallbacks = Object.assign( - {}, - { getCrossSigningKey, saveCrossSigningKeys }, - options.cryptoCallbacks || {}, - ); - const testClient = new TestClient(userInfo.userId, userInfo.deviceId, undefined, undefined, options); - const client = testClient.client; - - await client.initLegacyCrypto(); - - return { client, httpBackend: testClient.httpBackend }; -} - -describe("Cross Signing", function () { - if (!globalThis.Olm) { - logger.warn("Not running megolm backup unit tests: libolm not present"); - return; - } - - beforeAll(function () { - return globalThis.Olm.init(); - }); - - it("should sign the master key with the device key", async function () { - const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - alice.uploadDeviceSigningKeys = jest.fn().mockImplementation(async (auth, keys) => { - await olmlib.verifySignature( - alice.crypto!.olmDevice, - keys.master_key, - "@alice:example.com", - "Osborne2", - alice.crypto!.olmDevice.deviceEd25519Key!, - ); - }); - alice.uploadKeySignatures = async () => ({ failures: {} }); - alice.setAccountData = async () => ({}); - alice.getAccountDataFromServer = async () => ({}) as T; - // set Alice's cross-signing key - await alice.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async (func) => { - await func({}); - }, - }); - expect(alice.uploadDeviceSigningKeys).toHaveBeenCalled(); - alice.stopClient(); - }); - - it("should abort bootstrap if device signing auth fails", async function () { - const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - alice.uploadDeviceSigningKeys = async (auth, keys) => { - const errorResponse = { - session: "sessionId", - flows: [ - { - stages: ["m.login.password"], - }, - ], - params: {}, - }; - - // If we're not just polling for flows, add on error rejecting the - // auth attempt. - if (auth) { - Object.assign(errorResponse, { - completed: [], - error: "Invalid password", - errcode: "M_FORBIDDEN", - }); - } - - throw new MatrixError(errorResponse, 401); - }; - alice.uploadKeySignatures = async () => ({ failures: {} }); - alice.setAccountData = async () => ({}); - alice.getAccountDataFromServer = async (): Promise => ({}) as T; - const authUploadDeviceSigningKeys: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => { - await func({}); - }; - - // Try bootstrap, expecting `authUploadDeviceSigningKeys` to pass - // through failure, stopping before actually applying changes. - let bootstrapDidThrow = false; - try { - await alice.bootstrapCrossSigning({ - authUploadDeviceSigningKeys, - }); - } catch (e) { - if ((e).errcode === "M_FORBIDDEN") { - bootstrapDidThrow = true; - } - } - expect(bootstrapDidThrow).toBeTruthy(); - alice.stopClient(); - }); - - it("should upload a signature when a user is verified", async function () { - const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - alice.uploadDeviceSigningKeys = async () => ({}); - alice.uploadKeySignatures = async () => ({ failures: {} }); - // set Alice's cross-signing key - await resetCrossSigningKeys(alice); - // Alice downloads Bob's device key - alice.crypto!.deviceList.storeCrossSigningForUser("@bob:example.com", { - keys: { - master: { - user_id: "@bob:example.com", - usage: ["master"], - keys: { - "ed25519:bobs+master+pubkey": "bobs+master+pubkey", - }, - }, - }, - firstUse: false, - crossSigningVerifiedBefore: false, - }); - // Alice verifies Bob's key - const promise = new Promise((resolve, reject) => { - alice.uploadKeySignatures = async (...args) => { - resolve(...args); - return { failures: {} }; - }; - }); - await alice.setDeviceVerified("@bob:example.com", "bobs+master+pubkey", true); - // Alice should send a signature of Bob's key to the server - await promise; - alice.stopClient(); - }); - - it.skip("should get cross-signing keys from sync", async function () { - const masterKey = new Uint8Array([ - 0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, 0xae, 0xb1, - 0x05, 0xc1, 0xe7, 0x62, 0x78, 0xa6, 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d, - ]); - const selfSigningKey = new Uint8Array([ - 0x1e, 0xf4, 0x01, 0x6d, 0x4f, 0xa1, 0x73, 0x66, 0x6b, 0xf8, 0x93, 0xf5, 0xb0, 0x4d, 0x17, 0xc0, 0x17, 0xb5, - 0xa5, 0xf6, 0x59, 0x11, 0x8b, 0x49, 0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f, - ]); - - const { client: alice, httpBackend } = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - { - cryptoCallbacks: { - // will be called to sign our own device - getCrossSigningKey: async (type) => { - if (type === "master") { - return masterKey; - } else { - return selfSigningKey; - } - }, - }, - }, - ); - - const keyChangePromise = new Promise((resolve, reject) => { - alice.once(CryptoEvent.KeysChanged, async (e) => { - resolve(e); - await alice.checkOwnCrossSigningTrust({ - allowPrivateKeyRequests: true, - }); - }); - }); - - const uploadSigsPromise = new Promise((resolve, reject) => { - alice.uploadKeySignatures = jest.fn().mockImplementation(async (content) => { - try { - await olmlib.verifySignature( - alice.crypto!.olmDevice, - content["@alice:example.com"]["nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"], - "@alice:example.com", - "Osborne2", - alice.crypto!.olmDevice.deviceEd25519Key!, - ); - olmlib.pkVerify( - content["@alice:example.com"]["Osborne2"], - "EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ", - "@alice:example.com", - ); - resolve(); - } catch (e) { - reject(e); - } - }); - }); - - // @ts-ignore private property - const deviceInfo = alice.crypto!.deviceList.devices["@alice:example.com"].Osborne2; - const aliceDevice = { - user_id: "@alice:example.com", - device_id: "Osborne2", - keys: deviceInfo.keys, - algorithms: deviceInfo.algorithms, - }; - await alice.crypto!.signObject(aliceDevice); - olmlib.pkSign(aliceDevice as ISignedKey, selfSigningKey as unknown as PkSigning, "@alice:example.com", ""); - - // feed sync result that includes master key, ssk, device key - const responses: Response[] = [ - PUSH_RULES_RESPONSE, - { - method: "POST", - path: "/keys/upload", - data: { - one_time_key_counts: { - curve25519: 100, - signed_curve25519: 100, - }, - }, - }, - filterResponse("@alice:example.com"), - { - method: "GET", - path: "/sync", - data: { - next_batch: "abcdefg", - device_lists: { - changed: ["@alice:example.com", "@bob:example.com"], - }, - }, - }, - { - method: "POST", - path: "/keys/query", - data: { - failures: {}, - device_keys: { - "@alice:example.com": { - Osborne2: aliceDevice, - }, - }, - master_keys: { - "@alice:example.com": { - user_id: "@alice:example.com", - usage: ["master"], - keys: { - "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": - "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", - }, - }, - }, - self_signing_keys: { - "@alice:example.com": { - user_id: "@alice:example.com", - usage: ["self-signing"], - keys: { - "ed25519:EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ": - "EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ", - }, - signatures: { - "@alice:example.com": { - "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": - "Wqx/HXR851KIi8/u/UX+fbAMtq9Uj8sr8FsOcqrLfVYa6lAmbXs" + - "Vhfy4AlZ3dnEtjgZx0U0QDrghEn2eYBeOCA", - }, - }, - }, - }, - }, - }, - { - method: "POST", - path: "/keys/upload", - data: { - one_time_key_counts: { - curve25519: 100, - signed_curve25519: 100, - }, - }, - }, - ]; - setHttpResponses(httpBackend, responses); - - alice.startClient(); - httpBackend.flushAllExpected(); - - // once ssk is confirmed, device key should be trusted - await keyChangePromise; - await uploadSigsPromise; - - const aliceTrust = alice.checkUserTrust("@alice:example.com"); - expect(aliceTrust.isCrossSigningVerified()).toBeTruthy(); - expect(aliceTrust.isTofu()).toBeTruthy(); - expect(aliceTrust.isVerified()).toBeTruthy(); - - const aliceDeviceTrust = alice.checkDeviceTrust("@alice:example.com", "Osborne2"); - expect(aliceDeviceTrust.isCrossSigningVerified()).toBeTruthy(); - expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy(); - expect(aliceDeviceTrust.isTofu()).toBeTruthy(); - expect(aliceDeviceTrust.isVerified()).toBeTruthy(); - alice.stopClient(); - }); - - it("should use trust chain to determine device verification", async function () { - const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - alice.uploadDeviceSigningKeys = async () => ({}); - alice.uploadKeySignatures = async () => ({ failures: {} }); - // set Alice's cross-signing key - await resetCrossSigningKeys(alice); - // Alice downloads Bob's ssk and device key - const bobMasterSigning = new globalThis.Olm.PkSigning(); - const bobMasterPrivkey = bobMasterSigning.generate_seed(); - const bobMasterPubkey = bobMasterSigning.init_with_seed(bobMasterPrivkey); - const bobSigning = new globalThis.Olm.PkSigning(); - const bobPrivkey = bobSigning.generate_seed(); - const bobPubkey = bobSigning.init_with_seed(bobPrivkey); - const bobSSK: CrossSigningKeyInfo = { - user_id: "@bob:example.com", - usage: ["self_signing"], - keys: { - ["ed25519:" + bobPubkey]: bobPubkey, - }, - }; - const sskSig = bobMasterSigning.sign(anotherjson.stringify(bobSSK)); - bobSSK.signatures = { - "@bob:example.com": { - ["ed25519:" + bobMasterPubkey]: sskSig, - }, - }; - alice.crypto!.deviceList.storeCrossSigningForUser("@bob:example.com", { - keys: { - master: { - user_id: "@bob:example.com", - usage: ["master"], - keys: { - ["ed25519:" + bobMasterPubkey]: bobMasterPubkey, - }, - }, - self_signing: bobSSK, - }, - firstUse: true, - crossSigningVerifiedBefore: false, - }); - const bobDeviceUnsigned = { - user_id: "@bob:example.com", - device_id: "Dynabook", - algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], - keys: { - "curve25519:Dynabook": "somePubkey", - "ed25519:Dynabook": "someOtherPubkey", - }, - }; - const sig = bobSigning.sign(anotherjson.stringify(bobDeviceUnsigned)); - const bobDevice: IDevice = { - ...bobDeviceUnsigned, - signatures: { - "@bob:example.com": { - ["ed25519:" + bobPubkey]: sig, - }, - }, - verified: 0, - known: false, - }; - alice.crypto!.deviceList.storeDevicesForUser("@bob:example.com", { - Dynabook: bobDevice, - }); - // Bob's device key should be TOFU - const bobTrust = alice.checkUserTrust("@bob:example.com"); - expect(bobTrust.isVerified()).toBeFalsy(); - expect(bobTrust.isTofu()).toBeTruthy(); - - const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); - expect(bobDeviceTrust.isVerified()).toBeFalsy(); - expect(bobDeviceTrust.isTofu()).toBeTruthy(); - - // Alice verifies Bob's SSK - alice.uploadKeySignatures = async () => ({ failures: {} }); - await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true); - - // Bob's device key should be trusted - const bobTrust2 = alice.checkUserTrust("@bob:example.com"); - expect(bobTrust2.isCrossSigningVerified()).toBeTruthy(); - expect(bobTrust2.isTofu()).toBeTruthy(); - - const bobDeviceTrust2 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); - expect(bobDeviceTrust2.isCrossSigningVerified()).toBeTruthy(); - expect(bobDeviceTrust2.isLocallyVerified()).toBeFalsy(); - expect(bobDeviceTrust2.isTofu()).toBeTruthy(); - alice.stopClient(); - }); - - it.skip("should trust signatures received from other devices", async function () { - const aliceKeys: Record = {}; - const { client: alice, httpBackend } = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - undefined, - aliceKeys, - ); - alice.crypto!.deviceList.startTrackingDeviceList("@bob:example.com"); - alice.crypto!.deviceList.stopTrackingAllDeviceLists = () => {}; - alice.uploadDeviceSigningKeys = async () => ({}); - alice.uploadKeySignatures = async () => ({ failures: {} }); - - // set Alice's cross-signing key - await resetCrossSigningKeys(alice); - - const selfSigningKey = new Uint8Array([ - 0x1e, 0xf4, 0x01, 0x6d, 0x4f, 0xa1, 0x73, 0x66, 0x6b, 0xf8, 0x93, 0xf5, 0xb0, 0x4d, 0x17, 0xc0, 0x17, 0xb5, - 0xa5, 0xf6, 0x59, 0x11, 0x8b, 0x49, 0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f, - ]); - - const keyChangePromise = new Promise((resolve, reject) => { - alice.crypto!.deviceList.once(CryptoEvent.UserCrossSigningUpdated, (userId) => { - if (userId === "@bob:example.com") { - resolve(); - } - }); - }); - - // @ts-ignore private property - const deviceInfo = alice.crypto!.deviceList.devices["@alice:example.com"].Osborne2; - const aliceDevice = { - user_id: "@alice:example.com", - device_id: "Osborne2", - keys: deviceInfo.keys, - algorithms: deviceInfo.algorithms, - }; - await alice.crypto!.signObject(aliceDevice); - - const bobOlmAccount = new globalThis.Olm.Account(); - bobOlmAccount.create(); - const bobKeys = JSON.parse(bobOlmAccount.identity_keys()); - const bobDeviceUnsigned = { - user_id: "@bob:example.com", - device_id: "Dynabook", - algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], - keys: { - "ed25519:Dynabook": bobKeys.ed25519, - "curve25519:Dynabook": bobKeys.curve25519, - }, - }; - const deviceStr = anotherjson.stringify(bobDeviceUnsigned); - const bobDevice: IDevice = { - ...bobDeviceUnsigned, - signatures: { - "@bob:example.com": { - "ed25519:Dynabook": bobOlmAccount.sign(deviceStr), - }, - }, - verified: 0, - known: false, - }; - olmlib.pkSign(bobDevice, selfSigningKey as unknown as PkSigning, "@bob:example.com", ""); - - const bobMaster: CrossSigningKeyInfo = { - user_id: "@bob:example.com", - usage: ["master"], - keys: { - "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", - }, - }; - olmlib.pkSign(bobMaster, aliceKeys.user_signing, "@alice:example.com", ""); - - // Alice downloads Bob's keys - // - device key - // - ssk - // - master key signed by her usk (pretend that it was signed by another - // of Alice's devices) - const responses: Response[] = [ - PUSH_RULES_RESPONSE, - { - method: "POST", - path: "/keys/upload", - data: { - one_time_key_counts: { - curve25519: 100, - signed_curve25519: 100, - }, - }, - }, - filterResponse("@alice:example.com"), - { - method: "GET", - path: "/sync", - data: { - next_batch: "abcdefg", - device_lists: { - changed: ["@bob:example.com"], - }, - }, - }, - { - method: "POST", - path: "/keys/query", - data: { - failures: {}, - device_keys: { - "@alice:example.com": { - Osborne2: aliceDevice, - }, - "@bob:example.com": { - Dynabook: bobDevice, - }, - }, - master_keys: { - "@bob:example.com": bobMaster, - }, - self_signing_keys: { - "@bob:example.com": { - user_id: "@bob:example.com", - usage: ["self-signing"], - keys: { - "ed25519:EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ": - "EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ", - }, - signatures: { - "@bob:example.com": { - "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": - "2KLiufImvEbfJuAFvsaZD+PsL8ELWl7N1u9yr/9hZvwRghBfQMB" + - "LAI86b1kDV9+Cq1lt85ykReeCEzmTEPY2BQ", - }, - }, - }, - }, - }, - }, - { - method: "POST", - path: "/keys/upload", - data: { - one_time_key_counts: { - curve25519: 100, - signed_curve25519: 100, - }, - }, - }, - ]; - setHttpResponses(httpBackend, responses); - - alice.startClient(); - httpBackend.flushAllExpected(); - await keyChangePromise; - - // Bob's device key should be trusted - const bobTrust = alice.checkUserTrust("@bob:example.com"); - expect(bobTrust.isCrossSigningVerified()).toBeTruthy(); - expect(bobTrust.isTofu()).toBeTruthy(); - - const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); - expect(bobDeviceTrust.isCrossSigningVerified()).toBeTruthy(); - expect(bobDeviceTrust.isLocallyVerified()).toBeFalsy(); - expect(bobDeviceTrust.isTofu()).toBeTruthy(); - alice.stopClient(); - }); - - it("should dis-trust an unsigned device", async function () { - const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - alice.uploadDeviceSigningKeys = async () => ({}); - alice.uploadKeySignatures = async () => ({ failures: {} }); - // set Alice's cross-signing key - await resetCrossSigningKeys(alice); - // Alice downloads Bob's ssk and device key - // (NOTE: device key is not signed by ssk) - const bobMasterSigning = new globalThis.Olm.PkSigning(); - const bobMasterPrivkey = bobMasterSigning.generate_seed(); - const bobMasterPubkey = bobMasterSigning.init_with_seed(bobMasterPrivkey); - const bobSigning = new globalThis.Olm.PkSigning(); - const bobPrivkey = bobSigning.generate_seed(); - const bobPubkey = bobSigning.init_with_seed(bobPrivkey); - const bobSSK: CrossSigningKeyInfo = { - user_id: "@bob:example.com", - usage: ["self_signing"], - keys: { - ["ed25519:" + bobPubkey]: bobPubkey, - }, - }; - const sskSig = bobMasterSigning.sign(anotherjson.stringify(bobSSK)); - bobSSK.signatures = { - "@bob:example.com": { - ["ed25519:" + bobMasterPubkey]: sskSig, - }, - }; - alice.crypto!.deviceList.storeCrossSigningForUser("@bob:example.com", { - keys: { - master: { - user_id: "@bob:example.com", - usage: ["master"], - keys: { - ["ed25519:" + bobMasterPubkey]: bobMasterPubkey, - }, - }, - self_signing: bobSSK, - }, - firstUse: true, - crossSigningVerifiedBefore: false, - }); - const bobDevice = { - user_id: "@bob:example.com", - device_id: "Dynabook", - algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], - keys: { - "curve25519:Dynabook": "somePubkey", - "ed25519:Dynabook": "someOtherPubkey", - }, - }; - alice.crypto!.deviceList.storeDevicesForUser("@bob:example.com", { - Dynabook: bobDevice as unknown as IDevice, - }); - // Bob's device key should be untrusted - const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); - expect(bobDeviceTrust.isVerified()).toBeFalsy(); - expect(bobDeviceTrust.isTofu()).toBeFalsy(); - - // Alice verifies Bob's SSK - await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true); - - // Bob's device key should be untrusted - const bobDeviceTrust2 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); - expect(bobDeviceTrust2.isVerified()).toBeFalsy(); - expect(bobDeviceTrust2.isTofu()).toBeFalsy(); - alice.stopClient(); - }); - - it("should dis-trust a user when their ssk changes", async function () { - const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - alice.uploadDeviceSigningKeys = async () => ({}); - alice.uploadKeySignatures = async () => ({ failures: {} }); - await resetCrossSigningKeys(alice); - // Alice downloads Bob's keys - const bobMasterSigning = new globalThis.Olm.PkSigning(); - const bobMasterPrivkey = bobMasterSigning.generate_seed(); - const bobMasterPubkey = bobMasterSigning.init_with_seed(bobMasterPrivkey); - const bobSigning = new globalThis.Olm.PkSigning(); - const bobPrivkey = bobSigning.generate_seed(); - const bobPubkey = bobSigning.init_with_seed(bobPrivkey); - const bobSSK: CrossSigningKeyInfo = { - user_id: "@bob:example.com", - usage: ["self_signing"], - keys: { - ["ed25519:" + bobPubkey]: bobPubkey, - }, - }; - const sskSig = bobMasterSigning.sign(anotherjson.stringify(bobSSK)); - bobSSK.signatures = { - "@bob:example.com": { - ["ed25519:" + bobMasterPubkey]: sskSig, - }, - }; - alice.crypto!.deviceList.storeCrossSigningForUser("@bob:example.com", { - keys: { - master: { - user_id: "@bob:example.com", - usage: ["master"], - keys: { - ["ed25519:" + bobMasterPubkey]: bobMasterPubkey, - }, - }, - self_signing: bobSSK, - }, - firstUse: true, - crossSigningVerifiedBefore: false, - }); - const bobDeviceUnsigned = { - user_id: "@bob:example.com", - device_id: "Dynabook", - algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], - keys: { - "curve25519:Dynabook": "somePubkey", - "ed25519:Dynabook": "someOtherPubkey", - }, - }; - const bobDeviceString = anotherjson.stringify(bobDeviceUnsigned); - const sig = bobSigning.sign(bobDeviceString); - const bobDevice: IDevice = { - ...bobDeviceUnsigned, - verified: 0, - known: false, - signatures: { - "@bob:example.com": { - ["ed25519:" + bobPubkey]: sig, - }, - }, - }; - alice.crypto!.deviceList.storeDevicesForUser("@bob:example.com", { - Dynabook: bobDevice, - }); - // Alice verifies Bob's SSK - alice.uploadKeySignatures = async () => ({ failures: {} }); - await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true); - - // Bob's device key should be trusted - const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); - expect(bobDeviceTrust.isVerified()).toBeTruthy(); - expect(bobDeviceTrust.isTofu()).toBeTruthy(); - - // Alice downloads new SSK for Bob - const bobMasterSigning2 = new globalThis.Olm.PkSigning(); - const bobMasterPrivkey2 = bobMasterSigning2.generate_seed(); - const bobMasterPubkey2 = bobMasterSigning2.init_with_seed(bobMasterPrivkey2); - const bobSigning2 = new globalThis.Olm.PkSigning(); - const bobPrivkey2 = bobSigning2.generate_seed(); - const bobPubkey2 = bobSigning2.init_with_seed(bobPrivkey2); - const bobSSK2: CrossSigningKeyInfo = { - user_id: "@bob:example.com", - usage: ["self_signing"], - keys: { - ["ed25519:" + bobPubkey2]: bobPubkey2, - }, - }; - const sskSig2 = bobMasterSigning2.sign(anotherjson.stringify(bobSSK2)); - bobSSK2.signatures = { - "@bob:example.com": { - ["ed25519:" + bobMasterPubkey2]: sskSig2, - }, - }; - alice.crypto!.deviceList.storeCrossSigningForUser("@bob:example.com", { - keys: { - master: { - user_id: "@bob:example.com", - usage: ["master"], - keys: { - ["ed25519:" + bobMasterPubkey2]: bobMasterPubkey2, - }, - }, - self_signing: bobSSK2, - }, - firstUse: false, - crossSigningVerifiedBefore: false, - }); - // Bob's and his device should be untrusted - const bobTrust = alice.checkUserTrust("@bob:example.com"); - expect(bobTrust.isVerified()).toBeFalsy(); - expect(bobTrust.isTofu()).toBeFalsy(); - - const bobDeviceTrust2 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); - expect(bobDeviceTrust2.isVerified()).toBeFalsy(); - expect(bobDeviceTrust2.isTofu()).toBeFalsy(); - - // Alice verifies Bob's SSK - alice.uploadKeySignatures = async () => ({ failures: {} }); - await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey2, true); - - // Bob should be trusted but not his device - const bobTrust2 = alice.checkUserTrust("@bob:example.com"); - expect(bobTrust2.isVerified()).toBeTruthy(); - - const bobDeviceTrust3 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); - expect(bobDeviceTrust3.isVerified()).toBeFalsy(); - - // Alice gets new signature for device - const sig2 = bobSigning2.sign(bobDeviceString); - bobDevice.signatures!["@bob:example.com"]["ed25519:" + bobPubkey2] = sig2; - alice.crypto!.deviceList.storeDevicesForUser("@bob:example.com", { - Dynabook: bobDevice, - }); - - // Bob's device should be trusted again (but not TOFU) - const bobTrust3 = alice.checkUserTrust("@bob:example.com"); - expect(bobTrust3.isVerified()).toBeTruthy(); - - const bobDeviceTrust4 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); - expect(bobDeviceTrust4.isCrossSigningVerified()).toBeTruthy(); - alice.stopClient(); - }); - - it("should offer to upgrade device verifications to cross-signing", async function () { - let upgradeResolveFunc: () => void; - - const { client: alice } = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - { - cryptoCallbacks: { - shouldUpgradeDeviceVerifications: async (verifs) => { - expect(verifs.users["@bob:example.com"]).toBeDefined(); - upgradeResolveFunc(); - return ["@bob:example.com"]; - }, - }, - }, - ); - const { client: bob } = await makeTestClient({ userId: "@bob:example.com", deviceId: "Dynabook" }); - - bob.uploadDeviceSigningKeys = async () => ({}); - bob.uploadKeySignatures = async () => ({ failures: {} }); - // set Bob's cross-signing key - await resetCrossSigningKeys(bob); - alice.crypto!.deviceList.storeDevicesForUser("@bob:example.com", { - Dynabook: { - algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], - keys: { - "curve25519:Dynabook": bob.crypto!.olmDevice.deviceCurve25519Key!, - "ed25519:Dynabook": bob.crypto!.olmDevice.deviceEd25519Key!, - }, - verified: 1, - known: true, - }, - }); - alice.crypto!.deviceList.storeCrossSigningForUser("@bob:example.com", bob.crypto!.crossSigningInfo.toStorage()); - - alice.uploadDeviceSigningKeys = async () => ({}); - alice.uploadKeySignatures = async () => ({ failures: {} }); - // when alice sets up cross-signing, she should notice that bob's - // cross-signing key is signed by his Dynabook, which alice has - // verified, and ask if the device verification should be upgraded to a - // cross-signing verification - let upgradePromise = new Promise((resolve) => { - upgradeResolveFunc = resolve; - }); - await resetCrossSigningKeys(alice); - await upgradePromise; - - const bobTrust = alice.checkUserTrust("@bob:example.com"); - expect(bobTrust.isCrossSigningVerified()).toBeTruthy(); - expect(bobTrust.isTofu()).toBeTruthy(); - - // "forget" that Bob is trusted - delete alice.crypto!.deviceList.crossSigningInfo["@bob:example.com"].keys.master.signatures![ - "@alice:example.com" - ]; - - const bobTrust2 = alice.checkUserTrust("@bob:example.com"); - expect(bobTrust2.isCrossSigningVerified()).toBeFalsy(); - expect(bobTrust2.isTofu()).toBeTruthy(); - - upgradePromise = new Promise((resolve) => { - upgradeResolveFunc = resolve; - }); - alice.crypto!.deviceList.emit(CryptoEvent.UserCrossSigningUpdated, "@bob:example.com"); - await new Promise((resolve) => { - alice.crypto!.on(CryptoEvent.UserTrustStatusChanged, resolve); - }); - await upgradePromise; - - const bobTrust3 = alice.checkUserTrust("@bob:example.com"); - expect(bobTrust3.isCrossSigningVerified()).toBeTruthy(); - expect(bobTrust3.isTofu()).toBeTruthy(); - alice.stopClient(); - bob.stopClient(); - }); - - it("should observe that our own device is cross-signed, even if this device doesn't trust the key", async function () { - const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - alice.uploadDeviceSigningKeys = async () => ({}); - alice.uploadKeySignatures = async () => ({ failures: {} }); - - // Generate Alice's SSK etc - const aliceMasterSigning = new globalThis.Olm.PkSigning(); - const aliceMasterPrivkey = aliceMasterSigning.generate_seed(); - const aliceMasterPubkey = aliceMasterSigning.init_with_seed(aliceMasterPrivkey); - const aliceSigning = new globalThis.Olm.PkSigning(); - const alicePrivkey = aliceSigning.generate_seed(); - const alicePubkey = aliceSigning.init_with_seed(alicePrivkey); - const aliceSSK: CrossSigningKeyInfo = { - user_id: "@alice:example.com", - usage: ["self_signing"], - keys: { - ["ed25519:" + alicePubkey]: alicePubkey, - }, - }; - const sskSig = aliceMasterSigning.sign(anotherjson.stringify(aliceSSK)); - aliceSSK.signatures = { - "@alice:example.com": { - ["ed25519:" + aliceMasterPubkey]: sskSig, - }, - }; - - // Alice's device downloads the keys, but doesn't trust them yet - alice.crypto!.deviceList.storeCrossSigningForUser("@alice:example.com", { - keys: { - master: { - user_id: "@alice:example.com", - usage: ["master"], - keys: { - ["ed25519:" + aliceMasterPubkey]: aliceMasterPubkey, - }, - }, - self_signing: aliceSSK, - }, - firstUse: true, - crossSigningVerifiedBefore: false, - }); - - // Alice has a second device that's cross-signed - const aliceDeviceId = "Dynabook"; - const aliceUnsignedDevice = { - user_id: "@alice:example.com", - device_id: aliceDeviceId, - algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], - keys: { - "curve25519:Dynabook": "somePubkey", - "ed25519:Dynabook": "someOtherPubkey", - }, - }; - const sig = aliceSigning.sign(anotherjson.stringify(aliceUnsignedDevice)); - const aliceCrossSignedDevice: IDevice = { - ...aliceUnsignedDevice, - verified: 0, - known: false, - signatures: { - "@alice:example.com": { - ["ed25519:" + alicePubkey]: sig, - }, - }, - }; - alice.crypto!.deviceList.storeDevicesForUser("@alice:example.com", { - [aliceDeviceId]: aliceCrossSignedDevice, - }); - - // We don't trust the cross-signing keys yet... - expect(alice.checkDeviceTrust("@alice:example.com", aliceDeviceId).isCrossSigningVerified()).toBeFalsy(); - // ... but we do acknowledge that the device is signed by them - expect(alice.checkIfOwnDeviceCrossSigned(aliceDeviceId)).toBeTruthy(); - alice.stopClient(); - }); - - it("should observe that our own device isn't cross-signed", async function () { - const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - alice.uploadDeviceSigningKeys = async () => ({}); - alice.uploadKeySignatures = async () => ({ failures: {} }); - - // Generate Alice's SSK etc - const aliceMasterSigning = new globalThis.Olm.PkSigning(); - const aliceMasterPrivkey = aliceMasterSigning.generate_seed(); - const aliceMasterPubkey = aliceMasterSigning.init_with_seed(aliceMasterPrivkey); - const aliceSigning = new globalThis.Olm.PkSigning(); - const alicePrivkey = aliceSigning.generate_seed(); - const alicePubkey = aliceSigning.init_with_seed(alicePrivkey); - const aliceSSK: CrossSigningKeyInfo = { - user_id: "@alice:example.com", - usage: ["self_signing"], - keys: { - ["ed25519:" + alicePubkey]: alicePubkey, - }, - }; - const sskSig = aliceMasterSigning.sign(anotherjson.stringify(aliceSSK)); - aliceSSK.signatures = { - "@alice:example.com": { - ["ed25519:" + aliceMasterPubkey]: sskSig, - }, - }; - - // Alice's device downloads the keys - alice.crypto!.deviceList.storeCrossSigningForUser("@alice:example.com", { - keys: { - master: { - user_id: "@alice:example.com", - usage: ["master"], - keys: { - ["ed25519:" + aliceMasterPubkey]: aliceMasterPubkey, - }, - }, - self_signing: aliceSSK, - }, - firstUse: true, - crossSigningVerifiedBefore: false, - }); - - const deviceId = "Dynabook"; - const aliceNotCrossSignedDevice: IDevice = { - verified: 0, - known: false, - algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], - keys: { - "curve25519:Dynabook": "somePubkey", - "ed25519:Dynabook": "someOtherPubkey", - }, - }; - alice.crypto!.deviceList.storeDevicesForUser("@alice:example.com", { - [deviceId]: aliceNotCrossSignedDevice, - }); - - expect(alice.checkIfOwnDeviceCrossSigned(deviceId)).toBeFalsy(); - alice.stopClient(); - }); - - it("checkIfOwnDeviceCrossSigned should sanely handle unknown devices", async () => { - const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - alice.uploadDeviceSigningKeys = async () => ({}); - alice.uploadKeySignatures = async () => ({ failures: {} }); - - // Generate Alice's SSK etc - const aliceMasterSigning = new globalThis.Olm.PkSigning(); - const aliceMasterPrivkey = aliceMasterSigning.generate_seed(); - const aliceMasterPubkey = aliceMasterSigning.init_with_seed(aliceMasterPrivkey); - const aliceSigning = new globalThis.Olm.PkSigning(); - const alicePrivkey = aliceSigning.generate_seed(); - const alicePubkey = aliceSigning.init_with_seed(alicePrivkey); - const aliceSSK: CrossSigningKeyInfo = { - user_id: "@alice:example.com", - usage: ["self_signing"], - keys: { - ["ed25519:" + alicePubkey]: alicePubkey, - }, - }; - const sskSig = aliceMasterSigning.sign(anotherjson.stringify(aliceSSK)); - aliceSSK.signatures = { - "@alice:example.com": { - ["ed25519:" + aliceMasterPubkey]: sskSig, - }, - }; - - // Alice's device downloads the keys - alice.crypto!.deviceList.storeCrossSigningForUser("@alice:example.com", { - keys: { - master: { - user_id: "@alice:example.com", - usage: ["master"], - keys: { - ["ed25519:" + aliceMasterPubkey]: aliceMasterPubkey, - }, - }, - self_signing: aliceSSK, - }, - firstUse: true, - crossSigningVerifiedBefore: false, - }); - - expect(alice.checkIfOwnDeviceCrossSigned("notadevice")).toBeFalsy(); - alice.stopClient(); - }); - - it("checkIfOwnDeviceCrossSigned should sanely handle unknown users", async () => { - const { client: alice } = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - expect(alice.checkIfOwnDeviceCrossSigned("notadevice")).toBeFalsy(); - alice.stopClient(); - }); -}); - -describe("userHasCrossSigningKeys", function () { - if (!globalThis.Olm) { - return; - } - - beforeAll(() => { - return globalThis.Olm.init(); - }); - - let aliceClient: MatrixClient; - let httpBackend: HttpBackend; - beforeEach(async () => { - const testClient = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - aliceClient = testClient.client; - httpBackend = testClient.httpBackend; - }); - - afterEach(() => { - aliceClient.stopClient(); - }); - - it("should download devices and return true if one is a cross-signing key", async () => { - httpBackend.when("POST", "/keys/query").respond(200, { - master_keys: { - "@alice:example.com": { - user_id: "@alice:example.com", - usage: ["master"], - keys: { - "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": - "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", - }, - }, - }, - }); - - let result: boolean; - await Promise.all([ - httpBackend.flush("/keys/query"), - aliceClient.userHasCrossSigningKeys().then((res) => { - result = res; - }), - ]); - expect(result!).toBeTruthy(); - }); - - it("should download devices and return false if there is no cross-signing key", async () => { - httpBackend.when("POST", "/keys/query").respond(200, {}); - - let result: boolean; - await Promise.all([ - httpBackend.flush("/keys/query"), - aliceClient.userHasCrossSigningKeys().then((res) => { - result = res; - }), - ]); - expect(result!).toBeFalsy(); - }); - - it("throws an error if crypto is disabled", () => { - aliceClient["cryptoBackend"] = undefined; - expect(() => aliceClient.userHasCrossSigningKeys()).toThrow("encryption disabled"); - }); -}); diff --git a/spec/unit/crypto/crypto-utils.ts b/spec/unit/crypto/crypto-utils.ts deleted file mode 100644 index 3ef2b5050..000000000 --- a/spec/unit/crypto/crypto-utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { type IRecoveryKey } from "../../../src/crypto/api"; -import { type CrossSigningLevel } from "../../../src/crypto/CrossSigning"; -import { IndexedDBCryptoStore } from "../../../src/crypto/store/indexeddb-crypto-store"; -import { type MatrixClient } from "../../../src"; -import { CryptoEvent } from "../../../src/crypto"; - -// needs to be phased out and replaced with bootstrapSecretStorage, -// but that is doing too much extra stuff for it to be an easy transition. -export async function resetCrossSigningKeys( - client: MatrixClient, - { level }: { level?: CrossSigningLevel } = {}, -): Promise { - const crypto = client.crypto!; - - const oldKeys = Object.assign({}, crypto.crossSigningInfo.keys); - try { - await crypto.crossSigningInfo.resetKeys(level); - await crypto.signObject(crypto.crossSigningInfo.keys.master); - // write a copy locally so we know these are trusted keys - await crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - crypto.cryptoStore.storeCrossSigningKeys(txn, crypto.crossSigningInfo.keys); - }); - } catch (e) { - // If anything failed here, revert the keys so we know to try again from the start - // next time. - crypto.crossSigningInfo.keys = oldKeys; - throw e; - } - crypto.emit(CryptoEvent.KeysChanged, {}); - // @ts-ignore - await crypto.afterCrossSigningLocalKeyChange(); -} - -export async function createSecretStorageKey(): Promise { - const decryption = new globalThis.Olm.PkDecryption(); - decryption.generate_key(); - const storagePrivateKey = decryption.get_private_key(); - decryption.free(); - return { - privateKey: storagePrivateKey, - }; -} diff --git a/spec/unit/crypto/dehydration.spec.ts b/spec/unit/crypto/dehydration.spec.ts deleted file mode 100644 index d9a0dac89..000000000 --- a/spec/unit/crypto/dehydration.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* -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 "../../olm-loader"; -import { TestClient } from "../../TestClient"; -import { logger } from "../../../src/logger"; -import { DEHYDRATION_ALGORITHM } from "../../../src/crypto/dehydration"; - -const Olm = globalThis.Olm; - -describe("Dehydration", () => { - if (!globalThis.Olm) { - logger.warn("Not running dehydration unit tests: libolm not present"); - return; - } - - beforeAll(function () { - return globalThis.Olm.init(); - }); - - it("should rehydrate a dehydrated device", async () => { - const key = new Uint8Array([1, 2, 3]); - const alice = new TestClient("@alice:example.com", "Osborne2", undefined, undefined, { - cryptoCallbacks: { - getDehydrationKey: async (t) => key, - }, - }); - - const dehydratedDevice = new Olm.Account(); - dehydratedDevice.create(); - - alice.httpBackend.when("GET", "/dehydrated_device").respond(200, { - device_id: "ABCDEFG", - device_data: { - algorithm: DEHYDRATION_ALGORITHM, - account: dehydratedDevice.pickle(new Uint8Array(key)), - }, - }); - alice.httpBackend.when("POST", "/dehydrated_device/claim").respond(200, { - success: true, - }); - - expect((await Promise.all([alice.client.rehydrateDevice(), alice.httpBackend.flushAllExpected()]))[0]).toEqual( - "ABCDEFG", - ); - - expect(alice.client.getDeviceId()).toEqual("ABCDEFG"); - }); - - it("should dehydrate a device", async () => { - const key = new Uint8Array([1, 2, 3]); - const alice = new TestClient("@alice:example.com", "Osborne2", undefined, undefined, { - cryptoCallbacks: { - getDehydrationKey: async (t) => key, - }, - }); - - await alice.client.initLegacyCrypto(); - - alice.httpBackend.when("GET", "/room_keys/version").respond(404, { - errcode: "M_NOT_FOUND", - }); - - let pickledAccount = ""; - - alice.httpBackend - .when("PUT", "/dehydrated_device") - .check((req) => { - expect(req.data.device_data).toMatchObject({ - algorithm: DEHYDRATION_ALGORITHM, - account: expect.any(String), - }); - pickledAccount = req.data.device_data.account; - }) - .respond(200, { - device_id: "ABCDEFG", - }); - alice.httpBackend - .when("POST", "/keys/upload/ABCDEFG") - .check((req) => { - expect(req.data).toMatchObject({ - "device_keys": expect.objectContaining({ - algorithms: expect.any(Array), - device_id: "ABCDEFG", - user_id: "@alice:example.com", - keys: expect.objectContaining({ - "ed25519:ABCDEFG": expect.any(String), - "curve25519:ABCDEFG": expect.any(String), - }), - signatures: expect.objectContaining({ - "@alice:example.com": expect.objectContaining({ - "ed25519:ABCDEFG": expect.any(String), - }), - }), - }), - "one_time_keys": expect.any(Object), - "org.matrix.msc2732.fallback_keys": expect.any(Object), - }); - }) - .respond(200, {}); - - try { - const deviceId = ( - await Promise.all([ - alice.client.createDehydratedDevice(new Uint8Array(key), {}), - alice.httpBackend.flushAllExpected(), - ]) - )[0]; - - expect(deviceId).toEqual("ABCDEFG"); - expect(deviceId).not.toEqual(""); - - // try to rehydrate the dehydrated device - const rehydrated = new Olm.Account(); - try { - rehydrated.unpickle(new Uint8Array(key), pickledAccount); - } finally { - rehydrated.free(); - } - } finally { - alice.client?.crypto?.dehydrationManager?.stop(); - alice.client?.crypto?.deviceList.stop(); - } - }); -}); diff --git a/spec/unit/crypto/device-converter.spec.ts b/spec/unit/crypto/device-converter.spec.ts deleted file mode 100644 index d54f8f4e7..000000000 --- a/spec/unit/crypto/device-converter.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* -Copyright 2023 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 { DeviceInfo } from "../../../src/crypto/deviceinfo"; -import { DeviceVerification } from "../../../src"; -import { deviceInfoToDevice } from "../../../src/crypto/device-converter"; - -describe("device-converter", () => { - const userId = "@alice:example.com"; - const deviceId = "xcvf"; - - // All parameters for DeviceInfo initialization - const keys = { - [`ed25519:${deviceId}`]: "key1", - [`curve25519:${deviceId}`]: "key2", - }; - const algorithms = ["algo1", "algo2"]; - const verified = DeviceVerification.Verified; - const signatures = { [userId]: { [deviceId]: "sign1" } }; - const displayName = "display name"; - const unsigned = { - device_display_name: displayName, - }; - - describe("deviceInfoToDevice", () => { - it("should convert a DeviceInfo to a Device", () => { - const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified, signatures, unsigned }, deviceId); - const device = deviceInfoToDevice(deviceInfo, userId); - - expect(device.deviceId).toBe(deviceId); - expect(device.userId).toBe(userId); - expect(device.verified).toBe(verified); - expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]); - expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]); - expect(device.displayName).toBe(displayName); - }); - - it("should add empty signatures", () => { - const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified }, deviceId); - const device = deviceInfoToDevice(deviceInfo, userId); - - expect(device.signatures.size).toBe(0); - }); - }); -}); diff --git a/spec/unit/crypto/outgoing-room-key-requests.spec.ts b/spec/unit/crypto/outgoing-room-key-requests.spec.ts deleted file mode 100644 index b0d421f13..000000000 --- a/spec/unit/crypto/outgoing-room-key-requests.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* -Copyright 2020 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 CryptoStore } from "../../../src/crypto/store/base"; -import { IndexedDBCryptoStore } from "../../../src/crypto/store/indexeddb-crypto-store"; -import { LocalStorageCryptoStore } from "../../../src/crypto/store/localStorage-crypto-store"; -import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store"; -import { RoomKeyRequestState } from "../../../src/crypto/OutgoingRoomKeyRequestManager"; - -import "fake-indexeddb/auto"; -import "jest-localstorage-mock"; - -const requests = [ - { - requestId: "A", - requestBody: { session_id: "A", room_id: "A", sender_key: "A", algorithm: "m.megolm.v1.aes-sha2" }, - state: RoomKeyRequestState.Sent, - recipients: [ - { userId: "@alice:example.com", deviceId: "*" }, - { userId: "@becca:example.com", deviceId: "foobarbaz" }, - ], - }, - { - requestId: "B", - requestBody: { session_id: "B", room_id: "B", sender_key: "B", algorithm: "m.megolm.v1.aes-sha2" }, - state: RoomKeyRequestState.Sent, - recipients: [ - { userId: "@alice:example.com", deviceId: "*" }, - { userId: "@carrie:example.com", deviceId: "barbazquux" }, - ], - }, - { - requestId: "C", - requestBody: { session_id: "C", room_id: "C", sender_key: "B", algorithm: "m.megolm.v1.aes-sha2" }, - state: RoomKeyRequestState.Unsent, - recipients: [{ userId: "@becca:example.com", deviceId: "foobarbaz" }], - }, -]; - -describe.each([ - ["IndexedDBCryptoStore", () => new IndexedDBCryptoStore(globalThis.indexedDB, "tests")], - ["LocalStorageCryptoStore", () => new LocalStorageCryptoStore(localStorage)], - ["MemoryCryptoStore", () => new MemoryCryptoStore()], -])("Outgoing room key requests [%s]", function (name, dbFactory) { - let store: CryptoStore; - - beforeAll(async () => { - store = dbFactory(); - await store.startup(); - await Promise.all(requests.map((request) => store.getOrAddOutgoingRoomKeyRequest(request))); - }); - - it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state", async () => { - const r = await store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); - expect(r).toHaveLength(2); - requests - .filter((e) => e.state === RoomKeyRequestState.Sent) - .forEach((e) => { - expect(r).toContainEqual(e); - }); - }); - - it("getOutgoingRoomKeyRequestsByTarget retrieves all entries with a given target", async () => { - const r = await store.getOutgoingRoomKeyRequestsByTarget("@becca:example.com", "foobarbaz", [ - RoomKeyRequestState.Sent, - ]); - expect(r).toHaveLength(1); - expect(r[0]).toEqual(requests[0]); - }); - - test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state", async () => { - const r = await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]); - expect(r).not.toBeNull(); - expect(r).not.toBeUndefined(); - expect(r!.state).toEqual(RoomKeyRequestState.Sent); - expect(requests).toContainEqual(r); - }); -}); diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts deleted file mode 100644 index 477441ea3..000000000 --- a/spec/unit/crypto/secrets.spec.ts +++ /dev/null @@ -1,697 +0,0 @@ -/* -Copyright 2019, 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 "../../olm-loader"; -import * as olmlib from "../../../src/crypto/olmlib"; -import { type IObject } from "../../../src/crypto/olmlib"; -import { MatrixEvent } from "../../../src/models/event"; -import { TestClient } from "../../TestClient"; -import { makeTestClients } from "./verification/util"; -import encryptAESSecretStorageItem from "../../../src/utils/encryptAESSecretStorageItem.ts"; -import { createSecretStorageKey, resetCrossSigningKeys } from "./crypto-utils"; -import { logger } from "../../../src/logger"; -import { ClientEvent, type ICreateClientOpts, type MatrixClient } from "../../../src/client"; -import { DeviceInfo } from "../../../src/crypto/deviceinfo"; -import { type ISignatures } from "../../../src/@types/signed"; -import { type ICurve25519AuthData } from "../../../src/crypto/keybackup"; -import { type SecretStorageKeyDescription, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage"; -import { decodeBase64 } from "../../../src/base64"; -import { type CrossSigningKeyInfo } from "../../../src/crypto-api"; -import { type SecretInfo } from "../../../src/secret-storage.ts"; - -async function makeTestClient( - userInfo: { userId: string; deviceId: string }, - options: Partial = {}, -) { - const client = new TestClient(userInfo.userId, userInfo.deviceId, undefined, undefined, options).client; - - // Make it seem as if we've synced and thus the store can be trusted to - // contain valid account data. - client.isInitialSyncComplete = function () { - return true; - }; - - await client.initLegacyCrypto(); - - // No need to download keys for these tests - jest.spyOn(client.crypto!, "downloadKeys").mockResolvedValue(new Map()); - - return client; -} - -// Wrapper around pkSign to return a signed object. pkSign returns the -// signature, rather than the signed object. -function sign( - obj: T, - key: Uint8Array, - userId: string, -): T & { - signatures: ISignatures; - unsigned?: object; -} { - olmlib.pkSign(obj, key, userId, ""); - return obj as T & { - signatures: ISignatures; - unsigned?: object; - }; -} - -declare module "../../../src/@types/event" { - interface SecretStorageAccountDataEvents { - foo: SecretInfo; - } -} - -describe("Secrets", function () { - if (!globalThis.Olm) { - logger.warn("Not running megolm backup unit tests: libolm not present"); - return; - } - - beforeAll(function () { - return globalThis.Olm.init(); - }); - - it("should store and retrieve a secret", async function () { - const key = new Uint8Array(16); - for (let i = 0; i < 16; i++) key[i] = i; - - const signing = new globalThis.Olm.PkSigning(); - const signingKey = signing.generate_seed(); - const signingPubKey = signing.init_with_seed(signingKey); - - const signingkeyInfo = { - user_id: "@alice:example.com", - usage: ["master"], - keys: { - ["ed25519:" + signingPubKey]: signingPubKey, - }, - }; - - const getKey = jest.fn().mockImplementation(async (e) => { - expect(Object.keys(e.keys)).toEqual(["abc"]); - return ["abc", key]; - }); - - const alice = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - { - cryptoCallbacks: { - getCrossSigningKey: async (t) => signingKey, - getSecretStorageKey: getKey, - }, - }, - ); - alice.crypto!.crossSigningInfo.setKeys({ - master: signingkeyInfo, - }); - - const secretStorage = alice.crypto!.secretStorage; - - jest.spyOn(alice, "setAccountData").mockImplementation(async function (eventType, contents) { - alice.store.storeAccountDataEvents([ - new MatrixEvent({ - type: eventType, - content: contents, - }), - ]); - return {}; - }); - - const keyAccountData = { - algorithm: SECRET_STORAGE_ALGORITHM_V1_AES, - }; - await alice.crypto!.crossSigningInfo.signObject(keyAccountData, "master"); - - alice.store.storeAccountDataEvents([ - new MatrixEvent({ - type: "m.secret_storage.key.abc", - content: keyAccountData, - }), - ]); - - expect(await secretStorage.isStored("foo")).toBeFalsy(); - - await secretStorage.store("foo", "bar", ["abc"]); - - expect(await secretStorage.isStored("foo")).toBeTruthy(); - expect(await secretStorage.get("foo")).toBe("bar"); - - expect(getKey).toHaveBeenCalled(); - alice.stopClient(); - }); - - it("should throw if given a key that doesn't exist", async function () { - const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - - await expect(alice.storeSecret("foo", "bar", ["this secret does not exist"])).rejects.toBeTruthy(); - alice.stopClient(); - }); - - it("should refuse to encrypt with zero keys", async function () { - const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - - await expect(alice.storeSecret("foo", "bar", [])).rejects.toBeTruthy(); - alice.stopClient(); - }); - - it("should encrypt with default key if keys is null", async function () { - const key = new Uint8Array(16); - for (let i = 0; i < 16; i++) key[i] = i; - const getKey = jest.fn().mockImplementation(async (e) => { - expect(Object.keys(e.keys)).toEqual([newKeyId]); - return [newKeyId, key]; - }); - - let keys: Record = {}; - const alice = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - { - cryptoCallbacks: { - getCrossSigningKey: (t) => Promise.resolve(keys[t]), - saveCrossSigningKeys: (k) => (keys = k), - getSecretStorageKey: getKey, - }, - }, - ); - alice.setAccountData = async function (eventType, contents) { - alice.store.storeAccountDataEvents([ - new MatrixEvent({ - type: eventType, - content: contents, - }), - ]); - return {}; - }; - resetCrossSigningKeys(alice); - - const { keyId: newKeyId } = await alice.addSecretStorageKey(SECRET_STORAGE_ALGORITHM_V1_AES, { key }); - // we don't await on this because it waits for the event to come down the sync - // which won't happen in the test setup - alice.setDefaultSecretStorageKeyId(newKeyId); - await alice.storeSecret("foo", "bar"); - - const accountData = alice.getAccountData("foo"); - expect(accountData!.getContent().encrypted).toBeTruthy(); - alice.stopClient(); - }); - - it("should refuse to encrypt if no keys given and no default key", async function () { - const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - - await expect(alice.storeSecret("foo", "bar")).rejects.toBeTruthy(); - alice.stopClient(); - }); - - it("should request secrets from other clients", async function () { - const [[osborne2, vax], clearTestClientTimeouts] = await makeTestClients( - [ - { userId: "@alice:example.com", deviceId: "Osborne2" }, - { userId: "@alice:example.com", deviceId: "VAX" }, - ], - { - cryptoCallbacks: { - onSecretRequested: (userId, deviceId, requestId, secretName, deviceTrust) => { - expect(secretName).toBe("foo"); - return Promise.resolve("bar"); - }, - }, - }, - ); - - const vaxDevice = vax.client.crypto!.olmDevice; - const osborne2Device = osborne2.client.crypto!.olmDevice; - const secretStorage = osborne2.client.crypto!.secretStorage; - - osborne2.client.crypto!.deviceList.storeDevicesForUser("@alice:example.com", { - VAX: { - known: false, - algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], - keys: { - "ed25519:VAX": vaxDevice.deviceEd25519Key!, - "curve25519:VAX": vaxDevice.deviceCurve25519Key!, - }, - verified: DeviceInfo.DeviceVerification.VERIFIED, - }, - }); - vax.client.crypto!.deviceList.storeDevicesForUser("@alice:example.com", { - Osborne2: { - algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], - verified: 0, - known: false, - keys: { - "ed25519:Osborne2": osborne2Device.deviceEd25519Key!, - "curve25519:Osborne2": osborne2Device.deviceCurve25519Key!, - }, - }, - }); - - await osborne2Device.generateOneTimeKeys(1); - const otks = (await osborne2Device.getOneTimeKeys()).curve25519; - await osborne2Device.markKeysAsPublished(); - - await vax.client.crypto!.olmDevice.createOutboundSession( - osborne2Device.deviceCurve25519Key!, - Object.values(otks)[0], - ); - - osborne2.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map()); - osborne2.client.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com"; - - const request = await secretStorage.request("foo", ["VAX"]); - await request.promise; // return value not used - - osborne2.stop(); - vax.stop(); - clearTestClientTimeouts(); - }); - - describe("bootstrap", function () { - // keys used in some of the tests - const XSK = new Uint8Array(decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q=")); - const XSPubKey = "DRb8pFVJyEJ9OWvXeUoM0jq/C2Wt+NxzBZVuk2nRb+0"; - const USK = new Uint8Array(decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU=")); - const USPubKey = "CUpoiTtHiyXpUmd+3ohb7JVxAlUaOG1NYs9Jlx8soQU"; - const SSK = new Uint8Array(decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M=")); - const SSPubKey = "0DfNsRDzEvkCLA0gD3m7VAGJ5VClhjEsewI35xq873Q"; - const SSSSKey = new Uint8Array(decodeBase64("XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0=")); - - it("bootstraps when no storage or cross-signing keys locally", async function () { - const key = new Uint8Array(16); - for (let i = 0; i < 16; i++) key[i] = i; - const getKey = jest.fn().mockImplementation(async (e) => { - return [Object.keys(e.keys)[0], key]; - }); - - const bob = await makeTestClient( - { - userId: "@bob:example.com", - deviceId: "bob1", - }, - { - cryptoCallbacks: { - getSecretStorageKey: getKey, - }, - }, - ); - bob.uploadDeviceSigningKeys = async () => ({}); - bob.uploadKeySignatures = jest.fn().mockResolvedValue(undefined); - bob.setAccountData = async function (eventType, contents) { - const event = new MatrixEvent({ - type: eventType, - content: contents, - }); - this.store.storeAccountDataEvents([event]); - this.emit(ClientEvent.AccountData, event); - return {}; - }; - bob.getKeyBackupVersion = jest.fn().mockResolvedValue(null); - - await bob.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async (func) => { - await func({}); - }, - }); - await bob.bootstrapSecretStorage({ - createSecretStorageKey, - }); - - const crossSigning = bob.crypto!.crossSigningInfo; - const secretStorage = bob.crypto!.secretStorage; - - expect(crossSigning.getId()).toBeTruthy(); - expect(await crossSigning.isStoredInSecretStorage(secretStorage)).toBeTruthy(); - expect(await secretStorage.hasKey()).toBeTruthy(); - bob.stopClient(); - }); - - it("bootstraps when cross-signing keys in secret storage", async function () { - const decryption = new globalThis.Olm.PkDecryption(); - const storagePrivateKey = decryption.get_private_key(); - - const bob: MatrixClient = await makeTestClient( - { - userId: "@bob:example.com", - deviceId: "bob1", - }, - { - cryptoCallbacks: { - getSecretStorageKey: async (request) => { - const defaultKeyId = await bob.getDefaultSecretStorageKeyId(); - expect(Object.keys(request.keys)).toEqual([defaultKeyId]); - return [defaultKeyId!, storagePrivateKey]; - }, - }, - }, - ); - - bob.uploadDeviceSigningKeys = async () => ({}); - bob.uploadKeySignatures = async () => ({ failures: {} }); - bob.setAccountData = async function (eventType, contents) { - const event = new MatrixEvent({ - type: eventType, - content: contents, - }); - this.store.storeAccountDataEvents([event]); - this.emit(ClientEvent.AccountData, event); - return {}; - }; - bob.crypto!.backupManager.checkKeyBackup = async () => null; - - const crossSigning = bob.crypto!.crossSigningInfo; - const secretStorage = bob.crypto!.secretStorage; - - // Set up cross-signing keys from scratch with specific storage key - await bob.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async (func) => { - await func({}); - }, - }); - await bob.bootstrapSecretStorage({ - createSecretStorageKey: async () => ({ - privateKey: storagePrivateKey, - }), - }); - - // Clear local cross-signing keys and read from secret storage - bob.crypto!.deviceList.storeCrossSigningForUser("@bob:example.com", crossSigning.toStorage()); - crossSigning.keys = {}; - await bob.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async (func) => { - await func({}); - }, - }); - - expect(crossSigning.getId()).toBeTruthy(); - expect(await crossSigning.isStoredInSecretStorage(secretStorage)).toBeTruthy(); - expect(await secretStorage.hasKey()).toBeTruthy(); - bob.stopClient(); - }); - - it("adds passphrase checking if it's lacking", async function () { - let crossSigningKeys: Record = { - master: XSK, - user_signing: USK, - self_signing: SSK, - }; - const secretStorageKeys: Record = { - key_id: SSSSKey, - }; - const alice = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - { - cryptoCallbacks: { - getCrossSigningKey: async (t) => crossSigningKeys[t], - saveCrossSigningKeys: (k) => (crossSigningKeys = k), - getSecretStorageKey: async ({ keys }, name) => { - for (const keyId of Object.keys(keys)) { - if (secretStorageKeys[keyId]) { - return [keyId, secretStorageKeys[keyId]]; - } - } - return null; - }, - }, - }, - ); - alice.store.storeAccountDataEvents([ - new MatrixEvent({ - type: "m.secret_storage.default_key", - content: { - key: "key_id", - }, - }), - new MatrixEvent({ - type: "m.secret_storage.key.key_id", - content: { - algorithm: "m.secret_storage.v1.aes-hmac-sha2", - passphrase: { - algorithm: "m.pbkdf2", - iterations: 500000, - salt: "GbkvwKHVMveo1zGVSb2GMMdCinG2npJK", - }, - }, - }), - // we never use these values, other than checking that they - // exist, so just use dummy values - new MatrixEvent({ - type: "m.cross_signing.master", - content: { - encrypted: { - key_id: { ciphertext: "bla", mac: "bla", iv: "bla" }, - }, - }, - }), - new MatrixEvent({ - type: "m.cross_signing.self_signing", - content: { - encrypted: { - key_id: { ciphertext: "bla", mac: "bla", iv: "bla" }, - }, - }, - }), - new MatrixEvent({ - type: "m.cross_signing.user_signing", - content: { - encrypted: { - key_id: { ciphertext: "bla", mac: "bla", iv: "bla" }, - }, - }, - }), - ]); - alice.crypto!.deviceList.storeCrossSigningForUser("@alice:example.com", { - firstUse: false, - crossSigningVerifiedBefore: false, - keys: { - master: { - user_id: "@alice:example.com", - usage: ["master"], - keys: { - [`ed25519:${XSPubKey}`]: XSPubKey, - }, - }, - self_signing: sign( - { - user_id: "@alice:example.com", - usage: ["self_signing"], - keys: { - [`ed25519:${SSPubKey}`]: SSPubKey, - }, - }, - XSK, - "@alice:example.com", - ), - user_signing: sign( - { - user_id: "@alice:example.com", - usage: ["user_signing"], - keys: { - [`ed25519:${USPubKey}`]: USPubKey, - }, - }, - XSK, - "@alice:example.com", - ), - }, - }); - alice.getKeyBackupVersion = async () => { - return { - version: "1", - algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", - auth_data: sign( - { - public_key: "pxEXhg+4vdMf/kFwP4bVawFWdb0EmytL3eFJx++zQ0A", - }, - XSK, - "@alice:example.com", - ), - }; - }; - alice.setAccountData = async function (name, data) { - const event = new MatrixEvent({ - type: name, - content: data, - }); - alice.store.storeAccountDataEvents([event]); - this.emit(ClientEvent.AccountData, event); - return {}; - }; - - await alice.bootstrapSecretStorage({}); - - expect(alice.getAccountData("m.secret_storage.default_key")!.getContent()).toEqual({ key: "key_id" }); - const keyInfo = alice - .getAccountData("m.secret_storage.key.key_id")! - .getContent(); - expect(keyInfo.algorithm).toEqual("m.secret_storage.v1.aes-hmac-sha2"); - expect(keyInfo.passphrase).toEqual({ - algorithm: "m.pbkdf2", - iterations: 500000, - salt: "GbkvwKHVMveo1zGVSb2GMMdCinG2npJK", - }); - expect(keyInfo).toHaveProperty("iv"); - expect(keyInfo).toHaveProperty("mac"); - expect(alice.checkSecretStorageKey(secretStorageKeys.key_id, keyInfo)).toBeTruthy(); - alice.stopClient(); - }); - it("fixes backup keys in the wrong format", async function () { - let crossSigningKeys: Record = { - master: XSK, - user_signing: USK, - self_signing: SSK, - }; - const secretStorageKeys: Record = { - key_id: SSSSKey, - }; - const alice = await makeTestClient( - { userId: "@alice:example.com", deviceId: "Osborne2" }, - { - cryptoCallbacks: { - getCrossSigningKey: async (t) => crossSigningKeys[t], - saveCrossSigningKeys: (k) => (crossSigningKeys = k), - getSecretStorageKey: async ({ keys }, name) => { - for (const keyId of Object.keys(keys)) { - if (secretStorageKeys[keyId]) { - return [keyId, secretStorageKeys[keyId]]; - } - } - return null; - }, - }, - }, - ); - alice.store.storeAccountDataEvents([ - new MatrixEvent({ - type: "m.secret_storage.default_key", - content: { - key: "key_id", - }, - }), - new MatrixEvent({ - type: "m.secret_storage.key.key_id", - content: { - algorithm: "m.secret_storage.v1.aes-hmac-sha2", - passphrase: { - algorithm: "m.pbkdf2", - iterations: 500000, - salt: "GbkvwKHVMveo1zGVSb2GMMdCinG2npJK", - }, - }, - }), - new MatrixEvent({ - type: "m.cross_signing.master", - content: { - encrypted: { - key_id: { ciphertext: "bla", mac: "bla", iv: "bla" }, - }, - }, - }), - new MatrixEvent({ - type: "m.cross_signing.self_signing", - content: { - encrypted: { - key_id: { ciphertext: "bla", mac: "bla", iv: "bla" }, - }, - }, - }), - new MatrixEvent({ - type: "m.cross_signing.user_signing", - content: { - encrypted: { - key_id: { ciphertext: "bla", mac: "bla", iv: "bla" }, - }, - }, - }), - new MatrixEvent({ - type: "m.megolm_backup.v1", - content: { - encrypted: { - key_id: await encryptAESSecretStorageItem( - "123,45,6,7,89,1,234,56,78,90,12,34,5,67,8,90", - secretStorageKeys.key_id, - "m.megolm_backup.v1", - ), - }, - }, - }), - ]); - alice.crypto!.deviceList.storeCrossSigningForUser("@alice:example.com", { - firstUse: false, - crossSigningVerifiedBefore: false, - keys: { - master: { - user_id: "@alice:example.com", - usage: ["master"], - keys: { - [`ed25519:${XSPubKey}`]: XSPubKey, - }, - }, - self_signing: sign( - { - user_id: "@alice:example.com", - usage: ["self_signing"], - keys: { - [`ed25519:${SSPubKey}`]: SSPubKey, - }, - }, - XSK, - "@alice:example.com", - ), - user_signing: sign( - { - user_id: "@alice:example.com", - usage: ["user_signing"], - keys: { - [`ed25519:${USPubKey}`]: USPubKey, - }, - }, - XSK, - "@alice:example.com", - ), - }, - }); - alice.getKeyBackupVersion = async () => { - return { - version: "1", - algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", - auth_data: sign( - { - public_key: "pxEXhg+4vdMf/kFwP4bVawFWdb0EmytL3eFJx++zQ0A", - }, - XSK, - "@alice:example.com", - ), - }; - }; - alice.setAccountData = async function (name, data) { - const event = new MatrixEvent({ - type: name, - content: data, - }); - alice.store.storeAccountDataEvents([event]); - this.emit(ClientEvent.AccountData, event); - return {}; - }; - - await alice.bootstrapSecretStorage({}); - - const backupKey = alice.getAccountData("m.megolm_backup.v1")!.getContent(); - expect(backupKey.encrypted).toHaveProperty("key_id"); - expect(await alice.getSecret("m.megolm_backup.v1")).toEqual("ey0GB1kB6jhOWgwiBUMIWg=="); - alice.stopClient(); - }); - }); -}); diff --git a/spec/unit/crypto/verification/InRoomChannel.spec.ts b/spec/unit/crypto/verification/InRoomChannel.spec.ts deleted file mode 100644 index 28a88c50b..000000000 --- a/spec/unit/crypto/verification/InRoomChannel.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* -Copyright 2020 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 MatrixClient } from "../../../../src/client"; -import { InRoomChannel } from "../../../../src/crypto/verification/request/InRoomChannel"; -import { MatrixEvent } from "../../../../src/models/event"; - -describe("InRoomChannel tests", function () { - const ALICE = "@alice:hs.tld"; - const BOB = "@bob:hs.tld"; - const MALORY = "@malory:hs.tld"; - const client = { - getUserId() { - return ALICE; - }, - } as unknown as MatrixClient; - - it("getEventType only returns .request for a message with a msgtype", function () { - const invalidEvent = new MatrixEvent({ - type: "m.key.verification.request", - }); - expect(InRoomChannel.getEventType(invalidEvent)).toStrictEqual(""); - const validEvent = new MatrixEvent({ - type: "m.room.message", - content: { msgtype: "m.key.verification.request" }, - }); - expect(InRoomChannel.getEventType(validEvent)).toStrictEqual("m.key.verification.request"); - const validFooEvent = new MatrixEvent({ type: "m.foo" }); - expect(InRoomChannel.getEventType(validFooEvent)).toStrictEqual("m.foo"); - }); - - it("getEventType should return m.room.message for messages", function () { - const messageEvent = new MatrixEvent({ - type: "m.room.message", - content: { msgtype: "m.text" }, - }); - // XXX: The event type doesn't matter too much, just as long as it's not a verification event - expect(InRoomChannel.getEventType(messageEvent)).toStrictEqual("m.room.message"); - }); - - it("getEventType should return actual type for non-message events", function () { - const event = new MatrixEvent({ - type: "m.room.member", - content: {}, - }); - expect(InRoomChannel.getEventType(event)).toStrictEqual("m.room.member"); - }); - - it("getOtherPartyUserId should not return anything for a request not " + "directed at me", function () { - const event = new MatrixEvent({ - sender: BOB, - type: "m.room.message", - content: { msgtype: "m.key.verification.request", to: MALORY }, - }); - expect(InRoomChannel.getOtherPartyUserId(event, client)).toStrictEqual(undefined); - }); - - it("getOtherPartyUserId should not return anything an event that is not of a valid " + "request type", function () { - // invalid because this should be a room message with msgtype - const invalidRequest = new MatrixEvent({ - sender: BOB, - type: "m.key.verification.request", - content: { to: ALICE }, - }); - expect(InRoomChannel.getOtherPartyUserId(invalidRequest, client)).toStrictEqual(undefined); - const startEvent = new MatrixEvent({ - sender: BOB, - type: "m.key.verification.start", - content: { to: ALICE }, - }); - expect(InRoomChannel.getOtherPartyUserId(startEvent, client)).toStrictEqual(undefined); - const fooEvent = new MatrixEvent({ - sender: BOB, - type: "m.foo", - content: { to: ALICE }, - }); - expect(InRoomChannel.getOtherPartyUserId(fooEvent, client)).toStrictEqual(undefined); - }); -}); diff --git a/spec/unit/crypto/verification/qr_code.spec.ts b/spec/unit/crypto/verification/qr_code.spec.ts deleted file mode 100644 index 0f8fdcba5..000000000 --- a/spec/unit/crypto/verification/qr_code.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* -Copyright 2018-2019 New Vector Ltd -Copyright 2019 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 "../../../olm-loader"; -import { logger } from "../../../../src/logger"; - -const Olm = globalThis.Olm; - -describe("QR code verification", function () { - if (!globalThis.Olm) { - logger.warn("Not running device verification tests: libolm not present"); - return; - } - - beforeAll(function () { - return Olm.init(); - }); - - describe("reciprocate", () => { - it("should verify the secret", () => { - // TODO: Actually write a test for this. - // Tests are hard because we are running before the verification - // process actually begins, and are largely UI-driven rather than - // logic-driven (compared to something like SAS). In the interest - // of time, tests are currently excluded. - }); - }); -}); diff --git a/spec/unit/crypto/verification/request.spec.ts b/spec/unit/crypto/verification/request.spec.ts deleted file mode 100644 index c3b45b7b8..000000000 --- a/spec/unit/crypto/verification/request.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* -Copyright 2019 New Vector Ltd -Copyright 2019 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 "../../../olm-loader"; -import { CryptoEvent, verificationMethods } from "../../../../src/crypto"; -import { logger } from "../../../../src/logger"; -import { SAS } from "../../../../src/crypto/verification/SAS"; -import { makeTestClients } from "./util"; - -const Olm = globalThis.Olm; - -jest.useFakeTimers(); - -describe("verification request integration tests with crypto layer", function () { - if (!globalThis.Olm) { - logger.warn("Not running device verification unit tests: libolm not present"); - return; - } - - beforeAll(function () { - return Olm.init(); - }); - - it("should request and accept a verification", async function () { - const [[alice, bob], clearTestClientTimeouts] = await makeTestClients( - [ - { userId: "@alice:example.com", deviceId: "Osborne2" }, - { userId: "@bob:example.com", deviceId: "Dynabook" }, - ], - { - verificationMethods: [verificationMethods.SAS], - }, - ); - alice.client.crypto!.deviceList.getRawStoredDevicesForUser = function () { - return { - Dynabook: { - algorithms: [], - verified: 0, - known: false, - keys: { - "ed25519:Dynabook": "bob+base64+ed25519+key", - }, - }, - }; - }; - alice.client.downloadKeys = jest.fn().mockResolvedValue({}); - bob.client.downloadKeys = jest.fn().mockResolvedValue({}); - bob.client.on(CryptoEvent.VerificationRequest, (request) => { - const bobVerifier = request.beginKeyVerification(verificationMethods.SAS); - bobVerifier.verify(); - - // @ts-ignore Private function access (but it's a test, so we're okay) - bobVerifier.endTimer(); - }); - const aliceRequest = await alice.client.requestVerification("@bob:example.com"); - await aliceRequest.waitFor((r) => r.started); - const aliceVerifier = aliceRequest.verifier; - expect(aliceVerifier).toBeInstanceOf(SAS); - - // @ts-ignore Private function access (but it's a test, so we're okay) - aliceVerifier.endTimer(); - - alice.stop(); - bob.stop(); - clearTestClientTimeouts(); - }); -}); diff --git a/spec/unit/crypto/verification/sas.spec.ts b/spec/unit/crypto/verification/sas.spec.ts deleted file mode 100644 index ced5f0863..000000000 --- a/spec/unit/crypto/verification/sas.spec.ts +++ /dev/null @@ -1,581 +0,0 @@ -/* -Copyright 2018-2019 New Vector Ltd -Copyright 2019 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 "../../../olm-loader"; -import { makeTestClients } from "./util"; -import { MatrixEvent } from "../../../../src/models/event"; -import { type ISasEvent, SAS, SasEvent } from "../../../../src/crypto/verification/SAS"; -import { DeviceInfo, type IDevice } from "../../../../src/crypto/deviceinfo"; -import { CryptoEvent, verificationMethods } from "../../../../src/crypto"; -import * as olmlib from "../../../../src/crypto/olmlib"; -import { logger } from "../../../../src/logger"; -import { resetCrossSigningKeys } from "../crypto-utils"; -import { type VerificationBase } from "../../../../src/crypto/verification/Base"; -import { type IVerificationChannel } from "../../../../src/crypto/verification/request/Channel"; -import { type MatrixClient } from "../../../../src"; -import { type VerificationRequest } from "../../../../src/crypto/verification/request/VerificationRequest"; -import { type TestClient } from "../../../TestClient"; - -const Olm = globalThis.Olm; - -let ALICE_DEVICES: Record; -let BOB_DEVICES: Record; - -describe("SAS verification", function () { - if (!globalThis.Olm) { - logger.warn("Not running device verification unit tests: libolm not present"); - return; - } - - beforeAll(function () { - return Olm.init(); - }); - - it("should error on an unexpected event", async function () { - //channel, baseApis, userId, deviceId, startEvent, request - const request = { - onVerifierCancelled: function () {}, - } as VerificationRequest; - const channel = { - send: function () { - return Promise.resolve(); - }, - } as unknown as IVerificationChannel; - const mockClient = {} as unknown as MatrixClient; - const event = new MatrixEvent({ type: "test" }); - const sas = new SAS(channel, mockClient, "@alice:example.com", "ABCDEFG", event, request); - sas.handleEvent( - new MatrixEvent({ - sender: "@alice:example.com", - type: "es.inquisition", - content: {}, - }), - ); - const spy = jest.fn(); - await sas.verify().catch(spy); - expect(spy).toHaveBeenCalled(); - - // Cancel the SAS for cleanup (we started a verification, so abort) - sas.cancel(new Error("error")); - }); - - describe("verification", () => { - let alice: TestClient; - let bob: TestClient; - let aliceSasEvent: ISasEvent | null; - let bobSasEvent: ISasEvent | null; - let aliceVerifier: SAS; - let bobPromise: Promise>; - let clearTestClientTimeouts: () => void; - - beforeEach(async () => { - [[alice, bob], clearTestClientTimeouts] = await makeTestClients( - [ - { userId: "@alice:example.com", deviceId: "Osborne2" }, - { userId: "@bob:example.com", deviceId: "Dynabook" }, - ], - { - verificationMethods: [verificationMethods.SAS], - }, - ); - - const aliceDevice = alice.client.crypto!.olmDevice; - const bobDevice = bob.client.crypto!.olmDevice; - - ALICE_DEVICES = { - Osborne2: { - algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], - keys: { - "ed25519:Osborne2": aliceDevice.deviceEd25519Key!, - "curve25519:Osborne2": aliceDevice.deviceCurve25519Key!, - }, - verified: DeviceInfo.DeviceVerification.UNVERIFIED, - known: false, - }, - }; - - BOB_DEVICES = { - Dynabook: { - algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], - keys: { - "ed25519:Dynabook": bobDevice.deviceEd25519Key!, - "curve25519:Dynabook": bobDevice.deviceCurve25519Key!, - }, - verified: DeviceInfo.DeviceVerification.UNVERIFIED, - known: false, - }, - }; - - alice.client.crypto!.deviceList.storeDevicesForUser("@bob:example.com", BOB_DEVICES); - alice.client.downloadKeys = () => { - return Promise.resolve(new Map()); - }; - - bob.client.crypto!.deviceList.storeDevicesForUser("@alice:example.com", ALICE_DEVICES); - bob.client.downloadKeys = () => { - return Promise.resolve(new Map()); - }; - - aliceSasEvent = null; - bobSasEvent = null; - - bobPromise = new Promise>((resolve, reject) => { - bob.client.on(CryptoEvent.VerificationRequest, (request) => { - (request.verifier!).on(SasEvent.ShowSas, (e) => { - if (!e.sas.emoji || !e.sas.decimal) { - e.cancel(); - } else if (!aliceSasEvent) { - bobSasEvent = e; - } else { - try { - expect(e.sas).toEqual(aliceSasEvent.sas); - e.confirm(); - aliceSasEvent.confirm(); - } catch { - e.mismatch(); - aliceSasEvent.mismatch(); - } - } - }); - resolve(request.verifier!); - }); - }); - - aliceVerifier = alice.client.beginKeyVerification( - verificationMethods.SAS, - bob.client.getUserId()!, - bob.deviceId!, - ) as SAS; - aliceVerifier.on(SasEvent.ShowSas, (e) => { - if (!e.sas.emoji || !e.sas.decimal) { - e.cancel(); - } else if (!bobSasEvent) { - aliceSasEvent = e; - } else { - try { - expect(e.sas).toEqual(bobSasEvent.sas); - e.confirm(); - bobSasEvent.confirm(); - } catch { - e.mismatch(); - bobSasEvent.mismatch(); - } - } - }); - }); - - afterEach(async () => { - await Promise.all([alice.stop(), bob.stop()]); - - clearTestClientTimeouts(); - }); - - it("should verify a key", async () => { - let macMethod; - let keyAgreement; - const origSendToDevice = bob.client.sendToDevice.bind(bob.client); - bob.client.sendToDevice = async (type, map) => { - if (type === "m.key.verification.accept") { - macMethod = map - .get(alice.client.getUserId()!) - ?.get(alice.client.deviceId!)?.message_authentication_code; - keyAgreement = map - .get(alice.client.getUserId()!) - ?.get(alice.client.deviceId!)?.key_agreement_protocol; - } - return origSendToDevice(type, map); - }; - - alice.httpBackend.when("POST", "/keys/query").respond(200, { - failures: {}, - device_keys: { - "@bob:example.com": BOB_DEVICES, - }, - }); - bob.httpBackend.when("POST", "/keys/query").respond(200, { - failures: {}, - device_keys: { - "@alice:example.com": ALICE_DEVICES, - }, - }); - - await Promise.all([ - aliceVerifier.verify(), - bobPromise.then((verifier) => verifier.verify()), - alice.httpBackend.flush(undefined), - bob.httpBackend.flush(undefined), - ]); - - // make sure that it uses the preferred method - expect(macMethod).toBe("hkdf-hmac-sha256.v2"); - expect(keyAgreement).toBe("curve25519-hkdf-sha256"); - - // make sure Alice and Bob verified each other - const bobDevice = await alice.client.getStoredDevice("@bob:example.com", "Dynabook"); - expect(bobDevice?.isVerified()).toBeTruthy(); - const aliceDevice = await bob.client.getStoredDevice("@alice:example.com", "Osborne2"); - expect(aliceDevice?.isVerified()).toBeTruthy(); - }); - - it("should be able to verify using the old base64", async () => { - // pretend that Alice can only understand the old (incorrect) base64 - // encoding, and make sure that she can still verify with Bob - let macMethod; - const aliceOrigSendToDevice = alice.client.sendToDevice.bind(alice.client); - alice.client.sendToDevice = (type, map) => { - if (type === "m.key.verification.start") { - // Note: this modifies not only the message that Bob - // receives, but also the copy of the message that Alice - // has, since it is the same object. If this does not - // happen, the verification will fail due to a hash - // commitment mismatch. - map.get(bob.client.getUserId()!)!.get(bob.client.deviceId!)!.message_authentication_codes = [ - "hkdf-hmac-sha256", - ]; - } - return aliceOrigSendToDevice(type, map); - }; - const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client); - bob.client.sendToDevice = (type, map) => { - if (type === "m.key.verification.accept") { - macMethod = map - .get(alice.client.getUserId()!)! - .get(alice.client.deviceId!)!.message_authentication_code; - } - return bobOrigSendToDevice(type, map); - }; - - alice.httpBackend.when("POST", "/keys/query").respond(200, { - failures: {}, - device_keys: { - "@bob:example.com": BOB_DEVICES, - }, - }); - bob.httpBackend.when("POST", "/keys/query").respond(200, { - failures: {}, - device_keys: { - "@alice:example.com": ALICE_DEVICES, - }, - }); - - await Promise.all([ - aliceVerifier.verify(), - bobPromise.then((verifier) => verifier.verify()), - alice.httpBackend.flush(undefined), - bob.httpBackend.flush(undefined), - ]); - - expect(macMethod).toBe("hkdf-hmac-sha256"); - - const bobDevice = await alice.client.getStoredDevice("@bob:example.com", "Dynabook"); - expect(bobDevice!.isVerified()).toBeTruthy(); - const aliceDevice = await bob.client.getStoredDevice("@alice:example.com", "Osborne2"); - expect(aliceDevice!.isVerified()).toBeTruthy(); - }); - - it("should be able to verify using the old MAC", async () => { - // pretend that Alice can only understand the old (incorrect) MAC, - // and make sure that she can still verify with Bob - let macMethod; - const aliceOrigSendToDevice = alice.client.sendToDevice.bind(alice.client); - alice.client.sendToDevice = (type, map) => { - if (type === "m.key.verification.start") { - // Note: this modifies not only the message that Bob - // receives, but also the copy of the message that Alice - // has, since it is the same object. If this does not - // happen, the verification will fail due to a hash - // commitment mismatch. - map.get(bob.client.getUserId()!)!.get(bob.client.deviceId!)!.message_authentication_codes = [ - "hmac-sha256", - ]; - } - return aliceOrigSendToDevice(type, map); - }; - const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client); - bob.client.sendToDevice = (type, map) => { - if (type === "m.key.verification.accept") { - macMethod = map - .get(alice.client.getUserId()!)! - .get(alice.client.deviceId!)!.message_authentication_code; - } - return bobOrigSendToDevice(type, map); - }; - - alice.httpBackend.when("POST", "/keys/query").respond(200, { - failures: {}, - device_keys: { - "@bob:example.com": BOB_DEVICES, - }, - }); - bob.httpBackend.when("POST", "/keys/query").respond(200, { - failures: {}, - device_keys: { - "@alice:example.com": ALICE_DEVICES, - }, - }); - - await Promise.all([ - aliceVerifier.verify(), - bobPromise.then((verifier) => verifier.verify()), - alice.httpBackend.flush(undefined), - bob.httpBackend.flush(undefined), - ]); - - expect(macMethod).toBe("hmac-sha256"); - - const bobDevice = await alice.client.getStoredDevice("@bob:example.com", "Dynabook"); - expect(bobDevice?.isVerified()).toBeTruthy(); - const aliceDevice = await bob.client.getStoredDevice("@alice:example.com", "Osborne2"); - expect(aliceDevice?.isVerified()).toBeTruthy(); - }); - - it("should verify a cross-signing key", async () => { - alice.httpBackend.when("POST", "/keys/device_signing/upload").respond(200, {}); - alice.httpBackend.when("POST", "/keys/signatures/upload").respond(200, {}); - alice.httpBackend.flush(undefined, 2); - await resetCrossSigningKeys(alice.client); - bob.httpBackend.when("POST", "/keys/device_signing/upload").respond(200, {}); - bob.httpBackend.when("POST", "/keys/signatures/upload").respond(200, {}); - bob.httpBackend.flush(undefined, 2); - - await resetCrossSigningKeys(bob.client); - - bob.client.crypto!.deviceList.storeCrossSigningForUser("@alice:example.com", { - keys: alice.client.crypto!.crossSigningInfo.keys, - crossSigningVerifiedBefore: false, - firstUse: true, - }); - - const verifyProm = Promise.all([ - aliceVerifier.verify(), - bobPromise.then((verifier) => { - bob.httpBackend.when("POST", "/keys/signatures/upload").respond(200, {}); - bob.httpBackend.flush(undefined, 1, 2000); - return verifier.verify(); - }), - ]); - - await verifyProm; - - const bobDeviceTrust = alice.client.checkDeviceTrust("@bob:example.com", "Dynabook"); - expect(bobDeviceTrust.isLocallyVerified()).toBeTruthy(); - expect(bobDeviceTrust.isCrossSigningVerified()).toBeFalsy(); - - const bobDeviceVerificationStatus = (await alice.client - .getCrypto()! - .getDeviceVerificationStatus("@bob:example.com", "Dynabook"))!; - expect(bobDeviceVerificationStatus.localVerified).toBe(true); - expect(bobDeviceVerificationStatus.crossSigningVerified).toBe(false); - - const aliceTrust = bob.client.checkUserTrust("@alice:example.com"); - expect(aliceTrust.isCrossSigningVerified()).toBeTruthy(); - expect(aliceTrust.isTofu()).toBeTruthy(); - - const aliceDeviceTrust = bob.client.checkDeviceTrust("@alice:example.com", "Osborne2"); - expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy(); - expect(aliceDeviceTrust.isCrossSigningVerified()).toBeFalsy(); - - const aliceDeviceVerificationStatus = (await bob.client - .getCrypto()! - .getDeviceVerificationStatus("@alice:example.com", "Osborne2"))!; - expect(aliceDeviceVerificationStatus.localVerified).toBe(true); - expect(aliceDeviceVerificationStatus.crossSigningVerified).toBe(false); - - const unknownDeviceVerificationStatus = await bob.client - .getCrypto()! - .getDeviceVerificationStatus("@alice:example.com", "xyz"); - expect(unknownDeviceVerificationStatus).toBe(null); - }); - }); - - it("should send a cancellation message on error", async function () { - const [[alice, bob], clearTestClientTimeouts] = await makeTestClients( - [ - { userId: "@alice:example.com", deviceId: "Osborne2" }, - { userId: "@bob:example.com", deviceId: "Dynabook" }, - ], - { - verificationMethods: [verificationMethods.SAS], - }, - ); - alice.client.setDeviceVerified = jest.fn(); - alice.client.downloadKeys = jest.fn().mockResolvedValue({}); - bob.client.setDeviceVerified = jest.fn(); - bob.client.downloadKeys = jest.fn().mockResolvedValue({}); - - const bobPromise = new Promise>((resolve, reject) => { - bob.client.on(CryptoEvent.VerificationRequest, (request) => { - (request.verifier!).on(SasEvent.ShowSas, (e) => { - e.mismatch(); - }); - resolve(request.verifier!); - }); - }); - - const aliceVerifier = alice.client.beginKeyVerification( - verificationMethods.SAS, - bob.client.getUserId()!, - bob.client.deviceId!, - ); - - const aliceSpy = jest.fn(); - const bobSpy = jest.fn(); - await Promise.all([ - aliceVerifier.verify().catch(aliceSpy), - bobPromise.then((verifier) => verifier.verify()).catch(bobSpy), - ]); - expect(aliceSpy).toHaveBeenCalled(); - expect(bobSpy).toHaveBeenCalled(); - expect(alice.client.setDeviceVerified).not.toHaveBeenCalled(); - expect(bob.client.setDeviceVerified).not.toHaveBeenCalled(); - - alice.stop(); - bob.stop(); - clearTestClientTimeouts(); - }); - - describe("verification in DM", function () { - let alice: TestClient; - let bob: TestClient; - let aliceSasEvent: ISasEvent | null; - let bobSasEvent: ISasEvent | null; - let aliceVerifier: SAS; - let bobPromise: Promise; - let clearTestClientTimeouts: () => void; - - beforeEach(async function () { - [[alice, bob], clearTestClientTimeouts] = await makeTestClients( - [ - { userId: "@alice:example.com", deviceId: "Osborne2" }, - { userId: "@bob:example.com", deviceId: "Dynabook" }, - ], - { - verificationMethods: [verificationMethods.SAS], - }, - ); - - alice.client.crypto!.setDeviceVerification = jest.fn(); - alice.client.getDeviceEd25519Key = () => { - return "alice+base64+ed25519+key"; - }; - alice.client.getStoredDevice = () => { - return DeviceInfo.fromStorage( - { - keys: { - "ed25519:Dynabook": "bob+base64+ed25519+key", - }, - }, - "Dynabook", - ); - }; - alice.client.downloadKeys = () => { - return Promise.resolve(new Map()); - }; - - bob.client.crypto!.setDeviceVerification = jest.fn(); - bob.client.getStoredDevice = () => { - return DeviceInfo.fromStorage( - { - keys: { - "ed25519:Osborne2": "alice+base64+ed25519+key", - }, - }, - "Osborne2", - ); - }; - bob.client.getDeviceEd25519Key = () => { - return "bob+base64+ed25519+key"; - }; - bob.client.downloadKeys = () => { - return Promise.resolve(new Map()); - }; - - aliceSasEvent = null; - bobSasEvent = null; - - bobPromise = new Promise((resolve, reject) => { - bob.client.on(CryptoEvent.VerificationRequest, async (request) => { - const verifier = request.beginKeyVerification(SAS.NAME) as SAS; - verifier.on(SasEvent.ShowSas, (e) => { - if (!e.sas.emoji || !e.sas.decimal) { - e.cancel(); - } else if (!aliceSasEvent) { - bobSasEvent = e; - } else { - try { - expect(e.sas).toEqual(aliceSasEvent.sas); - e.confirm(); - aliceSasEvent.confirm(); - } catch { - e.mismatch(); - aliceSasEvent.mismatch(); - } - } - }); - await verifier.verify(); - resolve(); - }); - }); - - const aliceRequest = await alice.client.requestVerificationDM(bob.client.getUserId()!, "!room_id"); - await aliceRequest.waitFor((r) => r.started); - aliceVerifier = aliceRequest.verifier! as SAS; - aliceVerifier.on(SasEvent.ShowSas, (e) => { - if (!e.sas.emoji || !e.sas.decimal) { - e.cancel(); - } else if (!bobSasEvent) { - aliceSasEvent = e; - } else { - try { - expect(e.sas).toEqual(bobSasEvent.sas); - e.confirm(); - bobSasEvent.confirm(); - } catch { - e.mismatch(); - bobSasEvent.mismatch(); - } - } - }); - }); - afterEach(async function () { - await Promise.all([alice.stop(), bob.stop()]); - - clearTestClientTimeouts(); - }); - - it("should verify a key", async function () { - await Promise.all([aliceVerifier.verify(), bobPromise]); - - // make sure Alice and Bob verified each other - expect(alice.client.crypto!.setDeviceVerification).toHaveBeenCalledWith( - bob.client.getUserId(), - bob.client.deviceId, - true, - null, - null, - { "ed25519:Dynabook": "bob+base64+ed25519+key" }, - ); - expect(bob.client.crypto!.setDeviceVerification).toHaveBeenCalledWith( - alice.client.getUserId(), - alice.client.deviceId, - true, - null, - null, - { "ed25519:Osborne2": "alice+base64+ed25519+key" }, - ); - }); - }); -}); diff --git a/spec/unit/crypto/verification/secret_request.spec.ts b/spec/unit/crypto/verification/secret_request.spec.ts deleted file mode 100644 index 515a15d40..000000000 --- a/spec/unit/crypto/verification/secret_request.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* -Copyright 2020 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 "../../../olm-loader"; -import { type MatrixClient, type MatrixEvent } from "../../../../src/matrix"; -import { encodeBase64 } from "../../../../src/base64"; -import "../../../../src/crypto"; // import this to cycle-break -import { CrossSigningInfo } from "../../../../src/crypto/CrossSigning"; -import { type VerificationRequest } from "../../../../src/crypto/verification/request/VerificationRequest"; -import { type IVerificationChannel } from "../../../../src/crypto/verification/request/Channel"; -import { VerificationBase } from "../../../../src/crypto/verification/Base"; - -jest.useFakeTimers(); - -// Private key for tests only -const testKey = new Uint8Array([ - 0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, 0xae, 0xb1, 0x05, - 0xc1, 0xe7, 0x62, 0x78, 0xa6, 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d, -]); -const testKeyPub = "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"; - -describe("self-verifications", () => { - beforeAll(function () { - return globalThis.Olm.init(); - }); - - it("triggers a request for key sharing upon completion", async () => { - const userId = "@test:localhost"; - - const cacheCallbacks = { - getCrossSigningKeyCache: jest.fn().mockReturnValue(null), - storeCrossSigningKeyCache: jest.fn(), - }; - - const crossSigningInfo = new CrossSigningInfo(userId, {}, cacheCallbacks); - crossSigningInfo.keys = { - master: { - keys: { X: testKeyPub }, - usage: [], - user_id: "user-id", - }, - self_signing: { - keys: { X: testKeyPub }, - usage: [], - user_id: "user-id", - }, - user_signing: { - keys: { X: testKeyPub }, - usage: [], - user_id: "user-id", - }, - }; - - const secretStorage = { - request: jest.fn().mockReturnValue({ - promise: Promise.resolve(encodeBase64(testKey)), - }), - }; - - const storeSessionBackupPrivateKey = jest.fn(); - const restoreKeyBackupWithCache = jest.fn(() => Promise.resolve()); - - const client = { - crypto: { - crossSigningInfo, - secretStorage, - storeSessionBackupPrivateKey, - getSessionBackupPrivateKey: () => null, - }, - requestSecret: secretStorage.request.bind(secretStorage), - getUserId: () => userId, - getKeyBackupVersion: () => Promise.resolve({}), - restoreKeyBackupWithCache, - } as unknown as MatrixClient; - - const request = { - onVerifierFinished: () => undefined, - } as unknown as VerificationRequest; - - const verification = new VerificationBase( - undefined as unknown as IVerificationChannel, // channel - client, // baseApis - userId, - "ABC", // deviceId - undefined as unknown as MatrixEvent, // startEvent - request, - ); - - // @ts-ignore set private property - verification.resolve = () => undefined; - - const result = await verification.done(); - - /* We should request, and store, 3 cross signing keys and the key backup key */ - expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls.length).toBe(3); - expect(secretStorage.request.mock.calls.length).toBe(4); - - expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[0][1]).toEqual(testKey); - expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[1][1]).toEqual(testKey); - - expect(storeSessionBackupPrivateKey.mock.calls[0][0]).toEqual(testKey); - - expect(restoreKeyBackupWithCache).toHaveBeenCalled(); - - expect(result).toBeInstanceOf(Array); - expect(result![0][0]).toBe(testKeyPub); - expect(result![1][0]).toBe(testKeyPub); - }); -}); diff --git a/spec/unit/crypto/verification/util.ts b/spec/unit/crypto/verification/util.ts deleted file mode 100644 index cf3cf0c49..000000000 --- a/spec/unit/crypto/verification/util.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* -Copyright 2019 New Vector Ltd -Copyright 2019 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 { TestClient } from "../../../TestClient"; -import { type IContent, MatrixEvent } from "../../../../src/models/event"; -import { type IRoomTimelineData } from "../../../../src/models/event-timeline-set"; -import { Room, RoomEvent } from "../../../../src/models/room"; -import { logger } from "../../../../src/logger"; -import { - type MatrixClient, - ClientEvent, - type ICreateClientOpts, - type SendToDeviceContentMap, -} from "../../../../src/client"; - -interface UserInfo { - userId: string; - deviceId: string; -} - -export async function makeTestClients( - userInfos: UserInfo[], - options: Partial, -): Promise<[TestClient[], () => void]> { - const clients: TestClient[] = []; - const timeouts: ReturnType[] = []; - const clientMap: Record> = {}; - const makeSendToDevice = - (matrixClient: MatrixClient): MatrixClient["sendToDevice"] => - async (type: string, contentMap: SendToDeviceContentMap) => { - // logger.log(this.getUserId(), "sends", type, map); - for (const [userId, deviceMessages] of contentMap) { - if (userId in clientMap) { - for (const [deviceId, message] of deviceMessages) { - if (deviceId in clientMap[userId]) { - const event = new MatrixEvent({ - sender: matrixClient.getUserId()!, - type: type, - content: message, - }); - const client = clientMap[userId][deviceId]; - const decryptionPromise = event.isEncrypted() - ? event.attemptDecryption(client.crypto!) - : Promise.resolve(); - - decryptionPromise.then(() => client.emit(ClientEvent.ToDeviceEvent, event)); - } - } - } - } - return {}; - }; - const makeSendEvent = (matrixClient: MatrixClient) => (room: string, type: string, content: IContent) => { - // make up a unique ID as the event ID - const eventId = "$" + matrixClient.makeTxnId(); - const rawEvent = { - sender: matrixClient.getUserId()!, - type: type, - content: content, - room_id: room, - event_id: eventId, - origin_server_ts: Date.now(), - }; - const event = new MatrixEvent(rawEvent); - const remoteEcho = new MatrixEvent( - Object.assign({}, rawEvent, { - unsigned: { - transaction_id: matrixClient.makeTxnId(), - }, - }), - ); - - const timeout = setTimeout(() => { - for (const tc of clients) { - const room = new Room("test", tc.client, tc.client.getUserId()!); - const roomTimelineData = {} as unknown as IRoomTimelineData; - if (tc.client === matrixClient) { - logger.log("sending remote echo!!"); - tc.client.emit(RoomEvent.Timeline, remoteEcho, room, false, false, roomTimelineData); - } else { - tc.client.emit(RoomEvent.Timeline, event, room, false, false, roomTimelineData); - } - } - }); - - timeouts.push(timeout as unknown as ReturnType); - - return Promise.resolve({ event_id: eventId }); - }; - - for (const userInfo of userInfos) { - let keys: Record = {}; - if (!options) options = {}; - if (!options.cryptoCallbacks) options.cryptoCallbacks = {}; - if (!options.cryptoCallbacks.saveCrossSigningKeys) { - options.cryptoCallbacks.saveCrossSigningKeys = (k) => { - keys = k; - }; - // @ts-ignore tsc getting confused by overloads - options.cryptoCallbacks.getCrossSigningKey = (typ) => keys[typ]; - } - const testClient = new TestClient(userInfo.userId, userInfo.deviceId, undefined, undefined, options); - if (!(userInfo.userId in clientMap)) { - clientMap[userInfo.userId] = {}; - } - clientMap[userInfo.userId][userInfo.deviceId] = testClient.client; - testClient.client.sendToDevice = makeSendToDevice(testClient.client); - // @ts-ignore tsc getting confused by overloads - testClient.client.sendEvent = makeSendEvent(testClient.client); - clients.push(testClient); - } - - await Promise.all(clients.map((testClient) => testClient.client.initLegacyCrypto())); - - const destroy = () => { - timeouts.forEach((t) => clearTimeout(t)); - }; - - return [clients, destroy]; -} diff --git a/spec/unit/crypto/verification/verification_request.spec.ts b/spec/unit/crypto/verification/verification_request.spec.ts deleted file mode 100644 index 5752fa4a2..000000000 --- a/spec/unit/crypto/verification/verification_request.spec.ts +++ /dev/null @@ -1,331 +0,0 @@ -/* -Copyright 2020 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 { - VerificationRequest, - READY_TYPE, - START_TYPE, - DONE_TYPE, -} from "../../../../src/crypto/verification/request/VerificationRequest"; -import { InRoomChannel } from "../../../../src/crypto/verification/request/InRoomChannel"; -import { ToDeviceChannel } from "../../../../src/crypto/verification/request/ToDeviceChannel"; -import { type IContent, MatrixEvent } from "../../../../src/models/event"; -import { type MatrixClient } from "../../../../src/client"; -import { type IVerificationChannel } from "../../../../src/crypto/verification/request/Channel"; -import { VerificationBase } from "../../../../src/crypto/verification/Base"; -import { MapWithDefault } from "../../../../src/utils"; - -type MockClient = MatrixClient & { - popEvents: () => MatrixEvent[]; - popDeviceEvents: (userId: string, deviceId: string) => MatrixEvent[]; -}; -function makeMockClient(userId: string, deviceId: string): MockClient { - let counter = 1; - let events: MatrixEvent[] = []; - const deviceEvents: MapWithDefault> = new MapWithDefault( - () => new MapWithDefault(() => []), - ); - return { - getUserId() { - return userId; - }, - getDeviceId() { - return deviceId; - }, - - sendEvent(roomId: string, type: string, content: IContent) { - counter = counter + 1; - const eventId = `$${userId}-${deviceId}-${counter}`; - events.push( - new MatrixEvent({ - sender: userId, - event_id: eventId, - room_id: roomId, - type, - content, - origin_server_ts: Date.now(), - }), - ); - return Promise.resolve({ event_id: eventId }); - }, - - sendToDevice(type: string, msgMap: Map>) { - for (const [userId, deviceMessages] of msgMap) { - for (const [deviceId, content] of deviceMessages) { - const event = new MatrixEvent({ content, type }); - deviceEvents.getOrCreate(userId).getOrCreate(deviceId).push(event); - } - } - return Promise.resolve({}); - }, - - // @ts-ignore special testing fn - popEvents(): MatrixEvent[] { - const e = events; - events = []; - return e; - }, - - popDeviceEvents(userId: string, deviceId: string): MatrixEvent[] { - const result = deviceEvents.get(userId)?.get(deviceId) || []; - deviceEvents?.get(userId)?.delete(deviceId); - return result; - }, - } as unknown as MockClient; -} - -const MOCK_METHOD = "mock-verify"; -class MockVerifier extends VerificationBase<"", any> { - public _channel; - public _startEvent; - constructor( - channel: IVerificationChannel, - client: MatrixClient, - userId: string, - deviceId: string, - startEvent: MatrixEvent, - ) { - super(channel, client, userId, deviceId, startEvent, {} as unknown as VerificationRequest); - this._channel = channel; - this._startEvent = startEvent; - } - - get events() { - return [DONE_TYPE]; - } - - async start() { - if (this._startEvent) { - await this._channel.send(DONE_TYPE, {}); - } else { - await this._channel.send(START_TYPE, { method: MOCK_METHOD }); - } - } - - async handleEvent(event: MatrixEvent) { - if (event.getType() === DONE_TYPE && !this._startEvent) { - await this._channel.send(DONE_TYPE, {}); - } - } - - canSwitchStartEvent() { - return false; - } -} - -function makeRemoteEcho(event: MatrixEvent) { - return new MatrixEvent( - Object.assign({}, event.event, { - unsigned: { - transaction_id: "abc", - }, - }), - ); -} - -async function distributeEvent( - ownRequest: VerificationRequest, - theirRequest: VerificationRequest, - event: MatrixEvent, -): Promise { - await ownRequest.channel.handleEvent(makeRemoteEcho(event), ownRequest, true); - await theirRequest.channel.handleEvent(event, theirRequest, true); -} - -jest.useFakeTimers(); - -describe("verification request unit tests", function () { - it("transition from UNSENT to DONE through happy path", async function () { - const alice = makeMockClient("@alice:matrix.tld", "device1"); - const bob = makeMockClient("@bob:matrix.tld", "device1"); - const verificationMethods = new Map([[MOCK_METHOD, MockVerifier]]) as unknown as Map< - string, - typeof VerificationBase - >; - const aliceRequest = new VerificationRequest( - new InRoomChannel(alice, "!room", bob.getUserId()!), - verificationMethods, - alice, - ); - const bobRequest = new VerificationRequest(new InRoomChannel(bob, "!room"), verificationMethods, bob); - expect(aliceRequest.invalid).toBe(true); - expect(bobRequest.invalid).toBe(true); - - await aliceRequest.sendRequest(); - const [requestEvent] = alice.popEvents(); - expect(requestEvent.getType()).toBe("m.room.message"); - await distributeEvent(aliceRequest, bobRequest, requestEvent); - expect(aliceRequest.requested).toBe(true); - expect(bobRequest.requested).toBe(true); - - await bobRequest.accept(); - const [readyEvent] = bob.popEvents(); - expect(readyEvent.getType()).toBe(READY_TYPE); - await distributeEvent(bobRequest, aliceRequest, readyEvent); - expect(bobRequest.ready).toBe(true); - expect(aliceRequest.ready).toBe(true); - - const verifier = aliceRequest.beginKeyVerification(MOCK_METHOD); - await (verifier as MockVerifier).start(); - const [startEvent] = alice.popEvents(); - expect(startEvent.getType()).toBe(START_TYPE); - await distributeEvent(aliceRequest, bobRequest, startEvent); - expect(aliceRequest.started).toBe(true); - expect(aliceRequest.verifier).toBeInstanceOf(MockVerifier); - expect(bobRequest.started).toBe(true); - expect(bobRequest.verifier).toBeInstanceOf(MockVerifier); - await (bobRequest.verifier as MockVerifier).start(); - const [bobDoneEvent] = bob.popEvents(); - expect(bobDoneEvent.getType()).toBe(DONE_TYPE); - await distributeEvent(bobRequest, aliceRequest, bobDoneEvent); - const [aliceDoneEvent] = alice.popEvents(); - expect(aliceDoneEvent.getType()).toBe(DONE_TYPE); - await distributeEvent(aliceRequest, bobRequest, aliceDoneEvent); - expect(aliceRequest.done).toBe(true); - expect(bobRequest.done).toBe(true); - }); - - it("methods only contains common methods", async function () { - const alice = makeMockClient("@alice:matrix.tld", "device1"); - const bob = makeMockClient("@bob:matrix.tld", "device1"); - const aliceVerificationMethods = new Map([ - ["c", function () {}], - ["a", function () {}], - ]) as unknown as Map; - const bobVerificationMethods = new Map([ - ["c", function () {}], - ["b", function () {}], - ]) as unknown as Map; - const aliceRequest = new VerificationRequest( - new InRoomChannel(alice, "!room", bob.getUserId()!), - aliceVerificationMethods, - alice, - ); - const bobRequest = new VerificationRequest(new InRoomChannel(bob, "!room"), bobVerificationMethods, bob); - await aliceRequest.sendRequest(); - const [requestEvent] = alice.popEvents(); - await distributeEvent(aliceRequest, bobRequest, requestEvent); - await bobRequest.accept(); - const [readyEvent] = bob.popEvents(); - await distributeEvent(bobRequest, aliceRequest, readyEvent); - expect(aliceRequest.methods).toStrictEqual(["c"]); - expect(bobRequest.methods).toStrictEqual(["c"]); - }); - - it("other client accepting request puts it in observeOnly mode", async function () { - const alice = makeMockClient("@alice:matrix.tld", "device1"); - const bob1 = makeMockClient("@bob:matrix.tld", "device1"); - const bob2 = makeMockClient("@bob:matrix.tld", "device2"); - const aliceRequest = new VerificationRequest( - new InRoomChannel(alice, "!room", bob1.getUserId()!), - new Map(), - alice, - ); - await aliceRequest.sendRequest(); - const [requestEvent] = alice.popEvents(); - const bob1Request = new VerificationRequest(new InRoomChannel(bob1, "!room"), new Map(), bob1); - const bob2Request = new VerificationRequest(new InRoomChannel(bob2, "!room"), new Map(), bob2); - - await bob1Request.channel.handleEvent(requestEvent, bob1Request, true); - await bob2Request.channel.handleEvent(requestEvent, bob2Request, true); - - await bob1Request.accept(); - const [readyEvent] = bob1.popEvents(); - expect(bob2Request.observeOnly).toBe(false); - await bob2Request.channel.handleEvent(readyEvent, bob2Request, true); - expect(bob2Request.observeOnly).toBe(true); - }); - - it("verify own device with to_device messages", async function () { - const bob1 = makeMockClient("@bob:matrix.tld", "device1"); - const bob2 = makeMockClient("@bob:matrix.tld", "device2"); - const verificationMethods = new Map([[MOCK_METHOD, MockVerifier]]) as unknown as Map< - string, - typeof VerificationBase - >; - const bob1Request = new VerificationRequest( - new ToDeviceChannel( - bob1, - bob1.getUserId()!, - ["device1", "device2"], - ToDeviceChannel.makeTransactionId(), - "device2", - ), - verificationMethods, - bob1, - ); - const to = { userId: "@bob:matrix.tld", deviceId: "device2" }; - const verifier = bob1Request.beginKeyVerification(MOCK_METHOD, to); - expect(verifier).toBeInstanceOf(MockVerifier); - await (verifier as MockVerifier).start(); - const [startEvent] = bob1.popDeviceEvents(to.userId, to.deviceId); - expect(startEvent.getType()).toBe(START_TYPE); - const bob2Request = new VerificationRequest( - new ToDeviceChannel(bob2, bob2.getUserId()!, ["device1"]), - verificationMethods, - bob2, - ); - - await bob2Request.channel.handleEvent(startEvent, bob2Request, true); - await (bob2Request.verifier as MockVerifier).start(); - const [doneEvent1] = bob2.popDeviceEvents("@bob:matrix.tld", "device1"); - expect(doneEvent1.getType()).toBe(DONE_TYPE); - await bob1Request.channel.handleEvent(doneEvent1, bob1Request, true); - const [doneEvent2] = bob1.popDeviceEvents("@bob:matrix.tld", "device2"); - expect(doneEvent2.getType()).toBe(DONE_TYPE); - await bob2Request.channel.handleEvent(doneEvent2, bob2Request, true); - - expect(bob1Request.done).toBe(true); - expect(bob2Request.done).toBe(true); - }); - - it("request times out after 10 minutes", async function () { - const alice = makeMockClient("@alice:matrix.tld", "device1"); - const bob = makeMockClient("@bob:matrix.tld", "device1"); - const aliceRequest = new VerificationRequest( - new InRoomChannel(alice, "!room", bob.getUserId()!), - new Map(), - alice, - ); - await aliceRequest.sendRequest(); - const [requestEvent] = alice.popEvents(); - await aliceRequest.channel.handleEvent(requestEvent, aliceRequest, true); - - expect(aliceRequest.cancelled).toBe(false); - expect(aliceRequest._cancellingUserId).toBe(undefined); - jest.advanceTimersByTime(10 * 60 * 1000); - expect(aliceRequest._cancellingUserId).toBe(alice.getUserId()); - }); - - it("request times out 2 minutes after receipt", async function () { - const alice = makeMockClient("@alice:matrix.tld", "device1"); - const bob = makeMockClient("@bob:matrix.tld", "device1"); - const aliceRequest = new VerificationRequest( - new InRoomChannel(alice, "!room", bob.getUserId()!), - new Map(), - alice, - ); - await aliceRequest.sendRequest(); - const [requestEvent] = alice.popEvents(); - const bobRequest = new VerificationRequest(new InRoomChannel(bob, "!room"), new Map(), bob); - - await bobRequest.channel.handleEvent(requestEvent, bobRequest, true); - - expect(bobRequest.cancelled).toBe(false); - expect(bobRequest._cancellingUserId).toBe(undefined); - jest.advanceTimersByTime(2 * 60 * 1000); - expect(bobRequest._cancellingUserId).toBe(bob.getUserId()); - }); -}); diff --git a/spec/unit/embedded.spec.ts b/spec/unit/embedded.spec.ts index 35bdc4dff..fc430678d 100644 --- a/spec/unit/embedded.spec.ts +++ b/spec/unit/embedded.spec.ts @@ -40,7 +40,6 @@ import { SyncState } from "../../src/sync"; import { type ICapabilities, type RoomWidgetClient } from "../../src/embedded"; import { MatrixEvent } from "../../src/models/event"; import { type ToDeviceBatch } from "../../src/models/ToDeviceMessage"; -import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { sleep } from "../../src/utils"; const testOIDCToken = { @@ -728,10 +727,11 @@ describe("RoomWidgetClient", () => { expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo"); const payload = { type: "org.example.foo", hello: "world" }; - await client.encryptAndSendToDevices( + const embeddedClient = client as RoomWidgetClient; + await embeddedClient.encryptAndSendToDevices( [ - { userId: "@alice:example.org", deviceInfo: new DeviceInfo("aliceWeb") }, - { userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobDesktop") }, + { userId: "@alice:example.org", deviceId: "aliceWeb" }, + { userId: "@bob:example.org", deviceId: "bobDesktop" }, ], payload, ); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 5b00a3f9e..17951cc6e 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -37,8 +37,6 @@ import { UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "../../src/@types/event"; -import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib"; -import { Crypto } from "../../src/crypto"; import { EventStatus, MatrixEvent } from "../../src/models/event"; import { Preset } from "../../src/@types/partials"; import { ReceiptType } from "../../src/@types/read_receipts"; @@ -74,16 +72,15 @@ import { PolicyRecommendation, PolicyScope, } from "../../src/models/invites-ignorer"; -import { type IOlmDevice } from "../../src/crypto/algorithms/megolm"; import { defer, type QueryDict } from "../../src/utils"; import { type SyncState } from "../../src/sync"; import * as featureUtils from "../../src/feature"; import { StubStore } from "../../src/store/stub"; -import { type SecretStorageKeyDescriptionAesV1, type ServerSideSecretStorageImpl } from "../../src/secret-storage"; -import { type CryptoBackend } from "../../src/common-crypto/CryptoBackend"; +import { type ServerSideSecretStorageImpl } from "../../src/secret-storage"; import { KnownMembership } from "../../src/@types/membership"; import { type RoomMessageEventContent } from "../../src/@types/events"; import { mockOpenIdConfiguration } from "../test-utils/oidc.ts"; +import { type CryptoBackend } from "../../src/common-crypto/CryptoBackend"; jest.useFakeTimers(); @@ -1196,7 +1193,7 @@ describe("MatrixClient", function () { type: EventType.RoomEncryption, state_key: "", content: { - algorithm: MEGOLM_ALGORITHM, + algorithm: "m.megolm.v1.aes-sha2", }, }, ], @@ -1922,7 +1919,7 @@ describe("MatrixClient", function () { hasEncryptionStateEvent: jest.fn().mockReturnValue(true), } as unknown as Room; - let mockCrypto: Mocked; + let mockCrypto: Mocked; let event: MatrixEvent; beforeEach(async () => { @@ -1942,8 +1939,8 @@ describe("MatrixClient", function () { isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(true), encryptEvent: jest.fn(), stop: jest.fn(), - } as unknown as Mocked; - client.crypto = client["cryptoBackend"] = mockCrypto; + } as unknown as Mocked; + client["cryptoBackend"] = mockCrypto; }); function assertCancelled() { @@ -2329,21 +2326,6 @@ describe("MatrixClient", function () { }); }); - describe("encryptAndSendToDevices", () => { - it("throws an error if crypto is unavailable", () => { - client.crypto = undefined; - expect(() => client.encryptAndSendToDevices([], {})).toThrow(); - }); - - it("is an alias for the crypto method", async () => { - client.crypto = testUtils.mock(Crypto, "Crypto"); - const deviceInfos: IOlmDevice[] = []; - const payload = {}; - await client.encryptAndSendToDevices(deviceInfos, payload); - expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload); - }); - }); - describe("support for ignoring invites", () => { beforeEach(() => { // Mockup `getAccountData`/`setAccountData`. @@ -3205,24 +3187,6 @@ describe("MatrixClient", function () { client["_secretStorage"] = mockSecretStorage; }); - it("hasSecretStorageKey", async () => { - mockSecretStorage.hasKey.mockResolvedValue(false); - expect(await client.hasSecretStorageKey("mykey")).toBe(false); - expect(mockSecretStorage.hasKey).toHaveBeenCalledWith("mykey"); - }); - - it("isSecretStored", async () => { - const mockResult = { key: {} as SecretStorageKeyDescriptionAesV1 }; - mockSecretStorage.isStored.mockResolvedValue(mockResult); - expect(await client.isSecretStored("mysecret")).toBe(mockResult); - expect(mockSecretStorage.isStored).toHaveBeenCalledWith("mysecret"); - }); - - it("getDefaultSecretStorageKeyId", async () => { - mockSecretStorage.getDefaultKeyId.mockResolvedValue("bzz"); - expect(await client.getDefaultSecretStorageKeyId()).toEqual("bzz"); - }); - it("isKeyBackupKeyStored", async () => { mockSecretStorage.isStored.mockResolvedValue(null); expect(await client.isKeyBackupKeyStored()).toBe(null); @@ -3230,60 +3194,6 @@ describe("MatrixClient", function () { }); }); - // these wrappers are deprecated, but we need coverage of them to pass the quality gate - describe("Crypto wrappers", () => { - describe("exception if no crypto", () => { - it("isCrossSigningReady", () => { - expect(() => client.isCrossSigningReady()).toThrow("End-to-end encryption disabled"); - }); - - it("bootstrapCrossSigning", () => { - expect(() => client.bootstrapCrossSigning({})).toThrow("End-to-end encryption disabled"); - }); - - it("isSecretStorageReady", () => { - expect(() => client.isSecretStorageReady()).toThrow("End-to-end encryption disabled"); - }); - }); - - describe("defer to crypto backend", () => { - let mockCryptoBackend: Mocked; - - beforeEach(() => { - mockCryptoBackend = { - isCrossSigningReady: jest.fn(), - bootstrapCrossSigning: jest.fn(), - isSecretStorageReady: jest.fn(), - stop: jest.fn().mockResolvedValue(undefined), - } as unknown as Mocked; - client["cryptoBackend"] = mockCryptoBackend; - }); - - it("isCrossSigningReady", async () => { - const testResult = "test"; - mockCryptoBackend.isCrossSigningReady.mockResolvedValue(testResult as unknown as boolean); - expect(await client.isCrossSigningReady()).toBe(testResult); - expect(mockCryptoBackend.isCrossSigningReady).toHaveBeenCalledTimes(1); - }); - - it("bootstrapCrossSigning", async () => { - const testOpts = {}; - mockCryptoBackend.bootstrapCrossSigning.mockResolvedValue(undefined); - await client.bootstrapCrossSigning(testOpts); - expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledTimes(1); - expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledWith(testOpts); - }); - - it("isSecretStorageReady", async () => { - client["cryptoBackend"] = mockCryptoBackend; - const testResult = "test"; - mockCryptoBackend.isSecretStorageReady.mockResolvedValue(testResult as unknown as boolean); - expect(await client.isSecretStorageReady()).toBe(testResult); - expect(mockCryptoBackend.isSecretStorageReady).toHaveBeenCalledTimes(1); - }); - }); - }); - describe("paginateEventTimeline()", () => { describe("notifications timeline", () => { const unsafeNotification = { diff --git a/spec/unit/models/event.spec.ts b/spec/unit/models/event.spec.ts index e539e15a8..2dc02ed43 100644 --- a/spec/unit/models/event.spec.ts +++ b/spec/unit/models/event.spec.ts @@ -18,7 +18,6 @@ import { type MockedObject } from "jest-mock"; import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event"; import { emitPromise } from "../../test-utils/test-utils"; -import { type Crypto, type IEventDecryptionResult } from "../../../src/crypto"; import { type IAnnotatedPushRule, type MatrixClient, @@ -28,7 +27,11 @@ import { TweakName, } from "../../../src"; import { DecryptionFailureCode } from "../../../src/crypto-api"; -import { DecryptionError } from "../../../src/common-crypto/CryptoBackend"; +import { + type CryptoBackend, + DecryptionError, + type EventDecryptionResult, +} from "../../../src/common-crypto/CryptoBackend"; describe("MatrixEvent", () => { it("should create copies of itself", () => { @@ -369,7 +372,7 @@ describe("MatrixEvent", () => { const testError = new Error("test error"); const crypto = { decryptEvent: jest.fn().mockRejectedValue(testError), - } as unknown as Crypto; + } as unknown as CryptoBackend; await encryptedEvent.attemptDecryption(crypto); expect(encryptedEvent.isEncrypted()).toBeTruthy(); @@ -391,7 +394,7 @@ describe("MatrixEvent", () => { const testError = new DecryptionError(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, "uisi"); const crypto = { decryptEvent: jest.fn().mockRejectedValue(testError), - } as unknown as Crypto; + } as unknown as CryptoBackend; await encryptedEvent.attemptDecryption(crypto); expect(encryptedEvent.isEncrypted()).toBeTruthy(); @@ -418,7 +421,7 @@ describe("MatrixEvent", () => { "The sender has disabled encrypting to unverified devices.", ), ), - } as unknown as Crypto; + } as unknown as CryptoBackend; await encryptedEvent.attemptDecryption(crypto); expect(encryptedEvent.isEncrypted()).toBeTruthy(); @@ -453,7 +456,7 @@ describe("MatrixEvent", () => { }, }); }), - } as unknown as Crypto; + } as unknown as CryptoBackend; await encryptedEvent.attemptDecryption(crypto); @@ -478,7 +481,7 @@ describe("MatrixEvent", () => { const crypto = { decryptEvent: jest.fn().mockImplementationOnce(() => { - return Promise.resolve({ + return Promise.resolve({ clearEvent: { type: "m.room.message", content: { @@ -491,7 +494,7 @@ describe("MatrixEvent", () => { }, }); }), - } as unknown as Crypto; + } as unknown as CryptoBackend; await encryptedEvent.attemptDecryption(crypto); expect(encryptedEvent.getType()).toEqual("m.room.message"); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index f7a694c91..88f1361a1 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -53,12 +53,12 @@ import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; import { TestClient } from "../TestClient"; import { ReceiptType, type WrappedReceipt } from "../../src/@types/read_receipts"; import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread"; -import { type Crypto } from "../../src/crypto"; import * as threadUtils from "../test-utils/thread"; import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client"; import { logger } from "../../src/logger"; import { flushPromises } from "../test-utils/flushPromises"; import { KnownMembership } from "../../src/@types/membership"; +import type { CryptoBackend } from "../../src/common-crypto/CryptoBackend"; describe("Room", function () { const roomId = "!foo:bar"; @@ -3774,9 +3774,9 @@ 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["cryptoBackend"] = { + client["cryptoBackend"] = { decryptEvent: jest.fn().mockResolvedValue({ clearEvent: { body: "enc" } }), - } as unknown as Crypto; + } as unknown as CryptoBackend; client.store.getPendingEvents = jest.fn(async (roomId) => [ { event_id: "$1:server", diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index d124d12bf..21569870d 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -430,13 +430,18 @@ describe("initRustCrypto", () => { expect(session.senderSigningKey).toBe(undefined); }, 10000); - async function encryptAndStoreSecretKey(type: string, key: Uint8Array, pickleKey: string, store: CryptoStore) { + async function encryptAndStoreSecretKey( + type: string, + key: Uint8Array, + pickleKey: string, + store: MemoryCryptoStore, + ) { const encryptedKey = await encryptAESSecretStorageItem(encodeBase64(key), Buffer.from(pickleKey), type); store.storeSecretStorePrivateKey(undefined, type as keyof SecretStorePrivateKeys, encryptedKey); } /** Create a bunch of fake Olm sessions and stash them in the DB. */ - function createSessions(store: CryptoStore, nDevices: number, nSessionsPerDevice: number) { + function createSessions(store: MemoryCryptoStore, nDevices: number, nSessionsPerDevice: number) { for (let i = 0; i < nDevices; i++) { for (let j = 0; j < nSessionsPerDevice; j++) { const sessionData = { @@ -451,7 +456,7 @@ describe("initRustCrypto", () => { } /** Create a bunch of fake Megolm sessions and stash them in the DB. */ - function createMegolmSessions(store: CryptoStore, nDevices: number, nSessionsPerDevice: number) { + function createMegolmSessions(store: MemoryCryptoStore, nDevices: number, nSessionsPerDevice: number) { for (let i = 0; i < nDevices; i++) { for (let j = 0; j < nSessionsPerDevice; j++) { store.storeEndToEndInboundGroupSession( @@ -1009,34 +1014,6 @@ describe("RustCrypto", () => { }); }); - describe(".getEventEncryptionInfo", () => { - let rustCrypto: RustCrypto; - - beforeEach(async () => { - rustCrypto = await makeTestRustCrypto(); - }); - - it("should handle unencrypted events", () => { - const event = mkEvent({ event: true, type: "m.room.message", content: { body: "xyz" } }); - const res = rustCrypto.getEventEncryptionInfo(event); - expect(res.encrypted).toBeFalsy(); - }); - - it("should handle encrypted events", async () => { - const event = mkEvent({ event: true, type: "m.room.encrypted", content: { algorithm: "fake_alg" } }); - const mockCryptoBackend = { - decryptEvent: () => - ({ - senderCurve25519Key: "1234", - }) as IEventDecryptionResult, - } as unknown as CryptoBackend; - await event.attemptDecryption(mockCryptoBackend); - - const res = rustCrypto.getEventEncryptionInfo(event); - expect(res.encrypted).toBeTruthy(); - }); - }); - describe(".getEncryptionInfoForEvent", () => { let rustCrypto: RustCrypto; let olmMachine: Mocked; diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index 4157b146e..982432fcd 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -48,6 +48,8 @@ import { import { CallFeed } from "../../../src/webrtc/callFeed"; import { EventType, type IContent, type ISendEventResponse, type MatrixEvent, type Room } from "../../../src"; import { emitPromise } from "../../test-utils/test-utils"; +import type { CryptoApi } from "../../../src/crypto-api"; +import { GroupCallUnknownDeviceError } from "../../../src/webrtc/groupCall"; const FAKE_ROOM_ID = "!foo:bar"; const CALL_LIFETIME = 60000; @@ -1839,4 +1841,31 @@ describe("Call", function () { const err = await prom; expect(err.code).toBe(CallErrorCode.IceFailed); }); + + it("should throw an error when trying to call 'placeCallWithCallFeeds' when crypto is enabled", async () => { + jest.spyOn(client.client, "getCrypto").mockReturnValue({} as unknown as CryptoApi); + call = new MatrixCall({ + client: client.client, + roomId: FAKE_ROOM_ID, + opponentDeviceId: "opponent_device_id", + invitee: "invitee", + }); + call.on(CallEvent.Error, jest.fn()); + + await expect( + call.placeCallWithCallFeeds([ + new CallFeed({ + client: client.client, + stream: new MockMediaStream("local_stream1", [ + new MockMediaStreamTrack("track_id", "audio"), + ]) as unknown as MediaStream, + userId: client.getUserId(), + deviceId: undefined, + purpose: SDPStreamMetadataPurpose.Usermedia, + audioMuted: false, + videoMuted: false, + }), + ]), + ).rejects.toThrow(new GroupCallUnknownDeviceError("invitee")); + }); }); diff --git a/src/@types/crypto.ts b/src/@types/crypto.ts index 54389e336..fa8dd228e 100644 --- a/src/@types/crypto.ts +++ b/src/@types/crypto.ts @@ -16,11 +16,6 @@ limitations under the License. import type { ISignatures } from "./signed.ts"; -export type OlmGroupSessionExtraData = { - untrusted?: boolean; - sharedHistory?: boolean; -}; - // Backwards compatible re-export export type { EventDecryptionResult as IEventDecryptionResult } from "../common-crypto/CryptoBackend.ts"; @@ -30,7 +25,7 @@ interface Extensible { /* eslint-disable camelcase */ -/** The result of a call to {@link MatrixClient.exportRoomKeys} */ +/** The result of a call to {@link crypto-api!CryptoApi.exportRoomKeys} */ export interface IMegolmSessionData extends Extensible { /** Sender's Curve25519 device key */ sender_key: string; diff --git a/src/client.ts b/src/client.ts index 8c7e490ca..8f6fc0408 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,7 +20,7 @@ limitations under the License. import { type Optional } from "matrix-events-sdk"; -import type { IDeviceKeys, IMegolmSessionData, IOneTimeKey } from "./@types/crypto.ts"; +import type { IDeviceKeys, IOneTimeKey } from "./@types/crypto.ts"; import { type ISyncStateData, type SetPresence, SyncApi, type SyncApiOptions, SyncState } from "./sync.ts"; import { EventStatus, @@ -56,11 +56,8 @@ import { noUnsafeEventProps, type QueryDict, replaceParam, safeSet, sleep } from import { Direction, EventTimeline } from "./models/event-timeline.ts"; import { type IActionsObject, PushProcessor } from "./pushprocessor.ts"; import { AutoDiscovery, type AutoDiscoveryAction } from "./autodiscovery.ts"; -import { decodeBase64, encodeBase64, encodeUnpaddedBase64Url } from "./base64.ts"; -import { type IExportedDevice as IExportedOlmDevice } from "./crypto/OlmDevice.ts"; -import { type IOlmDevice } from "./crypto/algorithms/megolm.ts"; +import { encodeUnpaddedBase64Url } from "./base64.ts"; import { TypedReEmitter } from "./ReEmitter.ts"; -import { type IRoomEncryption } from "./crypto/RoomList.ts"; import { logger, type Logger } from "./logger.ts"; import { SERVICE_TYPES } from "./service-types.ts"; import { @@ -83,49 +80,16 @@ import { type UploadOpts, type UploadResponse, } from "./http-api/index.ts"; -import { - Crypto, - CryptoEvent as LegacyCryptoEvent, - type CryptoEventHandlerMap as LegacyCryptoEventHandlerMap, - fixBackupKey, - type ICheckOwnCrossSigningTrustOpts, - type IRoomKeyRequestBody, - isCryptoAvailable, -} from "./crypto/index.ts"; -import { type DeviceInfo } from "./crypto/deviceinfo.ts"; import { User, UserEvent, type UserEventHandlerMap } from "./models/user.ts"; import { getHttpUriForMxc } from "./content-repo.ts"; import { SearchResult } from "./models/search-result.ts"; -import { DEHYDRATION_ALGORITHM, type IDehydratedDevice, type IDehydratedDeviceKeyInfo } from "./crypto/dehydration.ts"; -import { - type IKeyBackupInfo, - type IKeyBackupPrepareOpts, - type IKeyBackupRestoreOpts, - type IKeyBackupRestoreResult, - type IKeyBackupRoomSessions, - type IKeyBackupSession, -} from "./crypto/keybackup.ts"; import { type IIdentityServerProvider } from "./@types/IIdentityServerProvider.ts"; import { type MatrixScheduler } from "./scheduler.ts"; import { type BeaconEvent, type BeaconEventHandlerMap } from "./models/beacon.ts"; import { type AuthDict } from "./interactive-auth.ts"; import { type IMinimalEvent, type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts"; -import { - CrossSigningKey, - type ICreateSecretStorageOpts, - type IEncryptedEventInfo, - type IRecoveryKey, -} from "./crypto/api.ts"; -import { type EventTimelineSet } from "./models/event-timeline-set.ts"; -import { type VerificationRequest } from "./crypto/verification/request/VerificationRequest.ts"; -import { type VerificationBase as Verification } from "./crypto/verification/Base.ts"; +import type { EventTimelineSet } from "./models/event-timeline-set.ts"; import * as ContentHelpers from "./content-helpers.ts"; -import { - type CrossSigningInfo, - type DeviceTrustLevel, - type ICacheCallbacks, - type UserTrustLevel, -} from "./crypto/CrossSigning.ts"; import { NotificationCountType, type Room, @@ -188,17 +152,9 @@ import { } from "./@types/partials.ts"; import { type EventMapper, eventMapperFor, type MapperOpts } from "./event-mapper.ts"; import { secureRandomString } from "./randomstring.ts"; -import { - BackupManager, - type IKeyBackup, - type IKeyBackupCheck, - type IPreparedKeyBackupVersion, - type TrustInfo, -} from "./crypto/backup.ts"; import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace.ts"; import { type ISignatures } from "./@types/signed.ts"; import { type IStore } from "./store/index.ts"; -import { type ISecretRequest } from "./crypto/SecretStorage.ts"; import { type IEventWithRoomId, type ISearchRequestBody, @@ -220,7 +176,7 @@ import { type RuleId, } from "./@types/PushRules.ts"; import { type IThreepid } from "./@types/threepids.ts"; -import { type CryptoStore, type OutgoingRoomKeyRequest } from "./crypto/store/base.ts"; +import { type CryptoStore } from "./crypto/store/base.ts"; import { GroupCall, type GroupCallIntent, @@ -256,22 +212,16 @@ import { IgnoredInvites } from "./models/invites-ignorer.ts"; import { type UIARequest, type UIAResponse } from "./@types/uia.ts"; import { type LocalNotificationSettings } from "./@types/local_notifications.ts"; import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature.ts"; -import { type BackupDecryptor, type CryptoBackend } from "./common-crypto/CryptoBackend.ts"; +import { type CryptoBackend } from "./common-crypto/CryptoBackend.ts"; import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants.ts"; import { - type BootstrapCrossSigningOpts, type CrossSigningKeyInfo, type CryptoApi, - decodeRecoveryKey, - type ImportRoomKeysOpts, CryptoEvent, type CryptoEventHandlerMap, type CryptoCallbacks, } from "./crypto-api/index.ts"; -import { type DeviceInfoMap } from "./crypto/DeviceList.ts"; import { - type AddSecretStorageKeyOpts, - type SecretStorageKey, type SecretStorageKeyDescription, type ServerSideSecretStorage, ServerSideSecretStorageImpl, @@ -284,7 +234,6 @@ import { type RoomMessageEventContent, type StickerEventContent } from "./@types import { type ImageInfo } from "./@types/media.ts"; import { type Capabilities, ServerCapabilities } from "./serverCapabilities.ts"; import { sha256 } from "./digest.ts"; -import { keyFromAuthData } from "./common-crypto/key-passphrase.ts"; import { discoverAndValidateOIDCIssuerWellKnown, type OidcClientConfig, @@ -297,10 +246,7 @@ export type Store = IStore; export type ResetTimelineCallback = (roomId: string) => boolean; const SCROLLBACK_DELAY_MS = 3000; -/** - * @deprecated Not supported for Rust Cryptography. - */ -export const CRYPTO_ENABLED: boolean = isCryptoAvailable(); + const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue( @@ -308,12 +254,6 @@ export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue( "org.matrix.msc3852.last_seen_user_agent", ); -interface IExportedDevice { - olmDevice: IExportedOlmDevice; - userId: string; - deviceId: string; -} - export interface IKeysUploadResponse { one_time_key_counts: { // eslint-disable-line camelcase @@ -340,7 +280,7 @@ export interface ICreateClientOpts { * specified, uses a default implementation (indexeddb in the browser, * in-memory otherwise). * - * This is only used for the legacy crypto implementation (as used by {@link MatrixClient#initLegacyCrypto}), + * This is only used for the legacy crypto implementation, * but if you use the rust crypto implementation ({@link MatrixClient#initRustCrypto}) and the device * previously used legacy crypto (so must be migrated), then this must still be provided, so that the * data can be migrated from the legacy store. @@ -421,21 +361,12 @@ export interface ICreateClientOpts { */ queryParams?: Record; - /** - * Device data exported with - * "exportDevice" method that must be imported to recreate this device. - * Should only be useful for devices with end-to-end crypto enabled. - * If provided, deviceId and userId should **NOT** be provided at the top - * level (they are present in the exported data). - */ - deviceToImport?: IExportedDevice; - /** * Encryption key used for encrypting sensitive data (such as e2ee keys) in {@link ICreateClientOpts#cryptoStore}. * * This must be set to the same value every time the client is initialised for the same device. * - * This is only used for the legacy crypto implementation (as used by {@link MatrixClient#initLegacyCrypto}), + * This is only used for the legacy crypto implementation, * but if you use the rust crypto implementation ({@link MatrixClient#initRustCrypto}) and the device * previously used legacy crypto (so must be migrated), then this must still be provided, so that the * data can be migrated from the legacy store. @@ -927,14 +858,6 @@ export interface RoomSummary extends Omit; -} - interface IRoomHierarchy { rooms: IHierarchyRoom[]; next_batch?: string; @@ -993,24 +916,6 @@ type RoomStateEvents = | RoomStateEvent.Update | RoomStateEvent.Marker; -type LegacyCryptoEvents = - | LegacyCryptoEvent.KeySignatureUploadFailure - | LegacyCryptoEvent.KeyBackupStatus - | LegacyCryptoEvent.KeyBackupFailed - | LegacyCryptoEvent.KeyBackupSessionsRemaining - | LegacyCryptoEvent.KeyBackupDecryptionKeyCached - | LegacyCryptoEvent.RoomKeyRequest - | LegacyCryptoEvent.RoomKeyRequestCancellation - | LegacyCryptoEvent.VerificationRequest - | LegacyCryptoEvent.VerificationRequestReceived - | LegacyCryptoEvent.DeviceVerificationChanged - | LegacyCryptoEvent.UserTrustStatusChanged - | LegacyCryptoEvent.KeysChanged - | LegacyCryptoEvent.Warning - | LegacyCryptoEvent.DevicesUpdated - | LegacyCryptoEvent.WillUpdateDevices - | LegacyCryptoEvent.LegacyCryptoStoreMigrationProgress; - type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent]; type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange; @@ -1032,7 +937,6 @@ export type EmittedEvents = | ClientEvent | RoomEvents | RoomStateEvents - | LegacyCryptoEvents | CryptoEvents | MatrixEventEvents | RoomMemberEvents @@ -1244,7 +1148,6 @@ export type ClientEventHandlerMap = { [ClientEvent.TurnServersError]: (error: Error, fatal: boolean) => void; } & RoomEventHandlerMap & RoomStateEventHandlerMap & - LegacyCryptoEventHandlerMap & CryptoEventHandlerMap & MatrixEventHandlerMap & RoomMemberEventHandlerMap & @@ -1278,12 +1181,9 @@ export class MatrixClient extends TypedEventEmitter; // XXX: Intended private, used in code. - /** - * The libolm crypto implementation, if it is in use. - * - * @deprecated This should not be used. Instead, use the methods exposed directly on this class or - * (where they are available) via {@link getCrypto}. - */ - public crypto?: Crypto; // XXX: Intended private, used in code. Being replaced by cryptoBackend - private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto public cryptoCallbacks: CryptoCallbacks; // XXX: Intended private, used in code. public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code. @@ -1321,8 +1213,12 @@ export class MatrixClient extends TypedEventEmitter; errorTs?: number } } = {}; protected notifTimelineSet: EventTimelineSet | null = null; - /* @deprecated */ - protected cryptoStore?: CryptoStore; + + /** + * Legacy crypto store used for migration from the legacy crypto to the rust crypto + * @private + */ + private readonly legacyCryptoStore?: CryptoStore; protected verificationMethods?: string[]; protected fallbackICEServerAllowed = false; protected syncApi?: SlidingSyncSdk | SyncApi; @@ -1348,7 +1244,6 @@ export class MatrixClient extends TypedEventEmitter; - protected exportedOlmDeviceToImport?: IExportedOlmDevice; protected txnCtr = 0; protected mediaHandler = new MediaHandler(this); protected sessionId: string; @@ -1410,27 +1305,8 @@ export class MatrixClient extends TypedEventEmitter { if (!this.canResetTimelineCallback) { @@ -1648,171 +1523,6 @@ export class MatrixClient extends TypedEventEmitter { - if (this.crypto) { - throw new Error("Cannot rehydrate device after crypto is initialized"); - } - - if (!this.cryptoCallbacks.getDehydrationKey) { - return; - } - - const getDeviceResult = await this.getDehydratedDevice(); - if (!getDeviceResult) { - return; - } - - if (!getDeviceResult.device_data || !getDeviceResult.device_id) { - this.logger.info("no dehydrated device found"); - return; - } - - const account = new globalThis.Olm.Account(); - try { - const deviceData = getDeviceResult.device_data; - if (deviceData.algorithm !== DEHYDRATION_ALGORITHM) { - this.logger.warn("Wrong algorithm for dehydrated device"); - return; - } - this.logger.debug("unpickling dehydrated device"); - const key = await this.cryptoCallbacks.getDehydrationKey(deviceData, (k) => { - // copy the key so that it doesn't get clobbered - account.unpickle(new Uint8Array(k), deviceData.account); - }); - account.unpickle(key, deviceData.account); - this.logger.debug("unpickled device"); - - const rehydrateResult = await this.http.authedRequest<{ success: boolean }>( - Method.Post, - "/dehydrated_device/claim", - undefined, - { - device_id: getDeviceResult.device_id, - }, - { - prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2", - }, - ); - - if (rehydrateResult.success) { - this.deviceId = getDeviceResult.device_id; - this.logger.info("using dehydrated device"); - const pickleKey = this.pickleKey || "DEFAULT_KEY"; - this.exportedOlmDeviceToImport = { - pickledAccount: account.pickle(pickleKey), - sessions: [], - pickleKey: pickleKey, - }; - account.free(); - return this.deviceId; - } else { - account.free(); - this.logger.info("not using dehydrated device"); - return; - } - } catch (e) { - account.free(); - this.logger.warn("could not unpickle", e); - } - } - - /** - * Get the current dehydrated device, if any - * @returns A promise of an object containing the dehydrated device - * - * @deprecated MSC2697 device dehydration is not supported for rust cryptography. - */ - public async getDehydratedDevice(): Promise { - try { - return await this.http.authedRequest( - Method.Get, - "/dehydrated_device", - undefined, - undefined, - { - prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2", - }, - ); - } catch (e) { - this.logger.info("could not get dehydrated device", e); - return; - } - } - - /** - * Set the dehydration key. This will also periodically dehydrate devices to - * the server. - * - * @param key - the dehydration key - * @param keyInfo - Information about the key. Primarily for - * information about how to generate the key from a passphrase. - * @param deviceDisplayName - The device display name for the - * dehydrated device. - * @returns A promise that resolves when the dehydrated device is stored. - * - * @deprecated Not supported for Rust Cryptography. - */ - public async setDehydrationKey( - key: Uint8Array, - keyInfo: IDehydratedDeviceKeyInfo, - deviceDisplayName?: string, - ): Promise { - if (!this.crypto) { - this.logger.warn("not dehydrating device if crypto is not enabled"); - return; - } - return this.crypto.dehydrationManager.setKeyAndQueueDehydration(key, keyInfo, deviceDisplayName); - } - - /** - * Creates a new MSC2967 dehydrated device (without queuing periodic dehydration) - * @param key - the dehydration key - * @param keyInfo - Information about the key. Primarily for - * information about how to generate the key from a passphrase. - * @param deviceDisplayName - The device display name for the - * dehydrated device. - * @returns the device id of the newly created dehydrated device - * - * @deprecated Not supported for Rust Cryptography. Prefer {@link CryptoApi.startDehydration}. - */ - public async createDehydratedDevice( - key: Uint8Array, - keyInfo: IDehydratedDeviceKeyInfo, - deviceDisplayName?: string, - ): Promise { - if (!this.crypto) { - this.logger.warn("not dehydrating device if crypto is not enabled"); - return; - } - await this.crypto.dehydrationManager.setKey(key, keyInfo, deviceDisplayName); - return this.crypto.dehydrationManager.dehydrateDevice(); - } - - /** @deprecated Not supported for Rust Cryptography. */ - public async exportDevice(): Promise { - if (!this.crypto) { - this.logger.warn("not exporting device if crypto is not enabled"); - return; - } - return { - userId: this.credentials.userId!, - deviceId: this.deviceId!, - // XXX: Private member access. - olmDevice: await this.crypto.olmDevice.export(), - }; - } - /** * Clear any data out of the persistent stores used by the client. * @@ -1826,8 +1536,8 @@ export class MatrixClient extends TypedEventEmitter[] = []; promises.push(this.store.deleteAllData()); - if (this.cryptoStore) { - promises.push(this.cryptoStore.deleteAllData()); + if (this.legacyCryptoStore) { + promises.push(this.legacyCryptoStore.deleteAllData()); } // delete the stores used by the rust matrix-sdk-crypto, in case they were used @@ -2182,92 +1892,9 @@ export class MatrixClient extends TypedEventEmitter { - if (!isCryptoAvailable()) { - throw new Error( - `End-to-end encryption not supported in this js-sdk build: did ` + - `you remember to load the olm library?`, - ); - } - - if (this.cryptoBackend) { - this.logger.warn("Attempt to re-initialise e2e encryption on MatrixClient"); - return; - } - - if (!this.cryptoStore) { - // the cryptostore is provided by sdk.createClient, so this shouldn't happen - throw new Error(`Cannot enable encryption: no cryptoStore provided`); - } - - this.logger.debug("Crypto: Starting up crypto store..."); - await this.cryptoStore.startup(); - - const userId = this.getUserId(); - if (userId === null) { - throw new Error( - `Cannot enable encryption on MatrixClient with unknown userId: ` + - `ensure userId is passed in createClient().`, - ); - } - if (this.deviceId === null) { - throw new Error( - `Cannot enable encryption on MatrixClient with unknown deviceId: ` + - `ensure deviceId is passed in createClient().`, - ); - } - - const crypto = new Crypto(this, userId, this.deviceId, this.store, this.cryptoStore, this.verificationMethods!); - - this.reEmitter.reEmit(crypto, [ - LegacyCryptoEvent.KeyBackupFailed, - LegacyCryptoEvent.KeyBackupSessionsRemaining, - LegacyCryptoEvent.RoomKeyRequest, - LegacyCryptoEvent.RoomKeyRequestCancellation, - LegacyCryptoEvent.Warning, - LegacyCryptoEvent.DevicesUpdated, - LegacyCryptoEvent.WillUpdateDevices, - LegacyCryptoEvent.DeviceVerificationChanged, - LegacyCryptoEvent.UserTrustStatusChanged, - LegacyCryptoEvent.KeysChanged, - ]); - - this.logger.debug("Crypto: initialising crypto object..."); - await crypto.init({ - exportedOlmDevice: this.exportedOlmDeviceToImport, - pickleKey: this.pickleKey, - }); - delete this.exportedOlmDeviceToImport; - - this.olmVersion = Crypto.getOlmVersion(); - - // if crypto initialisation was successful, tell it to attach its event handlers. - crypto.registerEventHandlers(this as Parameters[0]); - this.cryptoBackend = this.crypto = crypto; - - // upload our keys in the background - this.crypto.uploadDeviceKeys().catch((e) => { - // TODO: throwing away this error is a really bad idea. - this.logger.error("Error uploading device keys", e); - }); - } - /** * Initialise support for end-to-end encryption in this client, using the rust matrix-sdk-crypto. * - * An alternative to {@link initLegacyCrypto}. - * * **WARNING**: the cryptography stack is not thread-safe. Having multiple `MatrixClient` instances connected to * the same Indexed DB will cause data corruption and decryption failures. The application layer is responsible for * ensuring that only one `MatrixClient` issue is instantiated at a time. @@ -2326,8 +1953,8 @@ export class MatrixClient extends TypedEventEmitter { this.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, progress, total); }, @@ -2375,913 +2002,13 @@ export class MatrixClient extends TypedEventEmitter { - this.logger.warn("MatrixClient.uploadKeys is deprecated"); - } - - /** - * Download the keys for a list of users and stores the keys in the session - * store. - * @param userIds - The users to fetch. - * @param forceDownload - Always download the keys even if cached. - * - * @returns A promise which resolves to a map userId-\>deviceId-\>`DeviceInfo` - * - * @deprecated Not supported for Rust Cryptography. Prefer {@link CryptoApi.getUserDeviceInfo} - */ - public downloadKeys(userIds: string[], forceDownload?: boolean): Promise { - if (!this.crypto) { - return Promise.reject(new Error("End-to-end encryption disabled")); - } - return this.crypto.downloadKeys(userIds, forceDownload); - } - - /** - * Get the stored device keys for a user id - * - * @param userId - the user to list keys for. - * - * @returns list of devices - * @deprecated Not supported for Rust Cryptography. Prefer {@link CryptoApi.getUserDeviceInfo} - */ - public getStoredDevicesForUser(userId: string): DeviceInfo[] { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.getStoredDevicesForUser(userId) || []; - } - - /** - * Get the stored device key for a user id and device id - * - * @param userId - the user to list keys for. - * @param deviceId - unique identifier for the device - * - * @returns device or null - * @deprecated Not supported for Rust Cryptography. Prefer {@link CryptoApi.getUserDeviceInfo} - */ - public getStoredDevice(userId: string, deviceId: string): DeviceInfo | null { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.getStoredDevice(userId, deviceId) || null; - } - - /** - * Mark the given device as verified - * - * @param userId - owner of the device - * @param deviceId - unique identifier for the device or user's - * cross-signing public key ID. - * - * @param verified - whether to mark the device as verified. defaults - * to 'true'. - * - * @returns - * - * @remarks - * Fires {@link CryptoEvent#DeviceVerificationChanged} - * - * @deprecated Not supported for Rust Cryptography. - */ - public setDeviceVerified(userId: string, deviceId: string, verified = true): Promise { - const prom = this.setDeviceVerification(userId, deviceId, verified, null, null); - - // if one of the user's own devices is being marked as verified / unverified, - // check the key backup status, since whether or not we use this depends on - // whether it has a signature from a verified device - if (userId == this.credentials.userId) { - this.checkKeyBackup(); - } - return prom; - } - - /** - * Mark the given device as blocked/unblocked - * - * @param userId - owner of the device - * @param deviceId - unique identifier for the device or user's - * cross-signing public key ID. - * - * @param blocked - whether to mark the device as blocked. defaults - * to 'true'. - * - * @returns - * - * @remarks - * Fires {@link LegacyCryptoEvent.DeviceVerificationChanged} - * - * @deprecated Not supported for Rust Cryptography. - */ - public setDeviceBlocked(userId: string, deviceId: string, blocked = true): Promise { - return this.setDeviceVerification(userId, deviceId, null, blocked, null); - } - - /** - * Mark the given device as known/unknown - * - * @param userId - owner of the device - * @param deviceId - unique identifier for the device or user's - * cross-signing public key ID. - * - * @param known - whether to mark the device as known. defaults - * to 'true'. - * - * @returns - * - * @remarks - * Fires {@link CryptoEvent#DeviceVerificationChanged} - * - * @deprecated Not supported for Rust Cryptography. - */ - public setDeviceKnown(userId: string, deviceId: string, known = true): Promise { - return this.setDeviceVerification(userId, deviceId, null, null, known); - } - - private async setDeviceVerification( - userId: string, - deviceId: string, - verified?: boolean | null, - blocked?: boolean | null, - known?: boolean | null, - ): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - await this.crypto.setDeviceVerification(userId, deviceId, verified, blocked, known); - } - - /** - * Request a key verification from another user, using a DM. - * - * @param userId - the user to request verification with - * @param roomId - the room to use for verification - * - * @returns resolves to a VerificationRequest - * when the request has been sent to the other party. - * - * @deprecated Not supported for Rust Cryptography. Prefer {@link CryptoApi.requestVerificationDM}. - */ - public requestVerificationDM(userId: string, roomId: string): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.requestVerificationDM(userId, roomId); - } - - /** - * Finds a DM verification request that is already in progress for the given room id - * - * @param roomId - the room to use for verification - * - * @returns the VerificationRequest that is in progress, if any - * @deprecated Not supported for Rust Cryptography. Prefer {@link CryptoApi.findVerificationRequestDMInProgress}. - */ - public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } else if (!this.crypto) { - // Hack for element-R to avoid breaking the cypress tests. We can get rid of this once the react-sdk is - // updated to use CryptoApi.findVerificationRequestDMInProgress. - return undefined; - } - return this.crypto.findVerificationRequestDMInProgress(roomId); - } - - /** - * Returns all to-device verification requests that are already in progress for the given user id - * - * @param userId - the ID of the user to query - * - * @returns the VerificationRequests that are in progress - * @deprecated Not supported for Rust Cryptography. Prefer {@link CryptoApi.getVerificationRequestsToDeviceInProgress}. - */ - public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.getVerificationRequestsToDeviceInProgress(userId); - } - - /** - * Request a key verification from another user. - * - * @param userId - the user to request verification with - * @param devices - array of device IDs to send requests to. Defaults to - * all devices owned by the user - * - * @returns resolves to a VerificationRequest - * when the request has been sent to the other party. - * - * @deprecated Not supported for Rust Cryptography. Prefer {@link CryptoApi#requestOwnUserVerification} or {@link CryptoApi#requestDeviceVerification}. - */ - public requestVerification(userId: string, devices?: string[]): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.requestVerification(userId, devices); - } - - /** - * Begin a key verification. - * - * @param method - the verification method to use - * @param userId - the user to verify keys with - * @param deviceId - the device to verify - * - * @returns a verification object - * @deprecated Prefer {@link CryptoApi#requestOwnUserVerification} or {@link CryptoApi#requestDeviceVerification}. - */ - public beginKeyVerification(method: string, userId: string, deviceId: string): Verification { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.beginKeyVerification(method, userId, deviceId); - } - - /** - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#checkKey}. - */ - public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise { - return this.secretStorage.checkKey(key, info); - } - - /** - * Set the 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. - * - * @param value - whether to blacklist all unverified devices by default - * - * @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}: - * - * ```javascript - * client.getCrypto().globalBlacklistUnverifiedDevices = value; - * ``` - */ - public setGlobalBlacklistUnverifiedDevices(value: boolean): boolean { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - this.cryptoBackend.globalBlacklistUnverifiedDevices = value; - return value; - } - - /** - * @returns whether to blacklist all unverified devices by default - * - * @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}: - * - * ```javascript - * value = client.getCrypto().globalBlacklistUnverifiedDevices; - * ``` - */ - public getGlobalBlacklistUnverifiedDevices(): boolean { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.globalBlacklistUnverifiedDevices; - } - - /** - * Set whether sendMessage in a room with unknown and unverified devices - * should throw an error and not send them message. This has 'Global' for - * symmetry with setGlobalBlacklistUnverifiedDevices but there is currently - * no room-level equivalent for this setting. - * - * This API is currently UNSTABLE and may change or be removed without notice. - * - * It has no effect with the Rust crypto implementation. - * - * @param value - whether error on unknown devices - * - * ```ts - * client.getCrypto().globalErrorOnUnknownDevices = value; - * ``` - */ - public setGlobalErrorOnUnknownDevices(value: boolean): void { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - this.cryptoBackend.globalErrorOnUnknownDevices = value; - } - - /** - * @returns whether to error on unknown devices - * - * This API is currently UNSTABLE and may change or be removed without notice. - */ - public getGlobalErrorOnUnknownDevices(): boolean { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.globalErrorOnUnknownDevices; - } - - /** - * Get the ID of one of the user's cross-signing keys - * - * @param type - The type of key to get the ID of. One of - * "master", "self_signing", or "user_signing". Defaults to "master". - * - * @returns the key ID - * @deprecated Not supported for Rust Cryptography. prefer {@link Crypto.CryptoApi#getCrossSigningKeyId} - */ - public getCrossSigningId(type: CrossSigningKey | string = CrossSigningKey.Master): string | null { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.getCrossSigningId(type); - } - - /** - * Get the cross signing information for a given user. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @param userId - the user ID to get the cross-signing info for. - * - * @returns the cross signing information for the user. - * @deprecated Not supported for Rust Cryptography. Prefer {@link CryptoApi#userHasCrossSigningKeys} - */ - public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.getStoredCrossSigningForUser(userId); - } - - /** - * Check whether a given user is trusted. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @param userId - The ID of the user to check. - * - * @deprecated Use {@link Crypto.CryptoApi.getUserVerificationStatus | `CryptoApi.getUserVerificationStatus`} - */ - public checkUserTrust(userId: string): UserTrustLevel { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.checkUserTrust(userId); - } - - /** - * Check whether a given device is trusted. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @param userId - The ID of the user whose devices is to be checked. - * @param deviceId - The ID of the device to check - * - * @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus | `CryptoApi.getDeviceVerificationStatus`} - */ - public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.checkDeviceTrust(userId, deviceId); - } - - /** - * Check whether one of our own devices is cross-signed by our - * user's stored keys, regardless of whether we trust those keys yet. - * - * @param deviceId - The ID of the device to check - * - * @returns true if the device is cross-signed - * - * @deprecated Not supported for Rust Cryptography. - */ - public checkIfOwnDeviceCrossSigned(deviceId: string): boolean { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.checkIfOwnDeviceCrossSigned(deviceId); - } - - /** - * Check the copy of our cross-signing key that we have in the device list and - * see if we can get the private key. If so, mark it as trusted. - * @param opts - ICheckOwnCrossSigningTrustOpts object - * - * @deprecated Unneeded for the new crypto - */ - public checkOwnCrossSigningTrust(opts?: ICheckOwnCrossSigningTrustOpts): Promise { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.checkOwnCrossSigningTrust(opts); - } - - /** - * Checks that a given cross-signing private key matches a given public key. - * This can be used by the getCrossSigningKey callback to verify that the - * private key it is about to supply is the one that was requested. - * @param privateKey - The private key - * @param expectedPublicKey - The public key - * @returns true if the key matches, otherwise false - * - * @deprecated Not supported for Rust Cryptography. - */ - public checkCrossSigningPrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.checkCrossSigningPrivateKey(privateKey, expectedPublicKey); - } - - /** - * @deprecated Not supported for Rust Cryptography. Prefer {@link CryptoApi#requestDeviceVerification}. - */ - public legacyDeviceVerification(userId: string, deviceId: string, method: string): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.legacyDeviceVerification(userId, deviceId, method); - } - - /** - * Perform any background tasks that can be done before a message is ready to - * send, in order to speed up sending of the message. - * @param room - the room the event is in - * - * @deprecated Prefer {@link CryptoApi.prepareToEncrypt | `CryptoApi.prepareToEncrypt`}: - * - * ```javascript - * client.getCrypto().prepareToEncrypt(room); - * ``` - */ - public prepareToEncrypt(room: Room): void { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - this.cryptoBackend.prepareToEncrypt(room); - } - - /** - * 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. - * - * @deprecated Prefer {@link CryptoApi.userHasCrossSigningKeys | `CryptoApi.userHasCrossSigningKeys`}: - * - * ```javascript - * result = client.getCrypto().userHasCrossSigningKeys(); - * ``` - */ - public userHasCrossSigningKeys(): Promise { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.userHasCrossSigningKeys(); - } - - /** - * Checks whether cross signing: - * - is enabled on this account and trusted by this device - * - has private keys either cached locally or stored in secret storage - * - * If this function returns false, bootstrapCrossSigning() can be used - * to fix things such that it returns true. That is to say, after - * bootstrapCrossSigning() completes successfully, this function should - * return true. - * @returns True if cross-signing is ready to be used on this device - * @deprecated Prefer {@link CryptoApi.isCrossSigningReady | `CryptoApi.isCrossSigningReady`}: - */ - public isCrossSigningReady(): Promise { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.isCrossSigningReady(); - } - - /** - * Bootstrap cross-signing by creating keys if needed. If everything is already - * set up, then no changes are made, so this is safe to run to ensure - * cross-signing is ready for use. - * - * This function: - * - creates new cross-signing keys if they are not found locally cached nor in - * secret storage (if it has been set up) - * - * @deprecated Prefer {@link CryptoApi.bootstrapCrossSigning | `CryptoApi.bootstrapCrossSigning`}. - */ - public bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.bootstrapCrossSigning(opts); - } - - /** - * Whether to trust a others users signatures of their devices. - * If false, devices will only be considered 'verified' if we have - * verified that device individually (effectively disabling cross-signing). - * - * Default: true - * - * @returns True if trusting cross-signed devices - * - * @deprecated Prefer {@link CryptoApi.getTrustCrossSignedDevices | `CryptoApi.getTrustCrossSignedDevices`}. - */ - public getCryptoTrustCrossSignedDevices(): boolean { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.getTrustCrossSignedDevices(); - } - - /** - * See getCryptoTrustCrossSignedDevices - * - * @param val - True to trust cross-signed devices - * - * @deprecated Prefer {@link CryptoApi.setTrustCrossSignedDevices | `CryptoApi.setTrustCrossSignedDevices`}. - */ - public setCryptoTrustCrossSignedDevices(val: boolean): void { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - this.cryptoBackend.setTrustCrossSignedDevices(val); - } - - /** - * Counts the number of end to end session keys that are waiting to be backed up - * @returns Promise which resolves to the number of sessions requiring backup - * - * @deprecated Not supported for Rust Cryptography. - */ - public countSessionsNeedingBackup(): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.countSessionsNeedingBackup(); - } - - /** - * Get information about the encryption of an event - * - * @param event - event to be checked - * @returns The event information. - * @deprecated Prefer {@link Crypto.CryptoApi.getEncryptionInfoForEvent | `CryptoApi.getEncryptionInfoForEvent`}. - */ - public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.getEventEncryptionInfo(event); - } - - /** - * Create a recovery key from a user-supplied passphrase. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @param password - Passphrase string that can be entered by the user - * when restoring the backup as an alternative to entering the recovery key. - * Optional. - * @returns Object with public key metadata, encoded private - * recovery key which should be disposed of after displaying to the user, - * and raw private key to avoid round tripping if needed. - * - * @deprecated Prefer {@link CryptoApi.createRecoveryKeyFromPassphrase | `CryptoApi.createRecoveryKeyFromPassphrase`}. - */ - public createRecoveryKeyFromPassphrase(password?: string): Promise { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.createRecoveryKeyFromPassphrase(password); - } - - /** - * Checks whether secret storage: - * - is enabled on this account - * - is storing cross-signing private keys - * - is storing session backup key (if enabled) - * - * If this function returns false, bootstrapSecretStorage() can be used - * to fix things such that it returns true. That is to say, after - * bootstrapSecretStorage() completes successfully, this function should - * return true. - * - * @returns True if secret storage is ready to be used on this device - * @deprecated Prefer {@link CryptoApi.isSecretStorageReady | `CryptoApi.isSecretStorageReady`}. - */ - public isSecretStorageReady(): Promise { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.isSecretStorageReady(); - } - - /** - * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is - * already set up, then no changes are made, so this is safe to run to ensure secret - * storage is ready for use. - * - * This function - * - creates a new Secure Secret Storage key if no default key exists - * - if a key backup exists, it is migrated to store the key in the Secret - * Storage - * - creates a backup if none exists, and one is requested - * - migrates Secure Secret Storage to use the latest algorithm, if an outdated - * algorithm is found - * - * @deprecated Use {@link CryptoApi.bootstrapSecretStorage | `CryptoApi.bootstrapSecretStorage`}. - */ - public bootstrapSecretStorage(opts: ICreateSecretStorageOpts): Promise { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.bootstrapSecretStorage(opts); - } - - /** - * Add a key for encrypting secrets. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @param algorithm - the algorithm used by the key - * @param opts - the options for the algorithm. The properties used - * depend on the algorithm given. - * @param keyName - the name of the key. If not given, a random name will be generated. - * - * @returns An object with: - * keyId: the ID of the key - * keyInfo: details about the key (iv, mac, passphrase) - * - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#addKey}. - */ - public addSecretStorageKey( - algorithm: string, - opts: AddSecretStorageKeyOpts, - keyName?: string, - ): Promise<{ keyId: string; keyInfo: SecretStorageKeyDescription }> { - return this.secretStorage.addKey(algorithm, opts, keyName); - } - - /** - * Check whether we have a key with a given ID. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @param keyId - The ID of the key to check - * for. Defaults to the default key ID if not provided. - * @returns Whether we have the key. - * - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#hasKey}. - */ - public hasSecretStorageKey(keyId?: string): Promise { - return this.secretStorage.hasKey(keyId); - } - - /** - * Store an encrypted secret on the server. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @param name - The name of the secret - * @param secret - The secret contents. - * @param keys - The IDs of the keys to use to encrypt the secret or null/undefined - * to use the default (will throw if no default key is set). - * - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#store}. - */ - public storeSecret(name: string, secret: string, keys?: string[]): Promise { - return this.secretStorage.store(name, secret, keys); - } - - /** - * Get a secret from storage. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @param name - the name of the secret - * - * @returns the contents of the secret - * - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#get}. - */ - public getSecret(name: string): Promise { - return this.secretStorage.get(name); - } - - /** - * Check if a secret is stored on the server. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @param name - the name of the secret - * @returns map of key name to key info the secret is encrypted - * with, or null if it is not present or not encrypted with a trusted - * key - * - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#isStored}. - */ - public isSecretStored(name: SecretStorageKey): Promise | null> { - return this.secretStorage.isStored(name); - } - - /** - * Request a secret from another device. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @param name - the name of the secret to request - * @param devices - the devices to request the secret from - * - * @returns the secret request object - * @deprecated Not supported for Rust Cryptography. - */ - public requestSecret(name: string, devices: string[]): ISecretRequest { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.requestSecret(name, devices); - } - - /** - * Get the current default key ID for encrypting secrets. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @returns The default key ID or null if no default key ID is set - * - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getDefaultKeyId}. - */ - public getDefaultSecretStorageKeyId(): Promise { - return this.secretStorage.getDefaultKeyId(); - } - - /** - * Set the current default key ID for encrypting secrets. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @param keyId - The new default key ID - * - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#setDefaultKeyId}. - */ - public setDefaultSecretStorageKeyId(keyId: string): Promise { - return this.secretStorage.setDefaultKeyId(keyId); - } - - /** - * Checks that a given secret storage private key matches a given public key. - * This can be used by the getSecretStorageKey callback to verify that the - * private key it is about to supply is the one that was requested. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @param privateKey - The private key - * @param expectedPublicKey - The public key - * @returns true if the key matches, otherwise false - * - * @deprecated The use of asymmetric keys for SSSS is deprecated. - * Use {@link SecretStorage.ServerSideSecretStorage#checkKey} for symmetric keys. - */ - public checkSecretStoragePrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.checkSecretStoragePrivateKey(privateKey, expectedPublicKey); - } - - /** - * Get e2e information on the device that sent an event - * - * @param event - event to be checked - * @deprecated Not supported for Rust Cryptography. - */ - public async getEventSenderDeviceInfo(event: MatrixEvent): Promise { - if (!this.crypto) { - return null; - } - return this.crypto.getEventSenderDeviceInfo(event); - } - - /** - * Check if the sender of an event is verified - * - * @param event - event to be checked - * - * @returns true if the sender of this event has been verified using - * {@link MatrixClient#setDeviceVerified}. - * - * @deprecated Not supported for Rust Cryptography. - */ - public async isEventSenderVerified(event: MatrixEvent): Promise { - const device = await this.getEventSenderDeviceInfo(event); - if (!device) { - return false; - } - return device.isVerified(); - } - - /** - * Get outgoing room key request for this event if there is one. - * @param event - The event to check for - * - * @returns A room key request, or null if there is none - * - * @deprecated Not supported for Rust Cryptography. - */ - public getOutgoingRoomKeyRequest(event: MatrixEvent): Promise { - if (!this.crypto) { - throw new Error("End-to-End encryption disabled"); - } - const wireContent = event.getWireContent(); - const requestBody: IRoomKeyRequestBody = { - session_id: wireContent.session_id, - sender_key: wireContent.sender_key, - algorithm: wireContent.algorithm, - room_id: event.getRoomId()!, - }; - if (!requestBody.session_id || !requestBody.sender_key || !requestBody.algorithm || !requestBody.room_id) { - return Promise.resolve(null); - } - return this.crypto.cryptoStore.getOutgoingRoomKeyRequest(requestBody); - } - - /** - * Cancel a room key request for this event if one is ongoing and resend the - * request. - * @param event - event of which to cancel and resend the room - * key request. - * @returns A promise that will resolve when the key request is queued - * - * @deprecated Not supported for Rust Cryptography. - */ - public cancelAndResendEventRoomKeyRequest(event: MatrixEvent): Promise { - if (!this.crypto) { - throw new Error("End-to-End encryption disabled"); - } - return event.cancelAndResendKeyRequest(this.crypto, this.getUserId()!); - } - - /** - * Enable end-to-end encryption for a room. This does not modify room state. - * Any messages sent before the returned promise resolves will be sent unencrypted. - * @param roomId - The room ID to enable encryption in. - * @param config - The encryption config for the room. - * @returns A promise that will resolve when encryption is set up. - * - * @deprecated Not supported for Rust Cryptography. To enable encryption in a room, send an `m.room.encryption` - * state event. - */ - public setRoomEncryption(roomId: string, config: IRoomEncryption): Promise { - if (!this.crypto) { - throw new Error("End-to-End encryption disabled"); - } - return this.crypto.setRoomEncryption(roomId, config); - } - /** * Whether encryption is enabled for a room. * @param roomId - the room id to query. @@ -3300,241 +2027,7 @@ export class MatrixClient extends TypedEventEmitter[], payload: object): Promise { - if (!this.crypto) { - throw new Error("End-to-End encryption disabled"); - } - return this.crypto.encryptAndSendToDevices(userDeviceInfoArr, payload); - } - - /** - * Forces the current outbound group session to be discarded such - * that another one will be created next time an event is sent. - * - * @param roomId - The ID of the room to discard the session for - * - * @deprecated Prefer {@link CryptoApi.forceDiscardSession | `CryptoApi.forceDiscardSession`}: - */ - public forceDiscardSession(roomId: string): void { - if (!this.cryptoBackend) { - throw new Error("End-to-End encryption disabled"); - } - this.cryptoBackend.forceDiscardSession(roomId); - } - - /** - * Get a list containing all of the room keys - * - * This should be encrypted before returning it to the user. - * - * @returns a promise which resolves to a list of session export objects - * - * @deprecated Prefer {@link CryptoApi.exportRoomKeys | `CryptoApi.exportRoomKeys`}: - * - * ```javascript - * sessionData = await client.getCrypto().exportRoomKeys(); - * ``` - */ - public exportRoomKeys(): Promise { - if (!this.cryptoBackend) { - return Promise.reject(new Error("End-to-end encryption disabled")); - } - return this.cryptoBackend.exportRoomKeys(); - } - - /** - * Import a list of room keys previously exported by exportRoomKeys - * - * @param keys - a list of session export objects - * @param opts - options object - * - * @returns a promise which resolves when the keys have been imported - * - * @deprecated Prefer {@link CryptoApi.importRoomKeys | `CryptoApi.importRoomKeys`}: - * ```javascript - * await client.getCrypto()?.importRoomKeys([..]); - * ``` - */ - public importRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - return this.cryptoBackend.importRoomKeys(keys, opts); - } - - /** - * Force a re-check of the local key backup status against - * what's on the server. - * - * @returns Object with backup info (as returned by - * getKeyBackupVersion) in backupInfo and - * trust information (as returned by isKeyBackupTrusted) - * in trustInfo. - * - * @deprecated Prefer {@link Crypto.CryptoApi.checkKeyBackupAndEnable}. - */ - public checkKeyBackup(): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.backupManager.checkKeyBackup(); - } - - /** - * Get information about the current key backup from the server. - * - * Performs some basic validity checks on the shape of the result, and raises an error if it is not as expected. - * - * **Note**: there is no (supported) way to distinguish between "failure to talk to the server" and "another client - * uploaded a key backup version using an algorithm I don't understand. - * - * @returns Information object from API, or null if no backup is present on the server. - * - * @deprecated Prefer {@link CryptoApi.getKeyBackupInfo}. - */ - public async getKeyBackupVersion(): Promise { - let res: IKeyBackupInfo; - try { - res = await this.http.authedRequest( - Method.Get, - "/room_keys/version", - undefined, - undefined, - { prefix: ClientPrefix.V3 }, - ); - } catch (e) { - if ((e).errcode === "M_NOT_FOUND") { - return null; - } else { - throw e; - } - } - BackupManager.checkBackupVersion(res); - return res; - } - - /** - * @param info - key backup info dict from getKeyBackupVersion() - * - * @deprecated Not supported for Rust Cryptography. Prefer {@link CryptoApi.isKeyBackupTrusted | `CryptoApi.isKeyBackupTrusted`}. - */ - public isKeyBackupTrusted(info: IKeyBackupInfo): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.backupManager.isKeyBackupTrusted(info); - } - - /** - * @returns true if the client is configured to back up keys to - * the server, otherwise false. If we haven't completed a successful check - * of key backup status yet, returns null. - * - * @deprecated Not supported for Rust Cryptography. Prefer direct access to {@link Crypto.CryptoApi.getActiveSessionBackupVersion}: - * - * ```javascript - * let enabled = (await client.getCrypto().getActiveSessionBackupVersion()) !== null; - * ``` - */ - public getKeyBackupEnabled(): boolean | null { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - return this.crypto.backupManager.getKeyBackupEnabled(); - } - - /** - * Enable backing up of keys, using data previously returned from - * getKeyBackupVersion. - * - * @param info - Backup information object as returned by getKeyBackupVersion - * @returns Promise which resolves when complete. - * - * @deprecated Do not call this directly. Instead call {@link Crypto.CryptoApi.checkKeyBackupAndEnable}. - */ - public enableKeyBackup(info: IKeyBackupInfo): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - return this.crypto.backupManager.enableKeyBackup(info); - } - - /** - * Disable backing up of keys. - * - * @deprecated Not supported for Rust Cryptography. It should be unnecessary to disable key backup. - */ - public disableKeyBackup(): void { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - this.crypto.backupManager.disableKeyBackup(); - } - - /** - * Set up the data required to create a new backup version. The backup version - * will not be created and enabled until createKeyBackupVersion is called. - * - * @param password - Passphrase string that can be entered by the user - * when restoring the backup as an alternative to entering the recovery key. - * Optional. - * - * @returns Object that can be passed to createKeyBackupVersion and - * additionally has a 'recovery_key' member with the user-facing recovery key string. - * - * @deprecated Not supported for Rust cryptography. Use {@link Crypto.CryptoApi.resetKeyBackup | `CryptoApi.resetKeyBackup`}. - */ - public async prepareKeyBackupVersion( - password?: string | Uint8Array | null, - opts: IKeyBackupPrepareOpts = { secureSecretStorage: false }, - ): Promise> { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - // eslint-disable-next-line camelcase - const { algorithm, auth_data, recovery_key, privateKey } = - await this.crypto.backupManager.prepareKeyBackupVersion(password); - - if (opts.secureSecretStorage) { - await this.secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey)); - this.logger.info("Key backup private key stored in secret storage"); - } - - return { - algorithm, - /* eslint-disable camelcase */ - auth_data, - recovery_key, - /* eslint-enable camelcase */ - }; + return room.hasEncryptionStateEvent(); } /** @@ -3547,70 +2040,6 @@ export class MatrixClient extends TypedEventEmitter { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - await this.crypto.backupManager.createKeyBackupVersion(info); - - const data = { - algorithm: info.algorithm, - auth_data: info.auth_data, - }; - - // Sign the backup auth data with the device key for backwards compat with - // older devices with cross-signing. This can probably go away very soon in - // favour of just signing with the cross-singing master key. - // XXX: Private member access - await this.crypto.signObject(data.auth_data); - - if ( - this.cryptoCallbacks.getCrossSigningKey && - // XXX: Private member access - this.crypto.crossSigningInfo.getId() - ) { - // now also sign the auth data with the cross-signing master key - // we check for the callback explicitly here because we still want to be able - // to create an un-cross-signed key backup if there is a cross-signing key but - // no callback supplied. - // XXX: Private member access - await this.crypto.crossSigningInfo.signObject(data.auth_data, "master"); - } - - const res = await this.http.authedRequest(Method.Post, "/room_keys/version", undefined, data); - - // We could assume everything's okay and enable directly, but this ensures - // we run the same signature verification that will be used for future - // sessions. - await this.checkKeyBackup(); - if (!this.getKeyBackupEnabled()) { - this.logger.error("Key backup not usable even though we just created it"); - } - - return res; - } - - /** - * @deprecated Use {@link Crypto.CryptoApi.deleteKeyBackupVersion | `CryptoApi.deleteKeyBackupVersion`}. - */ - public async deleteKeyBackupVersion(version: string): Promise { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - - await this.cryptoBackend.deleteKeyBackupVersion(version); - } - private makeKeyBackupPath(roomId?: string, sessionId?: string, version?: string): IKeyBackupPath { let path: string; if (sessionId !== undefined) { @@ -3629,569 +2058,6 @@ export class MatrixClient extends TypedEventEmitter; - public sendKeyBackup( - roomId: string, - sessionId: undefined, - version: string | undefined, - data: IKeyBackup, - ): Promise; - public sendKeyBackup( - roomId: string, - sessionId: string, - version: string | undefined, - data: IKeyBackup, - ): Promise; - public async sendKeyBackup( - roomId: string | undefined, - sessionId: string | undefined, - version: string | undefined, - data: IKeyBackup, - ): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - const path = this.makeKeyBackupPath(roomId!, sessionId!, version); - await this.http.authedRequest(Method.Put, path.path, path.queryData, data, { prefix: ClientPrefix.V3 }); - } - - /** - * Marks all group sessions as needing to be backed up and schedules them to - * upload in the background as soon as possible. - * - * @deprecated Not supported for Rust Cryptography. This is done automatically as part of - * {@link CryptoApi.resetKeyBackup}, so there is probably no need to call this manually. - */ - public async scheduleAllGroupSessionsForBackup(): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - await this.crypto.backupManager.scheduleAllGroupSessionsForBackup(); - } - - /** - * Marks all group sessions as needing to be backed up without scheduling - * them to upload in the background. - * - * (This is done automatically as part of {@link CryptoApi.resetKeyBackup}, - * so there is probably no need to call this manually.) - * - * @returns Promise which resolves to the number of sessions requiring a backup. - * @deprecated Not supported for Rust Cryptography. - */ - public flagAllGroupSessionsForBackup(): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - return this.crypto.backupManager.flagAllGroupSessionsForBackup(); - } - - /** - * Return true if recovery key is valid. - * Try to decode the recovery key and check if it's successful. - * @param recoveryKey - * @deprecated Use {@link decodeRecoveryKey} directly - */ - public isValidRecoveryKey(recoveryKey: string): boolean { - try { - decodeRecoveryKey(recoveryKey); - return true; - } catch { - return false; - } - } - - /** - * Get the raw key for a key backup from the password - * Used when migrating key backups into SSSS - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @param password - Passphrase - * @param backupInfo - Backup metadata from `checkKeyBackup` - * @returns key backup key - * @deprecated Deriving a backup key from a passphrase is not part of the matrix spec. Instead, a random key is generated and stored/shared via 4S. - */ - public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupInfo): Promise { - return keyFromAuthData(backupInfo.auth_data, password); - } - - /** - * Get the raw key for a key backup from the recovery key - * Used when migrating key backups into SSSS - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @param recoveryKey - The recovery key - * @returns key backup key - * @deprecated Use {@link decodeRecoveryKey} directly - */ - public keyBackupKeyFromRecoveryKey(recoveryKey: string): Uint8Array { - return decodeRecoveryKey(recoveryKey); - } - - /** - * Restore from an existing key backup via a passphrase. - * - * @param password - Passphrase - * @param targetRoomId - Room ID to target a specific room. - * Restores all rooms if omitted. - * @param targetSessionId - Session ID to target a specific session. - * Restores all sessions if omitted. - * @param backupInfo - Backup metadata from `getKeyBackupVersion` or `checkKeyBackup`.`backupInfo` - * @param opts - Optional params such as callbacks - * @returns Status of restoration with `total` and `imported` - * key counts. - * - * @deprecated Prefer {@link CryptoApi.restoreKeyBackupWithPassphrase | `CryptoApi.restoreKeyBackupWithPassphrase`}. - */ - public async restoreKeyBackupWithPassword( - password: string, - targetRoomId: undefined, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts: IKeyBackupRestoreOpts, - ): Promise; - /** - * @deprecated Prefer {@link CryptoApi.restoreKeyBackupWithPassphrase | `CryptoApi.restoreKeyBackupWithPassphrase`}. - */ - public async restoreKeyBackupWithPassword( - password: string, - targetRoomId: string, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts: IKeyBackupRestoreOpts, - ): Promise; - /** - * @deprecated Prefer {@link CryptoApi.restoreKeyBackupWithPassphrase | `CryptoApi.restoreKeyBackupWithPassphrase`}. - */ - public async restoreKeyBackupWithPassword( - password: string, - targetRoomId: string, - targetSessionId: string, - backupInfo: IKeyBackupInfo, - opts: IKeyBackupRestoreOpts, - ): Promise; - /** - * @deprecated Prefer {@link CryptoApi.restoreKeyBackupWithPassphrase | `CryptoApi.restoreKeyBackupWithPassphrase`}. - */ - public async restoreKeyBackupWithPassword( - password: string, - targetRoomId: string | undefined, - targetSessionId: string | undefined, - backupInfo: IKeyBackupInfo, - opts: IKeyBackupRestoreOpts, - ): Promise { - const privKey = await keyFromAuthData(backupInfo.auth_data, password); - return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts); - } - - /** - * Restore from an existing key backup via a private key stored in secret - * storage. - * - * @param backupInfo - Backup metadata from `checkKeyBackup` - * @param targetRoomId - Room ID to target a specific room. - * Restores all rooms if omitted. - * @param targetSessionId - Session ID to target a specific session. - * Restores all sessions if omitted. - * @param opts - Optional params such as callbacks - * @returns Status of restoration with `total` and `imported` - * key counts. - * - * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. - */ - public async restoreKeyBackupWithSecretStorage( - backupInfo: IKeyBackupInfo, - targetRoomId?: string, - targetSessionId?: string, - opts?: IKeyBackupRestoreOpts, - ): Promise { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - const storedKey = await this.secretStorage.get("m.megolm_backup.v1"); - - // ensure that the key is in the right format. If not, fix the key and - // store the fixed version - const fixedKey = fixBackupKey(storedKey); - if (fixedKey) { - const keys = await this.secretStorage.getKey(); - await this.secretStorage.store("m.megolm_backup.v1", fixedKey, [keys![0]]); - } - - const privKey = decodeBase64(fixedKey || storedKey!); - return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts); - } - - /** - * Restore from an existing key backup via an encoded recovery key. - * - * @param recoveryKey - Encoded recovery key - * @param targetRoomId - Room ID to target a specific room. - * Restores all rooms if omitted. - * @param targetSessionId - Session ID to target a specific session. - * Restores all sessions if omitted. - * @param backupInfo - Backup metadata from `checkKeyBackup` - * @param opts - Optional params such as callbacks - - * @returns Status of restoration with `total` and `imported` - * key counts. - * - * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. - */ - public restoreKeyBackupWithRecoveryKey( - recoveryKey: string, - targetRoomId: undefined, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise; - /** - * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. - */ - public restoreKeyBackupWithRecoveryKey( - recoveryKey: string, - targetRoomId: string, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise; - /** - * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. - */ - public restoreKeyBackupWithRecoveryKey( - recoveryKey: string, - targetRoomId: string, - targetSessionId: string, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise; - /** - * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. - */ - public restoreKeyBackupWithRecoveryKey( - recoveryKey: string, - targetRoomId: string | undefined, - targetSessionId: string | undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise { - const privKey = decodeRecoveryKey(recoveryKey); - return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts); - } - - /** - * Restore from an existing key backup via a private key stored locally - * @param targetRoomId - * @param targetSessionId - * @param backupInfo - * @param opts - * - * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. - */ - public async restoreKeyBackupWithCache( - targetRoomId: undefined, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise; - /** - * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. - */ - public async restoreKeyBackupWithCache( - targetRoomId: string, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise; - /** - * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. - */ - public async restoreKeyBackupWithCache( - targetRoomId: string, - targetSessionId: string, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise; - /** - * @deprecated Prefer {@link CryptoApi.restoreKeyBackup | `CryptoApi.restoreKeyBackup`}. - */ - public async restoreKeyBackupWithCache( - targetRoomId: string | undefined, - targetSessionId: string | undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise { - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - const privKey = await this.cryptoBackend.getSessionBackupPrivateKey(); - if (!privKey) { - throw new Error("Couldn't get key"); - } - return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts); - } - - private async restoreKeyBackup( - privKey: Uint8Array, - targetRoomId: undefined, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise; - private async restoreKeyBackup( - privKey: Uint8Array, - targetRoomId: string, - targetSessionId: undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise; - private async restoreKeyBackup( - privKey: Uint8Array, - targetRoomId: string, - targetSessionId: string, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise; - private async restoreKeyBackup( - privKey: Uint8Array, - targetRoomId: string | undefined, - targetSessionId: string | undefined, - backupInfo: IKeyBackupInfo, - opts?: IKeyBackupRestoreOpts, - ): Promise { - const cacheCompleteCallback = opts?.cacheCompleteCallback; - const progressCallback = opts?.progressCallback; - - if (!this.cryptoBackend) { - throw new Error("End-to-end encryption disabled"); - } - - if (!backupInfo.version) { - throw new Error("Backup version must be defined"); - } - const backupVersion = backupInfo.version!; - - let totalKeyCount = 0; - let totalFailures = 0; - let totalImported = 0; - - const path = this.makeKeyBackupPath(targetRoomId, targetSessionId, backupVersion); - - const backupDecryptor = await this.cryptoBackend.getBackupDecryptor(backupInfo, privKey); - - const untrusted = !backupDecryptor.sourceTrusted; - - try { - if (!(privKey instanceof Uint8Array)) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - throw new Error(`restoreKeyBackup expects Uint8Array, got ${privKey}`); - } - // Cache the key, if possible. - // This is async. - this.cryptoBackend - .storeSessionBackupPrivateKey(privKey, backupVersion) - .catch((e) => { - this.logger.warn("Error caching session backup key:", e); - }) - .then(cacheCompleteCallback); - - if (progressCallback) { - progressCallback({ - stage: "fetch", - }); - } - - const res = await this.http.authedRequest( - Method.Get, - path.path, - path.queryData, - undefined, - { prefix: ClientPrefix.V3 }, - ); - - // We have finished fetching the backup, go to next step - if (progressCallback) { - progressCallback({ - stage: "load_keys", - }); - } - - if ((res as IRoomsKeysResponse).rooms) { - // We have a full backup here, it can get quite big, so we need to decrypt and import it in chunks. - - // Get the total count as a first pass - totalKeyCount = this.getTotalKeyCount(res as IRoomsKeysResponse); - // Now decrypt and import the keys in chunks - await this.handleDecryptionOfAFullBackup( - res as IRoomsKeysResponse, - backupDecryptor, - 200, - async (chunk) => { - // We have a chunk of decrypted keys: import them - try { - const backupVersion = backupInfo.version!; - await this.cryptoBackend!.importBackedUpRoomKeys(chunk, backupVersion, { - untrusted, - }); - totalImported += chunk.length; - } catch (e) { - totalFailures += chunk.length; - // We failed to import some keys, but we should still try to import the rest? - // Log the error and continue - logger.error("Error importing keys from backup", e); - } - - if (progressCallback) { - progressCallback({ - total: totalKeyCount, - successes: totalImported, - stage: "load_keys", - failures: totalFailures, - }); - } - }, - ); - } else if ((res as IRoomKeysResponse).sessions) { - // For now we don't chunk for a single room backup, but we could in the future. - // Currently it is not used by the application. - const sessions = (res as IRoomKeysResponse).sessions; - totalKeyCount = Object.keys(sessions).length; - const keys = await backupDecryptor.decryptSessions(sessions); - for (const k of keys) { - k.room_id = targetRoomId!; - } - await this.cryptoBackend.importBackedUpRoomKeys(keys, backupVersion, { - progressCallback, - untrusted, - }); - totalImported = keys.length; - } else { - totalKeyCount = 1; - try { - const [key] = await backupDecryptor.decryptSessions({ - [targetSessionId!]: res as IKeyBackupSession, - }); - key.room_id = targetRoomId!; - key.session_id = targetSessionId!; - - await this.cryptoBackend.importBackedUpRoomKeys([key], backupVersion, { - progressCallback, - untrusted, - }); - totalImported = 1; - } catch (e) { - this.logger.debug("Failed to decrypt megolm session from backup", e); - } - } - } finally { - backupDecryptor.free(); - } - - /// in case entering the passphrase would add a new signature? - await this.cryptoBackend.checkKeyBackupAndEnable(); - - return { total: totalKeyCount, imported: totalImported }; - } - - /** - * This method calculates the total number of keys present in the response of a `/room_keys/keys` call. - * - * @param res - The response from the server containing the keys to be counted. - * - * @returns The total number of keys in the backup. - */ - private getTotalKeyCount(res: IRoomsKeysResponse): number { - const rooms = res.rooms; - let totalKeyCount = 0; - for (const roomData of Object.values(rooms)) { - if (!roomData.sessions) continue; - totalKeyCount += Object.keys(roomData.sessions).length; - } - return totalKeyCount; - } - - /** - * This method handles the decryption of a full backup, i.e a call to `/room_keys/keys`. - * It will decrypt the keys in chunks and call the `block` callback for each chunk. - * - * @param res - The response from the server containing the keys to be decrypted. - * @param backupDecryptor - An instance of the BackupDecryptor class used to decrypt the keys. - * @param chunkSize - The size of the chunks to be processed at a time. - * @param block - A callback function that is called for each chunk of keys. - * - * @returns A promise that resolves when the decryption is complete. - */ - private async handleDecryptionOfAFullBackup( - res: IRoomsKeysResponse, - backupDecryptor: BackupDecryptor, - chunkSize: number, - block: (chunk: IMegolmSessionData[]) => Promise, - ): Promise { - const rooms = (res as IRoomsKeysResponse).rooms; - - let groupChunkCount = 0; - let chunkGroupByRoom: Map = new Map(); - - const handleChunkCallback = async (roomChunks: Map): Promise => { - const currentChunk: IMegolmSessionData[] = []; - for (const roomId of roomChunks.keys()) { - const decryptedSessions = await backupDecryptor.decryptSessions(roomChunks.get(roomId)!); - for (const sessionId in decryptedSessions) { - const k = decryptedSessions[sessionId]; - k.room_id = roomId; - currentChunk.push(k); - } - } - await block(currentChunk); - }; - - for (const [roomId, roomData] of Object.entries(rooms)) { - if (!roomData.sessions) continue; - - chunkGroupByRoom.set(roomId, {}); - - for (const [sessionId, session] of Object.entries(roomData.sessions)) { - const sessionsForRoom = chunkGroupByRoom.get(roomId)!; - sessionsForRoom[sessionId] = session; - groupChunkCount += 1; - if (groupChunkCount >= chunkSize) { - // We have enough chunks to decrypt - await handleChunkCallback(chunkGroupByRoom); - chunkGroupByRoom = new Map(); - // There might be remaining keys for that room, so add back an entry for the current room. - chunkGroupByRoom.set(roomId, {}); - groupChunkCount = 0; - } - } - } - - // Handle remaining chunk if needed - if (groupChunkCount > 0) { - await handleChunkCallback(chunkGroupByRoom); - } - } - public deleteKeysFromBackup(roomId: undefined, sessionId: undefined, version?: string): Promise; public deleteKeysFromBackup(roomId: string, sessionId: undefined, version?: string): Promise; public deleteKeysFromBackup(roomId: string, sessionId: string, version?: string): Promise; @@ -8035,17 +5901,6 @@ export class MatrixClient extends TypedEventEmitterThis * method is experimental and may change. @@ -8061,7 +5916,7 @@ export class MatrixClient extends TypedEventEmitter { - if (event.shouldAttemptDecryption() && this.isCryptoEnabled()) { + if (event.shouldAttemptDecryption() && this.getCrypto()) { event.attemptDecryption(this.cryptoBackend!, options); } @@ -8406,17 +6261,6 @@ export class MatrixClient extends TypedEventEmitter { - if (this.crypto?.backupManager?.getKeyBackupEnabled()) { - try { - while ((await this.crypto.backupManager.backupPendingKeys(200)) > 0); - } catch (err) { - this.logger.error( - "Key backup request failed when logging out. Some keys may be missing from backup", - err, - ); - } - } - if (stopClient) { this.stopClient(); this.http.abort(); diff --git a/src/common-crypto/CryptoBackend.ts b/src/common-crypto/CryptoBackend.ts index 53351d6e4..0d52d0a06 100644 --- a/src/common-crypto/CryptoBackend.ts +++ b/src/common-crypto/CryptoBackend.ts @@ -18,8 +18,6 @@ import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts"; import { type IClearEvent, type MatrixEvent } from "../models/event.ts"; import { type Room } from "../models/room.ts"; import { type CryptoApi, type DecryptionFailureCode, type ImportRoomKeysOpts } from "../crypto-api/index.ts"; -import { type CrossSigningInfo, type UserTrustLevel } from "../crypto/CrossSigning.ts"; -import { type IEncryptedEventInfo } from "../crypto/api.ts"; import { type KeyBackupInfo, type KeyBackupSession } from "../crypto-api/keybackup.ts"; import { type IMegolmSessionData } from "../@types/crypto.ts"; @@ -45,15 +43,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi { */ stop(): void; - /** - * Get the verification level for a given user - * - * @param userId - user to be checked - * - * @deprecated Superceded by {@link CryptoApi#getUserVerificationStatus}. - */ - checkUserTrust(userId: string): UserTrustLevel; - /** * Encrypt an event according to the configuration of the room. * @@ -74,35 +63,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi { */ decryptEvent(event: MatrixEvent): Promise; - /** - * Get information about the encryption of an event - * - * @param event - event to be checked - * @deprecated Use {@link CryptoApi#getEncryptionInfoForEvent} instead - */ - getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo; - - /** - * Get the cross signing information for a given user. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @param userId - the user ID to get the cross-signing info for. - * - * @returns the cross signing information for the user. - * @deprecated Prefer {@link CryptoApi#userHasCrossSigningKeys} - */ - getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null; - - /** - * Check the cross signing trust of the current user - * - * @param opts - Options object. - * - * @deprecated Unneeded for the new crypto - */ - checkOwnCrossSigningTrust(opts?: CheckOwnCrossSigningTrustOpts): Promise; - /** * Get a backup decryptor capable of decrypting megolm session data encrypted with the given backup information. * @param backupInfo - The backup information @@ -195,13 +155,6 @@ export interface OnSyncCompletedData { catchingUp?: boolean; } -/** - * Options object for {@link CryptoBackend#checkOwnCrossSigningTrust}. - */ -export interface CheckOwnCrossSigningTrustOpts { - allowPrivateKeyRequests?: boolean; -} - /** * The result of a (successful) call to {@link CryptoBackend.decryptEvent} */ diff --git a/src/crypto-api/index.ts b/src/crypto-api/index.ts index 008fc2eac..55b5da8ad 100644 --- a/src/crypto-api/index.ts +++ b/src/crypto-api/index.ts @@ -283,8 +283,6 @@ export interface CryptoApi { * @param verified - whether to mark the device as verified. Defaults to 'true'. * * @throws an error if the device is unknown, or has not published any encryption keys. - * - * @remarks Fires {@link matrix.CryptoEvent.DeviceVerificationChanged} */ setDeviceVerified(userId: string, deviceId: string, verified?: boolean): Promise; @@ -586,7 +584,7 @@ export interface CryptoApi { /** * Determine if a key backup can be trusted. * - * @param info - key backup info dict from {@link matrix.MatrixClient.getKeyBackupVersion}. + * @param info - key backup info dict from {@link CryptoApi.getKeyBackupInfo}. */ isKeyBackupTrusted(info: KeyBackupInfo): Promise; @@ -991,7 +989,7 @@ export class DeviceVerificationStatus { * Check if we should consider this device "verified". * * A device is "verified" if either: - * * it has been manually marked as such via {@link matrix.MatrixClient.setDeviceVerified}. + * * it has been manually marked as such via {@link CryptoApi.setDeviceVerified}. * * it has been cross-signed with a verified signing key, **and** the client has been configured to trust * cross-signed devices via {@link CryptoApi.setTrustCrossSignedDevices}. * diff --git a/src/crypto-api/keybackup.ts b/src/crypto-api/keybackup.ts index 12eaddd6d..63bfed81b 100644 --- a/src/crypto-api/keybackup.ts +++ b/src/crypto-api/keybackup.ts @@ -35,8 +35,7 @@ export interface Aes256AuthData { /** * Information about a server-side key backup. * - * Returned by [`GET /_matrix/client/v3/room_keys/version`](https://spec.matrix.org/v1.7/client-server-api/#get_matrixclientv3room_keysversion) - * and hence {@link matrix.MatrixClient.getKeyBackupVersion}. + * Returned by [`GET /_matrix/client/v3/room_keys/version`](https://spec.matrix.org/v1.7/client-server-api/#get_matrixclientv3room_keysversion). */ export interface KeyBackupInfo { algorithm: string; diff --git a/src/crypto/CrossSigning.ts b/src/crypto/CrossSigning.ts deleted file mode 100644 index 175e8f8a4..000000000 --- a/src/crypto/CrossSigning.ts +++ /dev/null @@ -1,773 +0,0 @@ -/* -Copyright 2019 - 2021 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. -*/ - -/** - * Cross signing methods - */ - -import type { PkSigning } from "@matrix-org/olm"; -import { type IObject, pkSign, pkVerify } from "./olmlib.ts"; -import { logger } from "../logger.ts"; -import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store.ts"; -import { type DeviceInfo } from "./deviceinfo.ts"; -import { type ISignedKey, type MatrixClient } from "../client.ts"; -import { type OlmDevice } from "./OlmDevice.ts"; -import { type ICryptoCallbacks } from "./index.ts"; -import { type ISignatures } from "../@types/signed.ts"; -import { type CryptoStore, type SecretStorePrivateKeys } from "./store/base.ts"; -import { type ServerSideSecretStorage, type SecretStorageKeyDescription } from "../secret-storage.ts"; -import { - type CrossSigningKeyInfo, - DeviceVerificationStatus, - UserVerificationStatus as UserTrustLevel, -} from "../crypto-api/index.ts"; -import { decodeBase64, encodeBase64 } from "../base64.ts"; -import encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts"; -import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts"; - -// backwards-compatibility re-exports -export { UserTrustLevel }; - -const KEY_REQUEST_TIMEOUT_MS = 1000 * 60; - -function publicKeyFromKeyInfo(keyInfo: CrossSigningKeyInfo): string { - // `keys` is an object with { [`ed25519:${pubKey}`]: pubKey } - // We assume only a single key, and we want the bare form without type - // prefix, so we select the values. - return Object.values(keyInfo.keys)[0]; -} - -export interface ICacheCallbacks { - getCrossSigningKeyCache?(type: string, expectedPublicKey?: string): Promise; - storeCrossSigningKeyCache?(type: string, key?: Uint8Array): Promise; -} - -export interface ICrossSigningInfo { - keys: Record; - firstUse: boolean; - crossSigningVerifiedBefore: boolean; -} - -export class CrossSigningInfo { - public keys: Record = {}; - public firstUse = true; - // This tracks whether we've ever verified this user with any identity. - // When you verify a user, any devices online at the time that receive - // the verifying signature via the homeserver will latch this to true - // and can use it in the future to detect cases where the user has - // become unverified later for any reason. - private crossSigningVerifiedBefore = false; - - /** - * Information about a user's cross-signing keys - * - * @param userId - the user that the information is about - * @param callbacks - Callbacks used to interact with the app - * Requires getCrossSigningKey and saveCrossSigningKeys - * @param cacheCallbacks - Callbacks used to interact with the cache - */ - public constructor( - public readonly userId: string, - private callbacks: ICryptoCallbacks = {}, - private cacheCallbacks: ICacheCallbacks = {}, - ) {} - - public static fromStorage(obj: ICrossSigningInfo, userId: string): CrossSigningInfo { - const res = new CrossSigningInfo(userId); - for (const prop in obj) { - if (obj.hasOwnProperty(prop)) { - // @ts-ignore - ts doesn't like this and nor should we - res[prop] = obj[prop]; - } - } - return res; - } - - public toStorage(): ICrossSigningInfo { - return { - keys: this.keys, - firstUse: this.firstUse, - crossSigningVerifiedBefore: this.crossSigningVerifiedBefore, - }; - } - - /** - * Calls the app callback to ask for a private key - * - * @param type - The key type ("master", "self_signing", or "user_signing") - * @param expectedPubkey - The matching public key or undefined to use - * the stored public key for the given key type. - * @returns An array with [ public key, Olm.PkSigning ] - */ - public async getCrossSigningKey(type: string, expectedPubkey?: string): Promise<[string, PkSigning]> { - const shouldCache = ["master", "self_signing", "user_signing"].indexOf(type) >= 0; - - if (!this.callbacks.getCrossSigningKey) { - throw new Error("No getCrossSigningKey callback supplied"); - } - - if (expectedPubkey === undefined) { - expectedPubkey = this.getId(type)!; - } - - function validateKey(key: Uint8Array | null): [string, PkSigning] | undefined { - if (!key) return; - const signing = new globalThis.Olm.PkSigning(); - const gotPubkey = signing.init_with_seed(key); - if (gotPubkey === expectedPubkey) { - return [gotPubkey, signing]; - } - signing.free(); - } - - let privkey: Uint8Array | null = null; - if (this.cacheCallbacks.getCrossSigningKeyCache && shouldCache) { - privkey = await this.cacheCallbacks.getCrossSigningKeyCache(type, expectedPubkey); - } - - const cacheresult = validateKey(privkey); - if (cacheresult) { - return cacheresult; - } - - privkey = await this.callbacks.getCrossSigningKey(type, expectedPubkey); - const result = validateKey(privkey); - if (result) { - if (this.cacheCallbacks.storeCrossSigningKeyCache && shouldCache) { - await this.cacheCallbacks.storeCrossSigningKeyCache(type, privkey!); - } - return result; - } - - /* No keysource even returned a key */ - if (!privkey) { - throw new Error("getCrossSigningKey callback for " + type + " returned falsey"); - } - - /* We got some keys from the keysource, but none of them were valid */ - throw new Error("Key type " + type + " from getCrossSigningKey callback did not match"); - } - - /** - * Check whether the private keys exist in secret storage. - * XXX: This could be static, be we often seem to have an instance when we - * want to know this anyway... - * - * @param secretStorage - The secret store using account data - * @returns map of key name to key info the secret is encrypted - * with, or null if it is not present or not encrypted with a trusted - * key - */ - public async isStoredInSecretStorage( - secretStorage: ServerSideSecretStorage, - ): Promise | null> { - // check what SSSS keys have encrypted the master key (if any) - const stored = (await secretStorage.isStored("m.cross_signing.master")) || {}; - // then check which of those SSSS keys have also encrypted the SSK and USK - function intersect(s: Record): void { - for (const k of Object.keys(stored)) { - if (!s[k]) { - delete stored[k]; - } - } - } - for (const type of ["self_signing", "user_signing"] as const) { - intersect((await secretStorage.isStored(`m.cross_signing.${type}`)) || {}); - } - return Object.keys(stored).length ? stored : null; - } - - /** - * Store private keys in secret storage for use by other devices. This is - * typically called in conjunction with the creation of new cross-signing - * keys. - * - * @param keys - The keys to store - * @param secretStorage - The secret store using account data - */ - public static async storeInSecretStorage( - keys: Map, - secretStorage: ServerSideSecretStorage, - ): Promise { - for (const [type, privateKey] of keys) { - const encodedKey = encodeBase64(privateKey); - await secretStorage.store(`m.cross_signing.${type}`, encodedKey); - } - } - - /** - * Get private keys from secret storage created by some other device. This - * also passes the private keys to the app-specific callback. - * - * @param type - The type of key to get. One of "master", - * "self_signing", or "user_signing". - * @param secretStorage - The secret store using account data - * @returns The private key - */ - public static async getFromSecretStorage( - type: string, - secretStorage: ServerSideSecretStorage, - ): Promise { - const encodedKey = await secretStorage.get(`m.cross_signing.${type}`); - if (!encodedKey) { - return null; - } - return decodeBase64(encodedKey); - } - - /** - * Check whether the private keys exist in the local key cache. - * - * @param type - The type of key to get. One of "master", - * "self_signing", or "user_signing". Optional, will check all by default. - * @returns True if all keys are stored in the local cache. - */ - public async isStoredInKeyCache(type?: string): Promise { - const cacheCallbacks = this.cacheCallbacks; - if (!cacheCallbacks) return false; - const types = type ? [type] : ["master", "self_signing", "user_signing"]; - for (const t of types) { - if (!(await cacheCallbacks.getCrossSigningKeyCache?.(t))) { - return false; - } - } - return true; - } - - /** - * Get cross-signing private keys from the local cache. - * - * @returns A map from key type (string) to private key (Uint8Array) - */ - public async getCrossSigningKeysFromCache(): Promise> { - const keys = new Map(); - const cacheCallbacks = this.cacheCallbacks; - if (!cacheCallbacks) return keys; - for (const type of ["master", "self_signing", "user_signing"]) { - const privKey = await cacheCallbacks.getCrossSigningKeyCache?.(type); - if (!privKey) { - continue; - } - keys.set(type, privKey); - } - return keys; - } - - /** - * Get the ID used to identify the user. This can also be used to test for - * the existence of a given key type. - * - * @param type - The type of key to get the ID of. One of "master", - * "self_signing", or "user_signing". Defaults to "master". - * - * @returns the ID - */ - public getId(type = "master"): string | null { - if (!this.keys[type]) return null; - const keyInfo = this.keys[type]; - return publicKeyFromKeyInfo(keyInfo); - } - - /** - * Create new cross-signing keys for the given key types. The public keys - * will be held in this class, while the private keys are passed off to the - * `saveCrossSigningKeys` application callback. - * - * @param level - The key types to reset - */ - public async resetKeys(level?: CrossSigningLevel): Promise { - if (!this.callbacks.saveCrossSigningKeys) { - throw new Error("No saveCrossSigningKeys callback supplied"); - } - - // If we're resetting the master key, we reset all keys - if (level === undefined || level & CrossSigningLevel.MASTER || !this.keys.master) { - level = CrossSigningLevel.MASTER | CrossSigningLevel.USER_SIGNING | CrossSigningLevel.SELF_SIGNING; - } else if (level === (0 as CrossSigningLevel)) { - return; - } - - const privateKeys: Record = {}; - const keys: Record = {}; - let masterSigning: PkSigning | undefined; - let masterPub: string | undefined; - - try { - if (level & CrossSigningLevel.MASTER) { - masterSigning = new globalThis.Olm.PkSigning(); - privateKeys.master = masterSigning.generate_seed(); - masterPub = masterSigning.init_with_seed(privateKeys.master); - keys.master = { - user_id: this.userId, - usage: ["master"], - keys: { - ["ed25519:" + masterPub]: masterPub, - }, - }; - } else { - [masterPub, masterSigning] = await this.getCrossSigningKey("master"); - } - - if (level & CrossSigningLevel.SELF_SIGNING) { - const sskSigning = new globalThis.Olm.PkSigning(); - try { - privateKeys.self_signing = sskSigning.generate_seed(); - const sskPub = sskSigning.init_with_seed(privateKeys.self_signing); - keys.self_signing = { - user_id: this.userId, - usage: ["self_signing"], - keys: { - ["ed25519:" + sskPub]: sskPub, - }, - }; - pkSign(keys.self_signing, masterSigning, this.userId, masterPub); - } finally { - sskSigning.free(); - } - } - - if (level & CrossSigningLevel.USER_SIGNING) { - const uskSigning = new globalThis.Olm.PkSigning(); - try { - privateKeys.user_signing = uskSigning.generate_seed(); - const uskPub = uskSigning.init_with_seed(privateKeys.user_signing); - keys.user_signing = { - user_id: this.userId, - usage: ["user_signing"], - keys: { - ["ed25519:" + uskPub]: uskPub, - }, - }; - pkSign(keys.user_signing, masterSigning, this.userId, masterPub); - } finally { - uskSigning.free(); - } - } - - Object.assign(this.keys, keys); - this.callbacks.saveCrossSigningKeys(privateKeys); - } finally { - if (masterSigning) { - masterSigning.free(); - } - } - } - - /** - * unsets the keys, used when another session has reset the keys, to disable cross-signing - */ - public clearKeys(): void { - this.keys = {}; - } - - public setKeys(keys: Record): void { - const signingKeys: Record = {}; - if (keys.master) { - if (keys.master.user_id !== this.userId) { - const error = "Mismatched user ID " + keys.master.user_id + " in master key from " + this.userId; - logger.error(error); - throw new Error(error); - } - if (!this.keys.master) { - // this is the first key we've seen, so first-use is true - this.firstUse = true; - } else if (publicKeyFromKeyInfo(keys.master) !== this.getId()) { - // this is a different key, so first-use is false - this.firstUse = false; - } // otherwise, same key, so no change - signingKeys.master = keys.master; - } else if (this.keys.master) { - signingKeys.master = this.keys.master; - } else { - throw new Error("Tried to set cross-signing keys without a master key"); - } - const masterKey = publicKeyFromKeyInfo(signingKeys.master); - - // verify signatures - if (keys.user_signing) { - if (keys.user_signing.user_id !== this.userId) { - const error = "Mismatched user ID " + keys.master.user_id + " in user_signing key from " + this.userId; - logger.error(error); - throw new Error(error); - } - try { - pkVerify(keys.user_signing, masterKey, this.userId); - } catch (e) { - logger.error("invalid signature on user-signing key"); - // FIXME: what do we want to do here? - throw e; - } - } - if (keys.self_signing) { - if (keys.self_signing.user_id !== this.userId) { - const error = "Mismatched user ID " + keys.master.user_id + " in self_signing key from " + this.userId; - logger.error(error); - throw new Error(error); - } - try { - pkVerify(keys.self_signing, masterKey, this.userId); - } catch (e) { - logger.error("invalid signature on self-signing key"); - // FIXME: what do we want to do here? - throw e; - } - } - - // if everything checks out, then save the keys - if (keys.master) { - this.keys.master = keys.master; - // if the master key is set, then the old self-signing and user-signing keys are obsolete - delete this.keys["self_signing"]; - delete this.keys["user_signing"]; - } - if (keys.self_signing) { - this.keys.self_signing = keys.self_signing; - } - if (keys.user_signing) { - this.keys.user_signing = keys.user_signing; - } - } - - public updateCrossSigningVerifiedBefore(isCrossSigningVerified: boolean): void { - // It is critical that this value latches forward from false to true but - // never back to false to avoid a downgrade attack. - if (!this.crossSigningVerifiedBefore && isCrossSigningVerified) { - this.crossSigningVerifiedBefore = true; - } - } - - public async signObject(data: T, type: string): Promise { - if (!this.keys[type]) { - throw new Error("Attempted to sign with " + type + " key but no such key present"); - } - const [pubkey, signing] = await this.getCrossSigningKey(type); - try { - pkSign(data, signing, this.userId, pubkey); - return data as T & { signatures: ISignatures }; - } finally { - signing.free(); - } - } - - public async signUser(key: CrossSigningInfo): Promise { - if (!this.keys.user_signing) { - logger.info("No user signing key: not signing user"); - return; - } - return this.signObject(key.keys.master, "user_signing"); - } - - public async signDevice(userId: string, device: DeviceInfo): Promise { - if (userId !== this.userId) { - throw new Error(`Trying to sign ${userId}'s device; can only sign our own device`); - } - if (!this.keys.self_signing) { - logger.info("No self signing key: not signing device"); - return; - } - return this.signObject>( - { - algorithms: device.algorithms, - keys: device.keys, - device_id: device.deviceId, - user_id: userId, - }, - "self_signing", - ); - } - - /** - * Check whether a given user is trusted. - * - * @param userCrossSigning - Cross signing info for user - * - * @returns - */ - public checkUserTrust(userCrossSigning: CrossSigningInfo): UserTrustLevel { - // if we're checking our own key, then it's trusted if the master key - // and self-signing key match - if ( - this.userId === userCrossSigning.userId && - this.getId() && - this.getId() === userCrossSigning.getId() && - this.getId("self_signing") && - this.getId("self_signing") === userCrossSigning.getId("self_signing") - ) { - return new UserTrustLevel(true, true, this.firstUse); - } - - if (!this.keys.user_signing) { - // If there's no user signing key, they can't possibly be verified. - // They may be TOFU trusted though. - return new UserTrustLevel(false, false, userCrossSigning.firstUse); - } - - let userTrusted: boolean; - const userMaster = userCrossSigning.keys.master; - const uskId = this.getId("user_signing")!; - try { - pkVerify(userMaster, uskId, this.userId); - userTrusted = true; - } catch { - userTrusted = false; - } - return new UserTrustLevel(userTrusted, userCrossSigning.crossSigningVerifiedBefore, userCrossSigning.firstUse); - } - - /** - * Check whether a given device is trusted. - * - * @param userCrossSigning - Cross signing info for user - * @param device - The device to check - * @param localTrust - Whether the device is trusted locally - * @param trustCrossSignedDevices - Whether we trust cross signed devices - * - * @returns - */ - public checkDeviceTrust( - userCrossSigning: CrossSigningInfo, - device: DeviceInfo, - localTrust: boolean, - trustCrossSignedDevices: boolean, - ): DeviceTrustLevel { - const userTrust = this.checkUserTrust(userCrossSigning); - - const userSSK = userCrossSigning.keys.self_signing; - if (!userSSK) { - // if the user has no self-signing key then we cannot make any - // trust assertions about this device from cross-signing - return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices); - } - - const deviceObj = deviceToObject(device, userCrossSigning.userId); - try { - // if we can verify the user's SSK from their master key... - pkVerify(userSSK, userCrossSigning.getId()!, userCrossSigning.userId); - // ...and this device's key from their SSK... - pkVerify(deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId); - // ...then we trust this device as much as far as we trust the user - return DeviceTrustLevel.fromUserTrustLevel(userTrust, localTrust, trustCrossSignedDevices); - } catch { - return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices); - } - } - - /** - * @returns Cache callbacks - */ - public getCacheCallbacks(): ICacheCallbacks { - return this.cacheCallbacks; - } -} - -interface DeviceObject extends IObject { - algorithms: string[]; - keys: Record; - device_id: string; - user_id: string; -} - -function deviceToObject(device: DeviceInfo, userId: string): DeviceObject { - return { - algorithms: device.algorithms, - keys: device.keys, - device_id: device.deviceId, - user_id: userId, - signatures: device.signatures, - }; -} - -export enum CrossSigningLevel { - MASTER = 4, - USER_SIGNING = 2, - SELF_SIGNING = 1, -} - -/** - * Represents the ways in which we trust a device. - * - * @deprecated Use {@link DeviceVerificationStatus}. - */ -export class DeviceTrustLevel extends DeviceVerificationStatus { - public constructor( - crossSigningVerified: boolean, - tofu: boolean, - localVerified: boolean, - trustCrossSignedDevices: boolean, - signedByOwner = false, - ) { - super({ crossSigningVerified, tofu, localVerified, trustCrossSignedDevices, signedByOwner }); - } - - public static fromUserTrustLevel( - userTrustLevel: UserTrustLevel, - localVerified: boolean, - trustCrossSignedDevices: boolean, - ): DeviceTrustLevel { - return new DeviceTrustLevel( - userTrustLevel.isCrossSigningVerified(), - userTrustLevel.isTofu(), - localVerified, - trustCrossSignedDevices, - true, - ); - } - - /** - * @returns true if this device is verified via cross signing - */ - public isCrossSigningVerified(): boolean { - return this.crossSigningVerified; - } - - /** - * @returns true if this device is verified locally - */ - public isLocallyVerified(): boolean { - return this.localVerified; - } - - /** - * @returns true if this device is trusted from a user's key - * that is trusted on first use - */ - public isTofu(): boolean { - return this.tofu; - } -} - -export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: OlmDevice): ICacheCallbacks { - return { - getCrossSigningKeyCache: async function ( - type: keyof SecretStorePrivateKeys, - _expectedPublicKey: string, - ): Promise { - const key = await new Promise((resolve) => { - store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - store.getSecretStorePrivateKey(txn, resolve, type); - }); - }); - - if (key && key.ciphertext) { - const pickleKey = Buffer.from(olmDevice.pickleKey); - const decrypted = await decryptAESSecretStorageItem(key, pickleKey, type); - return decodeBase64(decrypted); - } else { - return key; - } - }, - storeCrossSigningKeyCache: async function ( - type: keyof SecretStorePrivateKeys, - key?: Uint8Array, - ): Promise { - if (!(key instanceof Uint8Array)) { - throw new Error(`storeCrossSigningKeyCache expects Uint8Array, got ${key}`); - } - const pickleKey = Buffer.from(olmDevice.pickleKey); - const encryptedKey = await encryptAESSecretStorageItem(encodeBase64(key), pickleKey, type); - return store.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - store.storeSecretStorePrivateKey(txn, type, encryptedKey); - }); - }, - }; -} - -export type KeysDuringVerification = [[string, PkSigning], [string, PkSigning], [string, PkSigning], void]; - -/** - * Request cross-signing keys from another device during verification. - * - * @param baseApis - base Matrix API interface - * @param userId - The user ID being verified - * @param deviceId - The device ID being verified - */ -export async function requestKeysDuringVerification( - baseApis: MatrixClient, - userId: string, - deviceId: string, -): Promise { - // If this is a self-verification, ask the other party for keys - if (baseApis.getUserId() !== userId) { - return; - } - logger.log("Cross-signing: Self-verification done; requesting keys"); - // This happens asynchronously, and we're not concerned about waiting for - // it. We return here in order to test. - return new Promise((resolve, reject) => { - const client = baseApis; - const original = client.crypto!.crossSigningInfo; - - // We already have all of the infrastructure we need to validate and - // cache cross-signing keys, so instead of replicating that, here we set - // up callbacks that request them from the other device and call - // CrossSigningInfo.getCrossSigningKey() to validate/cache - const crossSigning = new CrossSigningInfo( - original.userId, - { - getCrossSigningKey: async (type): Promise => { - logger.debug("Cross-signing: requesting secret", type, deviceId); - const { promise } = client.requestSecret(`m.cross_signing.${type}`, [deviceId]); - const result = await promise; - const decoded = decodeBase64(result); - return Uint8Array.from(decoded); - }, - }, - original.getCacheCallbacks(), - ); - crossSigning.keys = original.keys; - - // XXX: get all keys out if we get one key out - // https://github.com/vector-im/element-web/issues/12604 - // then change here to reject on the timeout - // Requests can be ignored, so don't wait around forever - const timeout = new Promise((resolve) => { - setTimeout(resolve, KEY_REQUEST_TIMEOUT_MS, new Error("Timeout")); - }); - - // also request and cache the key backup key - const backupKeyPromise = (async (): Promise => { - const cachedKey = await client.crypto!.getSessionBackupPrivateKey(); - if (!cachedKey) { - logger.info("No cached backup key found. Requesting..."); - const secretReq = client.requestSecret("m.megolm_backup.v1", [deviceId]); - const base64Key = await secretReq.promise; - logger.info("Got key backup key, decoding..."); - const decodedKey = decodeBase64(base64Key); - logger.info("Decoded backup key, storing..."); - await client.crypto!.storeSessionBackupPrivateKey(Uint8Array.from(decodedKey)); - logger.info("Backup key stored. Starting backup restore..."); - const backupInfo = await client.getKeyBackupVersion(); - // no need to await for this - just let it go in the bg - client.restoreKeyBackupWithCache(undefined, undefined, backupInfo!).then(() => { - logger.info("Backup restored."); - }); - } - })(); - - // We call getCrossSigningKey() for its side-effects - Promise.race([ - Promise.all([ - crossSigning.getCrossSigningKey("master"), - crossSigning.getCrossSigningKey("self_signing"), - crossSigning.getCrossSigningKey("user_signing"), - backupKeyPromise, - ]) as Promise, - timeout, - ]).then(resolve, reject); - }).catch((e) => { - logger.warn("Cross-signing: failure while requesting keys:", e); - }); -} diff --git a/src/crypto/DeviceList.ts b/src/crypto/DeviceList.ts deleted file mode 100644 index c7c389678..000000000 --- a/src/crypto/DeviceList.ts +++ /dev/null @@ -1,989 +0,0 @@ -/* -Copyright 2017 - 2021 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. -*/ - -/** - * Manages the list of other users' devices - */ - -import { logger } from "../logger.ts"; -import { DeviceInfo, type IDevice } from "./deviceinfo.ts"; -import { CrossSigningInfo, type ICrossSigningInfo } from "./CrossSigning.ts"; -import * as olmlib from "./olmlib.ts"; -import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store.ts"; -import { chunkPromises, defer, type IDeferred, sleep } from "../utils.ts"; -import { type DeviceKeys, type IDownloadKeyResult, type Keys, type MatrixClient, type SigningKeys } from "../client.ts"; -import { type OlmDevice } from "./OlmDevice.ts"; -import { type CryptoStore } from "./store/base.ts"; -import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; -import { CryptoEvent, type CryptoEventHandlerMap } from "./index.ts"; - -/* State transition diagram for DeviceList.deviceTrackingStatus - * - * | - * stopTrackingDeviceList V - * +---------------------> NOT_TRACKED - * | | - * +<--------------------+ | startTrackingDeviceList - * | | V - * | +-------------> PENDING_DOWNLOAD <--------------------+-+ - * | | ^ | | | - * | | restart download | | start download | | invalidateUserDeviceList - * | | client failed | | | | - * | | | V | | - * | +------------ DOWNLOAD_IN_PROGRESS -------------------+ | - * | | | | - * +<-------------------+ | download successful | - * ^ V | - * +----------------------- UP_TO_DATE ------------------------+ - */ - -// constants for DeviceList.deviceTrackingStatus -export enum TrackingStatus { - NotTracked, - PendingDownload, - DownloadInProgress, - UpToDate, -} - -// user-Id → device-Id → DeviceInfo -export type DeviceInfoMap = Map>; - -type EmittedEvents = CryptoEvent.WillUpdateDevices | CryptoEvent.DevicesUpdated | CryptoEvent.UserCrossSigningUpdated; - -export class DeviceList extends TypedEventEmitter { - private devices: { [userId: string]: { [deviceId: string]: IDevice } } = {}; - - public crossSigningInfo: { [userId: string]: ICrossSigningInfo } = {}; - - // map of identity keys to the user who owns it - private userByIdentityKey: Record = {}; - - // which users we are tracking device status for. - private deviceTrackingStatus: { [userId: string]: TrackingStatus } = {}; // loaded from storage in load() - - // The 'next_batch' sync token at the point the data was written, - // ie. a token representing the point immediately after the - // moment represented by the snapshot in the db. - private syncToken: string | null = null; - - private keyDownloadsInProgressByUser = new Map>(); - - // Set whenever changes are made other than setting the sync token - private dirty = false; - - // Promise resolved when device data is saved - private savePromise: Promise | null = null; - // Function that resolves the save promise - private resolveSavePromise: ((saved: boolean) => void) | null = null; - // The time the save is scheduled for - private savePromiseTime: number | null = null; - // The timer used to delay the save - private saveTimer: ReturnType | null = null; - // True if we have fetched data from the server or loaded a non-empty - // set of device data from the store - private hasFetched: boolean | null = null; - - private readonly serialiser: DeviceListUpdateSerialiser; - - public constructor( - baseApis: MatrixClient, - private readonly cryptoStore: CryptoStore, - olmDevice: OlmDevice, - // Maximum number of user IDs per request to prevent server overload (#1619) - public readonly keyDownloadChunkSize = 250, - ) { - super(); - - this.serialiser = new DeviceListUpdateSerialiser(baseApis, olmDevice, this); - } - - /** - * Load the device tracking state from storage - */ - public async load(): Promise { - await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { - this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => { - this.hasFetched = Boolean(deviceData?.devices); - this.devices = deviceData ? deviceData.devices : {}; - this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {}; - this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {}; - this.syncToken = deviceData?.syncToken ?? null; - this.userByIdentityKey = {}; - for (const user of Object.keys(this.devices)) { - const userDevices = this.devices[user]; - for (const device of Object.keys(userDevices)) { - const idKey = userDevices[device].keys["curve25519:" + device]; - if (idKey !== undefined) { - this.userByIdentityKey[idKey] = user; - } - } - } - }); - }); - - for (const u of Object.keys(this.deviceTrackingStatus)) { - // if a download was in progress when we got shut down, it isn't any more. - if (this.deviceTrackingStatus[u] == TrackingStatus.DownloadInProgress) { - this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload; - } - } - } - - public stop(): void { - if (this.saveTimer !== null) { - clearTimeout(this.saveTimer); - } - } - - /** - * Save the device tracking state to storage, if any changes are - * pending other than updating the sync token - * - * The actual save will be delayed by a short amount of time to - * aggregate multiple writes to the database. - * - * @param delay - Time in ms before which the save actually happens. - * By default, the save is delayed for a short period in order to batch - * multiple writes, but this behaviour can be disabled by passing 0. - * - * @returns true if the data was saved, false if - * it was not (eg. because no changes were pending). The promise - * will only resolve once the data is saved, so may take some time - * to resolve. - */ - public async saveIfDirty(delay = 500): Promise { - if (!this.dirty) return Promise.resolve(false); - // Delay saves for a bit so we can aggregate multiple saves that happen - // in quick succession (eg. when a whole room's devices are marked as known) - - const targetTime = Date.now() + delay; - if (this.savePromiseTime && targetTime < this.savePromiseTime) { - // There's a save scheduled but for after we would like: cancel - // it & schedule one for the time we want - clearTimeout(this.saveTimer!); - this.saveTimer = null; - this.savePromiseTime = null; - // (but keep the save promise since whatever called save before - // will still want to know when the save is done) - } - - let savePromise = this.savePromise; - if (savePromise === null) { - savePromise = new Promise((resolve) => { - this.resolveSavePromise = resolve; - }); - this.savePromise = savePromise; - } - - if (this.saveTimer === null) { - const resolveSavePromise = this.resolveSavePromise; - this.savePromiseTime = targetTime; - this.saveTimer = setTimeout(() => { - logger.log("Saving device tracking data", this.syncToken); - - // null out savePromise now (after the delay but before the write), - // otherwise we could return the existing promise when the save has - // actually already happened. - this.savePromiseTime = null; - this.saveTimer = null; - this.savePromise = null; - this.resolveSavePromise = null; - - this.cryptoStore - .doTxn("readwrite", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { - this.cryptoStore.storeEndToEndDeviceData( - { - devices: this.devices, - crossSigningInfo: this.crossSigningInfo, - trackingStatus: this.deviceTrackingStatus, - syncToken: this.syncToken ?? undefined, - }, - txn, - ); - }) - .then( - () => { - // The device list is considered dirty until the write completes. - this.dirty = false; - resolveSavePromise?.(true); - }, - (err) => { - logger.error("Failed to save device tracking data", this.syncToken); - logger.error(err); - }, - ); - }, delay); - } - - return savePromise; - } - - /** - * Gets the sync token last set with setSyncToken - * - * @returns The sync token - */ - public getSyncToken(): string | null { - return this.syncToken; - } - - /** - * Sets the sync token that the app will pass as the 'since' to the /sync - * endpoint next time it syncs. - * The sync token must always be set after any changes made as a result of - * data in that sync since setting the sync token to a newer one will mean - * those changed will not be synced from the server if a new client starts - * up with that data. - * - * @param st - The sync token - */ - public setSyncToken(st: string | null): void { - this.syncToken = st; - } - - /** - * Ensures up to date keys for a list of users are stored in the session store, - * downloading and storing them if they're not (or if forceDownload is - * true). - * @param userIds - The users to fetch. - * @param forceDownload - Always download the keys even if cached. - * - * @returns A promise which resolves to a map userId-\>deviceId-\>{@link DeviceInfo}. - */ - public downloadKeys(userIds: string[], forceDownload: boolean): Promise { - const usersToDownload: string[] = []; - const promises: Promise[] = []; - - userIds.forEach((u) => { - const trackingStatus = this.deviceTrackingStatus[u]; - if (this.keyDownloadsInProgressByUser.has(u)) { - // already a key download in progress/queued for this user; its results - // will be good enough for us. - logger.log(`downloadKeys: already have a download in progress for ` + `${u}: awaiting its result`); - promises.push(this.keyDownloadsInProgressByUser.get(u)!); - } else if (forceDownload || trackingStatus != TrackingStatus.UpToDate) { - usersToDownload.push(u); - } - }); - - if (usersToDownload.length != 0) { - logger.log("downloadKeys: downloading for", usersToDownload); - const downloadPromise = this.doKeyDownload(usersToDownload); - promises.push(downloadPromise); - } - - if (promises.length === 0) { - logger.log("downloadKeys: already have all necessary keys"); - } - - return Promise.all(promises).then(() => { - return this.getDevicesFromStore(userIds); - }); - } - - /** - * Get the stored device keys for a list of user ids - * - * @param userIds - the list of users to list keys for. - * - * @returns userId-\>deviceId-\>{@link DeviceInfo}. - */ - private getDevicesFromStore(userIds: string[]): DeviceInfoMap { - const stored: DeviceInfoMap = new Map(); - userIds.forEach((userId) => { - const deviceMap = new Map(); - this.getStoredDevicesForUser(userId)?.forEach(function (device) { - deviceMap.set(device.deviceId, device); - }); - stored.set(userId, deviceMap); - }); - return stored; - } - - /** - * Returns a list of all user IDs the DeviceList knows about - * - * @returns All known user IDs - */ - public getKnownUserIds(): string[] { - return Object.keys(this.devices); - } - - /** - * Get the stored device keys for a user id - * - * @param userId - the user to list keys for. - * - * @returns list of devices, or null if we haven't - * managed to get a list of devices for this user yet. - */ - public getStoredDevicesForUser(userId: string): DeviceInfo[] | null { - const devs = this.devices[userId]; - if (!devs) { - return null; - } - const res: DeviceInfo[] = []; - for (const deviceId in devs) { - if (devs.hasOwnProperty(deviceId)) { - res.push(DeviceInfo.fromStorage(devs[deviceId], deviceId)); - } - } - return res; - } - - /** - * Get the stored device data for a user, in raw object form - * - * @param userId - the user to get data for - * - * @returns `deviceId->{object}` devices, or undefined if - * there is no data for this user. - */ - public getRawStoredDevicesForUser(userId: string): Record { - return this.devices[userId]; - } - - public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null { - if (!this.crossSigningInfo[userId]) return null; - - return CrossSigningInfo.fromStorage(this.crossSigningInfo[userId], userId); - } - - public storeCrossSigningForUser(userId: string, info: ICrossSigningInfo): void { - this.crossSigningInfo[userId] = info; - this.dirty = true; - } - - /** - * Get the stored keys for a single device - * - * - * @returns device, or undefined - * if we don't know about this device - */ - public getStoredDevice(userId: string, deviceId: string): DeviceInfo | undefined { - const devs = this.devices[userId]; - if (!devs?.[deviceId]) { - return undefined; - } - return DeviceInfo.fromStorage(devs[deviceId], deviceId); - } - - /** - * Get a user ID by one of their device's curve25519 identity key - * - * @param algorithm - encryption algorithm - * @param senderKey - curve25519 key to match - * - * @returns user ID - */ - public getUserByIdentityKey(algorithm: string, senderKey: string): string | null { - if (algorithm !== olmlib.OLM_ALGORITHM && algorithm !== olmlib.MEGOLM_ALGORITHM) { - // we only deal in olm keys - return null; - } - - return this.userByIdentityKey[senderKey]; - } - - /** - * Find a device by curve25519 identity key - * - * @param algorithm - encryption algorithm - * @param senderKey - curve25519 key to match - */ - public getDeviceByIdentityKey(algorithm: string, senderKey: string): DeviceInfo | null { - const userId = this.getUserByIdentityKey(algorithm, senderKey); - if (!userId) { - return null; - } - - const devices = this.devices[userId]; - if (!devices) { - return null; - } - - for (const deviceId in devices) { - if (!devices.hasOwnProperty(deviceId)) { - continue; - } - - const device = devices[deviceId]; - for (const keyId in device.keys) { - if (!device.keys.hasOwnProperty(keyId)) { - continue; - } - if (keyId.indexOf("curve25519:") !== 0) { - continue; - } - const deviceKey = device.keys[keyId]; - if (deviceKey == senderKey) { - return DeviceInfo.fromStorage(device, deviceId); - } - } - } - - // doesn't match a known device - return null; - } - - /** - * Replaces the list of devices for a user with the given device list - * - * @param userId - The user ID - * @param devices - New device info for user - */ - public storeDevicesForUser(userId: string, devices: Record): void { - this.setRawStoredDevicesForUser(userId, devices); - this.dirty = true; - } - - /** - * flag the given user for device-list tracking, if they are not already. - * - * This will mean that a subsequent call to refreshOutdatedDeviceLists() - * will download the device list for the user, and that subsequent calls to - * invalidateUserDeviceList will trigger more updates. - * - */ - public startTrackingDeviceList(userId: string): void { - // sanity-check the userId. This is mostly paranoia, but if synapse - // can't parse the userId we give it as an mxid, it 500s the whole - // request and we can never update the device lists again (because - // the broken userId is always 'invalid' and always included in any - // refresh request). - // By checking it is at least a string, we can eliminate a class of - // silly errors. - if (typeof userId !== "string") { - throw new Error("userId must be a string; was " + userId); - } - if (!this.deviceTrackingStatus[userId]) { - logger.log("Now tracking device list for " + userId); - this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload; - // we don't yet persist the tracking status, since there may be a lot - // of calls; we save all data together once the sync is done - this.dirty = true; - } - } - - /** - * Mark the given user as no longer being tracked for device-list updates. - * - * This won't affect any in-progress downloads, which will still go on to - * complete; it will just mean that we don't think that we have an up-to-date - * list for future calls to downloadKeys. - * - */ - public stopTrackingDeviceList(userId: string): void { - if (this.deviceTrackingStatus[userId]) { - logger.log("No longer tracking device list for " + userId); - this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked; - - // we don't yet persist the tracking status, since there may be a lot - // of calls; we save all data together once the sync is done - this.dirty = true; - } - } - - /** - * Set all users we're currently tracking to untracked - * - * This will flag each user whose devices we are tracking as in need of an - * update. - */ - public stopTrackingAllDeviceLists(): void { - for (const userId of Object.keys(this.deviceTrackingStatus)) { - this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked; - } - this.dirty = true; - } - - /** - * Mark the cached device list for the given user outdated. - * - * If we are not tracking this user's devices, we'll do nothing. Otherwise - * we flag the user as needing an update. - * - * This doesn't actually set off an update, so that several users can be - * batched together. Call refreshOutdatedDeviceLists() for that. - * - */ - public invalidateUserDeviceList(userId: string): void { - if (this.deviceTrackingStatus[userId]) { - logger.log("Marking device list outdated for", userId); - this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload; - - // we don't yet persist the tracking status, since there may be a lot - // of calls; we save all data together once the sync is done - this.dirty = true; - } - } - - /** - * If we have users who have outdated device lists, start key downloads for them - * - * @returns which completes when the download completes; normally there - * is no need to wait for this (it's mostly for the unit tests). - */ - public refreshOutdatedDeviceLists(): Promise { - this.saveIfDirty(); - - const usersToDownload: string[] = []; - for (const userId of Object.keys(this.deviceTrackingStatus)) { - const stat = this.deviceTrackingStatus[userId]; - if (stat == TrackingStatus.PendingDownload) { - usersToDownload.push(userId); - } - } - - return this.doKeyDownload(usersToDownload); - } - - /** - * Set the stored device data for a user, in raw object form - * Used only by internal class DeviceListUpdateSerialiser - * - * @param userId - the user to get data for - * - * @param devices - `deviceId->{object}` the new devices - */ - public setRawStoredDevicesForUser(userId: string, devices: Record): void { - // remove old devices from userByIdentityKey - if (this.devices[userId] !== undefined) { - for (const [deviceId, dev] of Object.entries(this.devices[userId])) { - const identityKey = dev.keys["curve25519:" + deviceId]; - - delete this.userByIdentityKey[identityKey]; - } - } - - this.devices[userId] = devices; - - // add new devices into userByIdentityKey - for (const [deviceId, dev] of Object.entries(devices)) { - const identityKey = dev.keys["curve25519:" + deviceId]; - - this.userByIdentityKey[identityKey] = userId; - } - } - - public setRawStoredCrossSigningForUser(userId: string, info: ICrossSigningInfo): void { - this.crossSigningInfo[userId] = info; - } - - /** - * Fire off download update requests for the given users, and update the - * device list tracking status for them, and the - * keyDownloadsInProgressByUser map for them. - * - * @param users - list of userIds - * - * @returns resolves when all the users listed have - * been updated. rejects if there was a problem updating any of the - * users. - */ - private doKeyDownload(users: string[]): Promise { - if (users.length === 0) { - // nothing to do - return Promise.resolve(); - } - - const prom = this.serialiser.updateDevicesForUsers(users, this.syncToken!).then( - () => { - finished(true); - }, - (e) => { - logger.error("Error downloading keys for " + users + ":", e); - finished(false); - throw e; - }, - ); - - users.forEach((u) => { - this.keyDownloadsInProgressByUser.set(u, prom); - const stat = this.deviceTrackingStatus[u]; - if (stat == TrackingStatus.PendingDownload) { - this.deviceTrackingStatus[u] = TrackingStatus.DownloadInProgress; - } - }); - - const finished = (success: boolean): void => { - this.emit(CryptoEvent.WillUpdateDevices, users, !this.hasFetched); - users.forEach((u) => { - this.dirty = true; - - // we may have queued up another download request for this user - // since we started this request. If that happens, we should - // ignore the completion of the first one. - if (this.keyDownloadsInProgressByUser.get(u) !== prom) { - logger.log("Another update in the queue for", u, "- not marking up-to-date"); - return; - } - this.keyDownloadsInProgressByUser.delete(u); - const stat = this.deviceTrackingStatus[u]; - if (stat == TrackingStatus.DownloadInProgress) { - if (success) { - // we didn't get any new invalidations since this download started: - // this user's device list is now up to date. - this.deviceTrackingStatus[u] = TrackingStatus.UpToDate; - logger.log("Device list for", u, "now up to date"); - } else { - this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload; - } - } - }); - this.saveIfDirty(); - this.emit(CryptoEvent.DevicesUpdated, users, !this.hasFetched); - this.hasFetched = true; - }; - - return prom; - } -} - -/** - * Serialises updates to device lists - * - * Ensures that results from /keys/query are not overwritten if a second call - * completes *before* an earlier one. - * - * It currently does this by ensuring only one call to /keys/query happens at a - * time (and queuing other requests up). - */ -class DeviceListUpdateSerialiser { - private downloadInProgress = false; - - // users which are queued for download - // userId -> true - private keyDownloadsQueuedByUser: Record = {}; - - // deferred which is resolved when the queued users are downloaded. - // non-null indicates that we have users queued for download. - private queuedQueryDeferred?: IDeferred; - - private syncToken?: string; // The sync token we send with the requests - - /* - * @param baseApis - Base API object - * @param olmDevice - The Olm Device - * @param deviceList - The device list object, the device list to be updated - */ - public constructor( - private readonly baseApis: MatrixClient, - private readonly olmDevice: OlmDevice, - private readonly deviceList: DeviceList, - ) {} - - /** - * Make a key query request for the given users - * - * @param users - list of user ids - * - * @param syncToken - sync token to pass in the query request, to - * help the HS give the most recent results - * - * @returns resolves when all the users listed have - * been updated. rejects if there was a problem updating any of the - * users. - */ - public updateDevicesForUsers(users: string[], syncToken: string): Promise { - users.forEach((u) => { - this.keyDownloadsQueuedByUser[u] = true; - }); - - if (!this.queuedQueryDeferred) { - this.queuedQueryDeferred = defer(); - } - - // We always take the new sync token and just use the latest one we've - // been given, since it just needs to be at least as recent as the - // sync response the device invalidation message arrived in - this.syncToken = syncToken; - - if (this.downloadInProgress) { - // just queue up these users - logger.log("Queued key download for", users); - return this.queuedQueryDeferred.promise; - } - - // start a new download. - return this.doQueuedQueries(); - } - - private doQueuedQueries(): Promise { - if (this.downloadInProgress) { - throw new Error("DeviceListUpdateSerialiser.doQueuedQueries called with request active"); - } - - const downloadUsers = Object.keys(this.keyDownloadsQueuedByUser); - this.keyDownloadsQueuedByUser = {}; - const deferred = this.queuedQueryDeferred; - this.queuedQueryDeferred = undefined; - - logger.log("Starting key download for", downloadUsers); - this.downloadInProgress = true; - - const opts: Parameters[1] = {}; - if (this.syncToken) { - opts.token = this.syncToken; - } - - const factories: Array<() => Promise> = []; - for (let i = 0; i < downloadUsers.length; i += this.deviceList.keyDownloadChunkSize) { - const userSlice = downloadUsers.slice(i, i + this.deviceList.keyDownloadChunkSize); - factories.push(() => this.baseApis.downloadKeysForUsers(userSlice, opts)); - } - - chunkPromises(factories, 3) - .then(async (responses: IDownloadKeyResult[]) => { - const dk: IDownloadKeyResult["device_keys"] = Object.assign( - {}, - ...responses.map((res) => res.device_keys || {}), - ); - const masterKeys: IDownloadKeyResult["master_keys"] = Object.assign( - {}, - ...responses.map((res) => res.master_keys || {}), - ); - const ssks: IDownloadKeyResult["self_signing_keys"] = Object.assign( - {}, - ...responses.map((res) => res.self_signing_keys || {}), - ); - const usks: IDownloadKeyResult["user_signing_keys"] = Object.assign( - {}, - ...responses.map((res) => res.user_signing_keys || {}), - ); - - // yield to other things that want to execute in between users, to - // avoid wedging the CPU - // (https://github.com/vector-im/element-web/issues/3158) - // - // of course we ought to do this in a web worker or similar, but - // this serves as an easy solution for now. - for (const userId of downloadUsers) { - await sleep(5); - try { - await this.processQueryResponseForUser(userId, dk[userId], { - master: masterKeys?.[userId], - self_signing: ssks?.[userId], - user_signing: usks?.[userId], - }); - } catch (e) { - // log the error but continue, so that one bad key - // doesn't kill the whole process - logger.error(`Error processing keys for ${userId}:`, e); - } - } - }) - .then( - () => { - logger.log("Completed key download for " + downloadUsers); - - this.downloadInProgress = false; - deferred?.resolve(); - - // if we have queued users, fire off another request. - if (this.queuedQueryDeferred) { - this.doQueuedQueries(); - } - }, - (e) => { - logger.warn("Error downloading keys for " + downloadUsers + ":", e); - this.downloadInProgress = false; - deferred?.reject(e); - }, - ); - - return deferred!.promise; - } - - private async processQueryResponseForUser( - userId: string, - dkResponse: DeviceKeys, - crossSigningResponse: { - master?: Keys; - self_signing?: SigningKeys; - user_signing?: SigningKeys; - }, - ): Promise { - logger.log("got device keys for " + userId + ":", dkResponse); - logger.log("got cross-signing keys for " + userId + ":", crossSigningResponse); - - { - // map from deviceid -> deviceinfo for this user - const userStore: Record = {}; - const devs = this.deviceList.getRawStoredDevicesForUser(userId); - if (devs) { - Object.keys(devs).forEach((deviceId) => { - const d = DeviceInfo.fromStorage(devs[deviceId], deviceId); - userStore[deviceId] = d; - }); - } - - await updateStoredDeviceKeysForUser( - this.olmDevice, - userId, - userStore, - dkResponse || {}, - this.baseApis.getUserId()!, - this.baseApis.deviceId!, - ); - - // put the updates into the object that will be returned as our results - const storage: Record = {}; - Object.keys(userStore).forEach((deviceId) => { - storage[deviceId] = userStore[deviceId].toStorage(); - }); - - this.deviceList.setRawStoredDevicesForUser(userId, storage); - } - - // now do the same for the cross-signing keys - { - // FIXME: should we be ignoring empty cross-signing responses, or - // should we be dropping the keys? - if ( - crossSigningResponse && - (crossSigningResponse.master || crossSigningResponse.self_signing || crossSigningResponse.user_signing) - ) { - const crossSigning = - this.deviceList.getStoredCrossSigningForUser(userId) || new CrossSigningInfo(userId); - - crossSigning.setKeys(crossSigningResponse); - - this.deviceList.setRawStoredCrossSigningForUser(userId, crossSigning.toStorage()); - - // NB. Unlike most events in the js-sdk, this one is internal to the - // js-sdk and is not re-emitted - this.deviceList.emit(CryptoEvent.UserCrossSigningUpdated, userId); - } - } - } -} - -async function updateStoredDeviceKeysForUser( - olmDevice: OlmDevice, - userId: string, - userStore: Record, - userResult: IDownloadKeyResult["device_keys"]["user_id"], - localUserId: string, - localDeviceId: string, -): Promise { - let updated = false; - - // remove any devices in the store which aren't in the response - for (const deviceId in userStore) { - if (!userStore.hasOwnProperty(deviceId)) { - continue; - } - - if (!(deviceId in userResult)) { - if (userId === localUserId && deviceId === localDeviceId) { - logger.warn(`Local device ${deviceId} missing from sync, skipping removal`); - continue; - } - - logger.log("Device " + userId + ":" + deviceId + " has been removed"); - delete userStore[deviceId]; - updated = true; - } - } - - for (const deviceId in userResult) { - if (!userResult.hasOwnProperty(deviceId)) { - continue; - } - - const deviceResult = userResult[deviceId]; - - // check that the user_id and device_id in the response object are - // correct - if (deviceResult.user_id !== userId) { - logger.warn("Mismatched user_id " + deviceResult.user_id + " in keys from " + userId + ":" + deviceId); - continue; - } - if (deviceResult.device_id !== deviceId) { - logger.warn("Mismatched device_id " + deviceResult.device_id + " in keys from " + userId + ":" + deviceId); - continue; - } - - if (await storeDeviceKeys(olmDevice, userStore, deviceResult)) { - updated = true; - } - } - - return updated; -} - -/* - * Process a device in a /query response, and add it to the userStore - * - * returns (a promise for) true if a change was made, else false - */ -async function storeDeviceKeys( - olmDevice: OlmDevice, - userStore: Record, - deviceResult: IDownloadKeyResult["device_keys"]["user_id"]["device_id"], -): Promise { - if (!deviceResult.keys) { - // no keys? - return false; - } - - const deviceId = deviceResult.device_id; - const userId = deviceResult.user_id; - - const signKeyId = "ed25519:" + deviceId; - const signKey = deviceResult.keys[signKeyId]; - if (!signKey) { - logger.warn("Device " + userId + ":" + deviceId + " has no ed25519 key"); - return false; - } - - const unsigned = deviceResult.unsigned || {}; - const signatures = deviceResult.signatures || {}; - - try { - await olmlib.verifySignature(olmDevice, deviceResult, userId, deviceId, signKey); - } catch (e) { - logger.warn("Unable to verify signature on device " + userId + ":" + deviceId + ":" + e); - return false; - } - - // DeviceInfo - let deviceStore; - - if (deviceId in userStore) { - // already have this device. - deviceStore = userStore[deviceId]; - - if (deviceStore.getFingerprint() != signKey) { - // this should only happen if the list has been MITMed; we are - // best off sticking with the original keys. - // - // Should we warn the user about it somehow? - logger.warn("Ed25519 key for device " + userId + ":" + deviceId + " has changed"); - return false; - } - } else { - userStore[deviceId] = deviceStore = new DeviceInfo(deviceId); - } - - deviceStore.keys = deviceResult.keys || {}; - deviceStore.algorithms = deviceResult.algorithms || []; - deviceStore.unsigned = unsigned; - deviceStore.signatures = signatures; - return true; -} diff --git a/src/crypto/EncryptionSetup.ts b/src/crypto/EncryptionSetup.ts deleted file mode 100644 index 6ff27ddc4..000000000 --- a/src/crypto/EncryptionSetup.ts +++ /dev/null @@ -1,358 +0,0 @@ -/* -Copyright 2021 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 { logger } from "../logger.ts"; -import { MatrixEvent } from "../models/event.ts"; -import { createCryptoStoreCacheCallbacks, type ICacheCallbacks } from "./CrossSigning.ts"; -import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store.ts"; -import { Method, ClientPrefix } from "../http-api/index.ts"; -import { type Crypto, type ICryptoCallbacks } from "./index.ts"; -import { - ClientEvent, - type ClientEventHandlerMap, - type CrossSigningKeys, - type ISignedKey, - type KeySignatures, -} from "../client.ts"; -import { type IKeyBackupInfo } from "./keybackup.ts"; -import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; -import { type AccountDataClient, type SecretStorageKeyDescription } from "../secret-storage.ts"; -import { type BootstrapCrossSigningOpts, type CrossSigningKeyInfo } from "../crypto-api/index.ts"; -import { type AccountDataEvents } from "../@types/event.ts"; -import { type EmptyObject } from "../@types/common.ts"; - -interface ICrossSigningKeys { - authUpload: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"]; - keys: Record<"master" | "self_signing" | "user_signing", CrossSigningKeyInfo>; -} - -/** - * Builds an EncryptionSetupOperation by calling any of the add.. methods. - * Once done, `buildOperation()` can be called which allows to apply to operation. - * - * This is used as a helper by Crypto to keep track of all the network requests - * and other side-effects of bootstrapping, so it can be applied in one go (and retried in the future) - * Also keeps track of all the private keys created during bootstrapping, so we don't need to prompt for them - * more than once. - */ -export class EncryptionSetupBuilder { - public readonly accountDataClientAdapter: AccountDataClientAdapter; - public readonly crossSigningCallbacks: CrossSigningCallbacks; - public readonly ssssCryptoCallbacks: SSSSCryptoCallbacks; - - private crossSigningKeys?: ICrossSigningKeys; - private keySignatures?: KeySignatures; - private keyBackupInfo?: IKeyBackupInfo; - private sessionBackupPrivateKey?: Uint8Array; - - /** - * @param accountData - pre-existing account data, will only be read, not written. - * @param delegateCryptoCallbacks - crypto callbacks to delegate to if the key isn't in cache yet - */ - public constructor(accountData: Map, delegateCryptoCallbacks?: ICryptoCallbacks) { - this.accountDataClientAdapter = new AccountDataClientAdapter(accountData); - this.crossSigningCallbacks = new CrossSigningCallbacks(); - this.ssssCryptoCallbacks = new SSSSCryptoCallbacks(delegateCryptoCallbacks); - } - - /** - * Adds new cross-signing public keys - * - * @param authUpload - Function called to await an interactive auth - * flow when uploading device signing keys. - * Args: - * A function that makes the request requiring auth. Receives - * the auth data as an object. Can be called multiple times, first with - * an empty authDict, to obtain the flows. - * @param keys - the new keys - */ - public addCrossSigningKeys(authUpload: ICrossSigningKeys["authUpload"], keys: ICrossSigningKeys["keys"]): void { - this.crossSigningKeys = { authUpload, keys }; - } - - /** - * Adds the key backup info to be updated on the server - * - * Used either to create a new key backup, or add signatures - * from the new MSK. - * - * @param keyBackupInfo - as received from/sent to the server - */ - public addSessionBackup(keyBackupInfo: IKeyBackupInfo): void { - this.keyBackupInfo = keyBackupInfo; - } - - /** - * Adds the session backup private key to be updated in the local cache - * - * Used after fixing the format of the key - * - */ - public addSessionBackupPrivateKeyToCache(privateKey: Uint8Array): void { - this.sessionBackupPrivateKey = privateKey; - } - - /** - * Add signatures from a given user and device/x-sign key - * Used to sign the new cross-signing key with the device key - * - */ - public addKeySignature(userId: string, deviceId: string, signature: ISignedKey): void { - if (!this.keySignatures) { - this.keySignatures = {}; - } - const userSignatures = this.keySignatures[userId] ?? {}; - this.keySignatures[userId] = userSignatures; - userSignatures[deviceId] = signature; - } - - public async setAccountData( - type: K, - content: AccountDataEvents[K], - ): Promise { - await this.accountDataClientAdapter.setAccountData(type, content); - } - - /** - * builds the operation containing all the parts that have been added to the builder - */ - public buildOperation(): EncryptionSetupOperation { - const accountData = this.accountDataClientAdapter.values; - return new EncryptionSetupOperation(accountData, this.crossSigningKeys, this.keyBackupInfo, this.keySignatures); - } - - /** - * Stores the created keys locally. - * - * This does not yet store the operation in a way that it can be restored, - * but that is the idea in the future. - */ - public async persist(crypto: Crypto): Promise { - // store private keys in cache - if (this.crossSigningKeys) { - const cacheCallbacks = createCryptoStoreCacheCallbacks(crypto.cryptoStore, crypto.olmDevice); - for (const type of ["master", "self_signing", "user_signing"]) { - logger.log(`Cache ${type} cross-signing private key locally`); - const privateKey = this.crossSigningCallbacks.privateKeys.get(type); - await cacheCallbacks.storeCrossSigningKeyCache?.(type, privateKey); - } - // store own cross-sign pubkeys as trusted - await crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - crypto.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningKeys!.keys); - }); - } - // store session backup key in cache - if (this.sessionBackupPrivateKey) { - await crypto.storeSessionBackupPrivateKey(this.sessionBackupPrivateKey); - } - } -} - -/** - * Can be created from EncryptionSetupBuilder, or - * (in a follow-up PR, not implemented yet) restored from storage, to retry. - * - * It does not have knowledge of any private keys, unlike the builder. - */ -export class EncryptionSetupOperation { - /** - */ - public constructor( - private readonly accountData: Map, - private readonly crossSigningKeys?: ICrossSigningKeys, - private readonly keyBackupInfo?: IKeyBackupInfo, - private readonly keySignatures?: KeySignatures, - ) {} - - /** - * Runs the (remaining part of, in the future) operation by sending requests to the server. - */ - public async apply(crypto: Crypto): Promise { - const baseApis = crypto.baseApis; - // upload cross-signing keys - if (this.crossSigningKeys) { - const keys: Partial = {}; - for (const [name, key] of Object.entries(this.crossSigningKeys.keys)) { - keys[((name as keyof ICrossSigningKeys["keys"]) + "_key") as keyof CrossSigningKeys] = key; - } - - // We must only call `uploadDeviceSigningKeys` from inside this auth - // helper to ensure we properly handle auth errors. - await this.crossSigningKeys.authUpload?.((authDict) => { - return baseApis.uploadDeviceSigningKeys(authDict ?? undefined, keys as CrossSigningKeys); - }); - - // pass the new keys to the main instance of our own CrossSigningInfo. - crypto.crossSigningInfo.setKeys(this.crossSigningKeys.keys); - } - // set account data - if (this.accountData) { - for (const [type, content] of this.accountData) { - await baseApis.setAccountData(type, content.getContent()); - } - } - // upload first cross-signing signatures with the new key - // (e.g. signing our own device) - if (this.keySignatures) { - await baseApis.uploadKeySignatures(this.keySignatures); - } - // need to create/update key backup info - if (this.keyBackupInfo) { - if (this.keyBackupInfo.version) { - // session backup signature - // The backup is trusted because the user provided the private key. - // Sign the backup with the cross signing key so the key backup can - // be trusted via cross-signing. - await baseApis.http.authedRequest( - Method.Put, - "/room_keys/version/" + this.keyBackupInfo.version, - undefined, - { - algorithm: this.keyBackupInfo.algorithm, - auth_data: this.keyBackupInfo.auth_data, - }, - { prefix: ClientPrefix.V3 }, - ); - } else { - // add new key backup - await baseApis.http.authedRequest(Method.Post, "/room_keys/version", undefined, this.keyBackupInfo, { - prefix: ClientPrefix.V3, - }); - } - // tell the backup manager to re-check the keys now that they have been (maybe) updated - await crypto.backupManager.checkKeyBackup(); - } - } -} - -/** - * Catches account data set by SecretStorage during bootstrapping by - * implementing the methods related to account data in MatrixClient - */ -class AccountDataClientAdapter - extends TypedEventEmitter - implements AccountDataClient -{ - // - public readonly values = new Map(); - - /** - * @param existingValues - existing account data - */ - public constructor(private readonly existingValues: Map) { - super(); - } - - /** - * @returns the content of the account data - */ - public getAccountDataFromServer(type: K): Promise { - return Promise.resolve(this.getAccountData(type)); - } - - /** - * @returns the content of the account data - */ - public getAccountData(type: K): AccountDataEvents[K] | null { - const event = this.values.get(type) ?? this.existingValues.get(type); - return event?.getContent() ?? null; - } - - public setAccountData( - type: K, - content: AccountDataEvents[K] | Record, - ): Promise { - const event = new MatrixEvent({ type, content }); - const lastEvent = this.values.get(type); - this.values.set(type, event); - // ensure accountData is emitted on the next tick, - // as SecretStorage listens for it while calling this method - // and it seems to rely on this. - return Promise.resolve().then(() => { - this.emit(ClientEvent.AccountData, event, lastEvent); - return {}; - }); - } -} - -/** - * Catches the private cross-signing keys set during bootstrapping - * by both cache callbacks (see createCryptoStoreCacheCallbacks) as non-cache callbacks. - * See CrossSigningInfo constructor - */ -class CrossSigningCallbacks implements ICryptoCallbacks, ICacheCallbacks { - public readonly privateKeys = new Map(); - - // cache callbacks - public getCrossSigningKeyCache(type: string, expectedPublicKey: string): Promise { - return this.getCrossSigningKey(type, expectedPublicKey); - } - - public storeCrossSigningKeyCache(type: string, key: Uint8Array): Promise { - this.privateKeys.set(type, key); - return Promise.resolve(); - } - - // non-cache callbacks - public getCrossSigningKey(type: string, expectedPubkey: string): Promise { - return Promise.resolve(this.privateKeys.get(type) ?? null); - } - - public saveCrossSigningKeys(privateKeys: Record): void { - for (const [type, privateKey] of Object.entries(privateKeys)) { - this.privateKeys.set(type, privateKey); - } - } -} - -/** - * Catches the 4S private key set during bootstrapping by implementing - * the SecretStorage crypto callbacks - */ -class SSSSCryptoCallbacks { - private readonly privateKeys = new Map(); - - public constructor(private readonly delegateCryptoCallbacks?: ICryptoCallbacks) {} - - public async getSecretStorageKey( - { keys }: { keys: Record }, - name: string, - ): Promise<[string, Uint8Array] | null> { - for (const keyId of Object.keys(keys)) { - const privateKey = this.privateKeys.get(keyId); - if (privateKey) { - return [keyId, privateKey]; - } - } - // if we don't have the key cached yet, ask - // for it to the general crypto callbacks and cache it - if (this?.delegateCryptoCallbacks?.getSecretStorageKey) { - const result = await this.delegateCryptoCallbacks.getSecretStorageKey({ keys }, name); - if (result) { - const [keyId, privateKey] = result; - this.privateKeys.set(keyId, privateKey); - } - return result; - } - return null; - } - - public addPrivateKey(keyId: string, keyInfo: SecretStorageKeyDescription, privKey: Uint8Array): void { - this.privateKeys.set(keyId, privKey); - // Also pass along to application to cache if it wishes - this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey); - } -} diff --git a/src/crypto/OlmDevice.ts b/src/crypto/OlmDevice.ts deleted file mode 100644 index 0db4aa742..000000000 --- a/src/crypto/OlmDevice.ts +++ /dev/null @@ -1,1506 +0,0 @@ -/* -Copyright 2016 - 2021 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 Account, - type InboundGroupSession, - type OutboundGroupSession, - type Session, - type Utility, -} from "@matrix-org/olm"; - -import { logger, type Logger } from "../logger.ts"; -import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store.ts"; -import { type CryptoStore, type IProblem, type ISessionInfo, type IWithheld } from "./store/base.ts"; -import { type IOlmDevice, type IOutboundGroupSessionKey } from "./algorithms/megolm.ts"; -import { type IMegolmSessionData, type OlmGroupSessionExtraData } from "../@types/crypto.ts"; -import { type IMessage } from "./algorithms/olm.ts"; -import { DecryptionFailureCode } from "../crypto-api/index.ts"; -import { DecryptionError } from "../common-crypto/CryptoBackend.ts"; - -// The maximum size of an event is 65K, and we base64 the content, so this is a -// reasonable approximation to the biggest plaintext we can encrypt. -const MAX_PLAINTEXT_LENGTH = (65536 * 3) / 4; - -export class PayloadTooLargeError extends Error { - public readonly data = { - errcode: "M_TOO_LARGE", - error: "Payload too large for encrypted message", - }; -} - -function checkPayloadLength(payloadString: string): void { - if (payloadString === undefined) { - throw new Error("payloadString undefined"); - } - - if (payloadString.length > MAX_PLAINTEXT_LENGTH) { - // might as well fail early here rather than letting the olm library throw - // a cryptic memory allocation error. - // - // Note that even if we manage to do the encryption, the message send may fail, - // because by the time we've wrapped the ciphertext in the event object, it may - // exceed 65K. But at least we won't just fail with "abort()" in that case. - throw new PayloadTooLargeError( - `Message too long (${payloadString.length} bytes). ` + - `The maximum for an encrypted message is ${MAX_PLAINTEXT_LENGTH} bytes.`, - ); - } -} - -interface IInitOpts { - /** - * (Optional) data from exported device that must be re-created. - * If present, opts.pickleKey is ignored (exported data already provides a pickle key) - */ - fromExportedDevice?: IExportedDevice; - /** - * (Optional) pickle key to set instead of default one - */ - pickleKey?: string; -} - -/** data stored in the session store about an inbound group session */ -export interface InboundGroupSessionData { - room_id: string; // eslint-disable-line camelcase - /** pickled Olm.InboundGroupSession */ - session: string; - keysClaimed?: Record; - /** Devices involved in forwarding this session to us (normally empty). */ - forwardingCurve25519KeyChain: string[]; - /** whether this session is untrusted. */ - untrusted?: boolean; - /** whether this session exists during the room being set to shared history. */ - sharedHistory?: boolean; -} - -export interface IDecryptedGroupMessage { - result: string; - keysClaimed: Record; - senderKey: string; - forwardingCurve25519KeyChain: string[]; - untrusted: boolean; -} - -export interface IInboundSession { - payload: string; - session_id: string; -} - -export interface IExportedDevice { - pickleKey: string; - pickledAccount: string; - sessions: ISessionInfo[]; -} - -interface IUnpickledSessionInfo extends Omit { - session: Session; -} - -/* eslint-disable camelcase */ -interface IInboundGroupSessionKey { - chain_index: number; - key: string; - forwarding_curve25519_key_chain: string[]; - sender_claimed_ed25519_key: string | null; - shared_history: boolean; - untrusted?: boolean; -} -/* eslint-enable camelcase */ - -type OneTimeKeys = { curve25519: { [keyId: string]: string } }; - -/** - * Manages the olm cryptography functions. Each OlmDevice has a single - * OlmAccount and a number of OlmSessions. - * - * Accounts and sessions are kept pickled in the cryptoStore. - */ -export class OlmDevice { - public pickleKey = "DEFAULT_KEY"; // set by consumers - - /** Curve25519 key for the account, unknown until we load the account from storage in init() */ - public deviceCurve25519Key: string | null = null; - /** Ed25519 key for the account, unknown until we load the account from storage in init() */ - public deviceEd25519Key: string | null = null; - private maxOneTimeKeys: number | null = null; - - // we don't bother stashing outboundgroupsessions in the cryptoStore - - // instead we keep them here. - private outboundGroupSessionStore: Record = {}; - - // Store a set of decrypted message indexes for each group session. - // This partially mitigates a replay attack where a MITM resends a group - // message into the room. - // - // When we decrypt a message and the message index matches a previously - // decrypted message, one possible cause of that is that we are decrypting - // the same event, and may not indicate an actual replay attack. For - // example, this could happen if we receive events, forget about them, and - // then re-fetch them when we backfill. So we store the event ID and - // timestamp corresponding to each message index when we first decrypt it, - // and compare these against the event ID and timestamp every time we use - // that same index. If they match, then we're probably decrypting the same - // event and we don't consider it a replay attack. - // - // Keys are strings of form "||" - // Values are objects of the form "{id: , timestamp: }" - private inboundGroupSessionMessageIndexes: Record = {}; - - // Keep track of sessions that we're starting, so that we don't start - // multiple sessions for the same device at the same time. - public sessionsInProgress: Record> = {}; // set by consumers - - // Used by olm to serialise prekey message decryptions - public olmPrekeyPromise: Promise = Promise.resolve(); // set by consumers - - public constructor(private readonly cryptoStore: CryptoStore) {} - - /** - * @returns The version of Olm. - */ - public static getOlmVersion(): [number, number, number] { - return globalThis.Olm.get_library_version(); - } - - /** - * Initialise the OlmAccount. This must be called before any other operations - * on the OlmDevice. - * - * Data from an exported Olm device can be provided - * in order to re-create this device. - * - * Attempts to load the OlmAccount from the crypto store, or creates one if none is - * found. - * - * Reads the device keys from the OlmAccount object. - * - * @param IInitOpts - opts to initialise the OlmAccount with - */ - public async init({ pickleKey, fromExportedDevice }: IInitOpts = {}): Promise { - let e2eKeys; - const account = new globalThis.Olm.Account(); - - try { - if (fromExportedDevice) { - if (pickleKey) { - logger.warn("ignoring opts.pickleKey" + " because opts.fromExportedDevice is present."); - } - this.pickleKey = fromExportedDevice.pickleKey; - await this.initialiseFromExportedDevice(fromExportedDevice, account); - } else { - if (pickleKey) { - this.pickleKey = pickleKey; - } - await this.initialiseAccount(account); - } - e2eKeys = JSON.parse(account.identity_keys()); - - this.maxOneTimeKeys = account.max_number_of_one_time_keys(); - } finally { - account.free(); - } - - this.deviceCurve25519Key = e2eKeys.curve25519; - this.deviceEd25519Key = e2eKeys.ed25519; - } - - /** - * Populates the crypto store using data that was exported from an existing device. - * Note that for now only the “account” and “sessions” stores are populated; - * Other stores will be as with a new device. - * - * @param exportedData - Data exported from another device - * through the “export” method. - * @param account - an olm account to initialize - */ - private async initialiseFromExportedDevice(exportedData: IExportedDevice, account: Account): Promise { - await this.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.cryptoStore.storeAccount(txn, exportedData.pickledAccount); - exportedData.sessions.forEach((session) => { - const { deviceKey, sessionId } = session; - const sessionInfo = { - session: session.session, - lastReceivedMessageTs: session.lastReceivedMessageTs, - }; - this.cryptoStore.storeEndToEndSession(deviceKey!, sessionId!, sessionInfo, txn); - }); - }, - ); - account.unpickle(this.pickleKey, exportedData.pickledAccount); - } - - private async initialiseAccount(account: Account): Promise { - await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.cryptoStore.getAccount(txn, (pickledAccount) => { - if (pickledAccount !== null) { - account.unpickle(this.pickleKey, pickledAccount); - } else { - account.create(); - pickledAccount = account.pickle(this.pickleKey); - this.cryptoStore.storeAccount(txn, pickledAccount); - } - }); - }); - } - - /** - * extract our OlmAccount from the crypto store and call the given function - * with the account object - * The `account` object is usable only within the callback passed to this - * function and will be freed as soon the callback returns. It is *not* - * usable for the rest of the lifetime of the transaction. - * This function requires a live transaction object from cryptoStore.doTxn() - * and therefore may only be called in a doTxn() callback. - * - * @param txn - Opaque transaction object from cryptoStore.doTxn() - * @internal - */ - private getAccount(txn: unknown, func: (account: Account) => void): void { - this.cryptoStore.getAccount(txn, (pickledAccount: string | null) => { - const account = new globalThis.Olm.Account(); - try { - account.unpickle(this.pickleKey, pickledAccount!); - func(account); - } finally { - account.free(); - } - }); - } - - /* - * Saves an account to the crypto store. - * This function requires a live transaction object from cryptoStore.doTxn() - * and therefore may only be called in a doTxn() callback. - * - * @param txn - Opaque transaction object from cryptoStore.doTxn() - * @param Olm.Account object - * @internal - */ - private storeAccount(txn: unknown, account: Account): void { - this.cryptoStore.storeAccount(txn, account.pickle(this.pickleKey)); - } - - /** - * Export data for re-creating the Olm device later. - * TODO export data other than just account and (P2P) sessions. - * - * @returns The exported data - */ - public async export(): Promise { - const result: Partial = { - pickleKey: this.pickleKey, - }; - - await this.cryptoStore.doTxn( - "readonly", - [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.cryptoStore.getAccount(txn, (pickledAccount: string | null) => { - result.pickledAccount = pickledAccount!; - }); - result.sessions = []; - // Note that the pickledSession object we get in the callback - // is not exactly the same thing you get in method _getSession - // see documentation of IndexedDBCryptoStore.getAllEndToEndSessions - this.cryptoStore.getAllEndToEndSessions(txn, (pickledSession) => { - result.sessions!.push(pickledSession!); - }); - }, - ); - return result as IExportedDevice; - } - - /** - * extract an OlmSession from the session store and call the given function - * The session is usable only within the callback passed to this - * function and will be freed as soon the callback returns. It is *not* - * usable for the rest of the lifetime of the transaction. - * - * @param txn - Opaque transaction object from cryptoStore.doTxn() - * @internal - */ - private getSession( - deviceKey: string, - sessionId: string, - txn: unknown, - func: (unpickledSessionInfo: IUnpickledSessionInfo) => void, - ): void { - this.cryptoStore.getEndToEndSession(deviceKey, sessionId, txn, (sessionInfo: ISessionInfo | null) => { - this.unpickleSession(sessionInfo!, func); - }); - } - - /** - * Creates a session object from a session pickle and executes the given - * function with it. The session object is destroyed once the function - * returns. - * - * @internal - */ - private unpickleSession( - sessionInfo: ISessionInfo, - func: (unpickledSessionInfo: IUnpickledSessionInfo) => void, - ): void { - const session = new globalThis.Olm.Session(); - try { - session.unpickle(this.pickleKey, sessionInfo.session!); - const unpickledSessInfo: IUnpickledSessionInfo = Object.assign({}, sessionInfo, { session }); - - func(unpickledSessInfo); - } finally { - session.free(); - } - } - - /** - * store our OlmSession in the session store - * - * @param sessionInfo - `{session: OlmSession, lastReceivedMessageTs: int}` - * @param txn - Opaque transaction object from cryptoStore.doTxn() - * @internal - */ - private saveSession(deviceKey: string, sessionInfo: IUnpickledSessionInfo, txn: unknown): void { - const sessionId = sessionInfo.session.session_id(); - logger.debug(`Saving Olm session ${sessionId} with device ${deviceKey}: ${sessionInfo.session.describe()}`); - - // Why do we re-use the input object for this, overwriting the same key with a different - // type? Is it because we want to erase the unpickled session to enforce that it's no longer - // used? A comment would be great. - const pickledSessionInfo = Object.assign(sessionInfo, { - session: sessionInfo.session.pickle(this.pickleKey), - }); - this.cryptoStore.storeEndToEndSession(deviceKey, sessionId, pickledSessionInfo, txn); - } - - /** - * get an OlmUtility and call the given function - * - * @returns result of func - * @internal - */ - private getUtility(func: (utility: Utility) => T): T { - const utility = new globalThis.Olm.Utility(); - try { - return func(utility); - } finally { - utility.free(); - } - } - - /** - * Signs a message with the ed25519 key for this account. - * - * @param message - message to be signed - * @returns base64-encoded signature - */ - public async sign(message: string): Promise { - let result: string; - await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account: Account) => { - result = account.sign(message); - }); - }); - return result!; - } - - /** - * Get the current (unused, unpublished) one-time keys for this account. - * - * @returns one time keys; an object with the single property - * curve25519, which is itself an object mapping key id to Curve25519 - * key. - */ - public async getOneTimeKeys(): Promise { - let result: OneTimeKeys; - await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account) => { - result = JSON.parse(account.one_time_keys()); - }); - }); - - return result!; - } - - /** - * Get the maximum number of one-time keys we can store. - * - * @returns number of keys - */ - public maxNumberOfOneTimeKeys(): number { - return this.maxOneTimeKeys ?? -1; - } - - /** - * Marks all of the one-time keys as published. - */ - public async markKeysAsPublished(): Promise { - await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account: Account) => { - account.mark_keys_as_published(); - this.storeAccount(txn, account); - }); - }); - } - - /** - * Generate some new one-time keys - * - * @param numKeys - number of keys to generate - * @returns Resolved once the account is saved back having generated the keys - */ - public generateOneTimeKeys(numKeys: number): Promise { - return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account) => { - account.generate_one_time_keys(numKeys); - this.storeAccount(txn, account); - }); - }); - } - - /** - * Generate a new fallback keys - * - * @returns Resolved once the account is saved back having generated the key - */ - public async generateFallbackKey(): Promise { - await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account) => { - account.generate_fallback_key(); - this.storeAccount(txn, account); - }); - }); - } - - public async getFallbackKey(): Promise>> { - let result: Record>; - await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account: Account) => { - result = JSON.parse(account.unpublished_fallback_key()); - }); - }); - return result!; - } - - public async forgetOldFallbackKey(): Promise { - await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.getAccount(txn, (account: Account) => { - account.forget_old_fallback_key(); - this.storeAccount(txn, account); - }); - }); - } - - /** - * Generate a new outbound session - * - * The new session will be stored in the cryptoStore. - * - * @param theirIdentityKey - remote user's Curve25519 identity key - * @param theirOneTimeKey - remote user's one-time Curve25519 key - * @returns sessionId for the outbound session. - */ - public async createOutboundSession(theirIdentityKey: string, theirOneTimeKey: string): Promise { - let newSessionId: string; - await this.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.getAccount(txn, (account: Account) => { - const session = new globalThis.Olm.Session(); - try { - session.create_outbound(account, theirIdentityKey, theirOneTimeKey); - newSessionId = session.session_id(); - this.storeAccount(txn, account); - const sessionInfo: IUnpickledSessionInfo = { - session, - // Pretend we've received a message at this point, otherwise - // if we try to send a message to the device, it won't use - // this session - lastReceivedMessageTs: Date.now(), - }; - this.saveSession(theirIdentityKey, sessionInfo, txn); - } finally { - session.free(); - } - }); - }, - logger.getChild("[createOutboundSession]"), - ); - return newSessionId!; - } - - /** - * Generate a new inbound session, given an incoming message - * - * @param theirDeviceIdentityKey - remote user's Curve25519 identity key - * @param messageType - messageType field from the received message (must be 0) - * @param ciphertext - base64-encoded body from the received message - * - * @returns decrypted payload, and - * session id of new session - * - * @throws Error if the received message was not valid (for instance, it didn't use a valid one-time key). - */ - public async createInboundSession( - theirDeviceIdentityKey: string, - messageType: number, - ciphertext: string, - ): Promise { - if (messageType !== 0) { - throw new Error("Need messageType == 0 to create inbound session"); - } - - let result: { payload: string; session_id: string }; // eslint-disable-line camelcase - await this.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.getAccount(txn, (account: Account) => { - const session = new globalThis.Olm.Session(); - try { - session.create_inbound_from(account, theirDeviceIdentityKey, ciphertext); - account.remove_one_time_keys(session); - this.storeAccount(txn, account); - - const payloadString = session.decrypt(messageType, ciphertext); - - const sessionInfo: IUnpickledSessionInfo = { - session, - // this counts as a received message: set last received message time - // to now - lastReceivedMessageTs: Date.now(), - }; - this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); - - result = { - payload: payloadString, - session_id: session.session_id(), - }; - } finally { - session.free(); - } - }); - }, - logger.getChild("[createInboundSession]"), - ); - - return result!; - } - - /** - * Get a list of known session IDs for the given device - * - * @param theirDeviceIdentityKey - Curve25519 identity key for the - * remote device - * @returns a list of known session ids for the device - */ - public async getSessionIdsForDevice(theirDeviceIdentityKey: string): Promise { - const log = logger.getChild("[getSessionIdsForDevice]"); - - if (theirDeviceIdentityKey in this.sessionsInProgress) { - log.debug(`Waiting for Olm session for ${theirDeviceIdentityKey} to be created`); - try { - await this.sessionsInProgress[theirDeviceIdentityKey]; - } catch { - // if the session failed to be created, just fall through and - // return an empty result - } - } - let sessionIds: string[]; - await this.cryptoStore.doTxn( - "readonly", - [IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.cryptoStore.getEndToEndSessions(theirDeviceIdentityKey, txn, (sessions) => { - sessionIds = Object.keys(sessions); - }); - }, - log, - ); - - return sessionIds!; - } - - /** - * Get the right olm session id for encrypting messages to the given identity key - * - * @param theirDeviceIdentityKey - Curve25519 identity key for the - * remote device - * @param nowait - Don't wait for an in-progress session to complete. - * This should only be set to true of the calling function is the function - * that marked the session as being in-progress. - * @param log - A possibly customised log - * @returns session id, or null if no established session - */ - public async getSessionIdForDevice( - theirDeviceIdentityKey: string, - nowait = false, - log?: Logger, - ): Promise { - const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey, nowait, log); - - if (sessionInfos.length === 0) { - return null; - } - // Use the session that has most recently received a message - let idxOfBest = 0; - for (let i = 1; i < sessionInfos.length; i++) { - const thisSessInfo = sessionInfos[i]; - const thisLastReceived = - thisSessInfo.lastReceivedMessageTs === undefined ? 0 : thisSessInfo.lastReceivedMessageTs; - - const bestSessInfo = sessionInfos[idxOfBest]; - const bestLastReceived = - bestSessInfo.lastReceivedMessageTs === undefined ? 0 : bestSessInfo.lastReceivedMessageTs; - if ( - thisLastReceived > bestLastReceived || - (thisLastReceived === bestLastReceived && thisSessInfo.sessionId < bestSessInfo.sessionId) - ) { - idxOfBest = i; - } - } - return sessionInfos[idxOfBest].sessionId; - } - - /** - * Get information on the active Olm sessions for a device. - *

- * Returns an array, with an entry for each active session. The first entry in - * the result will be the one used for outgoing messages. Each entry contains - * the keys 'hasReceivedMessage' (true if the session has received an incoming - * message and is therefore past the pre-key stage), and 'sessionId'. - * - * @param deviceIdentityKey - Curve25519 identity key for the device - * @param nowait - Don't wait for an in-progress session to complete. - * This should only be set to true of the calling function is the function - * that marked the session as being in-progress. - * @param log - A possibly customised log - */ - public async getSessionInfoForDevice( - deviceIdentityKey: string, - nowait = false, - log: Logger = logger, - ): Promise<{ sessionId: string; lastReceivedMessageTs: number; hasReceivedMessage: boolean }[]> { - log = log.getChild("[getSessionInfoForDevice]"); - - if (deviceIdentityKey in this.sessionsInProgress && !nowait) { - log.debug(`Waiting for Olm session for ${deviceIdentityKey} to be created`); - try { - await this.sessionsInProgress[deviceIdentityKey]; - } catch { - // if the session failed to be created, then just fall through and - // return an empty result - } - } - const info: { - lastReceivedMessageTs: number; - hasReceivedMessage: boolean; - sessionId: string; - }[] = []; - - await this.cryptoStore.doTxn( - "readonly", - [IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, (sessions) => { - const sessionIds = Object.keys(sessions).sort(); - for (const sessionId of sessionIds) { - this.unpickleSession(sessions[sessionId], (sessInfo: IUnpickledSessionInfo) => { - info.push({ - lastReceivedMessageTs: sessInfo.lastReceivedMessageTs!, - hasReceivedMessage: sessInfo.session.has_received_message(), - sessionId, - }); - }); - } - }); - }, - log, - ); - - return info; - } - - /** - * Encrypt an outgoing message using an existing session - * - * @param theirDeviceIdentityKey - Curve25519 identity key for the - * remote device - * @param sessionId - the id of the active session - * @param payloadString - payload to be encrypted and sent - * - * @returns ciphertext - */ - public async encryptMessage( - theirDeviceIdentityKey: string, - sessionId: string, - payloadString: string, - ): Promise { - checkPayloadLength(payloadString); - - let res: IMessage; - await this.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { - const sessionDesc = sessionInfo.session.describe(); - logger.log( - "encryptMessage: Olm Session ID " + - sessionId + - " to " + - theirDeviceIdentityKey + - ": " + - sessionDesc, - ); - res = sessionInfo.session.encrypt(payloadString); - this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); - }); - }, - logger.getChild("[encryptMessage]"), - ); - return res!; - } - - /** - * Decrypt an incoming message using an existing session - * - * @param theirDeviceIdentityKey - Curve25519 identity key for the - * remote device - * @param sessionId - the id of the active session - * @param messageType - messageType field from the received message - * @param ciphertext - base64-encoded body from the received message - * - * @returns decrypted payload. - */ - public async decryptMessage( - theirDeviceIdentityKey: string, - sessionId: string, - messageType: number, - ciphertext: string, - ): Promise { - let payloadString: string; - await this.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo: IUnpickledSessionInfo) => { - const sessionDesc = sessionInfo.session.describe(); - logger.log( - "decryptMessage: Olm Session ID " + - sessionId + - " from " + - theirDeviceIdentityKey + - ": " + - sessionDesc, - ); - payloadString = sessionInfo.session.decrypt(messageType, ciphertext); - sessionInfo.lastReceivedMessageTs = Date.now(); - this.saveSession(theirDeviceIdentityKey, sessionInfo, txn); - }); - }, - logger.getChild("[decryptMessage]"), - ); - return payloadString!; - } - - /** - * Determine if an incoming messages is a prekey message matching an existing session - * - * @param theirDeviceIdentityKey - Curve25519 identity key for the - * remote device - * @param sessionId - the id of the active session - * @param messageType - messageType field from the received message - * @param ciphertext - base64-encoded body from the received message - * - * @returns true if the received message is a prekey message which matches - * the given session. - */ - public async matchesSession( - theirDeviceIdentityKey: string, - sessionId: string, - messageType: number, - ciphertext: string, - ): Promise { - if (messageType !== 0) { - return false; - } - - let matches: boolean; - await this.cryptoStore.doTxn( - "readonly", - [IndexedDBCryptoStore.STORE_SESSIONS], - (txn) => { - this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { - matches = sessionInfo.session.matches_inbound(ciphertext); - }); - }, - logger.getChild("[matchesSession]"), - ); - return matches!; - } - - public async recordSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise { - logger.info(`Recording problem on olm session with ${deviceKey} of type ${type}. Recreating: ${fixed}`); - await this.cryptoStore.storeEndToEndSessionProblem(deviceKey, type, fixed); - } - - public sessionMayHaveProblems(deviceKey: string, timestamp: number): Promise { - return this.cryptoStore.getEndToEndSessionProblem(deviceKey, timestamp); - } - - public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise { - return this.cryptoStore.filterOutNotifiedErrorDevices(devices); - } - - // Outbound group session - // ====================== - - /** - * store an OutboundGroupSession in outboundGroupSessionStore - * - * @internal - */ - private saveOutboundGroupSession(session: OutboundGroupSession): void { - this.outboundGroupSessionStore[session.session_id()] = session.pickle(this.pickleKey); - } - - /** - * extract an OutboundGroupSession from outboundGroupSessionStore and call the - * given function - * - * @returns result of func - * @internal - */ - private getOutboundGroupSession(sessionId: string, func: (session: OutboundGroupSession) => T): T { - const pickled = this.outboundGroupSessionStore[sessionId]; - if (pickled === undefined) { - throw new Error("Unknown outbound group session " + sessionId); - } - - const session = new globalThis.Olm.OutboundGroupSession(); - try { - session.unpickle(this.pickleKey, pickled); - return func(session); - } finally { - session.free(); - } - } - - /** - * Generate a new outbound group session - * - * @returns sessionId for the outbound session. - */ - public createOutboundGroupSession(): string { - const session = new globalThis.Olm.OutboundGroupSession(); - try { - session.create(); - this.saveOutboundGroupSession(session); - return session.session_id(); - } finally { - session.free(); - } - } - - /** - * Encrypt an outgoing message with an outbound group session - * - * @param sessionId - the id of the outboundgroupsession - * @param payloadString - payload to be encrypted and sent - * - * @returns ciphertext - */ - public encryptGroupMessage(sessionId: string, payloadString: string): string { - logger.log(`encrypting msg with megolm session ${sessionId}`); - - checkPayloadLength(payloadString); - - return this.getOutboundGroupSession(sessionId, (session: OutboundGroupSession) => { - const res = session.encrypt(payloadString); - this.saveOutboundGroupSession(session); - return res; - }); - } - - /** - * Get the session keys for an outbound group session - * - * @param sessionId - the id of the outbound group session - * - * @returns current chain index, and - * base64-encoded secret key. - */ - public getOutboundGroupSessionKey(sessionId: string): IOutboundGroupSessionKey { - return this.getOutboundGroupSession(sessionId, function (session: OutboundGroupSession) { - return { - chain_index: session.message_index(), - key: session.session_key(), - }; - }); - } - - // Inbound group session - // ===================== - - /** - * Unpickle a session from a sessionData object and invoke the given function. - * The session is valid only until func returns. - * - * @param sessionData - Object describing the session. - * @param func - Invoked with the unpickled session - * @returns result of func - */ - private unpickleInboundGroupSession( - sessionData: InboundGroupSessionData, - func: (session: InboundGroupSession) => T, - ): T { - const session = new globalThis.Olm.InboundGroupSession(); - try { - session.unpickle(this.pickleKey, sessionData.session); - return func(session); - } finally { - session.free(); - } - } - - /** - * extract an InboundGroupSession from the crypto store and call the given function - * - * @param roomId - The room ID to extract the session for, or null to fetch - * sessions for any room. - * @param txn - Opaque transaction object from cryptoStore.doTxn() - * @param func - function to call. - * - * @internal - */ - private getInboundGroupSession( - roomId: string, - senderKey: string, - sessionId: string, - txn: unknown, - func: ( - session: InboundGroupSession | null, - data: InboundGroupSessionData | null, - withheld: IWithheld | null, - ) => void, - ): void { - this.cryptoStore.getEndToEndInboundGroupSession( - senderKey, - sessionId, - txn, - (sessionData: InboundGroupSessionData | null, withheld: IWithheld | null) => { - if (sessionData === null) { - func(null, null, withheld); - return; - } - - // if we were given a room ID, check that the it matches the original one for the session. This stops - // the HS pretending a message was targeting a different room. - if (roomId !== null && roomId !== sessionData.room_id) { - throw new Error( - "Mismatched room_id for inbound group session (expected " + - sessionData.room_id + - ", was " + - roomId + - ")", - ); - } - - this.unpickleInboundGroupSession(sessionData, (session: InboundGroupSession) => { - func(session, sessionData, withheld); - }); - }, - ); - } - - /** - * Add an inbound group session to the session store - * - * @param roomId - room in which this session will be used - * @param senderKey - base64-encoded curve25519 key of the sender - * @param forwardingCurve25519KeyChain - Devices involved in forwarding - * this session to us. - * @param sessionId - session identifier - * @param sessionKey - base64-encoded secret key - * @param keysClaimed - Other keys the sender claims. - * @param exportFormat - true if the megolm keys are in export format - * (ie, they lack an ed25519 signature) - * @param extraSessionData - any other data to be include with the session - */ - public async addInboundGroupSession( - roomId: string, - senderKey: string, - forwardingCurve25519KeyChain: string[], - sessionId: string, - sessionKey: string, - keysClaimed: Record, - exportFormat: boolean, - extraSessionData: OlmGroupSessionExtraData = {}, - ): Promise { - await this.cryptoStore.doTxn( - "readwrite", - [ - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS, - ], - (txn) => { - /* if we already have this session, consider updating it */ - this.getInboundGroupSession( - roomId, - senderKey, - sessionId, - txn, - ( - existingSession: InboundGroupSession | null, - existingSessionData: InboundGroupSessionData | null, - ) => { - // new session. - const session = new globalThis.Olm.InboundGroupSession(); - try { - if (exportFormat) { - session.import_session(sessionKey); - } else { - session.create(sessionKey); - } - if (sessionId != session.session_id()) { - throw new Error("Mismatched group session ID from senderKey: " + senderKey); - } - - if (existingSession) { - logger.log(`Update for megolm session ${senderKey}|${sessionId}`); - if (existingSession.first_known_index() <= session.first_known_index()) { - if (!existingSessionData!.untrusted || extraSessionData.untrusted) { - // existing session has less-than-or-equal index - // (i.e. can decrypt at least as much), and the - // new session's trust does not win over the old - // session's trust, so keep it - logger.log(`Keeping existing megolm session ${senderKey}|${sessionId}`); - return; - } - if (existingSession.first_known_index() < session.first_known_index()) { - // We want to upgrade the existing session's trust, - // but we can't just use the new session because we'll - // lose the lower index. Check that the sessions connect - // properly, and then manually set the existing session - // as trusted. - if ( - existingSession.export_session(session.first_known_index()) === - session.export_session(session.first_known_index()) - ) { - logger.info( - "Upgrading trust of existing megolm session " + - `${senderKey}|${sessionId} based on newly-received trusted session`, - ); - existingSessionData!.untrusted = false; - this.cryptoStore.storeEndToEndInboundGroupSession( - senderKey, - sessionId, - existingSessionData!, - txn, - ); - } else { - logger.warn( - `Newly-received megolm session ${senderKey}|$sessionId}` + - " does not match existing session! Keeping existing session", - ); - } - return; - } - // If the sessions have the same index, go ahead and store the new trusted one. - } - } - - logger.debug( - `Storing megolm session ${senderKey}|${sessionId} with first index ` + - session.first_known_index(), - ); - - const sessionData = Object.assign({}, extraSessionData, { - room_id: roomId, - session: session.pickle(this.pickleKey), - keysClaimed: keysClaimed, - forwardingCurve25519KeyChain: forwardingCurve25519KeyChain, - }); - - this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn); - - if (!existingSession && extraSessionData.sharedHistory) { - this.cryptoStore.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn); - } - } finally { - session.free(); - } - }, - ); - }, - logger.getChild("[addInboundGroupSession]"), - ); - } - - /** - * Record in the data store why an inbound group session was withheld. - * - * @param roomId - room that the session belongs to - * @param senderKey - base64-encoded curve25519 key of the sender - * @param sessionId - session identifier - * @param code - reason code - * @param reason - human-readable version of `code` - */ - public async addInboundGroupSessionWithheld( - roomId: string, - senderKey: string, - sessionId: string, - code: string, - reason: string, - ): Promise { - await this.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD], - (txn) => { - this.cryptoStore.storeEndToEndInboundGroupSessionWithheld( - senderKey, - sessionId, - { - room_id: roomId, - code: code, - reason: reason, - }, - txn, - ); - }, - ); - } - - /** - * Decrypt a received message with an inbound group session - * - * @param roomId - room in which the message was received - * @param senderKey - base64-encoded curve25519 key of the sender - * @param sessionId - session identifier - * @param body - base64-encoded body of the encrypted message - * @param eventId - ID of the event being decrypted - * @param timestamp - timestamp of the event being decrypted - * - * @returns null if the sessionId is unknown - */ - public async decryptGroupMessage( - roomId: string, - senderKey: string, - sessionId: string, - body: string, - eventId: string, - timestamp: number, - ): Promise { - let result: IDecryptedGroupMessage | null = null; - // when the localstorage crypto store is used as an indexeddb backend, - // exceptions thrown from within the inner function are not passed through - // to the top level, so we store exceptions in a variable and raise them at - // the end - let error: Error; - - await this.cryptoStore.doTxn( - "readwrite", - [ - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - ], - (txn) => { - this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData, withheld) => { - if (session === null || sessionData === null) { - if (withheld) { - const failureCode = - withheld.code === "m.unverified" - ? DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE - : DecryptionFailureCode.MEGOLM_KEY_WITHHELD; - error = new DecryptionError(failureCode, calculateWithheldMessage(withheld), { - session: senderKey + "|" + sessionId, - }); - } - result = null; - return; - } - let res: ReturnType; - try { - res = session.decrypt(body); - } catch (e) { - if ((e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX" && withheld) { - const failureCode = - withheld.code === "m.unverified" - ? DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE - : DecryptionFailureCode.MEGOLM_KEY_WITHHELD; - error = new DecryptionError(failureCode, calculateWithheldMessage(withheld), { - session: senderKey + "|" + sessionId, - }); - } else { - error = e; - } - return; - } - - let plaintext: string = res.plaintext; - if (plaintext === undefined) { - // @ts-ignore - Compatibility for older olm versions. - plaintext = res as string; - } else { - // Check if we have seen this message index before to detect replay attacks. - // If the event ID and timestamp are specified, and the match the event ID - // and timestamp from the last time we used this message index, then we - // don't consider it a replay attack. - const messageIndexKey = senderKey + "|" + sessionId + "|" + res.message_index; - if (messageIndexKey in this.inboundGroupSessionMessageIndexes) { - const msgInfo = this.inboundGroupSessionMessageIndexes[messageIndexKey]; - if (msgInfo.id !== eventId || msgInfo.timestamp !== timestamp) { - error = new Error( - "Duplicate message index, possible replay attack: " + messageIndexKey, - ); - return; - } - } - this.inboundGroupSessionMessageIndexes[messageIndexKey] = { - id: eventId, - timestamp: timestamp, - }; - } - - sessionData.session = session.pickle(this.pickleKey); - this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn); - result = { - result: plaintext, - keysClaimed: sessionData.keysClaimed || {}, - senderKey: senderKey, - forwardingCurve25519KeyChain: sessionData.forwardingCurve25519KeyChain || [], - untrusted: !!sessionData.untrusted, - }; - }); - }, - logger.getChild("[decryptGroupMessage]"), - ); - - if (error!) { - throw error; - } - return result!; - } - - /** - * Determine if we have the keys for a given megolm session - * - * @param roomId - room in which the message was received - * @param senderKey - base64-encoded curve25519 key of the sender - * @param sessionId - session identifier - * - * @returns true if we have the keys to this session - */ - public async hasInboundSessionKeys(roomId: string, senderKey: string, sessionId: string): Promise { - let result: boolean; - await this.cryptoStore.doTxn( - "readonly", - [ - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - ], - (txn) => { - this.cryptoStore.getEndToEndInboundGroupSession(senderKey, sessionId, txn, (sessionData) => { - if (sessionData === null) { - result = false; - return; - } - - if (roomId !== sessionData.room_id) { - logger.warn( - `requested keys for inbound group session ${senderKey}|` + - `${sessionId}, with incorrect room_id ` + - `(expected ${sessionData.room_id}, ` + - `was ${roomId})`, - ); - result = false; - } else { - result = true; - } - }); - }, - logger.getChild("[hasInboundSessionKeys]"), - ); - - return result!; - } - - /** - * Extract the keys to a given megolm session, for sharing - * - * @param roomId - room in which the message was received - * @param senderKey - base64-encoded curve25519 key of the sender - * @param sessionId - session identifier - * @param chainIndex - The chain index at which to export the session. - * If omitted, export at the first index we know about. - * - * @returns - * details of the session key. The key is a base64-encoded megolm key in - * export format. - * - * @throws Error If the given chain index could not be obtained from the known - * index (ie. the given chain index is before the first we have). - */ - public async getInboundGroupSessionKey( - roomId: string, - senderKey: string, - sessionId: string, - chainIndex?: number, - ): Promise { - let result: IInboundGroupSessionKey | null = null; - await this.cryptoStore.doTxn( - "readonly", - [ - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, - IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, - ], - (txn) => { - this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData) => { - if (session === null || sessionData === null) { - result = null; - return; - } - - if (chainIndex === undefined) { - chainIndex = session.first_known_index(); - } - - const exportedSession = session.export_session(chainIndex); - - const claimedKeys = sessionData.keysClaimed || {}; - const senderEd25519Key = claimedKeys.ed25519 || null; - - const forwardingKeyChain = sessionData.forwardingCurve25519KeyChain || []; - // older forwarded keys didn't set the "untrusted" - // property, but can be identified by having a - // non-empty forwarding key chain. These keys should - // be marked as untrusted since we don't know that they - // can be trusted - const untrusted = - "untrusted" in sessionData ? sessionData.untrusted : forwardingKeyChain.length > 0; - - result = { - chain_index: chainIndex, - key: exportedSession, - forwarding_curve25519_key_chain: forwardingKeyChain, - sender_claimed_ed25519_key: senderEd25519Key, - shared_history: sessionData.sharedHistory || false, - untrusted: untrusted, - }; - }); - }, - logger.getChild("[getInboundGroupSessionKey]"), - ); - - return result; - } - - /** - * Export an inbound group session - * - * @param senderKey - base64-encoded curve25519 key of the sender - * @param sessionId - session identifier - * @param sessionData - The session object from the store - * @returns exported session data - */ - public exportInboundGroupSession( - senderKey: string, - sessionId: string, - sessionData: InboundGroupSessionData, - ): IMegolmSessionData { - return this.unpickleInboundGroupSession(sessionData, (session) => { - const messageIndex = session.first_known_index(); - - return { - "sender_key": senderKey, - "sender_claimed_keys": sessionData.keysClaimed, - "room_id": sessionData.room_id, - "session_id": sessionId, - "session_key": session.export_session(messageIndex), - "forwarding_curve25519_key_chain": sessionData.forwardingCurve25519KeyChain || [], - "first_known_index": session.first_known_index(), - "org.matrix.msc3061.shared_history": sessionData.sharedHistory || false, - } as IMegolmSessionData; - }); - } - - public async getSharedHistoryInboundGroupSessions( - roomId: string, - ): Promise<[senderKey: string, sessionId: string][]> { - let result: Promise<[senderKey: string, sessionId: string][]>; - await this.cryptoStore.doTxn( - "readonly", - [IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS], - (txn) => { - result = this.cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn); - }, - logger.getChild("[getSharedHistoryInboundGroupSessionsForRoom]"), - ); - return result!; - } - - // Utilities - // ========= - - /** - * Verify an ed25519 signature. - * - * @param key - ed25519 key - * @param message - message which was signed - * @param signature - base64-encoded signature to be checked - * - * @throws Error if there is a problem with the verification. If the key was - * too small then the message will be "OLM.INVALID_BASE64". If the signature - * was invalid then the message will be "OLM.BAD_MESSAGE_MAC". - */ - public verifySignature(key: string, message: string, signature: string): void { - this.getUtility(function (util: Utility) { - util.ed25519_verify(key, message, signature); - }); - } -} - -export const WITHHELD_MESSAGES: Record = { - "m.unverified": "The sender has disabled encrypting to unverified devices.", - "m.blacklisted": "The sender has blocked you.", - "m.unauthorised": "You are not authorised to read the message.", - "m.no_olm": "Unable to establish a secure channel.", -}; - -/** - * Calculate the message to use for the exception when a session key is withheld. - * - * @param withheld - An object that describes why the key was withheld. - * - * @returns the message - * - * @internal - */ -function calculateWithheldMessage(withheld: IWithheld): string { - if (withheld.code && withheld.code in WITHHELD_MESSAGES) { - return WITHHELD_MESSAGES[withheld.code]; - } else if (withheld.reason) { - return withheld.reason; - } else { - return "decryption key withheld"; - } -} diff --git a/src/crypto/OutgoingRoomKeyRequestManager.ts b/src/crypto/OutgoingRoomKeyRequestManager.ts deleted file mode 100644 index 59bff2592..000000000 --- a/src/crypto/OutgoingRoomKeyRequestManager.ts +++ /dev/null @@ -1,486 +0,0 @@ -/* -Copyright 2017 - 2021 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 { v4 as uuidv4 } from "uuid"; - -import { logger } from "../logger.ts"; -import { type MatrixClient } from "../client.ts"; -import { type IRoomKeyRequestBody, type IRoomKeyRequestRecipient } from "./index.ts"; -import { type CryptoStore, type OutgoingRoomKeyRequest } from "./store/base.ts"; -import { EventType, ToDeviceMessageId } from "../@types/event.ts"; -import { MapWithDefault } from "../utils.ts"; -import { type EmptyObject } from "../@types/common.ts"; - -/** - * Internal module. Management of outgoing room key requests. - * - * See https://docs.google.com/document/d/1m4gQkcnJkxNuBmb5NoFCIadIY-DyqqNAS3lloE73BlQ - * for draft documentation on what we're supposed to be implementing here. - */ - -// delay between deciding we want some keys, and sending out the request, to -// allow for (a) it turning up anyway, (b) grouping requests together -const SEND_KEY_REQUESTS_DELAY_MS = 500; - -/** - * possible states for a room key request - * - * The state machine looks like: - * ``` - * - * | (cancellation sent) - * | .-------------------------------------------------. - * | | | - * V V (cancellation requested) | - * UNSENT -----------------------------+ | - * | | | - * | | | - * | (send successful) | CANCELLATION_PENDING_AND_WILL_RESEND - * V | Λ - * SENT | | - * |-------------------------------- | --------------' - * | | (cancellation requested with intent - * | | to resend the original request) - * | | - * | (cancellation requested) | - * V | - * CANCELLATION_PENDING | - * | | - * | (cancellation sent) | - * V | - * (deleted) <---------------------------+ - * ``` - */ -export enum RoomKeyRequestState { - /** request not yet sent */ - Unsent, - /** request sent, awaiting reply */ - Sent, - /** reply received, cancellation not yet sent */ - CancellationPending, - /** - * Cancellation not yet sent and will transition to UNSENT instead of - * being deleted once the cancellation has been sent. - */ - CancellationPendingAndWillResend, -} - -interface RequestMessageBase { - requesting_device_id: string; - request_id: string; -} - -interface RequestMessageRequest extends RequestMessageBase { - action: "request"; - body: IRoomKeyRequestBody; -} - -interface RequestMessageCancellation extends RequestMessageBase { - action: "request_cancellation"; -} - -type RequestMessage = RequestMessageRequest | RequestMessageCancellation; - -export class OutgoingRoomKeyRequestManager { - // handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null - // if the callback has been set, or if it is still running. - private sendOutgoingRoomKeyRequestsTimer?: ReturnType; - - // sanity check to ensure that we don't end up with two concurrent runs - // of sendOutgoingRoomKeyRequests - private sendOutgoingRoomKeyRequestsRunning = false; - - private clientRunning = true; - - public constructor( - private readonly baseApis: MatrixClient, - private readonly deviceId: string, - private readonly cryptoStore: CryptoStore, - ) {} - - /** - * Called when the client is stopped. Stops any running background processes. - */ - public stop(): void { - logger.log("stopping OutgoingRoomKeyRequestManager"); - // stop the timer on the next run - this.clientRunning = false; - } - - /** - * Send any requests that have been queued - */ - public sendQueuedRequests(): void { - this.startTimer(); - } - - /** - * Queue up a room key request, if we haven't already queued or sent one. - * - * The `requestBody` is compared (with a deep-equality check) against - * previous queued or sent requests and if it matches, no change is made. - * Otherwise, a request is added to the pending list, and a job is started - * in the background to send it. - * - * @param resend - whether to resend the key request if there is - * already one - * - * @returns resolves when the request has been added to the - * pending list (or we have established that a similar request already - * exists) - */ - public async queueRoomKeyRequest( - requestBody: IRoomKeyRequestBody, - recipients: IRoomKeyRequestRecipient[], - resend = false, - ): Promise { - const req = await this.cryptoStore.getOutgoingRoomKeyRequest(requestBody); - if (!req) { - await this.cryptoStore.getOrAddOutgoingRoomKeyRequest({ - requestBody: requestBody, - recipients: recipients, - requestId: this.baseApis.makeTxnId(), - state: RoomKeyRequestState.Unsent, - }); - } else { - switch (req.state) { - case RoomKeyRequestState.CancellationPendingAndWillResend: - case RoomKeyRequestState.Unsent: - // nothing to do here, since we're going to send a request anyways - return; - - case RoomKeyRequestState.CancellationPending: { - // existing request is about to be cancelled. If we want to - // resend, then change the state so that it resends after - // cancelling. Otherwise, just cancel the cancellation. - const state = resend - ? RoomKeyRequestState.CancellationPendingAndWillResend - : RoomKeyRequestState.Sent; - await this.cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, - RoomKeyRequestState.CancellationPending, - { - state, - cancellationTxnId: this.baseApis.makeTxnId(), - }, - ); - break; - } - case RoomKeyRequestState.Sent: { - // a request has already been sent. If we don't want to - // resend, then do nothing. If we do want to, then cancel the - // existing request and send a new one. - if (resend) { - const state = RoomKeyRequestState.CancellationPendingAndWillResend; - const updatedReq = await this.cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, - RoomKeyRequestState.Sent, - { - state, - cancellationTxnId: this.baseApis.makeTxnId(), - // need to use a new transaction ID so that - // the request gets sent - requestTxnId: this.baseApis.makeTxnId(), - }, - ); - if (!updatedReq) { - // updateOutgoingRoomKeyRequest couldn't find the request - // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have - // raced with another tab to mark the request cancelled. - // Try again, to make sure the request is resent. - return this.queueRoomKeyRequest(requestBody, recipients, resend); - } - - // We don't want to wait for the timer, so we send it - // immediately. (We might actually end up racing with the timer, - // but that's ok: even if we make the request twice, we'll do it - // with the same transaction_id, so only one message will get - // sent). - // - // (We also don't want to wait for the response from the server - // here, as it will slow down processing of received keys if we - // do.) - try { - await this.sendOutgoingRoomKeyRequestCancellation(updatedReq, true); - } catch (e) { - logger.error("Error sending room key request cancellation;" + " will retry later.", e); - } - // The request has transitioned from - // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We - // still need to resend the request which is now UNSENT, so - // start the timer if it isn't already started. - } - break; - } - default: - throw new Error("unhandled state: " + req.state); - } - } - } - - /** - * Cancel room key requests, if any match the given requestBody - * - * - * @returns resolves when the request has been updated in our - * pending list. - */ - public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise { - return this.cryptoStore.getOutgoingRoomKeyRequest(requestBody).then((req): unknown => { - if (!req) { - // no request was made for this key - return; - } - switch (req.state) { - case RoomKeyRequestState.CancellationPending: - case RoomKeyRequestState.CancellationPendingAndWillResend: - // nothing to do here - return; - - case RoomKeyRequestState.Unsent: - // just delete it - - // FIXME: ghahah we may have attempted to send it, and - // not yet got a successful response. So the server - // may have seen it, so we still need to send a cancellation - // in that case :/ - - logger.log("deleting unnecessary room key request for " + stringifyRequestBody(requestBody)); - return this.cryptoStore.deleteOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Unsent); - - case RoomKeyRequestState.Sent: { - // send a cancellation. - return this.cryptoStore - .updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Sent, { - state: RoomKeyRequestState.CancellationPending, - cancellationTxnId: this.baseApis.makeTxnId(), - }) - .then((updatedReq) => { - if (!updatedReq) { - // updateOutgoingRoomKeyRequest couldn't find the - // request in state ROOM_KEY_REQUEST_STATES.SENT, - // so we must have raced with another tab to mark - // the request cancelled. There is no point in - // sending another cancellation since the other tab - // will do it. - logger.log( - "Tried to cancel room key request for " + - stringifyRequestBody(requestBody) + - " but it was already cancelled in another tab", - ); - return; - } - - // We don't want to wait for the timer, so we send it - // immediately. (We might actually end up racing with the timer, - // but that's ok: even if we make the request twice, we'll do it - // with the same transaction_id, so only one message will get - // sent). - // - // (We also don't want to wait for the response from the server - // here, as it will slow down processing of received keys if we - // do.) - this.sendOutgoingRoomKeyRequestCancellation(updatedReq).catch((e) => { - logger.error("Error sending room key request cancellation;" + " will retry later.", e); - this.startTimer(); - }); - }); - } - default: - throw new Error("unhandled state: " + req.state); - } - }); - } - - /** - * Look for room key requests by target device and state - * - * @param userId - Target user ID - * @param deviceId - Target device ID - * - * @returns resolves to a list of all the {@link OutgoingRoomKeyRequest} - */ - public getOutgoingSentRoomKeyRequest(userId: string, deviceId: string): Promise { - return this.cryptoStore.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, [RoomKeyRequestState.Sent]); - } - - /** - * Find anything in `sent` state, and kick it around the loop again. - * This is intended for situations where something substantial has changed, and we - * don't really expect the other end to even care about the cancellation. - * For example, after initialization or self-verification. - * @returns An array of `queueRoomKeyRequest` outputs. - */ - public async cancelAndResendAllOutgoingRequests(): Promise { - const outgoings = await this.cryptoStore.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); - return Promise.all( - outgoings.map(({ requestBody, recipients }) => this.queueRoomKeyRequest(requestBody, recipients, true)), - ); - } - - // start the background timer to send queued requests, if the timer isn't - // already running - private startTimer(): void { - if (this.sendOutgoingRoomKeyRequestsTimer) { - return; - } - - const startSendingOutgoingRoomKeyRequests = (): void => { - if (this.sendOutgoingRoomKeyRequestsRunning) { - throw new Error("RoomKeyRequestSend already in progress!"); - } - this.sendOutgoingRoomKeyRequestsRunning = true; - - this.sendOutgoingRoomKeyRequests() - .finally(() => { - this.sendOutgoingRoomKeyRequestsRunning = false; - }) - .catch((e) => { - // this should only happen if there is an indexeddb error, - // in which case we're a bit stuffed anyway. - logger.warn(`error in OutgoingRoomKeyRequestManager: ${e}`); - }); - }; - - this.sendOutgoingRoomKeyRequestsTimer = setTimeout( - startSendingOutgoingRoomKeyRequests, - SEND_KEY_REQUESTS_DELAY_MS, - ); - } - - // look for and send any queued requests. Runs itself recursively until - // there are no more requests, or there is an error (in which case, the - // timer will be restarted before the promise resolves). - private async sendOutgoingRoomKeyRequests(): Promise { - if (!this.clientRunning) { - this.sendOutgoingRoomKeyRequestsTimer = undefined; - return; - } - - const req = await this.cryptoStore.getOutgoingRoomKeyRequestByState([ - RoomKeyRequestState.CancellationPending, - RoomKeyRequestState.CancellationPendingAndWillResend, - RoomKeyRequestState.Unsent, - ]); - - if (!req) { - this.sendOutgoingRoomKeyRequestsTimer = undefined; - return; - } - - try { - switch (req.state) { - case RoomKeyRequestState.Unsent: - await this.sendOutgoingRoomKeyRequest(req); - break; - case RoomKeyRequestState.CancellationPending: - await this.sendOutgoingRoomKeyRequestCancellation(req); - break; - case RoomKeyRequestState.CancellationPendingAndWillResend: - await this.sendOutgoingRoomKeyRequestCancellation(req, true); - break; - } - - // go around the loop again - return this.sendOutgoingRoomKeyRequests(); - } catch (e) { - logger.error("Error sending room key request; will retry later.", e); - this.sendOutgoingRoomKeyRequestsTimer = undefined; - } - } - - // given a RoomKeyRequest, send it and update the request record - private sendOutgoingRoomKeyRequest(req: OutgoingRoomKeyRequest): Promise { - logger.log( - `Requesting keys for ${stringifyRequestBody(req.requestBody)}` + - ` from ${stringifyRecipientList(req.recipients)}` + - `(id ${req.requestId})`, - ); - - const requestMessage: RequestMessage = { - action: "request", - requesting_device_id: this.deviceId, - request_id: req.requestId, - body: req.requestBody, - }; - - return this.sendMessageToDevices(requestMessage, req.recipients, req.requestTxnId || req.requestId).then(() => { - return this.cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Unsent, { - state: RoomKeyRequestState.Sent, - }); - }); - } - - // Given a RoomKeyRequest, cancel it and delete the request record unless - // andResend is set, in which case transition to UNSENT. - private sendOutgoingRoomKeyRequestCancellation(req: OutgoingRoomKeyRequest, andResend = false): Promise { - logger.log( - `Sending cancellation for key request for ` + - `${stringifyRequestBody(req.requestBody)} to ` + - `${stringifyRecipientList(req.recipients)} ` + - `(cancellation id ${req.cancellationTxnId})`, - ); - - const requestMessage: RequestMessage = { - action: "request_cancellation", - requesting_device_id: this.deviceId, - request_id: req.requestId, - }; - - return this.sendMessageToDevices(requestMessage, req.recipients, req.cancellationTxnId).then(() => { - if (andResend) { - // We want to resend, so transition to UNSENT - return this.cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, - RoomKeyRequestState.CancellationPendingAndWillResend, - { state: RoomKeyRequestState.Unsent }, - ); - } - return this.cryptoStore.deleteOutgoingRoomKeyRequest( - req.requestId, - RoomKeyRequestState.CancellationPending, - ); - }); - } - - // send a RoomKeyRequest to a list of recipients - private sendMessageToDevices( - message: RequestMessage, - recipients: IRoomKeyRequestRecipient[], - txnId?: string, - ): Promise { - const contentMap = new MapWithDefault>>(() => new Map()); - for (const recip of recipients) { - const userDeviceMap = contentMap.getOrCreate(recip.userId); - userDeviceMap.set(recip.deviceId, { - ...message, - [ToDeviceMessageId]: uuidv4(), - }); - } - - return this.baseApis.sendToDevice(EventType.RoomKeyRequest, contentMap, txnId); - } -} - -function stringifyRequestBody(requestBody: IRoomKeyRequestBody): string { - // we assume that the request is for megolm keys, which are identified by - // room id and session id - return requestBody.room_id + " / " + requestBody.session_id; -} - -function stringifyRecipientList(recipients: IRoomKeyRequestRecipient[]): string { - return `[${recipients.map((r) => `${r.userId}:${r.deviceId}`).join(",")}]`; -} diff --git a/src/crypto/RoomList.ts b/src/crypto/RoomList.ts deleted file mode 100644 index 84a231069..000000000 --- a/src/crypto/RoomList.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* -Copyright 2018 - 2021 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. -*/ - -/** - * Manages the list of encrypted rooms - */ - -import { type CryptoStore } from "./store/base.ts"; -import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store.ts"; - -/* eslint-disable camelcase */ -export interface IRoomEncryption { - algorithm: string; - rotation_period_ms?: number; - rotation_period_msgs?: number; -} -/* eslint-enable camelcase */ - -/** - * Information about the encryption settings of rooms. Loads this information - * from the supplied crypto store when `init()` is called, and saves it to the - * crypto store whenever it is updated via `setRoomEncryption()`. Can supply - * full information about a room's encryption via `getRoomEncryption()`, or just - * answer whether or not a room has encryption via `isRoomEncrypted`. - */ -export class RoomList { - // Object of roomId -> room e2e info object (body of the m.room.encryption event) - private roomEncryption: Record = {}; - - public constructor(private readonly cryptoStore?: CryptoStore) {} - - public async init(): Promise { - await this.cryptoStore!.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { - this.cryptoStore!.getEndToEndRooms(txn, (result) => { - this.roomEncryption = result; - }); - }); - } - - public getRoomEncryption(roomId: string): IRoomEncryption | null { - return this.roomEncryption[roomId] || null; - } - - public isRoomEncrypted(roomId: string): boolean { - return Boolean(this.getRoomEncryption(roomId)); - } - - public async setRoomEncryption(roomId: string, roomInfo: IRoomEncryption): Promise { - // important that this happens before calling into the store - // as it prevents the Crypto::setRoomEncryption from calling - // this twice for consecutive m.room.encryption events - this.roomEncryption[roomId] = roomInfo; - await this.cryptoStore!.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { - this.cryptoStore!.storeEndToEndRoom(roomId, roomInfo, txn); - }); - } -} diff --git a/src/crypto/SecretSharing.ts b/src/crypto/SecretSharing.ts deleted file mode 100644 index c2705f81b..000000000 --- a/src/crypto/SecretSharing.ts +++ /dev/null @@ -1,240 +0,0 @@ -/* -Copyright 2019-2023 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 { v4 as uuidv4 } from "uuid"; - -import { type MatrixClient } from "../client.ts"; -import { type ICryptoCallbacks, type IEncryptedContent } from "./index.ts"; -import { defer, type IDeferred } from "../utils.ts"; -import { ToDeviceMessageId } from "../@types/event.ts"; -import { logger } from "../logger.ts"; -import { type MatrixEvent } from "../models/event.ts"; -import * as olmlib from "./olmlib.ts"; - -export interface ISecretRequest { - requestId: string; - promise: Promise; - cancel: (reason: string) => void; -} - -interface ISecretRequestInternal { - name: string; - devices: string[]; - deferred: IDeferred; -} - -export class SecretSharing { - private requests = new Map(); - - public constructor( - private readonly baseApis: MatrixClient, - private readonly cryptoCallbacks: ICryptoCallbacks, - ) {} - - /** - * Request a secret from another device - * - * @param name - the name of the secret to request - * @param devices - the devices to request the secret from - */ - public request(name: string, devices: string[]): ISecretRequest { - const requestId = this.baseApis.makeTxnId(); - - const deferred = defer(); - this.requests.set(requestId, { name, devices, deferred }); - - const cancel = (reason: string): void => { - // send cancellation event - const cancelData = { - action: "request_cancellation", - requesting_device_id: this.baseApis.deviceId, - request_id: requestId, - }; - const toDevice: Map = new Map(); - for (const device of devices) { - toDevice.set(device, cancelData); - } - this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]])); - - // and reject the promise so that anyone waiting on it will be - // notified - deferred.reject(new Error(reason || "Cancelled")); - }; - - // send request to devices - const requestData = { - name, - action: "request", - requesting_device_id: this.baseApis.deviceId, - request_id: requestId, - [ToDeviceMessageId]: uuidv4(), - }; - const toDevice: Map = new Map(); - for (const device of devices) { - toDevice.set(device, requestData); - } - logger.info(`Request secret ${name} from ${devices}, id ${requestId}`); - this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]])); - - return { - requestId, - promise: deferred.promise, - cancel, - }; - } - - public async onRequestReceived(event: MatrixEvent): Promise { - const sender = event.getSender(); - const content = event.getContent(); - if ( - sender !== this.baseApis.getUserId() || - !(content.name && content.action && content.requesting_device_id && content.request_id) - ) { - // ignore requests from anyone else, for now - return; - } - const deviceId = content.requesting_device_id; - // check if it's a cancel - if (content.action === "request_cancellation") { - /* - Looks like we intended to emit events when we got cancelations, but - we never put anything in the _incomingRequests object, and the request - itself doesn't use events anyway so if we were to wire up cancellations, - they probably ought to use the same callback interface. I'm leaving them - disabled for now while converting this file to typescript. - if (this._incomingRequests[deviceId] - && this._incomingRequests[deviceId][content.request_id]) { - logger.info( - "received request cancellation for secret (" + sender + - ", " + deviceId + ", " + content.request_id + ")", - ); - this.baseApis.emit("crypto.secrets.requestCancelled", { - user_id: sender, - device_id: deviceId, - request_id: content.request_id, - }); - } - */ - } else if (content.action === "request") { - if (deviceId === this.baseApis.deviceId) { - // no point in trying to send ourself the secret - return; - } - - // check if we have the secret - logger.info("received request for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")"); - if (!this.cryptoCallbacks.onSecretRequested) { - return; - } - const secret = await this.cryptoCallbacks.onSecretRequested( - sender, - deviceId, - content.request_id, - content.name, - this.baseApis.checkDeviceTrust(sender, deviceId), - ); - if (secret) { - logger.info(`Preparing ${content.name} secret for ${deviceId}`); - const payload = { - type: "m.secret.send", - content: { - request_id: content.request_id, - secret: secret, - }, - }; - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.baseApis.crypto!.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - await olmlib.ensureOlmSessionsForDevices( - this.baseApis.crypto!.olmDevice, - this.baseApis, - new Map([[sender, [this.baseApis.getStoredDevice(sender, deviceId)!]]]), - ); - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this.baseApis.getUserId()!, - this.baseApis.deviceId!, - this.baseApis.crypto!.olmDevice, - sender, - this.baseApis.getStoredDevice(sender, deviceId)!, - payload, - ); - const contentMap = new Map([[sender, new Map([[deviceId, encryptedContent]])]]); - - logger.info(`Sending ${content.name} secret for ${deviceId}`); - this.baseApis.sendToDevice("m.room.encrypted", contentMap); - } else { - logger.info(`Request denied for ${content.name} secret for ${deviceId}`); - } - } - } - - public onSecretReceived(event: MatrixEvent): void { - if (event.getSender() !== this.baseApis.getUserId()) { - // we shouldn't be receiving secrets from anyone else, so ignore - // because someone could be trying to send us bogus data - return; - } - - if (!olmlib.isOlmEncrypted(event)) { - logger.error("secret event not properly encrypted"); - return; - } - - const content = event.getContent(); - - const senderKeyUser = this.baseApis.crypto!.deviceList.getUserByIdentityKey( - olmlib.OLM_ALGORITHM, - event.getSenderKey() || "", - ); - if (senderKeyUser !== event.getSender()) { - logger.error("sending device does not belong to the user it claims to be from"); - return; - } - - logger.log("got secret share for request", content.request_id); - const requestControl = this.requests.get(content.request_id); - if (requestControl) { - // make sure that the device that sent it is one of the devices that - // we requested from - const deviceInfo = this.baseApis.crypto!.deviceList.getDeviceByIdentityKey( - olmlib.OLM_ALGORITHM, - event.getSenderKey()!, - ); - if (!deviceInfo) { - logger.log("secret share from unknown device with key", event.getSenderKey()); - return; - } - if (!requestControl.devices.includes(deviceInfo.deviceId)) { - logger.log("unsolicited secret share from device", deviceInfo.deviceId); - return; - } - // unsure that the sender is trusted. In theory, this check is - // unnecessary since we only accept secret shares from devices that - // we requested from, but it doesn't hurt. - const deviceTrust = this.baseApis.crypto!.checkDeviceInfoTrust(event.getSender()!, deviceInfo); - if (!deviceTrust.isVerified()) { - logger.log("secret share from unverified device"); - return; - } - - logger.log(`Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`); - requestControl.deferred.resolve(content.secret); - } - } -} diff --git a/src/crypto/SecretStorage.ts b/src/crypto/SecretStorage.ts deleted file mode 100644 index 9052b804b..000000000 --- a/src/crypto/SecretStorage.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* -Copyright 2019 - 2021 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 ICryptoCallbacks } from "./index.ts"; -import { type MatrixEvent } from "../models/event.ts"; -import { type MatrixClient } from "../client.ts"; -import { - type SecretStorageKeyDescription, - type SecretStorageKeyTuple, - type SecretStorageKeyObject, - type AddSecretStorageKeyOpts, - type AccountDataClient, - type ServerSideSecretStorage, - ServerSideSecretStorageImpl, - type SecretStorageKey, -} from "../secret-storage.ts"; -import { type ISecretRequest, SecretSharing } from "./SecretSharing.ts"; - -/* re-exports for backwards compatibility */ -export type { - SecretStorageKeyTuple, - SecretStorageKeyObject, - SECRET_STORAGE_ALGORITHM_V1_AES, -} from "../secret-storage.ts"; - -export type { ISecretRequest } from "./SecretSharing.ts"; - -/** - * Implements Secure Secret Storage and Sharing (MSC1946) - * - * @deprecated This is just a backwards-compatibility hack which will be removed soon. - * Use {@link SecretStorage.ServerSideSecretStorageImpl} from `../secret-storage` and/or {@link SecretSharing} from `./SecretSharing`. - */ -export class SecretStorage implements ServerSideSecretStorage { - private readonly storageImpl: ServerSideSecretStorageImpl; - private readonly sharingImpl: SecretSharing; - - // In its pure javascript days, this was relying on some proper Javascript-style - // type-abuse where sometimes we'd pass in a fake client object with just the account - // data methods implemented, which is all this class needs unless you use the secret - // sharing code, so it was fine. As a low-touch TypeScript migration, we added - // an extra, optional param for a real matrix client, so you can not pass it as long - // as you don't request any secrets. - // - // Nowadays, the whole class is scheduled for destruction, once we get rid of the legacy - // Crypto impl that exposes it. - public constructor(accountDataAdapter: AccountDataClient, cryptoCallbacks: ICryptoCallbacks, baseApis: B) { - this.storageImpl = new ServerSideSecretStorageImpl(accountDataAdapter, cryptoCallbacks); - this.sharingImpl = new SecretSharing(baseApis as MatrixClient, cryptoCallbacks); - } - - public getDefaultKeyId(): Promise { - return this.storageImpl.getDefaultKeyId(); - } - - public setDefaultKeyId(keyId: string): Promise { - return this.storageImpl.setDefaultKeyId(keyId); - } - - /** - * Add a key for encrypting secrets. - */ - public addKey(algorithm: string, opts: AddSecretStorageKeyOpts, keyId?: string): Promise { - return this.storageImpl.addKey(algorithm, opts, keyId); - } - - /** - * Get the key information for a given ID. - */ - public getKey(keyId?: string | null): Promise { - return this.storageImpl.getKey(keyId); - } - - /** - * Check whether we have a key with a given ID. - */ - public hasKey(keyId?: string): Promise { - return this.storageImpl.hasKey(keyId); - } - - /** - * Check whether a key matches what we expect based on the key info - */ - public checkKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise { - return this.storageImpl.checkKey(key, info); - } - - /** - * Store an encrypted secret on the server - */ - public store(name: SecretStorageKey, secret: string, keys?: string[] | null): Promise { - return this.storageImpl.store(name, secret, keys); - } - - /** - * Get a secret from storage. - */ - public get(name: SecretStorageKey): Promise { - return this.storageImpl.get(name); - } - - /** - * Check if a secret is stored on the server. - */ - public async isStored(name: SecretStorageKey): Promise | null> { - return this.storageImpl.isStored(name); - } - - /** - * Request a secret from another device - */ - public request(name: string, devices: string[]): ISecretRequest { - return this.sharingImpl.request(name, devices); - } - - public onRequestReceived(event: MatrixEvent): Promise { - return this.sharingImpl.onRequestReceived(event); - } - - public onSecretReceived(event: MatrixEvent): void { - this.sharingImpl.onSecretReceived(event); - } -} diff --git a/src/crypto/aes.ts b/src/crypto/aes.ts deleted file mode 100644 index 450e1756a..000000000 --- a/src/crypto/aes.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2020 - 2021 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 encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts"; -import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts"; - -// Export for backwards compatibility -export type { AESEncryptedSecretStoragePayload as IEncryptedPayload } from "../@types/AESEncryptedSecretStoragePayload.ts"; -export { encryptAESSecretStorageItem as encryptAES, decryptAESSecretStorageItem as decryptAES }; -export { calculateKeyCheck } from "../secret-storage.ts"; diff --git a/src/crypto/algorithms/base.ts b/src/crypto/algorithms/base.ts deleted file mode 100644 index f13522fd7..000000000 --- a/src/crypto/algorithms/base.ts +++ /dev/null @@ -1,241 +0,0 @@ -/* -Copyright 2016 - 2021 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. -*/ - -/** - * Internal module. Defines the base classes of the encryption implementations - */ - -import type { IMegolmSessionData } from "../../@types/crypto.ts"; -import { type MatrixClient } from "../../client.ts"; -import { type Room } from "../../models/room.ts"; -import { type OlmDevice } from "../OlmDevice.ts"; -import { type IContent, type MatrixEvent, type RoomMember } from "../../matrix.ts"; -import { - type Crypto, - type IEncryptedContent, - type IEventDecryptionResult, - type IncomingRoomKeyRequest, -} from "../index.ts"; -import { type DeviceInfo } from "../deviceinfo.ts"; -import { type IRoomEncryption } from "../RoomList.ts"; -import { type DeviceInfoMap } from "../DeviceList.ts"; - -/** - * Map of registered encryption algorithm classes. A map from string to {@link EncryptionAlgorithm} class - */ -export const ENCRYPTION_CLASSES = new Map EncryptionAlgorithm>(); - -export type DecryptionClassParams

= Omit; - -/** - * map of registered encryption algorithm classes. Map from string to {@link DecryptionAlgorithm} class - */ -export const DECRYPTION_CLASSES = new Map DecryptionAlgorithm>(); - -export interface IParams { - /** The UserID for the local user */ - userId: string; - /** The identifier for this device. */ - deviceId: string; - /** crypto core */ - crypto: Crypto; - /** olm.js wrapper */ - olmDevice: OlmDevice; - /** base matrix api interface */ - baseApis: MatrixClient; - /** The ID of the room we will be sending to */ - roomId?: string; - /** The body of the m.room.encryption event */ - config: IRoomEncryption & object; -} - -/** - * base type for encryption implementations - */ -export abstract class EncryptionAlgorithm { - protected readonly userId: string; - protected readonly deviceId: string; - protected readonly crypto: Crypto; - protected readonly olmDevice: OlmDevice; - protected readonly baseApis: MatrixClient; - - /** - * @param params - parameters - */ - public constructor(params: IParams) { - this.userId = params.userId; - this.deviceId = params.deviceId; - this.crypto = params.crypto; - this.olmDevice = params.olmDevice; - this.baseApis = params.baseApis; - } - - /** - * Perform any background tasks that can be done before a message is ready to - * send, in order to speed up sending of the message. - * - * @param room - the room the event is in - */ - public prepareToEncrypt(room: Room): void {} - - /** - * Encrypt a message event - * - * @public - * - * @param content - event content - * - * @returns Promise which resolves to the new event body - */ - public abstract encryptMessage(room: Room, eventType: string, content: IContent): Promise; - - /** - * Called when the membership of a member of the room changes. - * - * @param event - event causing the change - * @param member - user whose membership changed - * @param oldMembership - previous membership - * @public - */ - public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {} - - public reshareKeyWithDevice?( - senderKey: string, - sessionId: string, - userId: string, - device: DeviceInfo, - ): Promise; - - public forceDiscardSession?(): void; -} - -/** - * base type for decryption implementations - */ -export abstract class DecryptionAlgorithm { - protected readonly userId: string; - protected readonly crypto: Crypto; - protected readonly olmDevice: OlmDevice; - protected readonly baseApis: MatrixClient; - - public constructor(params: DecryptionClassParams) { - this.userId = params.userId; - this.crypto = params.crypto; - this.olmDevice = params.olmDevice; - this.baseApis = params.baseApis; - } - - /** - * Decrypt an event - * - * @param event - undecrypted event - * - * @returns promise which - * resolves once we have finished decrypting. Rejects with an - * `algorithms.DecryptionError` if there is a problem decrypting the event. - */ - public abstract decryptEvent(event: MatrixEvent): Promise; - - /** - * Handle a key event - * - * @param params - event key event - */ - public async onRoomKeyEvent(params: MatrixEvent): Promise { - // ignore by default - } - - /** - * Import a room key - * - * @param opts - object - */ - public async importRoomKey(session: IMegolmSessionData, opts: object): Promise { - // ignore by default - } - - /** - * Determine if we have the keys necessary to respond to a room key request - * - * @returns true if we have the keys and could (theoretically) share - * them; else false. - */ - public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise { - return Promise.resolve(false); - } - - /** - * Send the response to a room key request - * - */ - public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { - throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm"); - } - - /** - * Retry decrypting all the events from a sender that haven't been - * decrypted yet. - * - * @param senderKey - the sender's key - */ - public async retryDecryptionFromSender(senderKey: string): Promise { - // ignore by default - return false; - } - - public onRoomKeyWithheldEvent?(event: MatrixEvent): Promise; - public sendSharedHistoryInboundSessions?(devicesByUser: Map): Promise; -} - -export class UnknownDeviceError extends Error { - /** - * Exception thrown specifically when we want to warn the user to consider - * the security of their conversation before continuing - * - * @param msg - message describing the problem - * @param devices - set of unknown devices per user we're warning about - */ - public constructor( - msg: string, - public readonly devices: DeviceInfoMap, - public event?: MatrixEvent, - ) { - super(msg); - this.name = "UnknownDeviceError"; - this.devices = devices; - } -} - -/** - * Registers an encryption/decryption class for a particular algorithm - * - * @param algorithm - algorithm tag to register for - * - * @param encryptor - {@link EncryptionAlgorithm} implementation - * - * @param decryptor - {@link DecryptionAlgorithm} implementation - */ -export function registerAlgorithm

( - algorithm: string, - encryptor: new (params: P) => EncryptionAlgorithm, - decryptor: new (params: DecryptionClassParams

) => DecryptionAlgorithm, -): void { - ENCRYPTION_CLASSES.set(algorithm, encryptor as new (params: IParams) => EncryptionAlgorithm); - DECRYPTION_CLASSES.set(algorithm, decryptor as new (params: DecryptionClassParams) => DecryptionAlgorithm); -} - -/* Re-export for backwards compatibility. Deprecated: this is an internal class. */ -export { DecryptionError } from "../../common-crypto/CryptoBackend.ts"; diff --git a/src/crypto/algorithms/index.ts b/src/crypto/algorithms/index.ts deleted file mode 100644 index 947c6e0ea..000000000 --- a/src/crypto/algorithms/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* -Copyright 2016 - 2021 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 "./olm.ts"; -import "./megolm.ts"; - -export * from "./base.ts"; diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts deleted file mode 100644 index d3057e248..000000000 --- a/src/crypto/algorithms/megolm.ts +++ /dev/null @@ -1,2216 +0,0 @@ -/* -Copyright 2015 - 2021, 2023 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. -*/ - -/** - * Defines m.olm encryption/decryption - */ - -import { v4 as uuidv4 } from "uuid"; - -import type { IEventDecryptionResult, IMegolmSessionData } from "../../@types/crypto.ts"; -import { logger, type Logger } from "../../logger.ts"; -import * as olmlib from "../olmlib.ts"; -import { - DecryptionAlgorithm, - type DecryptionClassParams, - EncryptionAlgorithm, - type IParams, - registerAlgorithm, - UnknownDeviceError, -} from "./base.ts"; -import { type IDecryptedGroupMessage, WITHHELD_MESSAGES } from "../OlmDevice.ts"; -import { type Room } from "../../models/room.ts"; -import { type DeviceInfo } from "../deviceinfo.ts"; -import { type IOlmSessionResult } from "../olmlib.ts"; -import { type DeviceInfoMap } from "../DeviceList.ts"; -import { type IContent, type MatrixEvent } from "../../models/event.ts"; -import { EventType, MsgType, ToDeviceMessageId } from "../../@types/event.ts"; -import { type IMegolmEncryptedContent, type IncomingRoomKeyRequest, type IEncryptedContent } from "../index.ts"; -import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager.ts"; -import { type OlmGroupSessionExtraData } from "../../@types/crypto.ts"; -import { type MatrixError } from "../../http-api/index.ts"; -import { immediate, MapWithDefault } from "../../utils.ts"; -import { KnownMembership } from "../../@types/membership.ts"; -import { DecryptionFailureCode } from "../../crypto-api/index.ts"; -import { DecryptionError } from "../../common-crypto/CryptoBackend.ts"; - -// determine whether the key can be shared with invitees -export function isRoomSharedHistory(room: Room): boolean { - const visibilityEvent = room?.currentState?.getStateEvents("m.room.history_visibility", ""); - // NOTE: if the room visibility is unset, it would normally default to - // "world_readable". - // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) - // But we will be paranoid here, and treat it as a situation where the room - // is not shared-history - const visibility = visibilityEvent?.getContent()?.history_visibility; - return ["world_readable", "shared"].includes(visibility); -} - -interface IBlockedDevice { - code: string; - reason: string; - deviceInfo: DeviceInfo; -} - -// map user Id → device Id → IBlockedDevice -type BlockedMap = Map>; - -export interface IOlmDevice { - userId: string; - deviceInfo: T; -} - -/** - * Tests whether an encrypted content has a ciphertext. - * Ciphertext can be a string or object depending on the content type {@link IEncryptedContent}. - * - * @param content - Encrypted content - * @returns true: has ciphertext, else false - */ -const hasCiphertext = (content: IEncryptedContent): boolean => { - return typeof content.ciphertext === "string" - ? !!content.ciphertext.length - : !!Object.keys(content.ciphertext).length; -}; - -/** The result of parsing the an `m.room_key` or `m.forwarded_room_key` to-device event */ -interface RoomKey { - /** - * The Curve25519 key of the megolm session creator. - * - * For `m.room_key`, this is also the sender of the `m.room_key` to-device event. - * For `m.forwarded_room_key`, the two are different (and the key of the sender of the - * `m.forwarded_room_key` event is included in `forwardingKeyChain`) - */ - senderKey: string; - sessionId: string; - sessionKey: string; - exportFormat: boolean; - roomId: string; - algorithm: string; - /** - * A list of the curve25519 keys of the users involved in forwarding this key, most recent last. - * For `m.room_key` events, this is empty. - */ - forwardingKeyChain: string[]; - keysClaimed: Partial>; - extraSessionData: OlmGroupSessionExtraData; -} - -export interface IOutboundGroupSessionKey { - chain_index: number; - key: string; -} - -interface IMessage { - type: string; - content: { - "algorithm": string; - "room_id": string; - "sender_key"?: string; - "sender_claimed_ed25519_key"?: string; - "session_id": string; - "session_key": string; - "chain_index": number; - "forwarding_curve25519_key_chain"?: string[]; - "org.matrix.msc3061.shared_history": boolean; - }; -} - -interface IKeyForwardingMessage extends IMessage { - type: "m.forwarded_room_key"; -} - -interface IPayload extends Partial { - code?: string; - reason?: string; - room_id?: string; - session_id?: string; - algorithm?: string; - sender_key?: string; -} - -interface SharedWithData { - // The identity key of the device we shared with - deviceKey: string; - // The message index of the ratchet we shared with that device - messageIndex: number; -} - -/** - * @internal - */ -class OutboundSessionInfo { - /** number of times this session has been used */ - public useCount = 0; - /** when the session was created (ms since the epoch) */ - public creationTime: number; - /** devices with which we have shared the session key `userId -> {deviceId -> SharedWithData}` */ - public sharedWithDevices: MapWithDefault> = new MapWithDefault(() => new Map()); - public blockedDevicesNotified: MapWithDefault> = new MapWithDefault(() => new Map()); - - /** - * @param sharedHistory - whether the session can be freely shared with - * other group members, according to the room history visibility settings - */ - public constructor( - public readonly sessionId: string, - public readonly sharedHistory = false, - ) { - this.creationTime = new Date().getTime(); - } - - /** - * Check if it's time to rotate the session - */ - public needsRotation(rotationPeriodMsgs: number, rotationPeriodMs: number): boolean { - const sessionLifetime = new Date().getTime() - this.creationTime; - - if (this.useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) { - logger.log("Rotating megolm session after " + this.useCount + " messages, " + sessionLifetime + "ms"); - return true; - } - - return false; - } - - public markSharedWithDevice(userId: string, deviceId: string, deviceKey: string, chainIndex: number): void { - this.sharedWithDevices.getOrCreate(userId).set(deviceId, { deviceKey, messageIndex: chainIndex }); - } - - public markNotifiedBlockedDevice(userId: string, deviceId: string): void { - this.blockedDevicesNotified.getOrCreate(userId).set(deviceId, true); - } - - /** - * Determine if this session has been shared with devices which it shouldn't - * have been. - * - * @param devicesInRoom - `userId -> {deviceId -> object}` - * devices we should shared the session with. - * - * @returns true if we have shared the session with devices which aren't - * in devicesInRoom. - */ - public sharedWithTooManyDevices(devicesInRoom: DeviceInfoMap): boolean { - for (const [userId, devices] of this.sharedWithDevices) { - if (!devicesInRoom.has(userId)) { - logger.log("Starting new megolm session because we shared with " + userId); - return true; - } - - for (const [deviceId] of devices) { - if (!devicesInRoom.get(userId)?.get(deviceId)) { - logger.log("Starting new megolm session because we shared with " + userId + ":" + deviceId); - return true; - } - } - } - - return false; - } -} - -/** - * Megolm encryption implementation - * - * @param params - parameters, as per {@link EncryptionAlgorithm} - */ -export class MegolmEncryption extends EncryptionAlgorithm { - // the most recent attempt to set up a session. This is used to serialise - // the session setups, so that we have a race-free view of which session we - // are using, and which devices we have shared the keys with. It resolves - // with an OutboundSessionInfo (or undefined, for the first message in the - // room). - private setupPromise = Promise.resolve(null); - - // Map of outbound sessions by sessions ID. Used if we need a particular - // session (the session we're currently using to send is always obtained - // using setupPromise). - private outboundSessions: Record = {}; - - private readonly sessionRotationPeriodMsgs: number; - private readonly sessionRotationPeriodMs: number; - private encryptionPreparation?: { - promise: Promise; - startTime: number; - cancel: () => void; - }; - - protected readonly roomId: string; - private readonly prefixedLogger: Logger; - - public constructor(params: IParams & Required>) { - super(params); - this.roomId = params.roomId; - this.prefixedLogger = logger.getChild(`[${this.roomId} encryption]`); - - this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100; - this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000; - } - - /** - * @internal - * - * @param devicesInRoom - The devices in this room, indexed by user ID - * @param blocked - The devices that are blocked, indexed by user ID - * @param singleOlmCreationPhase - Only perform one round of olm - * session creation - * - * This method updates the setupPromise field of the class by chaining a new - * call on top of the existing promise, and then catching and discarding any - * errors that might happen while setting up the outbound group session. This - * is done to ensure that `setupPromise` always resolves to `null` or the - * `OutboundSessionInfo`. - * - * Using `>>=` to represent the promise chaining operation, it does the - * following: - * - * ``` - * setupPromise = previousSetupPromise >>= setup >>= discardErrors - * ``` - * - * The initial value for the `setupPromise` is a promise that resolves to - * `null`. The forceDiscardSession() resets setupPromise to this initial - * promise. - * - * @returns Promise which resolves to the - * OutboundSessionInfo when setup is complete. - */ - private async ensureOutboundSession( - room: Room, - devicesInRoom: DeviceInfoMap, - blocked: BlockedMap, - singleOlmCreationPhase = false, - ): Promise { - // takes the previous OutboundSessionInfo, and considers whether to create - // a new one. Also shares the key with any (new) devices in the room. - // - // returns a promise which resolves once the keyshare is successful. - const setup = async (oldSession: OutboundSessionInfo | null): Promise => { - const sharedHistory = isRoomSharedHistory(room); - const session = await this.prepareSession(devicesInRoom, sharedHistory, oldSession); - - await this.shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session); - - return session; - }; - - // first wait for the previous share to complete - const fallible = this.setupPromise.then(setup); - - // Ensure any failures are logged for debugging and make sure that the - // promise chain remains unbroken - // - // setupPromise resolves to `null` or the `OutboundSessionInfo` whether - // or not the share succeeds - this.setupPromise = fallible.catch((e) => { - this.prefixedLogger.error(`Failed to setup outbound session`, e); - return null; - }); - - // but we return a promise which only resolves if the share was successful. - return fallible; - } - - private async prepareSession( - devicesInRoom: DeviceInfoMap, - sharedHistory: boolean, - session: OutboundSessionInfo | null, - ): Promise { - // history visibility changed - if (session && sharedHistory !== session.sharedHistory) { - session = null; - } - - // need to make a brand new session? - if (session?.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) { - this.prefixedLogger.debug("Starting new megolm session because we need to rotate."); - session = null; - } - - // determine if we have shared with anyone we shouldn't have - if (session?.sharedWithTooManyDevices(devicesInRoom)) { - session = null; - } - - if (!session) { - this.prefixedLogger.debug("Starting new megolm session"); - session = await this.prepareNewSession(sharedHistory); - this.prefixedLogger.debug(`Started new megolm session ${session.sessionId}`); - this.outboundSessions[session.sessionId] = session; - } - - return session; - } - - private async shareSession( - devicesInRoom: DeviceInfoMap, - sharedHistory: boolean, - singleOlmCreationPhase: boolean, - blocked: BlockedMap, - session: OutboundSessionInfo, - ): Promise { - // now check if we need to share with any devices - const shareMap: Record = {}; - - for (const [userId, userDevices] of devicesInRoom) { - for (const [deviceId, deviceInfo] of userDevices) { - const key = deviceInfo.getIdentityKey(); - if (key == this.olmDevice.deviceCurve25519Key) { - // don't bother sending to ourself - continue; - } - - if (!session.sharedWithDevices.get(userId)?.get(deviceId)) { - shareMap[userId] = shareMap[userId] || []; - shareMap[userId].push(deviceInfo); - } - } - } - - const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId); - const payload: IPayload = { - type: "m.room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": this.roomId, - "session_id": session.sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "org.matrix.msc3061.shared_history": sharedHistory, - }, - }; - const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( - this.olmDevice, - this.baseApis, - shareMap, - ); - - await Promise.all([ - (async (): Promise => { - // share keys with devices that we already have a session for - const olmSessionList = Array.from(olmSessions.entries()) - .map(([userId, sessionsByUser]) => - Array.from(sessionsByUser.entries()).map( - ([deviceId, session]) => `${userId}/${deviceId}: ${session.sessionId}`, - ), - ) - .flat(1); - this.prefixedLogger.debug("Sharing keys with devices with existing Olm sessions:", olmSessionList); - await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); - this.prefixedLogger.debug("Shared keys with existing Olm sessions"); - })(), - (async (): Promise => { - const deviceList = Array.from(devicesWithoutSession.entries()) - .map(([userId, devicesByUser]) => devicesByUser.map((device) => `${userId}/${device.deviceId}`)) - .flat(1); - this.prefixedLogger.debug( - "Sharing keys (start phase 1) with devices without existing Olm sessions:", - deviceList, - ); - const errorDevices: IOlmDevice[] = []; - - // meanwhile, establish olm sessions for devices that we don't - // already have a session for, and share keys with them. If - // we're doing two phases of olm session creation, use a - // shorter timeout when fetching one-time keys for the first - // phase. - const start = Date.now(); - const failedServers: string[] = []; - await this.shareKeyWithDevices( - session, - key, - payload, - devicesWithoutSession, - errorDevices, - singleOlmCreationPhase ? 10000 : 2000, - failedServers, - ); - this.prefixedLogger.debug("Shared keys (end phase 1) with devices without existing Olm sessions"); - - if (!singleOlmCreationPhase && Date.now() - start < 10000) { - // perform the second phase of olm session creation if requested, - // and if the first phase didn't take too long - (async (): Promise => { - // Retry sending keys to devices that we were unable to establish - // an olm session for. This time, we use a longer timeout, but we - // do this in the background and don't block anything else while we - // do this. We only need to retry users from servers that didn't - // respond the first time. - const retryDevices: MapWithDefault = new MapWithDefault(() => []); - const failedServerMap = new Set(); - for (const server of failedServers) { - failedServerMap.add(server); - } - const failedDevices: IOlmDevice[] = []; - for (const { userId, deviceInfo } of errorDevices) { - const userHS = userId.slice(userId.indexOf(":") + 1); - if (failedServerMap.has(userHS)) { - retryDevices.getOrCreate(userId).push(deviceInfo); - } else { - // if we aren't going to retry, then handle it - // as a failed device - failedDevices.push({ userId, deviceInfo }); - } - } - - const retryDeviceList = Array.from(retryDevices.entries()) - .map(([userId, devicesByUser]) => - devicesByUser.map((device) => `${userId}/${device.deviceId}`), - ) - .flat(1); - - if (retryDeviceList.length > 0) { - this.prefixedLogger.debug( - "Sharing keys (start phase 2) with devices without existing Olm sessions:", - retryDeviceList, - ); - await this.shareKeyWithDevices(session, key, payload, retryDevices, failedDevices, 30000); - this.prefixedLogger.debug( - "Shared keys (end phase 2) with devices without existing Olm sessions", - ); - } - - await this.notifyFailedOlmDevices(session, key, failedDevices); - })(); - } else { - await this.notifyFailedOlmDevices(session, key, errorDevices); - } - })(), - (async (): Promise => { - this.prefixedLogger.debug( - `There are ${blocked.size} blocked devices:`, - Array.from(blocked.entries()) - .map(([userId, blockedByUser]) => - Array.from(blockedByUser.entries()).map( - ([deviceId, _deviceInfo]) => `${userId}/${deviceId}`, - ), - ) - .flat(1), - ); - - // also, notify newly blocked devices that they're blocked - const blockedMap: MapWithDefault> = new MapWithDefault( - () => new Map(), - ); - let blockedCount = 0; - for (const [userId, userBlockedDevices] of blocked) { - for (const [deviceId, device] of userBlockedDevices) { - if (session.blockedDevicesNotified.get(userId)?.get(deviceId) === undefined) { - blockedMap.getOrCreate(userId).set(deviceId, { device }); - blockedCount++; - } - } - } - - if (blockedCount) { - this.prefixedLogger.debug( - `Notifying ${blockedCount} newly blocked devices:`, - Array.from(blockedMap.entries()) - .map(([userId, blockedByUser]) => - Object.entries(blockedByUser).map(([deviceId, _deviceInfo]) => `${userId}/${deviceId}`), - ) - .flat(1), - ); - await this.notifyBlockedDevices(session, blockedMap); - this.prefixedLogger.debug(`Notified ${blockedCount} newly blocked devices`); - } - })(), - ]); - } - - /** - * @internal - * - * - * @returns session - */ - private async prepareNewSession(sharedHistory: boolean): Promise { - const sessionId = this.olmDevice.createOutboundGroupSession(); - const key = this.olmDevice.getOutboundGroupSessionKey(sessionId); - - await this.olmDevice.addInboundGroupSession( - this.roomId, - this.olmDevice.deviceCurve25519Key!, - [], - sessionId, - key.key, - { ed25519: this.olmDevice.deviceEd25519Key! }, - false, - { sharedHistory }, - ); - - // don't wait for it to complete - this.crypto.backupManager.backupGroupSession(this.olmDevice.deviceCurve25519Key!, sessionId); - - return new OutboundSessionInfo(sessionId, sharedHistory); - } - - /** - * Determines what devices in devicesByUser don't have an olm session as given - * in devicemap. - * - * @internal - * - * @param deviceMap - the devices that have olm sessions, as returned by - * olmlib.ensureOlmSessionsForDevices. - * @param devicesByUser - a map of user IDs to array of deviceInfo - * @param noOlmDevices - an array to fill with devices that don't have - * olm sessions - * - * @returns an array of devices that don't have olm sessions. If - * noOlmDevices is specified, then noOlmDevices will be returned. - */ - private getDevicesWithoutSessions( - deviceMap: Map>, - devicesByUser: Map, - noOlmDevices: IOlmDevice[] = [], - ): IOlmDevice[] { - for (const [userId, devicesToShareWith] of devicesByUser) { - const sessionResults = deviceMap.get(userId); - - for (const deviceInfo of devicesToShareWith) { - const deviceId = deviceInfo.deviceId; - - const sessionResult = sessionResults?.get(deviceId); - if (!sessionResult?.sessionId) { - // no session with this device, probably because there - // were no one-time keys. - - noOlmDevices.push({ userId, deviceInfo }); - sessionResults?.delete(deviceId); - - // ensureOlmSessionsForUsers has already done the logging, - // so just skip it. - continue; - } - } - } - - return noOlmDevices; - } - - /** - * Splits the user device map into multiple chunks to reduce the number of - * devices we encrypt to per API call. - * - * @internal - * - * @param devicesByUser - map from userid to list of devices - * - * @returns the blocked devices, split into chunks - */ - private splitDevices( - devicesByUser: Map>, - ): IOlmDevice[][] { - const maxDevicesPerRequest = 20; - - // use an array where the slices of a content map gets stored - let currentSlice: IOlmDevice[] = []; - const mapSlices = [currentSlice]; - - for (const [userId, userDevices] of devicesByUser) { - for (const deviceInfo of userDevices.values()) { - currentSlice.push({ - userId: userId, - deviceInfo: deviceInfo.device, - }); - } - - // We do this in the per-user loop as we prefer that all messages to the - // same user end up in the same API call to make it easier for the - // server (e.g. only have to send one EDU if a remote user, etc). This - // does mean that if a user has many devices we may go over the desired - // limit, but its not a hard limit so that is fine. - if (currentSlice.length > maxDevicesPerRequest) { - // the current slice is filled up. Start inserting into the next slice - currentSlice = []; - mapSlices.push(currentSlice); - } - } - if (currentSlice.length === 0) { - mapSlices.pop(); - } - return mapSlices; - } - - /** - * @internal - * - * - * @param chainIndex - current chain index - * - * @param userDeviceMap - mapping from userId to deviceInfo - * - * @param payload - fields to include in the encrypted payload - * - * @returns Promise which resolves once the key sharing - * for the given userDeviceMap is generated and has been sent. - */ - private encryptAndSendKeysToDevices( - session: OutboundSessionInfo, - chainIndex: number, - devices: IOlmDevice[], - payload: IPayload, - ): Promise { - return this.crypto - .encryptAndSendToDevices(devices, payload) - .then(() => { - // store that we successfully uploaded the keys of the current slice - for (const device of devices) { - session.markSharedWithDevice( - device.userId, - device.deviceInfo.deviceId, - device.deviceInfo.getIdentityKey(), - chainIndex, - ); - } - }) - .catch((error) => { - this.prefixedLogger.error("failed to encryptAndSendToDevices", error); - throw error; - }); - } - - /** - * @internal - * - * - * @param userDeviceMap - list of blocked devices to notify - * - * @param payload - fields to include in the notification payload - * - * @returns Promise which resolves once the notifications - * for the given userDeviceMap is generated and has been sent. - */ - private async sendBlockedNotificationsToDevices( - session: OutboundSessionInfo, - userDeviceMap: IOlmDevice[], - payload: IPayload, - ): Promise { - const contentMap: MapWithDefault> = new MapWithDefault(() => new Map()); - - for (const val of userDeviceMap) { - const userId = val.userId; - const blockedInfo = val.deviceInfo; - const deviceInfo = blockedInfo.deviceInfo; - const deviceId = deviceInfo.deviceId; - - const message = { - ...payload, - code: blockedInfo.code, - reason: blockedInfo.reason, - [ToDeviceMessageId]: uuidv4(), - }; - - if (message.code === "m.no_olm") { - delete message.room_id; - delete message.session_id; - } - - contentMap.getOrCreate(userId).set(deviceId, message); - } - - await this.baseApis.sendToDevice("m.room_key.withheld", contentMap); - - // record the fact that we notified these blocked devices - for (const [userId, userDeviceMap] of contentMap) { - for (const deviceId of userDeviceMap.keys()) { - session.markNotifiedBlockedDevice(userId, deviceId); - } - } - } - - /** - * Re-shares a megolm session key with devices if the key has already been - * sent to them. - * - * @param senderKey - The key of the originating device for the session - * @param sessionId - ID of the outbound session to share - * @param userId - ID of the user who owns the target device - * @param device - The target device - */ - public async reshareKeyWithDevice( - senderKey: string, - sessionId: string, - userId: string, - device: DeviceInfo, - ): Promise { - const obSessionInfo = this.outboundSessions[sessionId]; - if (!obSessionInfo) { - this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} not found: not re-sharing keys`); - return; - } - - // The chain index of the key we previously sent this device - if (!obSessionInfo.sharedWithDevices.has(userId)) { - this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} never shared with user ${userId}`); - return; - } - const sessionSharedData = obSessionInfo.sharedWithDevices.get(userId)?.get(device.deviceId); - if (sessionSharedData === undefined) { - this.prefixedLogger.debug( - `megolm session ${senderKey}|${sessionId} never shared with device ${userId}:${device.deviceId}`, - ); - return; - } - - if (sessionSharedData.deviceKey !== device.getIdentityKey()) { - this.prefixedLogger.warn( - `Megolm session ${senderKey}|${sessionId} has been shared with device ${device.deviceId} but ` + - `with identity key ${sessionSharedData.deviceKey}. Key is now ${device.getIdentityKey()}!`, - ); - return; - } - - // get the key from the inbound session: the outbound one will already - // have been ratcheted to the next chain index. - const key = await this.olmDevice.getInboundGroupSessionKey( - this.roomId, - senderKey, - sessionId, - sessionSharedData.messageIndex, - ); - - if (!key) { - this.prefixedLogger.warn( - `No inbound session key found for megolm session ${senderKey}|${sessionId}: not re-sharing keys`, - ); - return; - } - - await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[userId, [device]]])); - - const payload = { - type: "m.forwarded_room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": this.roomId, - "session_id": sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "sender_key": senderKey, - "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, - "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": key.shared_history || false, - }, - }; - - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this.userId, - this.deviceId, - this.olmDevice, - userId, - device, - payload, - ); - - await this.baseApis.sendToDevice( - "m.room.encrypted", - new Map([[userId, new Map([[device.deviceId, encryptedContent]])]]), - ); - this.prefixedLogger.debug( - `Re-shared key for megolm session ${senderKey}|${sessionId} with ${userId}:${device.deviceId}`, - ); - } - - /** - * @internal - * - * - * @param key - the session key as returned by - * OlmDevice.getOutboundGroupSessionKey - * - * @param payload - the base to-device message payload for sharing keys - * - * @param devicesByUser - map from userid to list of devices - * - * @param errorDevices - array that will be populated with the devices that we can't get an - * olm session for - * - * @param otkTimeout - The timeout in milliseconds when requesting - * one-time keys for establishing new olm sessions. - * - * @param failedServers - An array to fill with remote servers that - * failed to respond to one-time-key requests. - */ - private async shareKeyWithDevices( - session: OutboundSessionInfo, - key: IOutboundGroupSessionKey, - payload: IPayload, - devicesByUser: Map, - errorDevices: IOlmDevice[], - otkTimeout: number, - failedServers?: string[], - ): Promise { - const devicemap = await olmlib.ensureOlmSessionsForDevices( - this.olmDevice, - this.baseApis, - devicesByUser, - false, - otkTimeout, - failedServers, - this.prefixedLogger, - ); - this.getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); - await this.shareKeyWithOlmSessions(session, key, payload, devicemap); - } - - private async shareKeyWithOlmSessions( - session: OutboundSessionInfo, - key: IOutboundGroupSessionKey, - payload: IPayload, - deviceMap: Map>, - ): Promise { - const userDeviceMaps = this.splitDevices(deviceMap); - - for (let i = 0; i < userDeviceMaps.length; i++) { - const taskDetail = `megolm keys for ${session.sessionId} (slice ${i + 1}/${userDeviceMaps.length})`; - try { - this.prefixedLogger.debug( - `Sharing ${taskDetail}`, - userDeviceMaps[i].map((d) => `${d.userId}/${d.deviceInfo.deviceId}`), - ); - await this.encryptAndSendKeysToDevices(session, key.chain_index, userDeviceMaps[i], payload); - this.prefixedLogger.debug(`Shared ${taskDetail}`); - } catch (e) { - this.prefixedLogger.error(`Failed to share ${taskDetail}`); - throw e; - } - } - } - - /** - * Notify devices that we weren't able to create olm sessions. - * - * - * - * @param failedDevices - the devices that we were unable to - * create olm sessions for, as returned by shareKeyWithDevices - */ - private async notifyFailedOlmDevices( - session: OutboundSessionInfo, - key: IOutboundGroupSessionKey, - failedDevices: IOlmDevice[], - ): Promise { - this.prefixedLogger.debug(`Notifying ${failedDevices.length} devices we failed to create Olm sessions`); - - // mark the devices that failed as "handled" because we don't want to try - // to claim a one-time-key for dead devices on every message. - for (const { userId, deviceInfo } of failedDevices) { - const deviceId = deviceInfo.deviceId; - - session.markSharedWithDevice(userId, deviceId, deviceInfo.getIdentityKey(), key.chain_index); - } - - const unnotifiedFailedDevices = await this.olmDevice.filterOutNotifiedErrorDevices(failedDevices); - this.prefixedLogger.debug( - `Need to notify ${unnotifiedFailedDevices.length} failed devices which haven't been notified before`, - ); - const blockedMap: MapWithDefault> = new MapWithDefault( - () => new Map(), - ); - for (const { userId, deviceInfo } of unnotifiedFailedDevices) { - // we use a similar format to what - // olmlib.ensureOlmSessionsForDevices returns, so that - // we can use the same function to split - blockedMap.getOrCreate(userId).set(deviceInfo.deviceId, { - device: { - code: "m.no_olm", - reason: WITHHELD_MESSAGES["m.no_olm"], - deviceInfo, - }, - }); - } - - // send the notifications - await this.notifyBlockedDevices(session, blockedMap); - this.prefixedLogger.debug( - `Notified ${unnotifiedFailedDevices.length} devices we failed to create Olm sessions`, - ); - } - - /** - * Notify blocked devices that they have been blocked. - * - * - * @param devicesByUser - map from userid to device ID to blocked data - */ - private async notifyBlockedDevices( - session: OutboundSessionInfo, - devicesByUser: Map>, - ): Promise { - const payload: IPayload = { - room_id: this.roomId, - session_id: session.sessionId, - algorithm: olmlib.MEGOLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - }; - - const userDeviceMaps = this.splitDevices(devicesByUser); - - for (let i = 0; i < userDeviceMaps.length; i++) { - try { - await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload); - this.prefixedLogger.debug( - `Completed blacklist notification for ${session.sessionId} ` + - `(slice ${i + 1}/${userDeviceMaps.length})`, - ); - } catch (e) { - this.prefixedLogger.debug( - `blacklist notification for ${session.sessionId} ` + - `(slice ${i + 1}/${userDeviceMaps.length}) failed`, - ); - - throw e; - } - } - } - - /** - * Perform any background tasks that can be done before a message is ready to - * send, in order to speed up sending of the message. - * - * @param room - the room the event is in - * @returns A function that, when called, will stop the preparation - */ - public prepareToEncrypt(room: Room): () => void { - if (room.roomId !== this.roomId) { - throw new Error("MegolmEncryption.prepareToEncrypt called on unexpected room"); - } - - if (this.encryptionPreparation != null) { - // We're already preparing something, so don't do anything else. - const elapsedTime = Date.now() - this.encryptionPreparation.startTime; - this.prefixedLogger.debug( - `Already started preparing to encrypt for this room ${elapsedTime}ms ago, skipping`, - ); - return this.encryptionPreparation.cancel; - } - - this.prefixedLogger.debug("Preparing to encrypt events"); - - let cancelled = false; - const isCancelled = (): boolean => cancelled; - - this.encryptionPreparation = { - startTime: Date.now(), - promise: (async (): Promise => { - try { - // Attempt to enumerate the devices in room, and gracefully - // handle cancellation if it occurs. - const getDevicesResult = await this.getDevicesInRoom(room, false, isCancelled); - if (getDevicesResult === null) return; - const [devicesInRoom, blocked] = getDevicesResult; - - if (this.crypto.globalErrorOnUnknownDevices) { - // Drop unknown devices for now. When the message gets sent, we'll - // throw an error, but we'll still be prepared to send to the known - // devices. - this.removeUnknownDevices(devicesInRoom); - } - - this.prefixedLogger.debug("Ensuring outbound megolm session"); - await this.ensureOutboundSession(room, devicesInRoom, blocked, true); - - this.prefixedLogger.debug("Ready to encrypt events"); - } catch (e) { - this.prefixedLogger.error("Failed to prepare to encrypt events", e); - } finally { - delete this.encryptionPreparation; - } - })(), - - cancel: (): void => { - // The caller has indicated that the process should be cancelled, - // so tell the promise that we'd like to halt, and reset the preparation state. - cancelled = true; - delete this.encryptionPreparation; - }, - }; - - return this.encryptionPreparation.cancel; - } - - /** - * @param content - plaintext event content - * - * @returns Promise which resolves to the new event body - */ - public async encryptMessage(room: Room, eventType: string, content: IContent): Promise { - this.prefixedLogger.debug("Starting to encrypt event"); - - if (this.encryptionPreparation != null) { - // If we started sending keys, wait for it to be done. - // FIXME: check if we need to cancel - // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) - try { - await this.encryptionPreparation.promise; - } catch { - // ignore any errors -- if the preparation failed, we'll just - // restart everything here - } - } - - /** - * When using in-room messages and the room has encryption enabled, - * clients should ensure that encryption does not hinder the verification. - */ - const forceDistributeToUnverified = this.isVerificationEvent(eventType, content); - const [devicesInRoom, blocked] = await this.getDevicesInRoom(room, forceDistributeToUnverified); - - // check if any of these devices are not yet known to the user. - // if so, warn the user so they can verify or ignore. - if (this.crypto.globalErrorOnUnknownDevices) { - this.checkForUnknownDevices(devicesInRoom); - } - - const session = await this.ensureOutboundSession(room, devicesInRoom, blocked); - const payloadJson = { - room_id: this.roomId, - type: eventType, - content: content, - }; - - const ciphertext = this.olmDevice.encryptGroupMessage(session.sessionId, JSON.stringify(payloadJson)); - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.MEGOLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: ciphertext, - session_id: session.sessionId, - // Include our device ID so that recipients can send us a - // m.new_device message if they don't have our session key. - // XXX: Do we still need this now that m.new_device messages - // no longer exist since #483? - device_id: this.deviceId, - }; - - session.useCount++; - return encryptedContent; - } - - private isVerificationEvent(eventType: string, content: IContent): boolean { - switch (eventType) { - case EventType.KeyVerificationCancel: - case EventType.KeyVerificationDone: - case EventType.KeyVerificationMac: - case EventType.KeyVerificationStart: - case EventType.KeyVerificationKey: - case EventType.KeyVerificationReady: - case EventType.KeyVerificationAccept: { - return true; - } - case EventType.RoomMessage: { - return content["msgtype"] === MsgType.KeyVerificationRequest; - } - default: { - return false; - } - } - } - - /** - * Forces the current outbound group session to be discarded such - * that another one will be created next time an event is sent. - * - * This should not normally be necessary. - */ - public forceDiscardSession(): void { - this.setupPromise = this.setupPromise.then(() => null); - } - - /** - * Checks the devices we're about to send to and see if any are entirely - * unknown to the user. If so, warn the user, and mark them as known to - * give the user a chance to go verify them before re-sending this message. - * - * @param devicesInRoom - `userId -> {deviceId -> object}` - * devices we should shared the session with. - */ - private checkForUnknownDevices(devicesInRoom: DeviceInfoMap): void { - const unknownDevices: MapWithDefault> = new MapWithDefault(() => new Map()); - - for (const [userId, userDevices] of devicesInRoom) { - for (const [deviceId, device] of userDevices) { - if (device.isUnverified() && !device.isKnown()) { - unknownDevices.getOrCreate(userId).set(deviceId, device); - } - } - } - - if (unknownDevices.size) { - // it'd be kind to pass unknownDevices up to the user in this error - throw new UnknownDeviceError( - "This room contains unknown devices which have not been verified. " + - "We strongly recommend you verify them before continuing.", - unknownDevices, - ); - } - } - - /** - * Remove unknown devices from a set of devices. The devicesInRoom parameter - * will be modified. - * - * @param devicesInRoom - `userId -> {deviceId -> object}` - * devices we should shared the session with. - */ - private removeUnknownDevices(devicesInRoom: DeviceInfoMap): void { - for (const [userId, userDevices] of devicesInRoom) { - for (const [deviceId, device] of userDevices) { - if (device.isUnverified() && !device.isKnown()) { - userDevices.delete(deviceId); - } - } - - if (userDevices.size === 0) { - devicesInRoom.delete(userId); - } - } - } - - /** - * Get the list of unblocked devices for all users in the room - * - * @param forceDistributeToUnverified - if set to true will include the unverified devices - * even if setting is set to block them (useful for verification) - * @param isCancelled - will cause the procedure to abort early if and when it starts - * returning `true`. If omitted, cancellation won't happen. - * - * @returns Promise which resolves to `null`, or an array whose - * first element is a {@link DeviceInfoMap} indicating - * the devices that messages should be encrypted to, and whose second - * element is a map from userId to deviceId to data indicating the devices - * that are in the room but that have been blocked. - * If `isCancelled` is provided and returns `true` while processing, `null` - * will be returned. - * If `isCancelled` is not provided, the Promise will never resolve to `null`. - */ - private async getDevicesInRoom( - room: Room, - forceDistributeToUnverified?: boolean, - ): Promise<[DeviceInfoMap, BlockedMap]>; - private async getDevicesInRoom( - room: Room, - forceDistributeToUnverified?: boolean, - isCancelled?: () => boolean, - ): Promise; - private async getDevicesInRoom( - room: Room, - forceDistributeToUnverified = false, - isCancelled?: () => boolean, - ): Promise { - const members = await room.getEncryptionTargetMembers(); - this.prefixedLogger.debug( - `Encrypting for users (shouldEncryptForInvitedMembers: ${room.shouldEncryptForInvitedMembers()}):`, - members.map((u) => `${u.userId} (${u.membership})`), - ); - - const roomMembers = members.map(function (u) { - return u.userId; - }); - - // The global value is treated as a default for when rooms don't specify a value. - let isBlacklisting = this.crypto.globalBlacklistUnverifiedDevices; - const isRoomBlacklisting = room.getBlacklistUnverifiedDevices(); - if (typeof isRoomBlacklisting === "boolean") { - isBlacklisting = isRoomBlacklisting; - } - - // We are happy to use a cached version here: we assume that if we already - // have a list of the user's devices, then we already share an e2e room - // with them, which means that they will have announced any new devices via - // device_lists in their /sync response. This cache should then be maintained - // using all the device_lists changes and left fields. - // See https://github.com/vector-im/element-web/issues/2305 for details. - const devices = await this.crypto.downloadKeys(roomMembers, false); - - if (isCancelled?.() === true) { - return null; - } - - const blocked = new MapWithDefault>(() => new Map()); - // remove any blocked devices - for (const [userId, userDevices] of devices) { - for (const [deviceId, userDevice] of userDevices) { - // Yield prior to checking each device so that we don't block - // updating/rendering for too long. - // See https://github.com/vector-im/element-web/issues/21612 - if (isCancelled !== undefined) await immediate(); - if (isCancelled?.() === true) return null; - const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId); - - if ( - userDevice.isBlocked() || - (!deviceTrust.isVerified() && isBlacklisting && !forceDistributeToUnverified) - ) { - const blockedDevices = blocked.getOrCreate(userId); - const isBlocked = userDevice.isBlocked(); - blockedDevices.set(deviceId, { - code: isBlocked ? "m.blacklisted" : "m.unverified", - reason: WITHHELD_MESSAGES[isBlocked ? "m.blacklisted" : "m.unverified"], - deviceInfo: userDevice, - }); - userDevices.delete(deviceId); - } - } - } - - return [devices, blocked]; - } -} - -/** - * Megolm decryption implementation - * - * @param params - parameters, as per {@link DecryptionAlgorithm} - */ -export class MegolmDecryption extends DecryptionAlgorithm { - // events which we couldn't decrypt due to unknown sessions / - // indexes, or which we could only decrypt with untrusted keys: - // map from senderKey|sessionId to Set of MatrixEvents - private pendingEvents = new Map>>(); - - // this gets stubbed out by the unit tests. - private olmlib = olmlib; - - protected readonly roomId: string; - private readonly prefixedLogger: Logger; - - public constructor(params: DecryptionClassParams>>) { - super(params); - this.roomId = params.roomId; - this.prefixedLogger = logger.getChild(`[${this.roomId} decryption]`); - } - - /** - * returns a promise which resolves to a - * {@link EventDecryptionResult} once we have finished - * decrypting, or rejects with an `algorithms.DecryptionError` if there is a - * problem decrypting the event. - */ - public async decryptEvent(event: MatrixEvent): Promise { - const content = event.getWireContent(); - - if (!content.sender_key || !content.session_id || !content.ciphertext) { - throw new DecryptionError(DecryptionFailureCode.MEGOLM_MISSING_FIELDS, "Missing fields in input"); - } - - // we add the event to the pending list *before* we start decryption. - // - // then, if the key turns up while decryption is in progress (and - // decryption fails), we will schedule a retry. - // (fixes https://github.com/vector-im/element-web/issues/5001) - this.addEventToPendingList(event); - - let res: IDecryptedGroupMessage | null; - try { - res = await this.olmDevice.decryptGroupMessage( - event.getRoomId()!, - content.sender_key, - content.session_id, - content.ciphertext, - event.getId()!, - event.getTs(), - ); - } catch (e) { - if ((e).name === "DecryptionError") { - // re-throw decryption errors as-is - throw e; - } - - let errorCode = DecryptionFailureCode.OLM_DECRYPT_GROUP_MESSAGE_ERROR; - - if ((e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX") { - this.requestKeysForEvent(event); - - errorCode = DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX; - } - - throw new DecryptionError(errorCode, e instanceof Error ? e.message : "Unknown Error: Error is undefined", { - session: content.sender_key + "|" + content.session_id, - }); - } - - if (res === null) { - // We've got a message for a session we don't have. - // try and get the missing key from the backup first - this.crypto.backupManager.queryKeyBackupRateLimited(event.getRoomId(), content.session_id).catch(() => {}); - - // (XXX: We might actually have received this key since we started - // decrypting, in which case we'll have scheduled a retry, and this - // request will be redundant. We could probably check to see if the - // event is still in the pending list; if not, a retry will have been - // scheduled, so we needn't send out the request here.) - this.requestKeysForEvent(event); - - // See if there was a problem with the olm session at the time the - // event was sent. Use a fuzz factor of 2 minutes. - const problem = await this.olmDevice.sessionMayHaveProblems(content.sender_key, event.getTs() - 120000); - if (problem) { - this.prefixedLogger.info( - `When handling UISI from ${event.getSender()} (sender key ${content.sender_key}): ` + - `recent session problem with that sender:`, - problem, - ); - let problemDescription = PROBLEM_DESCRIPTIONS[problem.type as "no_olm"] || PROBLEM_DESCRIPTIONS.unknown; - if (problem.fixed) { - problemDescription += " Trying to create a new secure channel and re-requesting the keys."; - } - throw new DecryptionError(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, problemDescription, { - session: content.sender_key + "|" + content.session_id, - }); - } - - throw new DecryptionError( - DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, - "The sender's device has not sent us the keys for this message.", - { - session: content.sender_key + "|" + content.session_id, - }, - ); - } - - // Success. We can remove the event from the pending list, if - // that hasn't already happened. However, if the event was - // decrypted with an untrusted key, leave it on the pending - // list so it will be retried if we find a trusted key later. - if (!res.untrusted) { - this.removeEventFromPendingList(event); - } - - const payload = JSON.parse(res.result); - - // belt-and-braces check that the room id matches that indicated by the HS - // (this is somewhat redundant, since the megolm session is scoped to the - // room, so neither the sender nor a MITM can lie about the room_id). - if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError( - DecryptionFailureCode.MEGOLM_BAD_ROOM, - "Message intended for room " + payload.room_id, - ); - } - - return { - clearEvent: payload, - senderCurve25519Key: res.senderKey, - claimedEd25519Key: res.keysClaimed.ed25519, - forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, - untrusted: res.untrusted, - }; - } - - private requestKeysForEvent(event: MatrixEvent): void { - const wireContent = event.getWireContent(); - - const recipients = event.getKeyRequestRecipients(this.userId); - - this.crypto.requestRoomKey( - { - room_id: event.getRoomId()!, - algorithm: wireContent.algorithm, - sender_key: wireContent.sender_key, - session_id: wireContent.session_id, - }, - recipients, - ); - } - - /** - * Add an event to the list of those awaiting their session keys. - * - * @internal - * - */ - private addEventToPendingList(event: MatrixEvent): void { - const content = event.getWireContent(); - const senderKey = content.sender_key; - const sessionId = content.session_id; - if (!this.pendingEvents.has(senderKey)) { - this.pendingEvents.set(senderKey, new Map>()); - } - const senderPendingEvents = this.pendingEvents.get(senderKey)!; - if (!senderPendingEvents.has(sessionId)) { - senderPendingEvents.set(sessionId, new Set()); - } - senderPendingEvents.get(sessionId)?.add(event); - } - - /** - * Remove an event from the list of those awaiting their session keys. - * - * @internal - * - */ - private removeEventFromPendingList(event: MatrixEvent): void { - const content = event.getWireContent(); - const senderKey = content.sender_key; - const sessionId = content.session_id; - const senderPendingEvents = this.pendingEvents.get(senderKey); - const pendingEvents = senderPendingEvents?.get(sessionId); - if (!pendingEvents) { - return; - } - - pendingEvents.delete(event); - if (pendingEvents.size === 0) { - senderPendingEvents!.delete(sessionId); - } - if (senderPendingEvents!.size === 0) { - this.pendingEvents.delete(senderKey); - } - } - - /** - * Parse a RoomKey out of an `m.room_key` event. - * - * @param event - the event containing the room key. - * - * @returns The `RoomKey` if it could be successfully parsed out of the - * event. - * - * @internal - * - */ - private roomKeyFromEvent(event: MatrixEvent): RoomKey | undefined { - const senderKey = event.getSenderKey()!; - const content = event.getContent>(); - const extraSessionData: OlmGroupSessionExtraData = {}; - - if (!content.room_id || !content.session_key || !content.session_id || !content.algorithm) { - this.prefixedLogger.error("key event is missing fields"); - return; - } - - if (!olmlib.isOlmEncrypted(event)) { - this.prefixedLogger.error("key event not properly encrypted"); - return; - } - - if (content["org.matrix.msc3061.shared_history"]) { - extraSessionData.sharedHistory = true; - } - - const roomKey: RoomKey = { - senderKey: senderKey, - sessionId: content.session_id, - sessionKey: content.session_key, - extraSessionData, - exportFormat: false, - roomId: content.room_id, - algorithm: content.algorithm, - forwardingKeyChain: [], - keysClaimed: event.getKeysClaimed(), - }; - - return roomKey; - } - - /** - * Parse a RoomKey out of an `m.forwarded_room_key` event. - * - * @param event - the event containing the forwarded room key. - * - * @returns The `RoomKey` if it could be successfully parsed out of the - * event. - * - * @internal - * - */ - private forwardedRoomKeyFromEvent(event: MatrixEvent): RoomKey | undefined { - // the properties in m.forwarded_room_key are a superset of those in m.room_key, so - // start by parsing the m.room_key fields. - const roomKey = this.roomKeyFromEvent(event); - - if (!roomKey) { - return; - } - - const senderKey = event.getSenderKey()!; - const content = event.getContent>(); - - const senderKeyUser = this.baseApis.crypto!.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, senderKey); - - // We received this to-device event from event.getSenderKey(), but the original - // creator of the room key is claimed in the content. - const claimedCurve25519Key = content.sender_key; - const claimedEd25519Key = content.sender_claimed_ed25519_key; - - let forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain) - ? content.forwarding_curve25519_key_chain - : []; - - // copy content before we modify it - forwardingKeyChain = forwardingKeyChain.slice(); - forwardingKeyChain.push(senderKey); - - // Check if we have all the fields we need. - if (senderKeyUser !== event.getSender()) { - this.prefixedLogger.error("sending device does not belong to the user it claims to be from"); - return; - } - - if (!claimedCurve25519Key) { - this.prefixedLogger.error("forwarded_room_key event is missing sender_key field"); - return; - } - - if (!claimedEd25519Key) { - this.prefixedLogger.error(`forwarded_room_key_event is missing sender_claimed_ed25519_key field`); - return; - } - - const keysClaimed = { - ed25519: claimedEd25519Key, - }; - - // FIXME: We're reusing the same field to track both: - // - // 1. The Olm identity we've received this room key from. - // 2. The Olm identity deduced (in the trusted case) or claiming (in the - // untrusted case) to be the original creator of this room key. - // - // We now overwrite the value tracking usage 1 with the value tracking usage 2. - roomKey.senderKey = claimedCurve25519Key; - // Replace our keysClaimed as well. - roomKey.keysClaimed = keysClaimed; - roomKey.exportFormat = true; - roomKey.forwardingKeyChain = forwardingKeyChain; - // forwarded keys are always untrusted - roomKey.extraSessionData.untrusted = true; - - return roomKey; - } - - /** - * Determine if we should accept the forwarded room key that was found in the given - * event. - * - * @param event - An `m.forwarded_room_key` event. - * @param roomKey - The room key that was found in the event. - * - * @returns promise that will resolve to a boolean telling us if it's ok to - * accept the given forwarded room key. - * - * @internal - * - */ - private async shouldAcceptForwardedKey(event: MatrixEvent, roomKey: RoomKey): Promise { - const senderKey = event.getSenderKey()!; - - const sendingDevice = - this.crypto.deviceList.getDeviceByIdentityKey(olmlib.OLM_ALGORITHM, senderKey) ?? undefined; - const deviceTrust = this.crypto.checkDeviceInfoTrust(event.getSender()!, sendingDevice); - - // Using the plaintext sender here is fine since we checked that the - // sender matches to the user id in the device keys when this event was - // originally decrypted. This can obviously only happen if the device - // keys have been downloaded, but if they haven't the - // `deviceTrust.isVerified()` flag would be false as well. - // - // It would still be far nicer if the `sendingDevice` had a user ID - // attached to it that went through signature checks. - const fromUs = event.getSender() === this.baseApis.getUserId(); - const keyFromOurVerifiedDevice = deviceTrust.isVerified() && fromUs; - const weRequested = await this.wasRoomKeyRequested(event, roomKey); - const fromInviter = this.wasRoomKeyForwardedByInviter(event, roomKey); - const sharedAsHistory = this.wasRoomKeyForwardedAsHistory(roomKey); - - return (weRequested && keyFromOurVerifiedDevice) || (fromInviter && sharedAsHistory); - } - - /** - * Did we ever request the given room key from the event sender and its - * accompanying device. - * - * @param event - An `m.forwarded_room_key` event. - * @param roomKey - The room key that was found in the event. - * - * @internal - * - */ - private async wasRoomKeyRequested(event: MatrixEvent, roomKey: RoomKey): Promise { - // We send the `m.room_key_request` out as a wildcard to-device request, - // otherwise we would have to duplicate the same content for each - // device. This is why we need to pass in "*" as the device id here. - const outgoingRequests = await this.crypto.cryptoStore.getOutgoingRoomKeyRequestsByTarget( - event.getSender()!, - "*", - [RoomKeyRequestState.Sent], - ); - - return outgoingRequests.some( - (req) => req.requestBody.room_id === roomKey.roomId && req.requestBody.session_id === roomKey.sessionId, - ); - } - - private wasRoomKeyForwardedByInviter(event: MatrixEvent, roomKey: RoomKey): boolean { - // TODO: This is supposed to have a time limit. We should only accept - // such keys if we happen to receive them for a recently joined room. - const room = this.baseApis.getRoom(roomKey.roomId); - const senderKey = event.getSenderKey(); - - if (!senderKey) { - return false; - } - - const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, senderKey); - - if (!senderKeyUser) { - return false; - } - - const memberEvent = room?.getMember(this.userId)?.events.member; - const fromInviter = - memberEvent?.getSender() === senderKeyUser || - (memberEvent?.getUnsigned()?.prev_sender === senderKeyUser && - memberEvent?.getPrevContent()?.membership === KnownMembership.Invite); - - if (room && fromInviter) { - return true; - } else { - return false; - } - } - - private wasRoomKeyForwardedAsHistory(roomKey: RoomKey): boolean { - const room = this.baseApis.getRoom(roomKey.roomId); - - // If the key is not for a known room, then something fishy is going on, - // so we reject the key out of caution. In practice, this is a bit moot - // because we'll only accept shared_history forwarded by the inviter, and - // we won't know who was the inviter for an unknown room, so we'll reject - // it anyway. - if (room && roomKey.extraSessionData.sharedHistory) { - return true; - } else { - return false; - } - } - - /** - * Check if a forwarded room key should be parked. - * - * A forwarded room key should be parked if it's a key for a room we're not - * in. We park the forwarded room key in case *this sender* invites us to - * that room later. - */ - private shouldParkForwardedKey(roomKey: RoomKey): boolean { - const room = this.baseApis.getRoom(roomKey.roomId); - - if (!room && roomKey.extraSessionData.sharedHistory) { - return true; - } else { - return false; - } - } - - /** - * Park the given room key to our store. - * - * @param event - An `m.forwarded_room_key` event. - * @param roomKey - The room key that was found in the event. - * - * @internal - * - */ - private async parkForwardedKey(event: MatrixEvent, roomKey: RoomKey): Promise { - const parkedData = { - senderId: event.getSender()!, - senderKey: roomKey.senderKey, - sessionId: roomKey.sessionId, - sessionKey: roomKey.sessionKey, - keysClaimed: roomKey.keysClaimed, - forwardingCurve25519KeyChain: roomKey.forwardingKeyChain, - }; - await this.crypto.cryptoStore.doTxn( - "readwrite", - ["parked_shared_history"], - (txn) => this.crypto.cryptoStore.addParkedSharedHistory(roomKey.roomId, parkedData, txn), - logger.getChild("[addParkedSharedHistory]"), - ); - } - - /** - * Add the given room key to our store. - * - * @param roomKey - The room key that should be added to the store. - * - * @internal - * - */ - private async addRoomKey(roomKey: RoomKey): Promise { - try { - await this.olmDevice.addInboundGroupSession( - roomKey.roomId, - roomKey.senderKey, - roomKey.forwardingKeyChain, - roomKey.sessionId, - roomKey.sessionKey, - roomKey.keysClaimed, - roomKey.exportFormat, - roomKey.extraSessionData, - ); - - // have another go at decrypting events sent with this session. - if (await this.retryDecryption(roomKey.senderKey, roomKey.sessionId, !roomKey.extraSessionData.untrusted)) { - // cancel any outstanding room key requests for this session. - // Only do this if we managed to decrypt every message in the - // session, because if we didn't, we leave the other key - // requests in the hopes that someone sends us a key that - // includes an earlier index. - this.crypto.cancelRoomKeyRequest({ - algorithm: roomKey.algorithm, - room_id: roomKey.roomId, - session_id: roomKey.sessionId, - sender_key: roomKey.senderKey, - }); - } - - // don't wait for the keys to be backed up for the server - await this.crypto.backupManager.backupGroupSession(roomKey.senderKey, roomKey.sessionId); - } catch (e) { - this.prefixedLogger.error(`Error handling m.room_key_event: ${e}`); - } - } - - /** - * Handle room keys that have been forwarded to us as an - * `m.forwarded_room_key` event. - * - * Forwarded room keys need special handling since we have no way of knowing - * who the original creator of the room key was. This naturally means that - * forwarded room keys are always untrusted and should only be accepted in - * some cases. - * - * @param event - An `m.forwarded_room_key` event. - * - * @internal - * - */ - private async onForwardedRoomKey(event: MatrixEvent): Promise { - const roomKey = this.forwardedRoomKeyFromEvent(event); - - if (!roomKey) { - return; - } - - if (await this.shouldAcceptForwardedKey(event, roomKey)) { - await this.addRoomKey(roomKey); - } else if (this.shouldParkForwardedKey(roomKey)) { - await this.parkForwardedKey(event, roomKey); - } - } - - public async onRoomKeyEvent(event: MatrixEvent): Promise { - if (event.getType() == "m.forwarded_room_key") { - await this.onForwardedRoomKey(event); - } else { - const roomKey = this.roomKeyFromEvent(event); - - if (!roomKey) { - return; - } - - await this.addRoomKey(roomKey); - } - } - - /** - * @param event - key event - */ - public async onRoomKeyWithheldEvent(event: MatrixEvent): Promise { - const content = event.getContent(); - const senderKey = content.sender_key; - - if (content.code === "m.no_olm") { - await this.onNoOlmWithheldEvent(event); - } else if (content.code === "m.unavailable") { - // this simply means that the other device didn't have the key, which isn't very useful information. Don't - // record it in the storage - } else { - await this.olmDevice.addInboundGroupSessionWithheld( - content.room_id, - senderKey, - content.session_id, - content.code, - content.reason, - ); - } - - // Having recorded the problem, retry decryption on any affected messages. - // It's unlikely we'll be able to decrypt sucessfully now, but this will - // update the error message. - // - if (content.session_id) { - await this.retryDecryption(senderKey, content.session_id); - } else { - // no_olm messages aren't specific to a given megolm session, so - // we trigger retrying decryption for all the messages from the sender's - // key, so that we can update the error message to indicate the olm - // session problem. - await this.retryDecryptionFromSender(senderKey); - } - } - - private async onNoOlmWithheldEvent(event: MatrixEvent): Promise { - const content = event.getContent(); - const senderKey = content.sender_key; - const sender = event.getSender()!; - this.prefixedLogger.warn(`${sender}:${senderKey} was unable to establish an olm session with us`); - // if the sender says that they haven't been able to establish an olm - // session, let's proactively establish one - - if (await this.olmDevice.getSessionIdForDevice(senderKey)) { - // a session has already been established, so we don't need to - // create a new one. - this.prefixedLogger.debug("New session already created. Not creating a new one."); - await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); - return; - } - let device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey); - if (!device) { - // if we don't know about the device, fetch the user's devices again - // and retry before giving up - await this.crypto.downloadKeys([sender], false); - device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey); - if (!device) { - this.prefixedLogger.info( - "Couldn't find device for identity key " + senderKey + ": not establishing session", - ); - await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false); - return; - } - } - - // XXX: switch this to use encryptAndSendToDevices() rather than duplicating it? - - await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[sender, [device]]]), false); - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this.userId, - undefined, - this.olmDevice, - sender, - device, - { type: "m.dummy" }, - ); - - await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); - - await this.baseApis.sendToDevice( - "m.room.encrypted", - new Map([[sender, new Map([[device.deviceId, encryptedContent]])]]), - ); - } - - public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise { - const body = keyRequest.requestBody; - - return this.olmDevice.hasInboundSessionKeys( - body.room_id, - body.sender_key, - body.session_id, - // TODO: ratchet index - ); - } - - public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { - const userId = keyRequest.userId; - const deviceId = keyRequest.deviceId; - const deviceInfo = this.crypto.getStoredDevice(userId, deviceId)!; - const body = keyRequest.requestBody; - - // XXX: switch this to use encryptAndSendToDevices()? - - this.olmlib - .ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[userId, [deviceInfo]]])) - .then((devicemap) => { - const olmSessionResult = devicemap.get(userId)?.get(deviceId); - if (!olmSessionResult?.sessionId) { - // no session with this device, probably because there - // were no one-time keys. - // - // ensureOlmSessionsForUsers has already done the logging, - // so just skip it. - return null; - } - - this.prefixedLogger.debug( - "sharing keys for session " + - body.sender_key + - "|" + - body.session_id + - " with device " + - userId + - ":" + - deviceId, - ); - - return this.buildKeyForwardingMessage(body.room_id, body.sender_key, body.session_id); - }) - .then((payload) => { - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - - return this.olmlib - .encryptMessageForDevice( - encryptedContent.ciphertext, - this.userId, - undefined, - this.olmDevice, - userId, - deviceInfo, - payload!, - ) - .then(() => { - // TODO: retries - return this.baseApis.sendToDevice( - "m.room.encrypted", - new Map([[userId, new Map([[deviceId, encryptedContent]])]]), - ); - }); - }); - } - - private async buildKeyForwardingMessage( - roomId: string, - senderKey: string, - sessionId: string, - ): Promise { - const key = await this.olmDevice.getInboundGroupSessionKey(roomId, senderKey, sessionId); - - return { - type: "m.forwarded_room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": roomId, - "sender_key": senderKey, - "sender_claimed_ed25519_key": key!.sender_claimed_ed25519_key!, - "session_id": sessionId, - "session_key": key!.key, - "chain_index": key!.chain_index, - "forwarding_curve25519_key_chain": key!.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": key!.shared_history || false, - }, - }; - } - - /** - * @param untrusted - whether the key should be considered as untrusted - * @param source - where the key came from - */ - public importRoomKey( - session: IMegolmSessionData, - { untrusted, source }: { untrusted?: boolean; source?: string } = {}, - ): Promise { - const extraSessionData: OlmGroupSessionExtraData = {}; - if (untrusted || session.untrusted) { - extraSessionData.untrusted = true; - } - if (session["org.matrix.msc3061.shared_history"]) { - extraSessionData.sharedHistory = true; - } - return this.olmDevice - .addInboundGroupSession( - session.room_id, - session.sender_key, - session.forwarding_curve25519_key_chain, - session.session_id, - session.session_key, - session.sender_claimed_keys, - true, - extraSessionData, - ) - .then(() => { - if (source !== "backup") { - // don't wait for it to complete - this.crypto.backupManager.backupGroupSession(session.sender_key, session.session_id).catch((e) => { - // This throws if the upload failed, but this is fine - // since it will have written it to the db and will retry. - this.prefixedLogger.debug("Failed to back up megolm session", e); - }); - } - // have another go at decrypting events sent with this session. - this.retryDecryption(session.sender_key, session.session_id, !extraSessionData.untrusted); - }); - } - - /** - * Have another go at decrypting events after we receive a key. Resolves once - * decryption has been re-attempted on all events. - * - * @internal - * @param forceRedecryptIfUntrusted - whether messages that were already - * successfully decrypted using untrusted keys should be re-decrypted - * - * @returns whether all messages were successfully - * decrypted with trusted keys - */ - private async retryDecryption( - senderKey: string, - sessionId: string, - forceRedecryptIfUntrusted?: boolean, - ): Promise { - const senderPendingEvents = this.pendingEvents.get(senderKey); - if (!senderPendingEvents) { - return true; - } - - const pending = senderPendingEvents.get(sessionId); - if (!pending) { - return true; - } - - const pendingList = [...pending]; - this.prefixedLogger.debug( - "Retrying decryption on events:", - pendingList.map((e) => `${e.getId()}`), - ); - - await Promise.all( - pendingList.map(async (ev) => { - try { - await ev.attemptDecryption(this.crypto, { isRetry: true, forceRedecryptIfUntrusted }); - } catch { - // don't die if something goes wrong - } - }), - ); - - // If decrypted successfully with trusted keys, they'll have - // been removed from pendingEvents - return !this.pendingEvents.get(senderKey)?.has(sessionId); - } - - public async retryDecryptionFromSender(senderKey: string): Promise { - const senderPendingEvents = this.pendingEvents.get(senderKey); - if (!senderPendingEvents) { - return true; - } - - this.pendingEvents.delete(senderKey); - - await Promise.all( - [...senderPendingEvents].map(async ([_sessionId, pending]) => { - await Promise.all( - [...pending].map(async (ev) => { - try { - await ev.attemptDecryption(this.crypto); - } catch { - // don't die if something goes wrong - } - }), - ); - }), - ); - - return !this.pendingEvents.has(senderKey); - } - - public async sendSharedHistoryInboundSessions(devicesByUser: Map): Promise { - await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser); - - const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId); - this.prefixedLogger.debug( - `Sharing history in with users ${Array.from(devicesByUser.keys())}`, - sharedHistorySessions.map(([senderKey, sessionId]) => `${senderKey}|${sessionId}`), - ); - for (const [senderKey, sessionId] of sharedHistorySessions) { - const payload = await this.buildKeyForwardingMessage(this.roomId, senderKey, sessionId); - - // FIXME: use encryptAndSendToDevices() rather than duplicating it here. - const promises: Promise[] = []; - const contentMap: Map> = new Map(); - for (const [userId, devices] of devicesByUser) { - const deviceMessages = new Map(); - contentMap.set(userId, deviceMessages); - for (const deviceInfo of devices) { - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - deviceMessages.set(deviceInfo.deviceId, encryptedContent); - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this.userId, - undefined, - this.olmDevice, - userId, - deviceInfo, - payload, - ), - ); - } - } - await Promise.all(promises); - - // prune out any devices that encryptMessageForDevice could not encrypt for, - // in which case it will have just not added anything to the ciphertext object. - // There's no point sending messages to devices if we couldn't encrypt to them, - // since that's effectively a blank message. - for (const [userId, deviceMessages] of contentMap) { - for (const [deviceId, content] of deviceMessages) { - if (!hasCiphertext(content)) { - this.prefixedLogger.debug("No ciphertext for device " + userId + ":" + deviceId + ": pruning"); - deviceMessages.delete(deviceId); - } - } - // No devices left for that user? Strip that too. - if (deviceMessages.size === 0) { - this.prefixedLogger.debug("Pruned all devices for user " + userId); - contentMap.delete(userId); - } - } - - // Is there anything left? - if (contentMap.size === 0) { - this.prefixedLogger.debug("No users left to send to: aborting"); - return; - } - - await this.baseApis.sendToDevice("m.room.encrypted", contentMap); - } - } -} - -const PROBLEM_DESCRIPTIONS = { - no_olm: "The sender was unable to establish a secure channel.", - unknown: "The secure channel with the sender was corrupted.", -}; - -registerAlgorithm(olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption); diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts deleted file mode 100644 index 60a8f28a3..000000000 --- a/src/crypto/algorithms/olm.ts +++ /dev/null @@ -1,381 +0,0 @@ -/* -Copyright 2016 - 2021 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. -*/ - -/** - * Defines m.olm encryption/decryption - */ - -import type { IEventDecryptionResult } from "../../@types/crypto.ts"; -import { logger } from "../../logger.ts"; -import * as olmlib from "../olmlib.ts"; -import { DeviceInfo } from "../deviceinfo.ts"; -import { DecryptionAlgorithm, EncryptionAlgorithm, registerAlgorithm } from "./base.ts"; -import { type Room } from "../../models/room.ts"; -import { type IContent, type MatrixEvent } from "../../models/event.ts"; -import { type IEncryptedContent, type IOlmEncryptedContent } from "../index.ts"; -import { type IInboundSession } from "../OlmDevice.ts"; -import { DecryptionFailureCode } from "../../crypto-api/index.ts"; -import { DecryptionError } from "../../common-crypto/CryptoBackend.ts"; - -const DeviceVerification = DeviceInfo.DeviceVerification; - -export interface IMessage { - type: number; - body: string; -} - -/** - * Olm encryption implementation - * - * @param params - parameters, as per {@link EncryptionAlgorithm} - */ -class OlmEncryption extends EncryptionAlgorithm { - private sessionPrepared = false; - private prepPromise: Promise | null = null; - - /** - * @internal - - * @param roomMembers - list of currently-joined users in the room - * @returns Promise which resolves when setup is complete - */ - private ensureSession(roomMembers: string[]): Promise { - if (this.prepPromise) { - // prep already in progress - return this.prepPromise; - } - - if (this.sessionPrepared) { - // prep already done - return Promise.resolve(); - } - - this.prepPromise = this.crypto - .downloadKeys(roomMembers) - .then(() => { - return this.crypto.ensureOlmSessionsForUsers(roomMembers); - }) - .then(() => { - this.sessionPrepared = true; - }) - .finally(() => { - this.prepPromise = null; - }); - - return this.prepPromise; - } - - /** - * @param content - plaintext event content - * - * @returns Promise which resolves to the new event body - */ - public async encryptMessage(room: Room, eventType: string, content: IContent): Promise { - // pick the list of recipients based on the membership list. - // - // TODO: there is a race condition here! What if a new user turns up - // just as you are sending a secret message? - - const members = await room.getEncryptionTargetMembers(); - - const users = members.map(function (u) { - return u.userId; - }); - - await this.ensureSession(users); - - const payloadFields = { - room_id: room.roomId, - type: eventType, - content: content, - }; - - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - }; - - const promises: Promise[] = []; - - for (const userId of users) { - const devices = this.crypto.getStoredDevicesForUser(userId) || []; - - for (const deviceInfo of devices) { - const key = deviceInfo.getIdentityKey(); - if (key == this.olmDevice.deviceCurve25519Key) { - // don't bother sending to ourself - continue; - } - if (deviceInfo.verified == DeviceVerification.BLOCKED) { - // don't bother setting up sessions with blocked users - continue; - } - - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this.userId, - this.deviceId, - this.olmDevice, - userId, - deviceInfo, - payloadFields, - ), - ); - } - } - - return Promise.all(promises).then(() => encryptedContent); - } -} - -/** - * Olm decryption implementation - * - * @param params - parameters, as per {@link DecryptionAlgorithm} - */ -class OlmDecryption extends DecryptionAlgorithm { - /** - * returns a promise which resolves to a - * {@link EventDecryptionResult} once we have finished - * decrypting. Rejects with an `algorithms.DecryptionError` if there is a - * problem decrypting the event. - */ - public async decryptEvent(event: MatrixEvent): Promise { - const content = event.getWireContent(); - const deviceKey = content.sender_key; - const ciphertext = content.ciphertext; - - if (!ciphertext) { - throw new DecryptionError(DecryptionFailureCode.OLM_MISSING_CIPHERTEXT, "Missing ciphertext"); - } - - if (!(this.olmDevice.deviceCurve25519Key! in ciphertext)) { - throw new DecryptionError( - DecryptionFailureCode.OLM_NOT_INCLUDED_IN_RECIPIENTS, - "Not included in recipients", - ); - } - const message = ciphertext[this.olmDevice.deviceCurve25519Key!]; - let payloadString: string; - - try { - payloadString = await this.decryptMessage(deviceKey, message); - } catch (e) { - throw new DecryptionError(DecryptionFailureCode.OLM_BAD_ENCRYPTED_MESSAGE, "Bad Encrypted Message", { - sender: deviceKey, - err: e as Error, - }); - } - - const payload = JSON.parse(payloadString); - - // check that we were the intended recipient, to avoid unknown-key attack - // https://github.com/vector-im/vector-web/issues/2483 - if (payload.recipient != this.userId) { - throw new DecryptionError( - DecryptionFailureCode.OLM_BAD_RECIPIENT, - "Message was intended for " + payload.recipient, - ); - } - - if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) { - throw new DecryptionError( - DecryptionFailureCode.OLM_BAD_RECIPIENT_KEY, - "Message not intended for this device", - { - intended: payload.recipient_keys.ed25519, - our_key: this.olmDevice.deviceEd25519Key!, - }, - ); - } - - // check that the device that encrypted the event belongs to the user that the event claims it's from. - // - // If the device is unknown then we check that we don't have any pending key-query requests for the sender. If - // after that the device is still unknown, then we can only assume that the device logged out and accept it - // anyway. Some event handlers, such as secret sharing, may be more strict and reject events that come from - // unknown devices. - // - // This is a defence against the following scenario: - // - // * Alice has verified Bob and Mallory. - // * Mallory gets control of Alice's server, and sends a megolm session to Alice using her (Mallory's) - // senderkey, but claiming to be from Bob. - // * Mallory sends more events using that session, claiming to be from Bob. - // * Alice sees that the senderkey is verified (since she verified Mallory) so marks events those events as - // verified even though the sender is forged. - // - // In practice, it's not clear that the js-sdk would behave that way, so this may be only a defence in depth. - - let senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey); - if (senderKeyUser === undefined || senderKeyUser === null) { - // Wait for any pending key query fetches for the user to complete before trying the lookup again. - try { - await this.crypto.deviceList.downloadKeys([event.getSender()!], false); - } catch (e) { - throw new DecryptionError( - DecryptionFailureCode.OLM_BAD_SENDER_CHECK_FAILED, - "Could not verify sender identity", - { - sender: deviceKey, - err: e as Error, - }, - ); - } - - senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey); - } - if (senderKeyUser !== event.getSender() && senderKeyUser !== undefined && senderKeyUser !== null) { - throw new DecryptionError( - DecryptionFailureCode.OLM_BAD_SENDER, - "Message claimed to be from " + event.getSender(), - { - real_sender: senderKeyUser, - }, - ); - } - - // check that the original sender matches what the homeserver told us, to - // avoid people masquerading as others. - // (this check is also provided via the sender's embedded ed25519 key, - // which is checked elsewhere). - if (payload.sender != event.getSender()) { - throw new DecryptionError( - DecryptionFailureCode.OLM_FORWARDED_MESSAGE, - "Message forwarded from " + payload.sender, - { - reported_sender: event.getSender()!, - }, - ); - } - - // Olm events intended for a room have a room_id. - if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError( - DecryptionFailureCode.OLM_BAD_ROOM, - "Message intended for room " + payload.room_id, - { - reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED", - }, - ); - } - - const claimedKeys = payload.keys || {}; - - return { - clearEvent: payload, - senderCurve25519Key: deviceKey, - claimedEd25519Key: claimedKeys.ed25519 || null, - }; - } - - /** - * Attempt to decrypt an Olm message - * - * @param theirDeviceIdentityKey - Curve25519 identity key of the sender - * @param message - message object, with 'type' and 'body' fields - * - * @returns payload, if decrypted successfully. - */ - private decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise { - // This is a wrapper that serialises decryptions of prekey messages, because - // otherwise we race between deciding we have no active sessions for the message - // and creating a new one, which we can only do once because it removes the OTK. - if (message.type !== 0) { - // not a prekey message: we can safely just try & decrypt it - return this.reallyDecryptMessage(theirDeviceIdentityKey, message); - } else { - const myPromise = this.olmDevice.olmPrekeyPromise.then(() => { - return this.reallyDecryptMessage(theirDeviceIdentityKey, message); - }); - // we want the error, but don't propagate it to the next decryption - this.olmDevice.olmPrekeyPromise = myPromise.catch(() => {}); - return myPromise; - } - } - - private async reallyDecryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise { - const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey); - - // try each session in turn. - const decryptionErrors: Record = {}; - for (const sessionId of sessionIds) { - try { - const payload = await this.olmDevice.decryptMessage( - theirDeviceIdentityKey, - sessionId, - message.type, - message.body, - ); - logger.log("Decrypted Olm message from " + theirDeviceIdentityKey + " with session " + sessionId); - return payload; - } catch (e) { - const foundSession = await this.olmDevice.matchesSession( - theirDeviceIdentityKey, - sessionId, - message.type, - message.body, - ); - - if (foundSession) { - // decryption failed, but it was a prekey message matching this - // session, so it should have worked. - throw new Error( - "Error decrypting prekey message with existing session id " + - sessionId + - ": " + - (e).message, - ); - } - - // otherwise it's probably a message for another session; carry on, but - // keep a record of the error - decryptionErrors[sessionId] = (e).message; - } - } - - if (message.type !== 0) { - // not a prekey message, so it should have matched an existing session, but it - // didn't work. - - if (sessionIds.length === 0) { - throw new Error("No existing sessions"); - } - - throw new Error( - "Error decrypting non-prekey message with existing sessions: " + JSON.stringify(decryptionErrors), - ); - } - - // prekey message which doesn't match any existing sessions: make a new - // session. - - let res: IInboundSession; - try { - res = await this.olmDevice.createInboundSession(theirDeviceIdentityKey, message.type, message.body); - } catch (e) { - decryptionErrors["(new)"] = (e).message; - throw new Error("Error decrypting prekey message: " + JSON.stringify(decryptionErrors)); - } - - logger.log("created new inbound Olm session ID " + res.session_id + " with " + theirDeviceIdentityKey); - return res.payload; - } -} - -registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); diff --git a/src/crypto/api.ts b/src/crypto/api.ts deleted file mode 100644 index b1fd010e3..000000000 --- a/src/crypto/api.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* -Copyright 2021 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 DeviceInfo } from "./deviceinfo.ts"; - -/* re-exports for backwards compatibility. */ -// CrossSigningKey is used as a value in `client.ts`, we can't export it as a type -export { CrossSigningKey } from "../crypto-api/index.ts"; -export type { - GeneratedSecretStorageKey as IRecoveryKey, - CreateSecretStorageOpts as ICreateSecretStorageOpts, -} from "../crypto-api/index.ts"; - -export type { - ImportRoomKeyProgressData as IImportOpts, - ImportRoomKeysOpts as IImportRoomKeysOpts, -} from "../crypto-api/index.ts"; - -export type { - AddSecretStorageKeyOpts as IAddSecretStorageKeyOpts, - PassphraseInfo as IPassphraseInfo, - SecretStorageKeyDescription as ISecretStorageKeyInfo, -} from "../secret-storage.ts"; - -// TODO: Merge this with crypto.js once converted - -export interface IEncryptedEventInfo { - /** - * whether the event is encrypted (if not encrypted, some of the other properties may not be set) - */ - encrypted: boolean; - - /** - * the sender's key - */ - senderKey: string; - - /** - * the algorithm used to encrypt the event - */ - algorithm: string; - - /** - * whether we can be sure that the owner of the senderKey sent the event - */ - authenticated: boolean; - - /** - * the sender's device information, if available - */ - sender?: DeviceInfo; - - /** - * if the event's ed25519 and curve25519 keys don't match (only meaningful if `sender` is set) - */ - mismatchedSender: boolean; -} diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts deleted file mode 100644 index c328c2c4f..000000000 --- a/src/crypto/backup.ts +++ /dev/null @@ -1,922 +0,0 @@ -/* -Copyright 2021 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. -*/ - -/** - * Classes for dealing with key backup. - */ - -import type { IMegolmSessionData } from "../@types/crypto.ts"; -import { MatrixClient } from "../client.ts"; -import { logger } from "../logger.ts"; -import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib.ts"; -import { type DeviceInfo } from "./deviceinfo.ts"; -import { type DeviceTrustLevel } from "./CrossSigning.ts"; -import { keyFromPassphrase } from "./key_passphrase.ts"; -import { encodeUri, safeSet, sleep } from "../utils.ts"; -import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store.ts"; -import { - type Curve25519SessionData, - type IAes256AuthData, - type ICurve25519AuthData, - type IKeyBackupInfo, - type IKeyBackupSession, -} from "./keybackup.ts"; -import { UnstableValue } from "../NamespacedValue.ts"; -import { CryptoEvent } from "./index.ts"; -import { ClientPrefix, type HTTPError, MatrixError, Method } from "../http-api/index.ts"; -import { type BackupTrustInfo } from "../crypto-api/keybackup.ts"; -import { type BackupDecryptor } from "../common-crypto/CryptoBackend.ts"; -import { encodeRecoveryKey } from "../crypto-api/index.ts"; -import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts"; -import encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts"; -import { type AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts"; -import { calculateKeyCheck } from "../secret-storage.ts"; - -const KEY_BACKUP_KEYS_PER_REQUEST = 200; -const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms - -type AuthData = IKeyBackupInfo["auth_data"]; - -type SigInfo = { - deviceId: string; - valid?: boolean | null; // true: valid, false: invalid, null: cannot attempt validation - device?: DeviceInfo | null; - crossSigningId?: boolean; - deviceTrust?: DeviceTrustLevel; -}; - -/** @deprecated Prefer {@link BackupTrustInfo} */ -export type TrustInfo = { - usable: boolean; // is the backup trusted, true iff there is a sig that is valid & from a trusted device - sigs: SigInfo[]; - // eslint-disable-next-line camelcase - trusted_locally?: boolean; -}; - -export interface IKeyBackupCheck { - backupInfo?: IKeyBackupInfo; - trustInfo: TrustInfo; -} - -/* eslint-disable camelcase */ -export interface IPreparedKeyBackupVersion { - algorithm: string; - auth_data: AuthData; - recovery_key: string; - privateKey: Uint8Array; -} -/* eslint-enable camelcase */ - -/** A function used to get the secret key for a backup. - */ -type GetKey = () => Promise>; - -interface BackupAlgorithmClass { - algorithmName: string; - // initialize from an existing backup - init(authData: AuthData, getKey: GetKey): Promise; - - // prepare a brand new backup - prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]>; - - checkBackupVersion(info: IKeyBackupInfo): void; -} - -interface BackupAlgorithm { - untrusted: boolean; - encryptSession(data: Record): Promise; - decryptSessions(ciphertexts: Record): Promise; - authData: AuthData; - keyMatches(key: ArrayLike): Promise; - free(): void; -} - -export interface IKeyBackup { - rooms: { - [roomId: string]: { - sessions: { - [sessionId: string]: IKeyBackupSession; - }; - }; - }; -} - -/** - * Manages the key backup. - */ -export class BackupManager { - private algorithm: BackupAlgorithm | undefined; - public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version - public checkedForBackup: boolean; // Have we checked the server for a backup we can use? - private sendingBackups: boolean; // Are we currently sending backups? - private sessionLastCheckAttemptedTime: Record = {}; // When did we last try to check the server for a given session id? - // The backup manager will schedule backup of keys when active (`scheduleKeyBackupSend`), this allows cancel when client is stopped - private clientRunning = true; - - public constructor( - private readonly baseApis: MatrixClient, - public readonly getKey: GetKey, - ) { - this.checkedForBackup = false; - this.sendingBackups = false; - } - - /** - * Stop the backup manager from backing up keys and allow a clean shutdown. - */ - public stop(): void { - this.clientRunning = false; - } - - public get version(): string | undefined { - return this.backupInfo && this.backupInfo.version; - } - - /** - * Performs a quick check to ensure that the backup info looks sane. - * - * Throws an error if a problem is detected. - * - * @param info - the key backup info - */ - public static checkBackupVersion(info: IKeyBackupInfo): void { - const Algorithm = algorithmsByName[info.algorithm]; - if (!Algorithm) { - throw new Error("Unknown backup algorithm: " + info.algorithm); - } - if (typeof info.auth_data !== "object") { - throw new Error("Invalid backup data returned"); - } - return Algorithm.checkBackupVersion(info); - } - - public static makeAlgorithm(info: IKeyBackupInfo, getKey: GetKey): Promise { - const Algorithm = algorithmsByName[info.algorithm]; - if (!Algorithm) { - throw new Error("Unknown backup algorithm"); - } - return Algorithm.init(info.auth_data, getKey); - } - - public async enableKeyBackup(info: IKeyBackupInfo): Promise { - this.backupInfo = info; - if (this.algorithm) { - this.algorithm.free(); - } - - this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey); - - this.baseApis.emit(CryptoEvent.KeyBackupStatus, true); - - // There may be keys left over from a partially completed backup, so - // schedule a send to check. - this.scheduleKeyBackupSend(); - } - - /** - * Disable backing up of keys. - */ - public disableKeyBackup(): void { - if (this.algorithm) { - this.algorithm.free(); - } - this.algorithm = undefined; - - this.backupInfo = undefined; - - this.baseApis.emit(CryptoEvent.KeyBackupStatus, false); - } - - public getKeyBackupEnabled(): boolean | null { - if (!this.checkedForBackup) { - return null; - } - return Boolean(this.algorithm); - } - - public async prepareKeyBackupVersion( - key?: string | Uint8Array | null, - algorithm?: string | undefined, - ): Promise { - const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm; - if (!Algorithm) { - throw new Error("Unknown backup algorithm"); - } - - const [privateKey, authData] = await Algorithm.prepare(key); - const recoveryKey = encodeRecoveryKey(privateKey)!; - return { - algorithm: Algorithm.algorithmName, - auth_data: authData, - recovery_key: recoveryKey, - privateKey, - }; - } - - public async createKeyBackupVersion(info: IKeyBackupInfo): Promise { - this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey); - } - - /** - * Deletes all key backups. - * - * Will call the API to delete active backup until there is no more present. - */ - public async deleteAllKeyBackupVersions(): Promise { - // there could be several backup versions, delete all to be safe. - let current = (await this.baseApis.getKeyBackupVersion())?.version ?? null; - while (current != null) { - await this.deleteKeyBackupVersion(current); - this.disableKeyBackup(); - current = (await this.baseApis.getKeyBackupVersion())?.version ?? null; - } - } - - /** - * Deletes the given key backup. - * - * @param version - The backup version to delete. - */ - public async deleteKeyBackupVersion(version: string): Promise { - const path = encodeUri("/room_keys/version/$version", { $version: version }); - await this.baseApis.http.authedRequest(Method.Delete, path, undefined, undefined, { - prefix: ClientPrefix.V3, - }); - } - - /** - * Check the server for an active key backup and - * if one is present and has a valid signature from - * one of the user's verified devices, start backing up - * to it. - */ - public async checkAndStart(): Promise { - logger.log("Checking key backup status..."); - if (this.baseApis.isGuest()) { - logger.log("Skipping key backup check since user is guest"); - this.checkedForBackup = true; - return null; - } - let backupInfo: IKeyBackupInfo | undefined; - try { - backupInfo = (await this.baseApis.getKeyBackupVersion()) ?? undefined; - } catch (e) { - logger.log("Error checking for active key backup", e); - if ((e).httpStatus === 404) { - // 404 is returned when the key backup does not exist, so that - // counts as successfully checking. - this.checkedForBackup = true; - } - return null; - } - this.checkedForBackup = true; - - const trustInfo = await this.isKeyBackupTrusted(backupInfo); - - if (trustInfo.usable && !this.backupInfo) { - logger.log(`Found usable key backup v${backupInfo!.version}: enabling key backups`); - await this.enableKeyBackup(backupInfo!); - } else if (!trustInfo.usable && this.backupInfo) { - logger.log("No usable key backup: disabling key backup"); - this.disableKeyBackup(); - } else if (!trustInfo.usable && !this.backupInfo) { - logger.log("No usable key backup: not enabling key backup"); - } else if (trustInfo.usable && this.backupInfo) { - // may not be the same version: if not, we should switch - if (backupInfo!.version !== this.backupInfo.version) { - logger.log( - `On backup version ${this.backupInfo.version} but ` + - `found version ${backupInfo!.version}: switching.`, - ); - this.disableKeyBackup(); - await this.enableKeyBackup(backupInfo!); - // We're now using a new backup, so schedule all the keys we have to be - // uploaded to the new backup. This is a bit of a workaround to upload - // keys to a new backup in *most* cases, but it won't cover all cases - // because we don't remember what backup version we uploaded keys to: - // see https://github.com/vector-im/element-web/issues/14833 - await this.scheduleAllGroupSessionsForBackup(); - } else { - logger.log(`Backup version ${backupInfo!.version} still current`); - } - } - - return { backupInfo, trustInfo }; - } - - /** - * Forces a re-check of the key backup and enables/disables it - * as appropriate. - * - * @returns Object with backup info (as returned by - * getKeyBackupVersion) in backupInfo and - * trust information (as returned by isKeyBackupTrusted) - * in trustInfo. - */ - public async checkKeyBackup(): Promise { - this.checkedForBackup = false; - return this.checkAndStart(); - } - - /** - * Attempts to retrieve a session from a key backup, if enough time - * has elapsed since the last check for this session id. - */ - public async queryKeyBackupRateLimited( - targetRoomId: string | undefined, - targetSessionId: string | undefined, - ): Promise { - if (!this.backupInfo) { - return; - } - - const now = new Date().getTime(); - if ( - !this.sessionLastCheckAttemptedTime[targetSessionId!] || - now - this.sessionLastCheckAttemptedTime[targetSessionId!] > KEY_BACKUP_CHECK_RATE_LIMIT - ) { - this.sessionLastCheckAttemptedTime[targetSessionId!] = now; - await this.baseApis.restoreKeyBackupWithCache(targetRoomId!, targetSessionId!, this.backupInfo, {}); - } - } - - /** - * Check if the given backup info is trusted. - * - * @param backupInfo - key backup info dict from /room_keys/version - */ - public async isKeyBackupTrusted(backupInfo?: IKeyBackupInfo): Promise { - const ret = { - usable: false, - trusted_locally: false, - sigs: [] as SigInfo[], - }; - - if (!backupInfo || !backupInfo.algorithm || !backupInfo.auth_data || !backupInfo.auth_data.signatures) { - logger.info(`Key backup is absent or missing required data: ${JSON.stringify(backupInfo)}`); - return ret; - } - - const userId = this.baseApis.getUserId()!; - const privKey = await this.baseApis.crypto!.getSessionBackupPrivateKey(); - if (privKey) { - let algorithm: BackupAlgorithm | null = null; - try { - algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => privKey); - - if (await algorithm.keyMatches(privKey)) { - logger.info("Backup is trusted locally"); - ret.trusted_locally = true; - } - } catch { - // do nothing -- if we have an error, then we don't mark it as - // locally trusted - } finally { - algorithm?.free(); - } - } - - const mySigs = backupInfo.auth_data.signatures[userId] || {}; - - for (const keyId of Object.keys(mySigs)) { - const keyIdParts = keyId.split(":"); - if (keyIdParts[0] !== "ed25519") { - logger.log("Ignoring unknown signature type: " + keyIdParts[0]); - continue; - } - // Could be a cross-signing master key, but just say this is the device - // ID for backwards compat - const sigInfo: SigInfo = { deviceId: keyIdParts[1] }; - - // first check to see if it's from our cross-signing key - const crossSigningId = this.baseApis.crypto!.crossSigningInfo.getId(); - if (crossSigningId === sigInfo.deviceId) { - sigInfo.crossSigningId = true; - try { - await verifySignature( - this.baseApis.crypto!.olmDevice, - backupInfo.auth_data, - userId, - sigInfo.deviceId, - crossSigningId, - ); - sigInfo.valid = true; - } catch (e) { - logger.warn("Bad signature from cross signing key " + crossSigningId, e); - sigInfo.valid = false; - } - ret.sigs.push(sigInfo); - continue; - } - - // Now look for a sig from a device - // At some point this can probably go away and we'll just support - // it being signed by the cross-signing master key - const device = this.baseApis.crypto!.deviceList.getStoredDevice(userId, sigInfo.deviceId); - if (device) { - sigInfo.device = device; - sigInfo.deviceTrust = this.baseApis.checkDeviceTrust(userId, sigInfo.deviceId); - try { - await verifySignature( - this.baseApis.crypto!.olmDevice, - backupInfo.auth_data, - userId, - device.deviceId, - device.getFingerprint(), - ); - sigInfo.valid = true; - } catch (e) { - logger.info( - "Bad signature from key ID " + - keyId + - " userID " + - this.baseApis.getUserId() + - " device ID " + - device.deviceId + - " fingerprint: " + - device.getFingerprint(), - backupInfo.auth_data, - e, - ); - sigInfo.valid = false; - } - } else { - sigInfo.valid = null; // Can't determine validity because we don't have the signing device - logger.info("Ignoring signature from unknown key " + keyId); - } - ret.sigs.push(sigInfo); - } - - ret.usable = ret.sigs.some((s) => { - return s.valid && ((s.device && s.deviceTrust?.isVerified()) || s.crossSigningId); - }); - return ret; - } - - /** - * Schedules sending all keys waiting to be sent to the backup, if not already - * scheduled. Retries if necessary. - * - * @param maxDelay - Maximum delay to wait in ms. 0 means no delay. - */ - public async scheduleKeyBackupSend(maxDelay = 10000): Promise { - logger.debug(`Key backup: scheduleKeyBackupSend currentSending:${this.sendingBackups} delay:${maxDelay}`); - if (this.sendingBackups) return; - - this.sendingBackups = true; - - try { - // wait between 0 and `maxDelay` seconds, to avoid backup - // requests from different clients hitting the server all at - // the same time when a new key is sent - const delay = Math.random() * maxDelay; - await sleep(delay); - if (!this.clientRunning) { - this.sendingBackups = false; - return; - } - let numFailures = 0; // number of consecutive failures - for (;;) { - if (!this.algorithm) { - return; - } - try { - const numBackedUp = await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST); - if (numBackedUp === 0) { - // no sessions left needing backup: we're done - this.sendingBackups = false; - return; - } - numFailures = 0; - } catch (err) { - numFailures++; - logger.log("Key backup request failed", err); - if (err instanceof MatrixError) { - const errCode = err.data.errcode; - if (errCode == "M_NOT_FOUND" || errCode == "M_WRONG_ROOM_KEYS_VERSION") { - // Set to false now as `checkKeyBackup` might schedule a backupsend before this one ends. - this.sendingBackups = false; - // Backup version has changed or this backup version - // has been deleted - this.baseApis.crypto!.emit(CryptoEvent.KeyBackupFailed, errCode); - // Re-check key backup status on error, so we can be - // sure to present the current situation when asked. - // This call might restart the backup loop if new backup version is trusted - await this.checkKeyBackup(); - return; - } - } - } - if (numFailures) { - // exponential backoff if we have failures - await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4))); - } - - if (!this.clientRunning) { - logger.debug("Key backup send loop aborted, client stopped"); - this.sendingBackups = false; - return; - } - } - } catch (err) { - // No one actually checks errors on this promise, it's spawned internally. - // Just log, apps/client should use events to check status - logger.log(`Backup loop failed ${err}`); - this.sendingBackups = false; - } - } - - /** - * Take some e2e keys waiting to be backed up and send them - * to the backup. - * - * @param limit - Maximum number of keys to back up - * @returns Number of sessions backed up - */ - public async backupPendingKeys(limit: number): Promise { - const sessions = await this.baseApis.crypto!.cryptoStore.getSessionsNeedingBackup(limit); - if (!sessions.length) { - return 0; - } - - let remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup(); - this.baseApis.crypto!.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining); - - const rooms: IKeyBackup["rooms"] = {}; - for (const session of sessions) { - const roomId = session.sessionData!.room_id; - safeSet(rooms, roomId, rooms[roomId] || { sessions: {} }); - - const sessionData = this.baseApis.crypto!.olmDevice.exportInboundGroupSession( - session.senderKey, - session.sessionId, - session.sessionData!, - ); - sessionData.algorithm = MEGOLM_ALGORITHM; - - const forwardedCount = (sessionData.forwarding_curve25519_key_chain || []).length; - - const userId = this.baseApis.crypto!.deviceList.getUserByIdentityKey(MEGOLM_ALGORITHM, session.senderKey); - const device = - this.baseApis.crypto!.deviceList.getDeviceByIdentityKey(MEGOLM_ALGORITHM, session.senderKey) ?? - undefined; - const verified = this.baseApis.crypto!.checkDeviceInfoTrust(userId!, device).isVerified(); - - safeSet(rooms[roomId]["sessions"], session.sessionId, { - first_message_index: sessionData.first_known_index, - forwarded_count: forwardedCount, - is_verified: verified, - session_data: await this.algorithm!.encryptSession(sessionData), - }); - } - - await this.baseApis.sendKeyBackup(undefined, undefined, this.backupInfo!.version, { rooms }); - - await this.baseApis.crypto!.cryptoStore.unmarkSessionsNeedingBackup(sessions); - remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup(); - this.baseApis.crypto!.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining); - - return sessions.length; - } - - public async backupGroupSession(senderKey: string, sessionId: string): Promise { - await this.baseApis.crypto!.cryptoStore.markSessionsNeedingBackup([ - { - senderKey: senderKey, - sessionId: sessionId, - }, - ]); - - if (this.backupInfo) { - // don't wait for this to complete: it will delay so - // happens in the background - this.scheduleKeyBackupSend(); - } - // if this.backupInfo is not set, then the keys will be backed up when - // this.enableKeyBackup is called - } - - /** - * Marks all group sessions as needing to be backed up and schedules them to - * upload in the background as soon as possible. - */ - public async scheduleAllGroupSessionsForBackup(): Promise { - await this.flagAllGroupSessionsForBackup(); - - // Schedule keys to upload in the background as soon as possible. - this.scheduleKeyBackupSend(0 /* maxDelay */); - } - - /** - * Marks all group sessions as needing to be backed up without scheduling - * them to upload in the background. - * @returns Promise which resolves to the number of sessions now requiring a backup - * (which will be equal to the number of sessions in the store). - */ - public async flagAllGroupSessionsForBackup(): Promise { - await this.baseApis.crypto!.cryptoStore.doTxn( - "readwrite", - [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP], - (txn) => { - this.baseApis.crypto!.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => { - if (session !== null) { - this.baseApis.crypto!.cryptoStore.markSessionsNeedingBackup([session], txn); - } - }); - }, - ); - - const remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup(); - this.baseApis.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining); - return remaining; - } - - /** - * Counts the number of end to end session keys that are waiting to be backed up - * @returns Promise which resolves to the number of sessions requiring backup - */ - public countSessionsNeedingBackup(): Promise { - return this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup(); - } -} - -export class Curve25519 implements BackupAlgorithm { - public static algorithmName = "m.megolm_backup.v1.curve25519-aes-sha2"; - - public constructor( - public authData: ICurve25519AuthData, - private publicKey: any, // FIXME: PkEncryption - private getKey: () => Promise, - ) {} - - public static async init(authData: AuthData, getKey: () => Promise): Promise { - if (!authData || !("public_key" in authData)) { - throw new Error("auth_data missing required information"); - } - const publicKey = new globalThis.Olm.PkEncryption(); - publicKey.set_recipient_key(authData.public_key); - return new Curve25519(authData as ICurve25519AuthData, publicKey, getKey); - } - - public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> { - const decryption = new globalThis.Olm.PkDecryption(); - try { - const authData: Partial = {}; - if (!key) { - authData.public_key = decryption.generate_key(); - } else if (key instanceof Uint8Array) { - authData.public_key = decryption.init_with_private_key(key); - } else { - const derivation = await keyFromPassphrase(key); - authData.private_key_salt = derivation.salt; - authData.private_key_iterations = derivation.iterations; - authData.public_key = decryption.init_with_private_key(derivation.key); - } - const publicKey = new globalThis.Olm.PkEncryption(); - publicKey.set_recipient_key(authData.public_key); - - return [decryption.get_private_key(), authData as AuthData]; - } finally { - decryption.free(); - } - } - - public static checkBackupVersion(info: IKeyBackupInfo): void { - if (!("public_key" in info.auth_data)) { - throw new Error("Invalid backup data returned"); - } - } - - public get untrusted(): boolean { - return true; - } - - public async encryptSession(data: Record): Promise { - const plainText: Record = Object.assign({}, data); - delete plainText.session_id; - delete plainText.room_id; - delete plainText.first_known_index; - return this.publicKey.encrypt(JSON.stringify(plainText)); - } - - public async decryptSessions( - sessions: Record>, - ): Promise { - const privKey = await this.getKey(); - const decryption = new globalThis.Olm.PkDecryption(); - try { - const backupPubKey = decryption.init_with_private_key(privKey); - - if (backupPubKey !== this.authData.public_key) { - throw new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY }); - } - - const keys: IMegolmSessionData[] = []; - - for (const [sessionId, sessionData] of Object.entries(sessions)) { - try { - const decrypted = JSON.parse( - decryption.decrypt( - sessionData.session_data.ephemeral, - sessionData.session_data.mac, - sessionData.session_data.ciphertext, - ), - ); - decrypted.session_id = sessionId; - keys.push(decrypted); - } catch (e) { - logger.log("Failed to decrypt megolm session from backup", e, sessionData); - } - } - return keys; - } finally { - decryption.free(); - } - } - - public async keyMatches(key: Uint8Array): Promise { - const decryption = new globalThis.Olm.PkDecryption(); - let pubKey: string; - try { - pubKey = decryption.init_with_private_key(key); - } finally { - decryption.free(); - } - - return pubKey === this.authData.public_key; - } - - public free(): void { - this.publicKey.free(); - } -} - -function randomBytes(size: number): Uint8Array { - const buf = new Uint8Array(size); - globalThis.crypto.getRandomValues(buf); - return buf; -} - -const UNSTABLE_MSC3270_NAME = new UnstableValue( - "m.megolm_backup.v1.aes-hmac-sha2", - "org.matrix.msc3270.v1.aes-hmac-sha2", -); - -export class Aes256 implements BackupAlgorithm { - public static algorithmName = UNSTABLE_MSC3270_NAME.name; - - public constructor( - public readonly authData: IAes256AuthData, - private readonly key: Uint8Array, - ) {} - - public static async init(authData: IAes256AuthData, getKey: () => Promise): Promise { - if (!authData) { - throw new Error("auth_data missing"); - } - const key = await getKey(); - if (authData.mac) { - const { mac } = await calculateKeyCheck(key, authData.iv); - if (authData.mac.replace(/=+$/g, "") !== mac.replace(/=+/g, "")) { - throw new Error("Key does not match"); - } - } - return new Aes256(authData, key); - } - - public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> { - let outKey: Uint8Array; - const authData: Partial = {}; - if (!key) { - outKey = randomBytes(32); - } else if (key instanceof Uint8Array) { - outKey = new Uint8Array(key); - } else { - const derivation = await keyFromPassphrase(key); - authData.private_key_salt = derivation.salt; - authData.private_key_iterations = derivation.iterations; - outKey = derivation.key; - } - - const { iv, mac } = await calculateKeyCheck(outKey); - authData.iv = iv; - authData.mac = mac; - - return [outKey, authData as AuthData]; - } - - public static checkBackupVersion(info: IKeyBackupInfo): void { - if (!("iv" in info.auth_data && "mac" in info.auth_data)) { - throw new Error("Invalid backup data returned"); - } - } - - public get untrusted(): boolean { - return false; - } - - public encryptSession(data: Record): Promise { - const plainText: Record = Object.assign({}, data); - delete plainText.session_id; - delete plainText.room_id; - delete plainText.first_known_index; - return encryptAESSecretStorageItem(JSON.stringify(plainText), this.key, data.session_id); - } - - public async decryptSessions( - sessions: Record>, - ): Promise { - const keys: IMegolmSessionData[] = []; - - for (const [sessionId, sessionData] of Object.entries(sessions)) { - try { - const decrypted = JSON.parse( - await decryptAESSecretStorageItem(sessionData.session_data, this.key, sessionId), - ); - decrypted.session_id = sessionId; - keys.push(decrypted); - } catch (e) { - logger.log("Failed to decrypt megolm session from backup", e, sessionData); - } - } - return keys; - } - - public async keyMatches(key: Uint8Array): Promise { - if (this.authData.mac) { - const { mac } = await calculateKeyCheck(key, this.authData.iv); - return this.authData.mac.replace(/=+$/g, "") === mac.replace(/=+/g, ""); - } else { - // if we have no information, we have to assume the key is right - return true; - } - } - - public free(): void { - this.key.fill(0); - } -} - -export const algorithmsByName: Record = { - [Curve25519.algorithmName]: Curve25519, - [Aes256.algorithmName]: Aes256, -}; - -// the linter doesn't like this but knip does -// eslint-disable-next-line tsdoc/syntax -/** @alias */ -export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519; - -/** - * Map a legacy {@link TrustInfo} into a new-style {@link BackupTrustInfo}. - * - * @param trustInfo - trustInfo to convert - */ -export function backupTrustInfoFromLegacyTrustInfo(trustInfo: TrustInfo): BackupTrustInfo { - return { - trusted: trustInfo.usable, - matchesDecryptionKey: trustInfo.trusted_locally ?? false, - }; -} - -/** - * Implementation of {@link BackupDecryptor} for the libolm crypto backend. - */ -export class LibOlmBackupDecryptor implements BackupDecryptor { - private algorithm: BackupAlgorithm; - public readonly sourceTrusted: boolean; - - public constructor(algorithm: BackupAlgorithm) { - this.algorithm = algorithm; - this.sourceTrusted = !algorithm.untrusted; - } - - /** - * Implements {@link BackupDecryptor#free} - */ - public free(): void { - this.algorithm.free(); - } - - /** - * Implements {@link BackupDecryptor#decryptSessions} - */ - public async decryptSessions( - sessions: Record>, - ): Promise { - return await this.algorithm.decryptSessions(sessions); - } -} diff --git a/src/crypto/crypto.ts b/src/crypto/crypto.ts deleted file mode 100644 index 4aea59de5..000000000 --- a/src/crypto/crypto.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* -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. -*/ - -/** @deprecated this is a no-op and should no longer be called. */ -export function setCrypto(_crypto: Crypto): void {} diff --git a/src/crypto/dehydration.ts b/src/crypto/dehydration.ts deleted file mode 100644 index a995611f7..000000000 --- a/src/crypto/dehydration.ts +++ /dev/null @@ -1,272 +0,0 @@ -/* -Copyright 2020-2021 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 anotherjson from "another-json"; - -import type { IDeviceKeys, IOneTimeKey } from "../@types/crypto.ts"; -import { decodeBase64, encodeBase64 } from "../base64.ts"; -import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store.ts"; -import { logger } from "../logger.ts"; -import { type Crypto } from "./index.ts"; -import { Method } from "../http-api/index.ts"; -import { type SecretStorageKeyDescription } from "../secret-storage.ts"; -import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts"; -import encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts"; - -export interface IDehydratedDevice { - device_id?: string; // eslint-disable-line camelcase - device_data?: SecretStorageKeyDescription & { - // eslint-disable-line camelcase - algorithm: string; - account: string; // pickle - }; -} - -export interface IDehydratedDeviceKeyInfo { - passphrase?: string; -} - -export const DEHYDRATION_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle"; - -const oneweek = 7 * 24 * 60 * 60 * 1000; - -export class DehydrationManager { - private inProgress = false; - private timeoutId: any; - private key?: Uint8Array; - private keyInfo?: { [props: string]: any }; - private deviceDisplayName?: string; - - public constructor(private readonly crypto: Crypto) { - this.getDehydrationKeyFromCache(); - } - - public getDehydrationKeyFromCache(): Promise { - return this.crypto.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.crypto.cryptoStore.getSecretStorePrivateKey( - txn, - async (result) => { - if (result) { - const { key, keyInfo, deviceDisplayName, time } = result; - const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); - const decrypted = await decryptAESSecretStorageItem(key, pickleKey, DEHYDRATION_ALGORITHM); - this.key = decodeBase64(decrypted); - this.keyInfo = keyInfo; - this.deviceDisplayName = deviceDisplayName; - const now = Date.now(); - const delay = Math.max(1, time + oneweek - now); - this.timeoutId = globalThis.setTimeout(this.dehydrateDevice.bind(this), delay); - } - }, - "dehydration", - ); - }); - } - - /** set the key, and queue periodic dehydration to the server in the background */ - public async setKeyAndQueueDehydration( - key: Uint8Array, - keyInfo: { [props: string]: any } = {}, - deviceDisplayName?: string, - ): Promise { - const matches = await this.setKey(key, keyInfo, deviceDisplayName); - if (!matches) { - // start dehydration in the background - this.dehydrateDevice(); - } - } - - public async setKey( - key?: Uint8Array, - keyInfo: { [props: string]: any } = {}, - deviceDisplayName?: string, - ): Promise { - if (!key) { - // unsetting the key -- cancel any pending dehydration task - if (this.timeoutId) { - globalThis.clearTimeout(this.timeoutId); - this.timeoutId = undefined; - } - // clear storage - await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", null); - }); - this.key = undefined; - this.keyInfo = undefined; - return; - } - - // Check to see if it's the same key as before. If it's different, - // dehydrate a new device. If it's the same, we can keep the same - // device. (Assume that keyInfo and deviceDisplayName will be the - // same if the key is the same.) - let matches: boolean = !!this.key && key.length == this.key.length; - for (let i = 0; matches && i < key.length; i++) { - if (key[i] != this.key![i]) { - matches = false; - } - } - if (!matches) { - this.key = key; - this.keyInfo = keyInfo; - this.deviceDisplayName = deviceDisplayName; - } - return matches; - } - - /** returns the device id of the newly created dehydrated device */ - public async dehydrateDevice(): Promise { - if (this.inProgress) { - logger.log("Dehydration already in progress -- not starting new dehydration"); - return; - } - this.inProgress = true; - if (this.timeoutId) { - globalThis.clearTimeout(this.timeoutId); - this.timeoutId = undefined; - } - try { - const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey); - - // update the crypto store with the timestamp - const key = await encryptAESSecretStorageItem(encodeBase64(this.key!), pickleKey, DEHYDRATION_ALGORITHM); - await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", { - keyInfo: this.keyInfo, - key, - deviceDisplayName: this.deviceDisplayName!, - time: Date.now(), - }); - }); - logger.log("Attempting to dehydrate device"); - - logger.log("Creating account"); - // create the account and all the necessary keys - const account = new globalThis.Olm.Account(); - account.create(); - const e2eKeys = JSON.parse(account.identity_keys()); - - const maxKeys = account.max_number_of_one_time_keys(); - // FIXME: generate in small batches? - account.generate_one_time_keys(maxKeys / 2); - account.generate_fallback_key(); - const otks: Record = JSON.parse(account.one_time_keys()); - const fallbacks: Record = JSON.parse(account.fallback_key()); - account.mark_keys_as_published(); - - // dehydrate the account and store it on the server - const pickledAccount = account.pickle(new Uint8Array(this.key!)); - - const deviceData: { [props: string]: any } = { - algorithm: DEHYDRATION_ALGORITHM, - account: pickledAccount, - }; - if (this.keyInfo!.passphrase) { - deviceData.passphrase = this.keyInfo!.passphrase; - } - - logger.log("Uploading account to server"); - // eslint-disable-next-line camelcase - const dehydrateResult = await this.crypto.baseApis.http.authedRequest<{ device_id: string }>( - Method.Put, - "/dehydrated_device", - undefined, - { - device_data: deviceData, - initial_device_display_name: this.deviceDisplayName, - }, - { - prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2", - }, - ); - - // send the keys to the server - const deviceId = dehydrateResult.device_id; - logger.log("Preparing device keys", deviceId); - const deviceKeys: IDeviceKeys = { - algorithms: this.crypto.supportedAlgorithms, - device_id: deviceId, - user_id: this.crypto.userId, - keys: { - [`ed25519:${deviceId}`]: e2eKeys.ed25519, - [`curve25519:${deviceId}`]: e2eKeys.curve25519, - }, - }; - const deviceSignature = account.sign(anotherjson.stringify(deviceKeys)); - deviceKeys.signatures = { - [this.crypto.userId]: { - [`ed25519:${deviceId}`]: deviceSignature, - }, - }; - if (this.crypto.crossSigningInfo.getId("self_signing")) { - await this.crypto.crossSigningInfo.signObject(deviceKeys, "self_signing"); - } - - logger.log("Preparing one-time keys"); - const oneTimeKeys: Record = {}; - for (const [keyId, key] of Object.entries(otks.curve25519)) { - const k: IOneTimeKey = { key }; - const signature = account.sign(anotherjson.stringify(k)); - k.signatures = { - [this.crypto.userId]: { - [`ed25519:${deviceId}`]: signature, - }, - }; - oneTimeKeys[`signed_curve25519:${keyId}`] = k; - } - - logger.log("Preparing fallback keys"); - const fallbackKeys: Record = {}; - for (const [keyId, key] of Object.entries(fallbacks.curve25519)) { - const k: IOneTimeKey = { key, fallback: true }; - const signature = account.sign(anotherjson.stringify(k)); - k.signatures = { - [this.crypto.userId]: { - [`ed25519:${deviceId}`]: signature, - }, - }; - fallbackKeys[`signed_curve25519:${keyId}`] = k; - } - - logger.log("Uploading keys to server"); - await this.crypto.baseApis.http.authedRequest( - Method.Post, - "/keys/upload/" + encodeURI(deviceId), - undefined, - { - "device_keys": deviceKeys, - "one_time_keys": oneTimeKeys, - "org.matrix.msc2732.fallback_keys": fallbackKeys, - }, - ); - logger.log("Done dehydrating"); - - // dehydrate again in a week - this.timeoutId = globalThis.setTimeout(this.dehydrateDevice.bind(this), oneweek); - - return deviceId; - } finally { - this.inProgress = false; - } - } - - public stop(): void { - if (this.timeoutId) { - globalThis.clearTimeout(this.timeoutId); - this.timeoutId = undefined; - } - } -} diff --git a/src/crypto/device-converter.ts b/src/crypto/device-converter.ts deleted file mode 100644 index c1ffe42c6..000000000 --- a/src/crypto/device-converter.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* -Copyright 2023 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 { Device } from "../models/device.ts"; -import { type DeviceInfo } from "./deviceinfo.ts"; - -/** - * Convert a {@link DeviceInfo} to a {@link Device}. - * @param deviceInfo - deviceInfo to convert - * @param userId - id of the user that owns the device. - */ -export function deviceInfoToDevice(deviceInfo: DeviceInfo, userId: string): Device { - const keys = new Map(Object.entries(deviceInfo.keys)); - const displayName = deviceInfo.getDisplayName() || undefined; - - const signatures = new Map>(); - if (deviceInfo.signatures) { - for (const userId in deviceInfo.signatures) { - signatures.set(userId, new Map(Object.entries(deviceInfo.signatures[userId]))); - } - } - - return new Device({ - deviceId: deviceInfo.deviceId, - userId: userId, - keys, - algorithms: deviceInfo.algorithms, - verified: deviceInfo.verified, - signatures, - displayName, - }); -} diff --git a/src/crypto/deviceinfo.ts b/src/crypto/deviceinfo.ts deleted file mode 100644 index 9e130c674..000000000 --- a/src/crypto/deviceinfo.ts +++ /dev/null @@ -1,158 +0,0 @@ -/* -Copyright 2016 - 2021 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 ISignatures } from "../@types/signed.ts"; -import { DeviceVerification } from "../models/device.ts"; - -export interface IDevice { - keys: Record; - algorithms: string[]; - verified: DeviceVerification; - known: boolean; - unsigned?: Record; - signatures?: ISignatures; -} - -/** - * Information about a user's device - * - * Superceded by {@link Device}. - */ -export class DeviceInfo { - /** - * rehydrate a DeviceInfo from the session store - * - * @param obj - raw object from session store - * @param deviceId - id of the device - * - * @returns new DeviceInfo - */ - public static fromStorage(obj: Partial, deviceId: string): DeviceInfo { - const res = new DeviceInfo(deviceId); - for (const prop in obj) { - if (obj.hasOwnProperty(prop)) { - // @ts-ignore - this is messy and typescript doesn't like it - res[prop as keyof IDevice] = obj[prop as keyof IDevice]; - } - } - return res; - } - - public static DeviceVerification = { - VERIFIED: DeviceVerification.Verified, - UNVERIFIED: DeviceVerification.Unverified, - BLOCKED: DeviceVerification.Blocked, - }; - - /** list of algorithms supported by this device */ - public algorithms: string[] = []; - /** a map from `: -> ` */ - public keys: Record = {}; - /** whether the device has been verified/blocked by the user */ - public verified = DeviceVerification.Unverified; - /** - * whether the user knows of this device's existence - * (useful when warning the user that a user has added new devices) - */ - public known = false; - /** additional data from the homeserver */ - public unsigned: Record = {}; - public signatures: ISignatures = {}; - - /** - * @param deviceId - id of the device - */ - public constructor(public readonly deviceId: string) {} - - /** - * Prepare a DeviceInfo for JSON serialisation in the session store - * - * @returns deviceinfo with non-serialised members removed - */ - public toStorage(): IDevice { - return { - algorithms: this.algorithms, - keys: this.keys, - verified: this.verified, - known: this.known, - unsigned: this.unsigned, - signatures: this.signatures, - }; - } - - /** - * Get the fingerprint for this device (ie, the Ed25519 key) - * - * @returns base64-encoded fingerprint of this device - */ - public getFingerprint(): string { - return this.keys["ed25519:" + this.deviceId]; - } - - /** - * Get the identity key for this device (ie, the Curve25519 key) - * - * @returns base64-encoded identity key of this device - */ - public getIdentityKey(): string { - return this.keys["curve25519:" + this.deviceId]; - } - - /** - * Get the configured display name for this device, if any - * - * @returns displayname - */ - public getDisplayName(): string | null { - return this.unsigned.device_display_name || null; - } - - /** - * Returns true if this device is blocked - * - * @returns true if blocked - */ - public isBlocked(): boolean { - return this.verified == DeviceVerification.Blocked; - } - - /** - * Returns true if this device is verified - * - * @returns true if verified - */ - public isVerified(): boolean { - return this.verified == DeviceVerification.Verified; - } - - /** - * Returns true if this device is unverified - * - * @returns true if unverified - */ - public isUnverified(): boolean { - return this.verified == DeviceVerification.Unverified; - } - - /** - * Returns true if the user knows about this device's existence - * - * @returns true if known - */ - public isKnown(): boolean { - return this.known === true; - } -} diff --git a/src/crypto/index.ts b/src/crypto/index.ts deleted file mode 100644 index dd499d730..000000000 --- a/src/crypto/index.ts +++ /dev/null @@ -1,4438 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018-2019 New Vector Ltd -Copyright 2019-2021 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 anotherjson from "another-json"; -import { v4 as uuidv4 } from "uuid"; - -import type { IDeviceKeys, IEventDecryptionResult, IMegolmSessionData, IOneTimeKey } from "../@types/crypto.ts"; -import type { PkDecryption, PkSigning } from "@matrix-org/olm"; -import { EventType, ToDeviceMessageId } from "../@types/event.ts"; -import { TypedReEmitter } from "../ReEmitter.ts"; -import { logger } from "../logger.ts"; -import { type IExportedDevice, OlmDevice } from "./OlmDevice.ts"; -import { type IOlmDevice } from "./algorithms/megolm.ts"; -import * as olmlib from "./olmlib.ts"; -import { type DeviceInfoMap, DeviceList } from "./DeviceList.ts"; -import { DeviceInfo, type IDevice } from "./deviceinfo.ts"; -import type { DecryptionAlgorithm, EncryptionAlgorithm } from "./algorithms/index.ts"; -import * as algorithms from "./algorithms/index.ts"; -import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./CrossSigning.ts"; -import { EncryptionSetupBuilder } from "./EncryptionSetup.ts"; -import { SecretStorage as LegacySecretStorage } from "./SecretStorage.ts"; -import { CrossSigningKey, type ICreateSecretStorageOpts, type IEncryptedEventInfo, type IRecoveryKey } from "./api.ts"; -import { OutgoingRoomKeyRequestManager } from "./OutgoingRoomKeyRequestManager.ts"; -import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store.ts"; -import { type VerificationBase } from "./verification/Base.ts"; -import { ReciprocateQRCode, SCAN_QR_CODE_METHOD, SHOW_QR_CODE_METHOD } from "./verification/QRCode.ts"; -import { SAS as SASVerification } from "./verification/SAS.ts"; -import { keyFromPassphrase } from "./key_passphrase.ts"; -import { VerificationRequest } from "./verification/request/VerificationRequest.ts"; -import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel.ts"; -import { type Request, ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel.ts"; -import { IllegalMethod } from "./verification/IllegalMethod.ts"; -import { KeySignatureUploadError } from "../errors.ts"; -import { DehydrationManager } from "./dehydration.ts"; -import { BackupManager, LibOlmBackupDecryptor, backupTrustInfoFromLegacyTrustInfo } from "./backup.ts"; -import { type IStore } from "../store/index.ts"; -import { type Room, RoomEvent } from "../models/room.ts"; -import { type RoomMember, RoomMemberEvent } from "../models/room-member.ts"; -import { EventStatus, type IContent, type IEvent, MatrixEvent, MatrixEventEvent } from "../models/event.ts"; -import { type ToDeviceBatch, type ToDevicePayload } from "../models/ToDeviceMessage.ts"; -import { - ClientEvent, - type IKeysUploadResponse, - type ISignedKey, - type IUploadKeySignaturesResponse, - MatrixClient, -} from "../client.ts"; -import { type IRoomEncryption, RoomList } from "./RoomList.ts"; -import { type IKeyBackupInfo } from "./keybackup.ts"; -import { type ISyncStateData } from "../sync.ts"; -import { type CryptoStore } from "./store/base.ts"; -import { type IVerificationChannel } from "./verification/request/Channel.ts"; -import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; -import { type IDeviceLists, type ISyncResponse, type IToDeviceEvent } from "../sync-accumulator.ts"; -import { type ISignatures } from "../@types/signed.ts"; -import { type IMessage } from "./algorithms/olm.ts"; -import { - type BackupDecryptor, - type CryptoBackend, - DecryptionError, - type OnSyncCompletedData, -} from "../common-crypto/CryptoBackend.ts"; -import { type RoomState, RoomStateEvent } from "../models/room-state.ts"; -import { MapWithDefault, recursiveMapToObject } from "../utils.ts"; -import { - type AccountDataClient, - type AddSecretStorageKeyOpts, - calculateKeyCheck, - SECRET_STORAGE_ALGORITHM_V1_AES, - type SecretStorageKey, - type SecretStorageKeyDescription, - type SecretStorageKeyObject, - type SecretStorageKeyTuple, - ServerSideSecretStorageImpl, -} from "../secret-storage.ts"; -import { type ISecretRequest } from "./SecretSharing.ts"; -import { - type BackupTrustInfo, - type BootstrapCrossSigningOpts, - type CrossSigningKeyInfo, - type CrossSigningStatus, - decodeRecoveryKey, - DecryptionFailureCode, - type DeviceIsolationMode, - type DeviceVerificationStatus, - encodeRecoveryKey, - type EventEncryptionInfo, - EventShieldColour, - EventShieldReason, - type ImportRoomKeysOpts, - type KeyBackupCheck, - type KeyBackupInfo, - type OwnDeviceKeys, - CryptoEvent as CryptoApiCryptoEvent, - type CryptoEventHandlerMap as CryptoApiCryptoEventHandlerMap, - type KeyBackupRestoreResult, - type KeyBackupRestoreOpts, - type StartDehydrationOpts, -} from "../crypto-api/index.ts"; -import { type Device, type DeviceMap } from "../models/device.ts"; -import { deviceInfoToDevice } from "./device-converter.ts"; -import { ClientPrefix, MatrixError, Method } from "../http-api/index.ts"; -import { decodeBase64, encodeBase64 } from "../base64.ts"; -import { KnownMembership } from "../@types/membership.ts"; -import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts"; -import encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts"; -import { type AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts"; - -/* re-exports for backwards compatibility */ -export type { - BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts, - CryptoCallbacks as ICryptoCallbacks, -} from "../crypto-api/index.ts"; - -const DeviceVerification = DeviceInfo.DeviceVerification; - -const defaultVerificationMethods = { - [ReciprocateQRCode.NAME]: ReciprocateQRCode, - [SASVerification.NAME]: SASVerification, - - // These two can't be used for actual verification, but we do - // need to be able to define them here for the verification flows - // to start. - [SHOW_QR_CODE_METHOD]: IllegalMethod, - [SCAN_QR_CODE_METHOD]: IllegalMethod, -} as const; - -/** - * verification method names - */ -// legacy export identifier -export const verificationMethods = { - RECIPROCATE_QR_CODE: ReciprocateQRCode.NAME, - SAS: SASVerification.NAME, -} as const; - -export type VerificationMethod = keyof typeof verificationMethods | string; - -export function isCryptoAvailable(): boolean { - return Boolean(globalThis.Olm); -} - -// minimum time between attempting to unwedge an Olm session, if we succeeded -// in creating a new session -const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; // 1 hour -// minimum time between attempting to unwedge an Olm session, if we failed -// to create a new session -const FORCE_SESSION_RETRY_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes - -interface IInitOpts { - exportedOlmDevice?: IExportedDevice; - pickleKey?: string; -} - -/* eslint-disable camelcase */ -interface IRoomKey { - room_id: string; - algorithm: string; -} - -/** - * The parameters of a room key request. The details of the request may - * vary with the crypto algorithm, but the management and storage layers for - * outgoing requests expect it to have 'room_id' and 'session_id' properties. - */ -export interface IRoomKeyRequestBody extends IRoomKey { - session_id: string; - sender_key: string; -} - -/* eslint-enable camelcase */ - -interface IDeviceVerificationUpgrade { - devices: DeviceInfo[]; - crossSigningInfo: CrossSigningInfo; -} - -export interface ICheckOwnCrossSigningTrustOpts { - allowPrivateKeyRequests?: boolean; -} - -interface IUserOlmSession { - deviceIdKey: string; - sessions: { - sessionId: string; - hasReceivedMessage: boolean; - }[]; -} - -export interface IRoomKeyRequestRecipient { - userId: string; - deviceId: string; -} - -interface ISignableObject { - signatures?: ISignatures; - unsigned?: object; -} - -export interface IRequestsMap { - getRequest(event: MatrixEvent): VerificationRequest | undefined; - getRequestByChannel(channel: IVerificationChannel): VerificationRequest | undefined; - setRequest(event: MatrixEvent, request: VerificationRequest): void; - setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void; -} - -/* eslint-disable camelcase */ -export interface IOlmEncryptedContent { - algorithm: typeof olmlib.OLM_ALGORITHM; - sender_key: string; - ciphertext: Record; - [ToDeviceMessageId]?: string; -} - -export interface IMegolmEncryptedContent { - algorithm: typeof olmlib.MEGOLM_ALGORITHM; - sender_key: string; - session_id: string; - device_id: string; - ciphertext: string; - [ToDeviceMessageId]?: string; -} -/* eslint-enable camelcase */ - -export type IEncryptedContent = IOlmEncryptedContent | IMegolmEncryptedContent; - -export enum CryptoEvent { - /** @deprecated Event not fired by the rust crypto */ - DeviceVerificationChanged = "deviceVerificationChanged", - UserTrustStatusChanged = CryptoApiCryptoEvent.UserTrustStatusChanged, - /** @deprecated Event not fired by the rust crypto */ - UserCrossSigningUpdated = "userCrossSigningUpdated", - /** @deprecated Event not fired by the rust crypto */ - RoomKeyRequest = "crypto.roomKeyRequest", - /** @deprecated Event not fired by the rust crypto */ - RoomKeyRequestCancellation = "crypto.roomKeyRequestCancellation", - KeyBackupStatus = CryptoApiCryptoEvent.KeyBackupStatus, - KeyBackupFailed = CryptoApiCryptoEvent.KeyBackupFailed, - KeyBackupSessionsRemaining = CryptoApiCryptoEvent.KeyBackupSessionsRemaining, - - /** - * Fires when a new valid backup decryption key is in cache. - * This will happen when a secret is received from another session, from secret storage, - * or when a new backup is created from this session. - * - * The payload is the version of the backup for which we have the key for. - * - * This event is only fired by the rust crypto backend. - */ - KeyBackupDecryptionKeyCached = CryptoApiCryptoEvent.KeyBackupDecryptionKeyCached, - - /** @deprecated Event not fired by the rust crypto */ - KeySignatureUploadFailure = "crypto.keySignatureUploadFailure", - /** @deprecated Use `VerificationRequestReceived`. */ - VerificationRequest = "crypto.verification.request", - - /** - * Fires when a key verification request is received. - * - * The payload is a {@link Crypto.VerificationRequest}. - */ - VerificationRequestReceived = CryptoApiCryptoEvent.VerificationRequestReceived, - - /** @deprecated Event not fired by the rust crypto */ - Warning = "crypto.warning", - /** @deprecated Use {@link DevicesUpdated} instead when using rust crypto */ - WillUpdateDevices = CryptoApiCryptoEvent.WillUpdateDevices, - DevicesUpdated = CryptoApiCryptoEvent.DevicesUpdated, - KeysChanged = CryptoApiCryptoEvent.KeysChanged, - - /** - * Fires when data is being migrated from legacy crypto to rust crypto. - * - * The payload is a pair `(progress, total)`, where `progress` is the number of steps completed so far, and - * `total` is the total number of steps. When migration is complete, a final instance of the event is emitted, with - * `progress === total === -1`. - */ - LegacyCryptoStoreMigrationProgress = CryptoApiCryptoEvent.LegacyCryptoStoreMigrationProgress, -} - -export type CryptoEventHandlerMap = CryptoApiCryptoEventHandlerMap & { - /** - * Fires when a device is marked as verified/unverified/blocked/unblocked by - * {@link MatrixClient#setDeviceVerified | MatrixClient.setDeviceVerified} or - * {@link MatrixClient#setDeviceBlocked | MatrixClient.setDeviceBlocked}. - * - * @param userId - the owner of the verified device - * @param deviceId - the id of the verified device - * @param deviceInfo - updated device information - */ - [CryptoEvent.DeviceVerificationChanged]: (userId: string, deviceId: string, deviceInfo: DeviceInfo) => void; - /** - * Fires when we receive a room key request - * - * @param request - request details - */ - [CryptoEvent.RoomKeyRequest]: (request: IncomingRoomKeyRequest) => void; - /** - * Fires when we receive a room key request cancellation - */ - [CryptoEvent.RoomKeyRequestCancellation]: (request: IncomingRoomKeyRequestCancellation) => void; - [CryptoEvent.KeySignatureUploadFailure]: ( - failures: IUploadKeySignaturesResponse["failures"], - source: "checkOwnCrossSigningTrust" | "afterCrossSigningLocalKeyChange" | "setDeviceVerification", - upload: (opts: { shouldEmit: boolean }) => Promise, - ) => void; - /** - * Fires when a key verification is requested. - * - * Deprecated: use `CryptoEvent.VerificationRequestReceived`. - */ - [CryptoEvent.VerificationRequest]: (request: VerificationRequest) => void; - /** - * Fires when the app may wish to warn the user about something related - * the end-to-end crypto. - * - * @param type - One of the strings listed above - */ - [CryptoEvent.Warning]: (type: string) => void; - [CryptoEvent.UserCrossSigningUpdated]: (userId: string) => void; -}; - -export class Crypto extends TypedEventEmitter implements CryptoBackend { - /** - * @returns The version of Olm. - */ - public static getOlmVersion(): [number, number, number] { - return OlmDevice.getOlmVersion(); - } - - public readonly backupManager: BackupManager; - public readonly crossSigningInfo: CrossSigningInfo; - public readonly olmDevice: OlmDevice; - public readonly deviceList: DeviceList; - public readonly dehydrationManager: DehydrationManager; - public readonly secretStorage: LegacySecretStorage; - - private readonly roomList: RoomList; - private readonly reEmitter: TypedReEmitter; - private readonly verificationMethods: Map; - public readonly supportedAlgorithms: string[]; - private readonly outgoingRoomKeyRequestManager: OutgoingRoomKeyRequestManager; - private readonly toDeviceVerificationRequests: ToDeviceRequests; - public readonly inRoomVerificationRequests: InRoomRequests; - - private trustCrossSignedDevices = true; - // the last time we did a check for the number of one-time-keys on the server. - private lastOneTimeKeyCheck: number | null = null; - private oneTimeKeyCheckInProgress = false; - - // EncryptionAlgorithm instance for each room - private roomEncryptors = new Map(); - // map from algorithm to DecryptionAlgorithm instance, for each room - private roomDecryptors = new Map>(); - - private deviceKeys: Record = {}; // type: key - - public globalBlacklistUnverifiedDevices = false; - public globalErrorOnUnknownDevices = true; - - // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations - // we received in the current sync. - private receivedRoomKeyRequests: IncomingRoomKeyRequest[] = []; - private receivedRoomKeyRequestCancellations: IncomingRoomKeyRequestCancellation[] = []; - // true if we are currently processing received room key requests - private processingRoomKeyRequests = false; - // controls whether device tracking is delayed - // until calling encryptEvent or trackRoomDevices, - // or done immediately upon enabling room encryption. - private lazyLoadMembers = false; - // in case lazyLoadMembers is true, - // track if an initial tracking of all the room members - // has happened for a given room. This is delayed - // to avoid loading room members as long as possible. - private roomDeviceTrackingState: { [roomId: string]: Promise } = {}; - - // The timestamp of the minimum time at which we will retry forcing establishment - // of a new session for each device, in milliseconds. - // { - // userId: { - // deviceId: 1234567890000, - // }, - // } - // Map: user Id → device Id → timestamp - private forceNewSessionRetryTime: MapWithDefault> = new MapWithDefault( - () => new MapWithDefault(() => 0), - ); - - // This flag will be unset whilst the client processes a sync response - // so that we don't start requesting keys until we've actually finished - // processing the response. - private sendKeyRequestsImmediately = false; - - private oneTimeKeyCount?: number; - private needsNewFallback?: boolean; - private fallbackCleanup?: ReturnType; - - /** - * Cryptography bits - * - * This module is internal to the js-sdk; the public API is via MatrixClient. - * - * @internal - * - * @param baseApis - base matrix api interface - * - * @param userId - The user ID for the local user - * - * @param deviceId - The identifier for this device. - * - * @param clientStore - the MatrixClient data store. - * - * @param cryptoStore - storage for the crypto layer. - * - * @param verificationMethods - Array of verification methods to use. - * Each element can either be a string from MatrixClient.verificationMethods - * or a class that implements a verification method. - */ - public constructor( - public readonly baseApis: MatrixClient, - public readonly userId: string, - private readonly deviceId: string, - private readonly clientStore: IStore, - public readonly cryptoStore: CryptoStore, - verificationMethods: Array, - ) { - super(); - - logger.debug("Crypto: initialising roomlist..."); - this.roomList = new RoomList(cryptoStore); - - this.reEmitter = new TypedReEmitter(this); - - if (verificationMethods) { - this.verificationMethods = new Map(); - for (const method of verificationMethods) { - if (typeof method === "string") { - if (defaultVerificationMethods[method]) { - this.verificationMethods.set( - method, - defaultVerificationMethods[method], - ); - } - } else if (method["NAME"]) { - this.verificationMethods.set(method["NAME"], method as typeof VerificationBase); - } else { - logger.warn(`Excluding unknown verification method ${method}`); - } - } - } else { - this.verificationMethods = new Map(Object.entries(defaultVerificationMethods)) as Map< - VerificationMethod, - typeof VerificationBase - >; - } - - this.backupManager = new BackupManager(baseApis, async () => { - // try to get key from cache - const cachedKey = await this.getSessionBackupPrivateKey(); - if (cachedKey) { - return cachedKey; - } - - // try to get key from secret storage - const storedKey = await this.secretStorage.get("m.megolm_backup.v1"); - - if (storedKey) { - // ensure that the key is in the right format. If not, fix the key and - // store the fixed version - const fixedKey = fixBackupKey(storedKey); - if (fixedKey) { - const keys = await this.secretStorage.getKey(); - await this.secretStorage.store("m.megolm_backup.v1", fixedKey, [keys![0]]); - } - - return decodeBase64(fixedKey || storedKey); - } - - // try to get key from app - if (this.baseApis.cryptoCallbacks && this.baseApis.cryptoCallbacks.getBackupKey) { - return this.baseApis.cryptoCallbacks.getBackupKey(); - } - - throw new Error("Unable to get private key"); - }); - - this.olmDevice = new OlmDevice(cryptoStore); - this.deviceList = new DeviceList(baseApis, cryptoStore, this.olmDevice); - - // XXX: This isn't removed at any point, but then none of the event listeners - // this class sets seem to be removed at any point... :/ - this.deviceList.on(CryptoEvent.UserCrossSigningUpdated, this.onDeviceListUserCrossSigningUpdated); - this.reEmitter.reEmit(this.deviceList, [CryptoEvent.DevicesUpdated, CryptoEvent.WillUpdateDevices]); - - this.supportedAlgorithms = Array.from(algorithms.DECRYPTION_CLASSES.keys()); - - this.outgoingRoomKeyRequestManager = new OutgoingRoomKeyRequestManager( - baseApis, - this.deviceId, - this.cryptoStore, - ); - - this.toDeviceVerificationRequests = new ToDeviceRequests(); - this.inRoomVerificationRequests = new InRoomRequests(); - - const cryptoCallbacks = this.baseApis.cryptoCallbacks || {}; - const cacheCallbacks = createCryptoStoreCacheCallbacks(cryptoStore, this.olmDevice); - - this.crossSigningInfo = new CrossSigningInfo(userId, cryptoCallbacks, cacheCallbacks); - // Yes, we pass the client twice here: see SecretStorage - this.secretStorage = new LegacySecretStorage(baseApis as AccountDataClient, cryptoCallbacks, baseApis); - this.dehydrationManager = new DehydrationManager(this); - - // Assuming no app-supplied callback, default to getting from SSSS. - if (!cryptoCallbacks.getCrossSigningKey && cryptoCallbacks.getSecretStorageKey) { - cryptoCallbacks.getCrossSigningKey = async (type): Promise => { - return CrossSigningInfo.getFromSecretStorage(type, this.secretStorage); - }; - } - } - - /** - * Initialise the crypto module so that it is ready for use - * - * Returns a promise which resolves once the crypto module is ready for use. - * - * @param exportedOlmDevice - (Optional) data from exported device - * that must be re-created. - */ - public async init({ exportedOlmDevice, pickleKey }: IInitOpts = {}): Promise { - logger.log("Crypto: initialising Olm..."); - await globalThis.Olm.init(); - logger.log( - exportedOlmDevice - ? "Crypto: initialising Olm device from exported device..." - : "Crypto: initialising Olm device...", - ); - await this.olmDevice.init({ fromExportedDevice: exportedOlmDevice, pickleKey }); - logger.log("Crypto: loading device list..."); - await this.deviceList.load(); - - // build our device keys: these will later be uploaded - this.deviceKeys["ed25519:" + this.deviceId] = this.olmDevice.deviceEd25519Key!; - this.deviceKeys["curve25519:" + this.deviceId] = this.olmDevice.deviceCurve25519Key!; - - logger.log("Crypto: fetching own devices..."); - let myDevices = this.deviceList.getRawStoredDevicesForUser(this.userId); - - if (!myDevices) { - myDevices = {}; - } - - if (!myDevices[this.deviceId]) { - // add our own deviceinfo to the cryptoStore - logger.log("Crypto: adding this device to the store..."); - const deviceInfo = { - keys: this.deviceKeys, - algorithms: this.supportedAlgorithms, - verified: DeviceVerification.VERIFIED, - known: true, - }; - - myDevices[this.deviceId] = deviceInfo; - this.deviceList.storeDevicesForUser(this.userId, myDevices); - this.deviceList.saveIfDirty(); - } - - await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.cryptoStore.getCrossSigningKeys(txn, (keys) => { - // can be an empty object after resetting cross-signing keys, see storeTrustedSelfKeys - if (keys && Object.keys(keys).length !== 0) { - logger.log("Loaded cross-signing public keys from crypto store"); - this.crossSigningInfo.setKeys(keys); - } - }); - }); - // make sure we are keeping track of our own devices - // (this is important for key backups & things) - this.deviceList.startTrackingDeviceList(this.userId); - - logger.debug("Crypto: initialising roomlist..."); - await this.roomList.init(); - - logger.log("Crypto: checking for key backup..."); - this.backupManager.checkAndStart(); - } - - /** - * Implementation of {@link Crypto.CryptoApi#setDeviceIsolationMode}. - */ - public setDeviceIsolationMode(isolationMode: DeviceIsolationMode): void { - throw new Error("Not supported"); - } - /** - * Implementation of {@link Crypto.CryptoApi#getVersion}. - */ - public getVersion(): string { - const olmVersionTuple = Crypto.getOlmVersion(); - return `Olm ${olmVersionTuple[0]}.${olmVersionTuple[1]}.${olmVersionTuple[2]}`; - } - - /** - * Whether to trust a others users signatures of their devices. - * If false, devices will only be considered 'verified' if we have - * verified that device individually (effectively disabling cross-signing). - * - * Default: true - * - * @returns True if trusting cross-signed devices - */ - public getTrustCrossSignedDevices(): boolean { - return this.trustCrossSignedDevices; - } - - /** - * @deprecated Use {@link Crypto.CryptoApi#getTrustCrossSignedDevices}. - */ - public getCryptoTrustCrossSignedDevices(): boolean { - return this.trustCrossSignedDevices; - } - - /** - * See getCryptoTrustCrossSignedDevices - * - * @param val - True to trust cross-signed devices - */ - public setTrustCrossSignedDevices(val: boolean): void { - this.trustCrossSignedDevices = val; - - for (const userId of this.deviceList.getKnownUserIds()) { - const devices = this.deviceList.getRawStoredDevicesForUser(userId); - for (const deviceId of Object.keys(devices)) { - const deviceTrust = this.checkDeviceTrust(userId, deviceId); - // If the device is locally verified then isVerified() is always true, - // so this will only have caused the value to change if the device is - // cross-signing verified but not locally verified - if (!deviceTrust.isLocallyVerified() && deviceTrust.isCrossSigningVerified()) { - const deviceObj = this.deviceList.getStoredDevice(userId, deviceId)!; - this.emit(CryptoEvent.DeviceVerificationChanged, userId, deviceId, deviceObj); - } - } - } - } - - /** - * @deprecated Use {@link Crypto.CryptoApi#setTrustCrossSignedDevices}. - */ - public setCryptoTrustCrossSignedDevices(val: boolean): void { - this.setTrustCrossSignedDevices(val); - } - - /** - * Create a recovery key from a user-supplied passphrase. - * - * @param password - Passphrase string that can be entered by the user - * when restoring the backup as an alternative to entering the recovery key. - * Optional. - * @returns Object with public key metadata, encoded private - * recovery key which should be disposed of after displaying to the user, - * and raw private key to avoid round tripping if needed. - */ - public async createRecoveryKeyFromPassphrase(password?: string): Promise { - const decryption = new globalThis.Olm.PkDecryption(); - try { - if (password) { - const derivation = await keyFromPassphrase(password); - - decryption.init_with_private_key(derivation.key); - const privateKey = decryption.get_private_key(); - return { - keyInfo: { - passphrase: { - algorithm: "m.pbkdf2", - iterations: derivation.iterations, - salt: derivation.salt, - }, - }, - privateKey: privateKey, - encodedPrivateKey: encodeRecoveryKey(privateKey), - }; - } else { - decryption.generate_key(); - const privateKey = decryption.get_private_key(); - return { - privateKey: privateKey, - encodedPrivateKey: encodeRecoveryKey(privateKey), - }; - } - } finally { - decryption?.free(); - } - } - - /** - * 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. - * - * @internal - */ - public async userHasCrossSigningKeys(userId = this.userId): Promise { - await this.downloadKeys([userId]); - return this.deviceList.getStoredCrossSigningForUser(userId) !== null; - } - - /** - * Checks whether cross signing: - * - is enabled on this account and trusted by this device - * - has private keys either cached locally or stored in secret storage - * - * If this function returns false, bootstrapCrossSigning() can be used - * to fix things such that it returns true. That is to say, after - * bootstrapCrossSigning() completes successfully, this function should - * return true. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @returns True if cross-signing is ready to be used on this device - */ - public async isCrossSigningReady(): Promise { - const publicKeysOnDevice = this.crossSigningInfo.getId(); - const privateKeysExistSomewhere = - (await this.crossSigningInfo.isStoredInKeyCache()) || - (await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage)); - - return !!(publicKeysOnDevice && privateKeysExistSomewhere); - } - - /** - * Checks whether secret storage: - * - is enabled on this account - * - is storing cross-signing private keys - * - is storing session backup key (if enabled) - * - * If this function returns false, bootstrapSecretStorage() can be used - * to fix things such that it returns true. That is to say, after - * bootstrapSecretStorage() completes successfully, this function should - * return true. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @returns True if secret storage is ready to be used on this device - */ - public async isSecretStorageReady(): Promise { - const secretStorageKeyInAccount = await this.secretStorage.hasKey(); - const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage); - const sessionBackupInStorage = - !this.backupManager.getKeyBackupEnabled() || (await this.baseApis.isKeyBackupKeyStored()); - - return !!(secretStorageKeyInAccount && privateKeysInStorage && sessionBackupInStorage); - } - - /** - * Implementation of {@link Crypto.CryptoApi#getCrossSigningStatus} - */ - public async getCrossSigningStatus(): Promise { - const publicKeysOnDevice = Boolean(this.crossSigningInfo.getId()); - const privateKeysInSecretStorage = Boolean( - await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage), - ); - const cacheCallbacks = this.crossSigningInfo.getCacheCallbacks(); - const masterKey = Boolean(await cacheCallbacks.getCrossSigningKeyCache?.("master")); - const selfSigningKey = Boolean(await cacheCallbacks.getCrossSigningKeyCache?.("self_signing")); - const userSigningKey = Boolean(await cacheCallbacks.getCrossSigningKeyCache?.("user_signing")); - - return { - publicKeysOnDevice, - privateKeysInSecretStorage, - privateKeysCachedLocally: { - masterKey, - selfSigningKey, - userSigningKey, - }, - }; - } - - /** - * Bootstrap cross-signing by creating keys if needed. If everything is already - * set up, then no changes are made, so this is safe to run to ensure - * cross-signing is ready for use. - * - * This function: - * - creates new cross-signing keys if they are not found locally cached nor in - * secret storage (if it has been setup) - * - * The cross-signing API is currently UNSTABLE and may change without notice. - */ - public async bootstrapCrossSigning({ - authUploadDeviceSigningKeys, - setupNewCrossSigning, - }: BootstrapCrossSigningOpts = {}): Promise { - logger.log("Bootstrapping cross-signing"); - - const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; - const builder = new EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks); - const crossSigningInfo = new CrossSigningInfo( - this.userId, - builder.crossSigningCallbacks, - builder.crossSigningCallbacks, - ); - - // Reset the cross-signing keys - const resetCrossSigning = async (): Promise => { - crossSigningInfo.resetKeys(); - // Sign master key with device key - await this.signObject(crossSigningInfo.keys.master); - - // Store auth flow helper function, as we need to call it when uploading - // to ensure we handle auth errors properly. - builder.addCrossSigningKeys(authUploadDeviceSigningKeys, crossSigningInfo.keys); - - // Cross-sign own device - const device = this.deviceList.getStoredDevice(this.userId, this.deviceId)!; - const deviceSignature = await crossSigningInfo.signDevice(this.userId, device); - builder.addKeySignature(this.userId, this.deviceId, deviceSignature!); - - // Sign message key backup with cross-signing master key - if (this.backupManager.backupInfo) { - await crossSigningInfo.signObject(this.backupManager.backupInfo.auth_data, "master"); - builder.addSessionBackup(this.backupManager.backupInfo); - } - }; - - const publicKeysOnDevice = this.crossSigningInfo.getId(); - const privateKeysInCache = await this.crossSigningInfo.isStoredInKeyCache(); - const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage); - const privateKeysExistSomewhere = privateKeysInCache || privateKeysInStorage; - - // Log all relevant state for easier parsing of debug logs. - logger.log({ - setupNewCrossSigning, - publicKeysOnDevice, - privateKeysInCache, - privateKeysInStorage, - privateKeysExistSomewhere, - }); - - if (!privateKeysExistSomewhere || setupNewCrossSigning) { - logger.log("Cross-signing private keys not found locally or in secret storage, " + "creating new keys"); - // If a user has multiple devices, it important to only call bootstrap - // as part of some UI flow (and not silently during startup), as they - // may have setup cross-signing on a platform which has not saved keys - // to secret storage, and this would reset them. In such a case, you - // should prompt the user to verify any existing devices first (and - // request private keys from those devices) before calling bootstrap. - await resetCrossSigning(); - } else if (publicKeysOnDevice && privateKeysInCache) { - logger.log("Cross-signing public keys trusted and private keys found locally"); - } else if (privateKeysInStorage) { - logger.log( - "Cross-signing private keys not found locally, but they are available " + - "in secret storage, reading storage and caching locally", - ); - await this.checkOwnCrossSigningTrust({ - allowPrivateKeyRequests: true, - }); - } - - // Assuming no app-supplied callback, default to storing new private keys in - // secret storage if it exists. If it does not, it is assumed this will be - // done as part of setting up secret storage later. - const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys; - if (crossSigningPrivateKeys.size && !this.baseApis.cryptoCallbacks.saveCrossSigningKeys) { - const secretStorage = new ServerSideSecretStorageImpl( - builder.accountDataClientAdapter, - builder.ssssCryptoCallbacks, - ); - if (await secretStorage.hasKey()) { - logger.log("Storing new cross-signing private keys in secret storage"); - // This is writing to in-memory account data in - // builder.accountDataClientAdapter so won't fail - await CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage); - } - } - - const operation = builder.buildOperation(); - await operation.apply(this); - // This persists private keys and public keys as trusted, - // only do this if apply succeeded for now as retry isn't in place yet - await builder.persist(this); - - logger.log("Cross-signing ready"); - } - - /** - * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is - * already set up, then no changes are made, so this is safe to run to ensure secret - * storage is ready for use. - * - * This function - * - creates a new Secure Secret Storage key if no default key exists - * - if a key backup exists, it is migrated to store the key in the Secret - * Storage - * - creates a backup if none exists, and one is requested - * - migrates Secure Secret Storage to use the latest algorithm, if an outdated - * algorithm is found - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * Returns: - * A promise which resolves to key creation data for - * SecretStorage#addKey: an object with `passphrase` etc fields. - */ - // TODO this does not resolve with what it says it does - public async bootstrapSecretStorage({ - createSecretStorageKey = async (): Promise => ({}) as IRecoveryKey, - keyBackupInfo, - setupNewKeyBackup, - setupNewSecretStorage, - getKeyBackupPassphrase, - }: ICreateSecretStorageOpts = {}): Promise { - logger.log("Bootstrapping Secure Secret Storage"); - const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; - const builder = new EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks); - const secretStorage = new ServerSideSecretStorageImpl( - builder.accountDataClientAdapter, - builder.ssssCryptoCallbacks, - ); - - // the ID of the new SSSS key, if we create one - let newKeyId: string | null = null; - - // create a new SSSS key and set it as default - const createSSSS = async (opts: AddSecretStorageKeyOpts): Promise => { - const { keyId, keyInfo } = await secretStorage.addKey(SECRET_STORAGE_ALGORITHM_V1_AES, opts); - - // make the private key available to encrypt 4S secrets - builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, opts.key); - - await secretStorage.setDefaultKeyId(keyId); - return keyId; - }; - - const ensureCanCheckPassphrase = async (keyId: string, keyInfo: SecretStorageKeyDescription): Promise => { - if (!keyInfo.mac) { - const key = await this.baseApis.cryptoCallbacks.getSecretStorageKey?.( - { keys: { [keyId]: keyInfo } }, - "", - ); - if (key) { - const privateKey = key[1]; - builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); - const { iv, mac } = await calculateKeyCheck(privateKey); - keyInfo.iv = iv; - keyInfo.mac = mac; - - await builder.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo); - } - } - }; - - const signKeyBackupWithCrossSigning = async (keyBackupAuthData: IKeyBackupInfo["auth_data"]): Promise => { - if (this.crossSigningInfo.getId() && (await this.crossSigningInfo.isStoredInKeyCache("master"))) { - try { - logger.log("Adding cross-signing signature to key backup"); - await this.crossSigningInfo.signObject(keyBackupAuthData, "master"); - } catch (e) { - // This step is not critical (just helpful), so we catch here - // and continue if it fails. - logger.error("Signing key backup with cross-signing keys failed", e); - } - } else { - logger.warn("Cross-signing keys not available, skipping signature on key backup"); - } - }; - - const oldSSSSKey = await this.secretStorage.getKey(); - const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null]; - const storageExists = - !setupNewSecretStorage && oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES; - - // Log all relevant state for easier parsing of debug logs. - logger.log({ - keyBackupInfo, - setupNewKeyBackup, - setupNewSecretStorage, - storageExists, - oldKeyInfo, - }); - - if (!storageExists && !keyBackupInfo) { - // either we don't have anything, or we've been asked to restart - // from scratch - logger.log("Secret storage does not exist, creating new storage key"); - - // if we already have a usable default SSSS key and aren't resetting - // SSSS just use it. otherwise, create a new one - // Note: we leave the old SSSS key in place: there could be other - // secrets using it, in theory. We could move them to the new key but a) - // that would mean we'd need to prompt for the old passphrase, and b) - // it's not clear that would be the right thing to do anyway. - const { keyInfo, privateKey } = await createSecretStorageKey(); - newKeyId = await createSSSS({ passphrase: keyInfo?.passphrase, key: privateKey, name: keyInfo?.name }); - } else if (!storageExists && keyBackupInfo) { - // we have an existing backup, but no SSSS - logger.log("Secret storage does not exist, using key backup key"); - - // if we have the backup key already cached, use it; otherwise use the - // callback to prompt for the key - const backupKey = (await this.getSessionBackupPrivateKey()) || (await getKeyBackupPassphrase?.()); - - // create a new SSSS key and use the backup key as the new SSSS key - const opts = { key: backupKey } as AddSecretStorageKeyOpts; - - if (keyBackupInfo.auth_data.private_key_salt && keyBackupInfo.auth_data.private_key_iterations) { - // FIXME: ??? - opts.passphrase = { - algorithm: "m.pbkdf2", - iterations: keyBackupInfo.auth_data.private_key_iterations, - salt: keyBackupInfo.auth_data.private_key_salt, - bits: 256, - }; - } - - newKeyId = await createSSSS(opts); - - // store the backup key in secret storage - await secretStorage.store("m.megolm_backup.v1", encodeBase64(backupKey!), [newKeyId]); - - // The backup is trusted because the user provided the private key. - // Sign the backup with the cross-signing key so the key backup can - // be trusted via cross-signing. - await signKeyBackupWithCrossSigning(keyBackupInfo.auth_data); - - builder.addSessionBackup(keyBackupInfo); - } else { - // 4S is already set up - logger.log("Secret storage exists"); - - if (oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { - // make sure that the default key has the information needed to - // check the passphrase - await ensureCanCheckPassphrase(oldKeyId, oldKeyInfo); - } - } - - // If we have cross-signing private keys cached, store them in secret - // storage if they are not there already. - if ( - !this.baseApis.cryptoCallbacks.saveCrossSigningKeys && - (await this.isCrossSigningReady()) && - (newKeyId || !(await this.crossSigningInfo.isStoredInSecretStorage(secretStorage))) - ) { - logger.log("Copying cross-signing private keys from cache to secret storage"); - const crossSigningPrivateKeys = await this.crossSigningInfo.getCrossSigningKeysFromCache(); - // This is writing to in-memory account data in - // builder.accountDataClientAdapter so won't fail - await CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage); - } - - if (setupNewKeyBackup && !keyBackupInfo) { - logger.log("Creating new message key backup version"); - const info = await this.baseApis.prepareKeyBackupVersion( - null /* random key */, - // don't write to secret storage, as it will write to this.secretStorage. - // Here, we want to capture all the side-effects of bootstrapping, - // and want to write to the local secretStorage object - { secureSecretStorage: false }, - ); - // write the key to 4S - const privateKey = decodeRecoveryKey(info.recovery_key); - await secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey)); - - // create keyBackupInfo object to add to builder - const data: IKeyBackupInfo = { - algorithm: info.algorithm, - auth_data: info.auth_data, - }; - - // Sign with cross-signing master key - await signKeyBackupWithCrossSigning(data.auth_data); - - // sign with the device fingerprint - await this.signObject(data.auth_data); - - builder.addSessionBackup(data); - } - - // Cache the session backup key - const sessionBackupKey = await secretStorage.get("m.megolm_backup.v1"); - if (sessionBackupKey) { - logger.info("Got session backup key from secret storage: caching"); - // fix up the backup key if it's in the wrong format, and replace - // in secret storage - const fixedBackupKey = fixBackupKey(sessionBackupKey); - if (fixedBackupKey) { - const keyId = newKeyId || oldKeyId; - await secretStorage.store("m.megolm_backup.v1", fixedBackupKey, keyId ? [keyId] : null); - } - const decodedBackupKey = new Uint8Array(decodeBase64(fixedBackupKey || sessionBackupKey)); - builder.addSessionBackupPrivateKeyToCache(decodedBackupKey); - } else if (this.backupManager.getKeyBackupEnabled()) { - // key backup is enabled but we don't have a session backup key in SSSS: see if we have one in - // the cache or the user can provide one, and if so, write it to SSSS - const backupKey = (await this.getSessionBackupPrivateKey()) || (await getKeyBackupPassphrase?.()); - if (!backupKey) { - // This will require user intervention to recover from since we don't have the key - // backup key anywhere. The user should probably just set up a new key backup and - // the key for the new backup will be stored. If we hit this scenario in the wild - // with any frequency, we should do more than just log an error. - logger.error("Key backup is enabled but couldn't get key backup key!"); - return; - } - logger.info("Got session backup key from cache/user that wasn't in SSSS: saving to SSSS"); - await secretStorage.store("m.megolm_backup.v1", encodeBase64(backupKey)); - } - - const operation = builder.buildOperation(); - await operation.apply(this); - // this persists private keys and public keys as trusted, - // only do this if apply succeeded for now as retry isn't in place yet - await builder.persist(this); - - logger.log("Secure Secret Storage ready"); - } - - /** - * Implementation of {@link Crypto.CryptoApi#resetKeyBackup}. - */ - public async resetKeyBackup(): Promise { - // Delete existing ones - // There is no use case for having several key backup version live server side. - // Even if not deleted it would be lost as the key to restore is lost. - // There should be only one backup at a time. - await this.backupManager.deleteAllKeyBackupVersions(); - - const info = await this.backupManager.prepareKeyBackupVersion(); - - await this.signObject(info.auth_data); - - // add new key backup - const { version } = await this.baseApis.http.authedRequest<{ version: string }>( - Method.Post, - "/room_keys/version", - undefined, - info, - { - prefix: ClientPrefix.V3, - }, - ); - - logger.log(`Created backup version ${version}`); - - // write the key to 4S - const privateKey = info.privateKey; - await this.secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey)); - await this.storeSessionBackupPrivateKey(privateKey); - - await this.backupManager.checkAndStart(); - await this.backupManager.scheduleAllGroupSessionsForBackup(); - } - - /** - * Implementation of {@link Crypto.CryptoApi#deleteKeyBackupVersion}. - */ - public async deleteKeyBackupVersion(version: string): Promise { - await this.backupManager.deleteKeyBackupVersion(version); - } - - /** - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#addKey}. - */ - public addSecretStorageKey( - algorithm: string, - opts: AddSecretStorageKeyOpts, - keyID?: string, - ): Promise { - return this.secretStorage.addKey(algorithm, opts, keyID); - } - - /** - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#hasKey}. - */ - public hasSecretStorageKey(keyID?: string): Promise { - return this.secretStorage.hasKey(keyID); - } - - /** - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getKey}. - */ - public getSecretStorageKey(keyID?: string): Promise { - return this.secretStorage.getKey(keyID); - } - - /** - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#store}. - */ - public storeSecret(name: SecretStorageKey, secret: string, keys?: string[]): Promise { - return this.secretStorage.store(name, secret, keys); - } - - /** - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#get}. - */ - public getSecret(name: SecretStorageKey): Promise { - return this.secretStorage.get(name); - } - - /** - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#isStored}. - */ - public isSecretStored(name: SecretStorageKey): Promise | null> { - return this.secretStorage.isStored(name); - } - - public requestSecret(name: string, devices: string[]): ISecretRequest { - if (!devices) { - devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(this.userId)); - } - return this.secretStorage.request(name, devices); - } - - /** - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getDefaultKeyId}. - */ - public getDefaultSecretStorageKeyId(): Promise { - return this.secretStorage.getDefaultKeyId(); - } - - /** - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#setDefaultKeyId}. - */ - public setDefaultSecretStorageKeyId(k: string): Promise { - return this.secretStorage.setDefaultKeyId(k); - } - - /** - * @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#checkKey}. - */ - public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise { - return this.secretStorage.checkKey(key, info); - } - - /** - * Checks that a given secret storage private key matches a given public key. - * This can be used by the getSecretStorageKey callback to verify that the - * private key it is about to supply is the one that was requested. - * - * @param privateKey - The private key - * @param expectedPublicKey - The public key - * @returns true if the key matches, otherwise false - */ - public checkSecretStoragePrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { - let decryption: PkDecryption | null = null; - try { - decryption = new globalThis.Olm.PkDecryption(); - const gotPubkey = decryption.init_with_private_key(privateKey); - // make sure it agrees with the given pubkey - return gotPubkey === expectedPublicKey; - } finally { - decryption?.free(); - } - } - - /** - * Fetches the backup private key, if cached - * @returns the key, if any, or null - */ - public async getSessionBackupPrivateKey(): Promise { - const encodedKey = await new Promise( - (resolve) => { - this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.cryptoStore.getSecretStorePrivateKey(txn, resolve, "m.megolm_backup.v1"); - }); - }, - ); - - let key: Uint8Array | null = null; - - // make sure we have a Uint8Array, rather than a string - if (typeof encodedKey === "string") { - key = new Uint8Array(decodeBase64(fixBackupKey(encodedKey) || encodedKey)); - await this.storeSessionBackupPrivateKey(key); - } - if (encodedKey && typeof encodedKey === "object" && "ciphertext" in encodedKey) { - const pickleKey = Buffer.from(this.olmDevice.pickleKey); - const decrypted = await decryptAESSecretStorageItem(encodedKey, pickleKey, "m.megolm_backup.v1"); - key = decodeBase64(decrypted); - } - return key; - } - - /** - * Stores the session backup key to the cache - * @param key - the private key - * @returns a promise so you can catch failures - */ - public async storeSessionBackupPrivateKey(key: ArrayLike, version?: string): Promise { - if (!(key instanceof Uint8Array)) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`); - } - const pickleKey = Buffer.from(this.olmDevice.pickleKey); - const encryptedKey = await encryptAESSecretStorageItem(encodeBase64(key), pickleKey, "m.megolm_backup.v1"); - return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey); - }); - } - - /** - * Implementation of {@link Crypto.loadSessionBackupPrivateKeyFromSecretStorage}. - */ - public loadSessionBackupPrivateKeyFromSecretStorage(): Promise { - throw new Error("Not implmeented"); - } - - /** - * Get the current status of key backup. - * - * Implementation of {@link Crypto.CryptoApi.getActiveSessionBackupVersion}. - */ - public async getActiveSessionBackupVersion(): Promise { - if (this.backupManager.getKeyBackupEnabled()) { - return this.backupManager.version ?? null; - } - return null; - } - - /** - * Implementation of {@link Crypto.CryptoApi#getKeyBackupInfo}. - */ - public async getKeyBackupInfo(): Promise { - throw new Error("Not implemented"); - } - - /** - * Determine if a key backup can be trusted. - * - * Implementation of {@link Crypto.CryptoApi.isKeyBackupTrusted}. - */ - public async isKeyBackupTrusted(info: KeyBackupInfo): Promise { - const trustInfo = await this.backupManager.isKeyBackupTrusted(info); - return backupTrustInfoFromLegacyTrustInfo(trustInfo); - } - - /** - * Force a re-check of the key backup and enable/disable it as appropriate. - * - * Implementation of {@link Crypto.CryptoApi.checkKeyBackupAndEnable}. - */ - public async checkKeyBackupAndEnable(): Promise { - const checkResult = await this.backupManager.checkKeyBackup(); - if (!checkResult || !checkResult.backupInfo) return null; - return { - backupInfo: checkResult.backupInfo, - trustInfo: backupTrustInfoFromLegacyTrustInfo(checkResult.trustInfo), - }; - } - - /** - * Checks that a given cross-signing private key matches a given public key. - * This can be used by the getCrossSigningKey callback to verify that the - * private key it is about to supply is the one that was requested. - * - * @param privateKey - The private key - * @param expectedPublicKey - The public key - * @returns true if the key matches, otherwise false - */ - public checkCrossSigningPrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { - let signing: PkSigning | null = null; - try { - signing = new globalThis.Olm.PkSigning(); - const gotPubkey = signing.init_with_seed(privateKey); - // make sure it agrees with the given pubkey - return gotPubkey === expectedPublicKey; - } finally { - signing?.free(); - } - } - - /** - * Run various follow-up actions after cross-signing keys have changed locally - * (either by resetting the keys for the account or by getting them from secret - * storage), such as signing the current device, upgrading device - * verifications, etc. - */ - private async afterCrossSigningLocalKeyChange(): Promise { - logger.info("Starting cross-signing key change post-processing"); - - // sign the current device with the new key, and upload to the server - const device = this.deviceList.getStoredDevice(this.userId, this.deviceId)!; - const signedDevice = await this.crossSigningInfo.signDevice(this.userId, device); - logger.info(`Starting background key sig upload for ${this.deviceId}`); - - const upload = ({ shouldEmit = false }): Promise => { - return this.baseApis - .uploadKeySignatures({ - [this.userId]: { - [this.deviceId]: signedDevice!, - }, - }) - .then((response) => { - const { failures } = response || {}; - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this.baseApis.emit( - CryptoEvent.KeySignatureUploadFailure, - failures, - "afterCrossSigningLocalKeyChange", - upload, // continuation - ); - } - throw new KeySignatureUploadError("Key upload failed", { failures }); - } - logger.info(`Finished background key sig upload for ${this.deviceId}`); - }) - .catch((e) => { - logger.error(`Error during background key sig upload for ${this.deviceId}`, e); - }); - }; - upload({ shouldEmit: true }); - - const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications; - if (shouldUpgradeCb) { - logger.info("Starting device verification upgrade"); - - // Check all users for signatures if upgrade callback present - // FIXME: do this in batches - const users: Record = {}; - for (const [userId, crossSigningInfo] of Object.entries(this.deviceList.crossSigningInfo)) { - const upgradeInfo = await this.checkForDeviceVerificationUpgrade( - userId, - CrossSigningInfo.fromStorage(crossSigningInfo, userId), - ); - if (upgradeInfo) { - users[userId] = upgradeInfo; - } - } - - if (Object.keys(users).length > 0) { - logger.info(`Found ${Object.keys(users).length} verif users to upgrade`); - try { - const usersToUpgrade = await shouldUpgradeCb({ users: users }); - if (usersToUpgrade) { - for (const userId of usersToUpgrade) { - if (userId in users) { - await this.baseApis.setDeviceVerified(userId, users[userId].crossSigningInfo.getId()!); - } - } - } - } catch (e) { - logger.log("shouldUpgradeDeviceVerifications threw an error: not upgrading", e); - } - } - - logger.info("Finished device verification upgrade"); - } - - logger.info("Finished cross-signing key change post-processing"); - } - - /** - * Check if a user's cross-signing key is a candidate for upgrading from device - * verification. - * - * @param userId - the user whose cross-signing information is to be checked - * @param crossSigningInfo - the cross-signing information to check - */ - private async checkForDeviceVerificationUpgrade( - userId: string, - crossSigningInfo: CrossSigningInfo, - ): Promise { - // only upgrade if this is the first cross-signing key that we've seen for - // them, and if their cross-signing key isn't already verified - const trustLevel = this.crossSigningInfo.checkUserTrust(crossSigningInfo); - if (crossSigningInfo.firstUse && !trustLevel.isVerified()) { - const devices = this.deviceList.getRawStoredDevicesForUser(userId); - const deviceIds = await this.checkForValidDeviceSignature(userId, crossSigningInfo.keys.master, devices); - if (deviceIds.length) { - return { - devices: deviceIds.map((deviceId) => DeviceInfo.fromStorage(devices[deviceId], deviceId)), - crossSigningInfo, - }; - } - } - } - - /** - * Check if the cross-signing key is signed by a verified device. - * - * @param userId - the user ID whose key is being checked - * @param key - the key that is being checked - * @param devices - the user's devices. Should be a map from device ID - * to device info - */ - private async checkForValidDeviceSignature( - userId: string, - key: CrossSigningKeyInfo, - devices: Record, - ): Promise { - const deviceIds: string[] = []; - if (devices && key.signatures && key.signatures[userId]) { - for (const signame of Object.keys(key.signatures[userId])) { - const [, deviceId] = signame.split(":", 2); - if (deviceId in devices && devices[deviceId].verified === DeviceVerification.VERIFIED) { - try { - await olmlib.verifySignature( - this.olmDevice, - key, - userId, - deviceId, - devices[deviceId].keys[signame], - ); - deviceIds.push(deviceId); - } catch {} - } - } - } - return deviceIds; - } - - /** - * Get the user's cross-signing key ID. - * - * @param type - The type of key to get the ID of. One of - * "master", "self_signing", or "user_signing". Defaults to "master". - * - * @returns the key ID - */ - public getCrossSigningKeyId(type: CrossSigningKey = CrossSigningKey.Master): Promise { - return Promise.resolve(this.getCrossSigningId(type)); - } - - // old name, for backwards compatibility - public getCrossSigningId(type: string): string | null { - return this.crossSigningInfo.getId(type); - } - - /** - * Get the cross signing information for a given user. - * - * @param userId - the user ID to get the cross-signing info for. - * - * @returns the cross signing information for the user. - */ - public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null { - return this.deviceList.getStoredCrossSigningForUser(userId); - } - - /** - * Check whether a given user is trusted. - * - * @param userId - The ID of the user to check. - * - * @returns - */ - public checkUserTrust(userId: string): UserTrustLevel { - const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); - if (!userCrossSigning) { - return new UserTrustLevel(false, false, false); - } - return this.crossSigningInfo.checkUserTrust(userCrossSigning); - } - - /** - * Implementation of {@link Crypto.CryptoApi.getUserVerificationStatus}. - */ - public async getUserVerificationStatus(userId: string): Promise { - return this.checkUserTrust(userId); - } - - /** - * Implementation of {@link Crypto.CryptoApi.pinCurrentUserIdentity}. - */ - public async pinCurrentUserIdentity(userId: string): Promise { - throw new Error("not implemented"); - } - - /** - * Implementation of {@link Crypto.CryptoApi.withdrawVerificationRequirement}. - */ - public async withdrawVerificationRequirement(userId: string): Promise { - throw new Error("not implemented"); - } - - /** - * Check whether a given device is trusted. - * - * @param userId - The ID of the user whose device is to be checked. - * @param deviceId - The ID of the device to check - */ - public async getDeviceVerificationStatus( - userId: string, - deviceId: string, - ): Promise { - const device = this.deviceList.getStoredDevice(userId, deviceId); - if (!device) { - return null; - } - return this.checkDeviceInfoTrust(userId, device); - } - - /** - * @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}. - */ - public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { - const device = this.deviceList.getStoredDevice(userId, deviceId); - return this.checkDeviceInfoTrust(userId, device); - } - - /** - * Check whether a given deviceinfo is trusted. - * - * @param userId - The ID of the user whose devices is to be checked. - * @param device - The device info object to check - * - * @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}. - */ - public checkDeviceInfoTrust(userId: string, device?: DeviceInfo): DeviceTrustLevel { - const trustedLocally = !!device?.isVerified(); - - const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); - if (device && userCrossSigning) { - // The trustCrossSignedDevices only affects trust of other people's cross-signing - // signatures - const trustCrossSig = this.trustCrossSignedDevices || userId === this.userId; - return this.crossSigningInfo.checkDeviceTrust(userCrossSigning, device, trustedLocally, trustCrossSig); - } else { - return new DeviceTrustLevel(false, false, trustedLocally, false); - } - } - - /** - * Check whether one of our own devices is cross-signed by our - * user's stored keys, regardless of whether we trust those keys yet. - * - * @param deviceId - The ID of the device to check - * - * @returns true if the device is cross-signed - */ - public checkIfOwnDeviceCrossSigned(deviceId: string): boolean { - const device = this.deviceList.getStoredDevice(this.userId, deviceId); - if (!device) return false; - const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(this.userId); - return ( - userCrossSigning?.checkDeviceTrust(userCrossSigning, device, false, true).isCrossSigningVerified() ?? false - ); - } - - /* - * Event handler for DeviceList's userNewDevices event - */ - private onDeviceListUserCrossSigningUpdated = async (userId: string): Promise => { - if (userId === this.userId) { - // An update to our own cross-signing key. - // Get the new key first: - const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); - const seenPubkey = newCrossSigning ? newCrossSigning.getId() : null; - const currentPubkey = this.crossSigningInfo.getId(); - const changed = currentPubkey !== seenPubkey; - - if (currentPubkey && seenPubkey && !changed) { - // If it's not changed, just make sure everything is up to date - await this.checkOwnCrossSigningTrust(); - } else { - // We'll now be in a state where cross-signing on the account is not trusted - // because our locally stored cross-signing keys will not match the ones - // on the server for our account. So we clear our own stored cross-signing keys, - // effectively disabling cross-signing until the user gets verified by the device - // that reset the keys - this.storeTrustedSelfKeys(null); - // emit cross-signing has been disabled - this.emit(CryptoEvent.KeysChanged, {}); - // as the trust for our own user has changed, - // also emit an event for this - this.emit(CryptoEvent.UserTrustStatusChanged, this.userId, this.checkUserTrust(userId)); - } - } else { - await this.checkDeviceVerifications(userId); - - // Update verified before latch using the current state and save the new - // latch value in the device list store. - const crossSigning = this.deviceList.getStoredCrossSigningForUser(userId); - if (crossSigning) { - crossSigning.updateCrossSigningVerifiedBefore(this.checkUserTrust(userId).isCrossSigningVerified()); - this.deviceList.setRawStoredCrossSigningForUser(userId, crossSigning.toStorage()); - } - - this.emit(CryptoEvent.UserTrustStatusChanged, userId, this.checkUserTrust(userId)); - } - }; - - /** - * Check the copy of our cross-signing key that we have in the device list and - * see if we can get the private key. If so, mark it as trusted. - */ - public async checkOwnCrossSigningTrust({ - allowPrivateKeyRequests = false, - }: ICheckOwnCrossSigningTrustOpts = {}): Promise { - const userId = this.userId; - - // Before proceeding, ensure our cross-signing public keys have been - // downloaded via the device list. - await this.downloadKeys([this.userId]); - - // Also check which private keys are locally cached. - const crossSigningPrivateKeys = await this.crossSigningInfo.getCrossSigningKeysFromCache(); - - // If we see an update to our own master key, check it against the master - // key we have and, if it matches, mark it as verified - - // First, get the new cross-signing info - const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); - if (!newCrossSigning) { - logger.error( - "Got cross-signing update event for user " + userId + " but no new cross-signing information found!", - ); - return; - } - - const seenPubkey = newCrossSigning.getId()!; - const masterChanged = this.crossSigningInfo.getId() !== seenPubkey; - const masterExistsNotLocallyCached = newCrossSigning.getId() && !crossSigningPrivateKeys.has("master"); - if (masterChanged) { - logger.info("Got new master public key", seenPubkey); - } - if (allowPrivateKeyRequests && (masterChanged || masterExistsNotLocallyCached)) { - logger.info("Attempting to retrieve cross-signing master private key"); - let signing: PkSigning | null = null; - // It's important for control flow that we leave any errors alone for - // higher levels to handle so that e.g. cancelling access properly - // aborts any larger operation as well. - try { - const ret = await this.crossSigningInfo.getCrossSigningKey("master", seenPubkey); - signing = ret[1]; - logger.info("Got cross-signing master private key"); - } finally { - signing?.free(); - } - } - - const oldSelfSigningId = this.crossSigningInfo.getId("self_signing"); - const oldUserSigningId = this.crossSigningInfo.getId("user_signing"); - - // Update the version of our keys in our cross-signing object and the local store - this.storeTrustedSelfKeys(newCrossSigning.keys); - - const selfSigningChanged = oldSelfSigningId !== newCrossSigning.getId("self_signing"); - const userSigningChanged = oldUserSigningId !== newCrossSigning.getId("user_signing"); - - const selfSigningExistsNotLocallyCached = - newCrossSigning.getId("self_signing") && !crossSigningPrivateKeys.has("self_signing"); - const userSigningExistsNotLocallyCached = - newCrossSigning.getId("user_signing") && !crossSigningPrivateKeys.has("user_signing"); - - const keySignatures: Record = {}; - - if (selfSigningChanged) { - logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); - } - if (allowPrivateKeyRequests && (selfSigningChanged || selfSigningExistsNotLocallyCached)) { - logger.info("Attempting to retrieve cross-signing self-signing private key"); - let signing: PkSigning | null = null; - try { - const ret = await this.crossSigningInfo.getCrossSigningKey( - "self_signing", - newCrossSigning.getId("self_signing")!, - ); - signing = ret[1]; - logger.info("Got cross-signing self-signing private key"); - } finally { - signing?.free(); - } - - const device = this.deviceList.getStoredDevice(this.userId, this.deviceId)!; - const signedDevice = await this.crossSigningInfo.signDevice(this.userId, device); - keySignatures[this.deviceId] = signedDevice!; - } - if (userSigningChanged) { - logger.info("Got new user-signing key", newCrossSigning.getId("user_signing")); - } - if (allowPrivateKeyRequests && (userSigningChanged || userSigningExistsNotLocallyCached)) { - logger.info("Attempting to retrieve cross-signing user-signing private key"); - let signing: PkSigning | null = null; - try { - const ret = await this.crossSigningInfo.getCrossSigningKey( - "user_signing", - newCrossSigning.getId("user_signing")!, - ); - signing = ret[1]; - logger.info("Got cross-signing user-signing private key"); - } finally { - signing?.free(); - } - } - - if (masterChanged) { - const masterKey = this.crossSigningInfo.keys.master; - await this.signObject(masterKey); - const deviceSig = masterKey.signatures![this.userId]["ed25519:" + this.deviceId]; - // Include only the _new_ device signature in the upload. - // We may have existing signatures from deleted devices, which will cause - // the entire upload to fail. - keySignatures[this.crossSigningInfo.getId()!] = Object.assign({} as ISignedKey, masterKey, { - signatures: { - [this.userId]: { - ["ed25519:" + this.deviceId]: deviceSig, - }, - }, - }); - } - - const keysToUpload = Object.keys(keySignatures); - if (keysToUpload.length) { - const upload = ({ shouldEmit = false }): Promise => { - logger.info(`Starting background key sig upload for ${keysToUpload}`); - return this.baseApis - .uploadKeySignatures({ [this.userId]: keySignatures }) - .then((response) => { - const { failures } = response || {}; - logger.info(`Finished background key sig upload for ${keysToUpload}`); - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this.baseApis.emit( - CryptoEvent.KeySignatureUploadFailure, - failures, - "checkOwnCrossSigningTrust", - upload, - ); - } - throw new KeySignatureUploadError("Key upload failed", { failures }); - } - }) - .catch((e) => { - logger.error(`Error during background key sig upload for ${keysToUpload}`, e); - }); - }; - upload({ shouldEmit: true }); - } - - this.emit(CryptoEvent.UserTrustStatusChanged, userId, this.checkUserTrust(userId)); - - if (masterChanged) { - this.emit(CryptoEvent.KeysChanged, {}); - await this.afterCrossSigningLocalKeyChange(); - } - - // Now we may be able to trust our key backup - await this.backupManager.checkKeyBackup(); - // FIXME: if we previously trusted the backup, should we automatically sign - // the backup with the new key (if not already signed)? - } - - /** - * Implementation of {@link CryptoBackend#getBackupDecryptor}. - */ - public async getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: Uint8Array): Promise { - if (!(privKey instanceof Uint8Array)) { - throw new Error(`getBackupDecryptor expects Uint8Array`); - } - - const algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => { - return privKey; - }); - - // If the pubkey computed from the private data we've been given - // doesn't match the one in the auth_data, the user has entered - // a different recovery key / the wrong passphrase. - if (!(await algorithm.keyMatches(privKey))) { - return Promise.reject(new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY })); - } - - return new LibOlmBackupDecryptor(algorithm); - } - - /** - * Implementation of {@link CryptoBackend#importBackedUpRoomKeys}. - */ - public importBackedUpRoomKeys( - keys: IMegolmSessionData[], - backupVersion: string, - opts: ImportRoomKeysOpts = {}, - ): Promise { - opts.source = "backup"; - return this.importRoomKeys(keys, opts); - } - - /** - * Store a set of keys as our own, trusted, cross-signing keys. - * - * @param keys - The new trusted set of keys - */ - private async storeTrustedSelfKeys(keys: Record | null): Promise { - if (keys) { - this.crossSigningInfo.setKeys(keys); - } else { - this.crossSigningInfo.clearKeys(); - } - await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningInfo.keys); - }); - } - - /** - * Check if the master key is signed by a verified device, and if so, prompt - * the application to mark it as verified. - * - * @param userId - the user ID whose key should be checked - */ - private async checkDeviceVerifications(userId: string): Promise { - const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications; - if (!shouldUpgradeCb) { - // Upgrading skipped when callback is not present. - return; - } - logger.info(`Starting device verification upgrade for ${userId}`); - if (this.crossSigningInfo.keys.user_signing) { - const crossSigningInfo = this.deviceList.getStoredCrossSigningForUser(userId); - if (crossSigningInfo) { - const upgradeInfo = await this.checkForDeviceVerificationUpgrade(userId, crossSigningInfo); - if (upgradeInfo) { - const usersToUpgrade = await shouldUpgradeCb({ - users: { - [userId]: upgradeInfo, - }, - }); - if (usersToUpgrade.includes(userId)) { - await this.baseApis.setDeviceVerified(userId, crossSigningInfo.getId()!); - } - } - } - } - logger.info(`Finished device verification upgrade for ${userId}`); - } - - /** - */ - public enableLazyLoading(): void { - this.lazyLoadMembers = true; - } - - /** - * Tell the crypto module to register for MatrixClient events which it needs to - * listen for - * - * @param eventEmitter - event source where we can register - * for event notifications - */ - public registerEventHandlers( - eventEmitter: TypedEventEmitter< - RoomMemberEvent.Membership | ClientEvent.ToDeviceEvent | RoomEvent.Timeline | MatrixEventEvent.Decrypted, - any - >, - ): void { - eventEmitter.on(RoomMemberEvent.Membership, this.onMembership); - eventEmitter.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); - eventEmitter.on(RoomEvent.Timeline, this.onTimelineEvent); - eventEmitter.on(MatrixEventEvent.Decrypted, this.onTimelineEvent); - } - - /** - * @deprecated this does nothing and will be removed in a future version - */ - public start(): void { - logger.warn("MatrixClient.crypto.start() is deprecated"); - } - - /** Stop background processes related to crypto */ - public stop(): void { - this.outgoingRoomKeyRequestManager.stop(); - this.deviceList.stop(); - this.dehydrationManager.stop(); - this.backupManager.stop(); - } - - /** - * Get the Ed25519 key for this device - * - * @returns base64-encoded ed25519 key. - * - * @deprecated Use {@link Crypto.CryptoApi#getOwnDeviceKeys}. - */ - public getDeviceEd25519Key(): string | null { - return this.olmDevice.deviceEd25519Key; - } - - /** - * Get the Curve25519 key for this device - * - * @returns base64-encoded curve25519 key. - * - * @deprecated Use {@link Crypto.CryptoApi#getOwnDeviceKeys} - */ - public getDeviceCurve25519Key(): string | null { - return this.olmDevice.deviceCurve25519Key; - } - - /** - * Implementation of {@link Crypto.CryptoApi#getOwnDeviceKeys}. - */ - public async getOwnDeviceKeys(): Promise { - if (!this.olmDevice.deviceCurve25519Key) { - throw new Error("Curve25519 key not yet created"); - } - if (!this.olmDevice.deviceEd25519Key) { - throw new Error("Ed25519 key not yet created"); - } - return { - ed25519: this.olmDevice.deviceEd25519Key, - curve25519: this.olmDevice.deviceCurve25519Key, - }; - } - - /** - * Set the 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. - * - * @param value - whether to blacklist all unverified devices by default - * - * @deprecated Set {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly. - */ - public setGlobalBlacklistUnverifiedDevices(value: boolean): void { - this.globalBlacklistUnverifiedDevices = value; - } - - /** - * @returns whether to blacklist all unverified devices by default - * - * @deprecated Reference {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly. - */ - public getGlobalBlacklistUnverifiedDevices(): boolean { - return this.globalBlacklistUnverifiedDevices; - } - - /** - * Upload the device keys to the homeserver. - * @returns A promise that will resolve when the keys are uploaded. - */ - public uploadDeviceKeys(): Promise { - const deviceKeys = { - algorithms: this.supportedAlgorithms, - device_id: this.deviceId, - keys: this.deviceKeys, - user_id: this.userId, - }; - - return this.signObject(deviceKeys).then(() => { - return this.baseApis.uploadKeysRequest({ - device_keys: deviceKeys as Required, - }); - }); - } - - public getNeedsNewFallback(): boolean { - return !!this.needsNewFallback; - } - - // check if it's time to upload one-time keys, and do so if so. - private maybeUploadOneTimeKeys(): void { - // frequency with which to check & upload one-time keys - const uploadPeriod = 1000 * 60; // one minute - - // max number of keys to upload at once - // Creating keys can be an expensive operation so we limit the - // number we generate in one go to avoid blocking the application - // for too long. - const maxKeysPerCycle = 5; - - if (this.oneTimeKeyCheckInProgress) { - return; - } - - const now = Date.now(); - if (this.lastOneTimeKeyCheck !== null && now - this.lastOneTimeKeyCheck < uploadPeriod) { - // we've done a key upload recently. - return; - } - - this.lastOneTimeKeyCheck = now; - - // We need to keep a pool of one time public keys on the server so that - // other devices can start conversations with us. But we can only store - // a finite number of private keys in the olm Account object. - // To complicate things further then can be a delay between a device - // claiming a public one time key from the server and it sending us a - // message. We need to keep the corresponding private key locally until - // we receive the message. - // But that message might never arrive leaving us stuck with duff - // private keys clogging up our local storage. - // So we need some kind of engineering compromise to balance all of - // these factors. - - // Check how many keys we can store in the Account object. - const maxOneTimeKeys = this.olmDevice.maxNumberOfOneTimeKeys(); - // Try to keep at most half that number on the server. This leaves the - // rest of the slots free to hold keys that have been claimed from the - // server but we haven't received a message for. - // If we run out of slots when generating new keys then olm will - // discard the oldest private keys first. This will eventually clean - // out stale private keys that won't receive a message. - const keyLimit = Math.floor(maxOneTimeKeys / 2); - - const uploadLoop = async (keyCount: number): Promise => { - while (keyLimit > keyCount || this.getNeedsNewFallback()) { - // Ask olm to generate new one time keys, then upload them to synapse. - if (keyLimit > keyCount) { - logger.info("generating oneTimeKeys"); - const keysThisLoop = Math.min(keyLimit - keyCount, maxKeysPerCycle); - await this.olmDevice.generateOneTimeKeys(keysThisLoop); - } - - if (this.getNeedsNewFallback()) { - const fallbackKeys = await this.olmDevice.getFallbackKey(); - // if fallbackKeys is non-empty, we've already generated a - // fallback key, but it hasn't been published yet, so we - // can use that instead of generating a new one - if (!fallbackKeys.curve25519 || Object.keys(fallbackKeys.curve25519).length == 0) { - logger.info("generating fallback key"); - if (this.fallbackCleanup) { - // cancel any pending fallback cleanup because generating - // a new fallback key will already drop the old fallback - // that would have been dropped, and we don't want to kill - // the current key - clearTimeout(this.fallbackCleanup); - delete this.fallbackCleanup; - } - await this.olmDevice.generateFallbackKey(); - } - } - - logger.info("calling uploadOneTimeKeys"); - const res = await this.uploadOneTimeKeys(); - if (res.one_time_key_counts && res.one_time_key_counts.signed_curve25519) { - // if the response contains a more up to date value use this - // for the next loop - keyCount = res.one_time_key_counts.signed_curve25519; - } else { - throw new Error( - "response for uploading keys does not contain " + "one_time_key_counts.signed_curve25519", - ); - } - } - }; - - this.oneTimeKeyCheckInProgress = true; - Promise.resolve() - .then(() => { - if (this.oneTimeKeyCount !== undefined) { - // We already have the current one_time_key count from a /sync response. - // Use this value instead of asking the server for the current key count. - return Promise.resolve(this.oneTimeKeyCount); - } - // ask the server how many keys we have - return this.baseApis.uploadKeysRequest({}).then((res) => { - return res.one_time_key_counts.signed_curve25519 || 0; - }); - }) - .then((keyCount) => { - // Start the uploadLoop with the current keyCount. The function checks if - // we need to upload new keys or not. - // If there are too many keys on the server then we don't need to - // create any more keys. - return uploadLoop(keyCount); - }) - .catch((e) => { - logger.error("Error uploading one-time keys", e.stack || e); - }) - .finally(() => { - // reset oneTimeKeyCount to prevent start uploading based on old data. - // it will be set again on the next /sync-response - this.oneTimeKeyCount = undefined; - this.oneTimeKeyCheckInProgress = false; - }); - } - - // returns a promise which resolves to the response - private async uploadOneTimeKeys(): Promise { - const promises: Promise[] = []; - - let fallbackJson: Record | undefined; - if (this.getNeedsNewFallback()) { - fallbackJson = {}; - const fallbackKeys = await this.olmDevice.getFallbackKey(); - for (const [keyId, key] of Object.entries(fallbackKeys.curve25519)) { - const k = { key, fallback: true }; - fallbackJson["signed_curve25519:" + keyId] = k; - promises.push(this.signObject(k)); - } - this.needsNewFallback = false; - } - - const oneTimeKeys = await this.olmDevice.getOneTimeKeys(); - const oneTimeJson: Record = {}; - - for (const keyId in oneTimeKeys.curve25519) { - if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) { - const k = { - key: oneTimeKeys.curve25519[keyId], - }; - oneTimeJson["signed_curve25519:" + keyId] = k; - promises.push(this.signObject(k)); - } - } - - await Promise.all(promises); - - const requestBody: Record = { - one_time_keys: oneTimeJson, - }; - - if (fallbackJson) { - requestBody["org.matrix.msc2732.fallback_keys"] = fallbackJson; - requestBody["fallback_keys"] = fallbackJson; - } - - const res = await this.baseApis.uploadKeysRequest(requestBody); - - if (fallbackJson) { - this.fallbackCleanup = setTimeout( - () => { - delete this.fallbackCleanup; - this.olmDevice.forgetOldFallbackKey(); - }, - 60 * 60 * 1000, - ); - } - - await this.olmDevice.markKeysAsPublished(); - return res; - } - - /** - * Download the keys for a list of users and stores the keys in the session - * store. - * @param userIds - The users to fetch. - * @param forceDownload - Always download the keys even if cached. - * - * @returns A promise which resolves to a map `userId->deviceId->{@link DeviceInfo}`. - */ - public downloadKeys(userIds: string[], forceDownload?: boolean): Promise { - return this.deviceList.downloadKeys(userIds, !!forceDownload); - } - - /** - * Get the stored device keys for a user id - * - * @param userId - the user to list keys for. - * - * @returns list of devices, or null if we haven't - * managed to get a list of devices for this user yet. - */ - public getStoredDevicesForUser(userId: string): Array | null { - return this.deviceList.getStoredDevicesForUser(userId); - } - - /** - * Get the device information for the given list of users. - * - * @param userIds - The users to fetch. - * @param downloadUncached - If true, download the device list for users whose device list we are not - * currently tracking. Defaults to false, in which case such users will not appear at all in the result map. - * - * @returns A map `{@link DeviceMap}`. - */ - public async getUserDeviceInfo(userIds: string[], downloadUncached = false): Promise { - const deviceMapByUserId = new Map>(); - // Keep the users without device to download theirs keys - const usersWithoutDeviceInfo: string[] = []; - - for (const userId of userIds) { - const deviceInfos = await this.getStoredDevicesForUser(userId); - // If there are device infos for a userId, we transform it into a map - // Else, the keys will be downloaded after - if (deviceInfos) { - const deviceMap = new Map( - // Convert DeviceInfo to Device - deviceInfos.map((deviceInfo) => [deviceInfo.deviceId, deviceInfoToDevice(deviceInfo, userId)]), - ); - deviceMapByUserId.set(userId, deviceMap); - } else { - usersWithoutDeviceInfo.push(userId); - } - } - - // Download device info for users without device infos - if (downloadUncached && usersWithoutDeviceInfo.length > 0) { - const newDeviceInfoMap = await this.downloadKeys(usersWithoutDeviceInfo); - - newDeviceInfoMap.forEach((deviceInfoMap, userId) => { - const deviceMap = new Map(); - // Convert DeviceInfo to Device - deviceInfoMap.forEach((deviceInfo, deviceId) => - deviceMap.set(deviceId, deviceInfoToDevice(deviceInfo, userId)), - ); - - // Put the new device infos into the returned map - deviceMapByUserId.set(userId, deviceMap); - }); - } - - return deviceMapByUserId; - } - - /** - * Get the stored keys for a single device - * - * - * @returns device, or undefined - * if we don't know about this device - */ - public getStoredDevice(userId: string, deviceId: string): DeviceInfo | undefined { - return this.deviceList.getStoredDevice(userId, deviceId); - } - - /** - * Save the device list, if necessary - * - * @param delay - Time in ms before which the save actually happens. - * By default, the save is delayed for a short period in order to batch - * multiple writes, but this behaviour can be disabled by passing 0. - * - * @returns true if the data was saved, false if - * it was not (eg. because no changes were pending). The promise - * will only resolve once the data is saved, so may take some time - * to resolve. - */ - public saveDeviceList(delay: number): Promise { - return this.deviceList.saveIfDirty(delay); - } - - /** - * Mark the given device as locally verified. - * - * Implementation of {@link Crypto.CryptoApi#setDeviceVerified}. - */ - public async setDeviceVerified(userId: string, deviceId: string, verified = true): Promise { - await this.setDeviceVerification(userId, deviceId, verified); - } - - /** - * Blindly cross-sign one of our other devices. - * - * Implementation of {@link Crypto.CryptoApi#crossSignDevice}. - */ - public async crossSignDevice(deviceId: string): Promise { - await this.setDeviceVerified(this.userId, deviceId, true); - } - - /** - * Update the blocked/verified state of the given device - * - * @param userId - owner of the device - * @param deviceId - unique identifier for the device or user's - * cross-signing public key ID. - * - * @param verified - whether to mark the device as verified. Null to - * leave unchanged. - * - * @param blocked - whether to mark the device as blocked. Null to - * leave unchanged. - * - * @param known - whether to mark that the user has been made aware of - * the existence of this device. Null to leave unchanged - * - * @param keys - The list of keys that was present - * during the device verification. This will be double checked with the list - * of keys the given device has currently. - * - * @returns updated DeviceInfo - */ - public async setDeviceVerification( - userId: string, - deviceId: string, - verified: boolean | null = null, - blocked: boolean | null = null, - known: boolean | null = null, - keys?: Record, - ): Promise { - // Check if the 'device' is actually a cross signing key - // The js-sdk's verification treats cross-signing keys as devices - // and so uses this method to mark them verified. - const xsk = this.deviceList.getStoredCrossSigningForUser(userId); - if (xsk?.getId() === deviceId) { - if (blocked !== null || known !== null) { - throw new Error("Cannot set blocked or known for a cross-signing key"); - } - if (!verified) { - throw new Error("Cannot set a cross-signing key as unverified"); - } - const gotKeyId = keys ? Object.values(keys)[0] : null; - if (keys && (Object.values(keys).length !== 1 || gotKeyId !== xsk.getId())) { - throw new Error(`Key did not match expected value: expected ${xsk.getId()}, got ${gotKeyId}`); - } - - if (!this.crossSigningInfo.getId() && userId === this.crossSigningInfo.userId) { - this.storeTrustedSelfKeys(xsk.keys); - // This will cause our own user trust to change, so emit the event - this.emit(CryptoEvent.UserTrustStatusChanged, this.userId, this.checkUserTrust(userId)); - } - - // Now sign the master key with our user signing key (unless it's ourself) - if (userId !== this.userId) { - logger.info("Master key " + xsk.getId() + " for " + userId + " marked verified. Signing..."); - const device = await this.crossSigningInfo.signUser(xsk); - if (device) { - const upload = async ({ shouldEmit = false }): Promise => { - logger.info("Uploading signature for " + userId + "..."); - const response = await this.baseApis.uploadKeySignatures({ - [userId]: { - [deviceId]: device, - }, - }); - const { failures } = response || {}; - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this.baseApis.emit( - CryptoEvent.KeySignatureUploadFailure, - failures, - "setDeviceVerification", - upload, - ); - } - /* Throwing here causes the process to be cancelled and the other - * user to be notified */ - throw new KeySignatureUploadError("Key upload failed", { failures }); - } - }; - await upload({ shouldEmit: true }); - - // This will emit events when it comes back down the sync - // (we could do local echo to speed things up) - } - return device!; - } else { - return xsk; - } - } - - const devices = this.deviceList.getRawStoredDevicesForUser(userId); - if (!devices || !devices[deviceId]) { - throw new Error("Unknown device " + userId + ":" + deviceId); - } - - const dev = devices[deviceId]; - let verificationStatus = dev.verified; - - if (verified) { - if (keys) { - for (const [keyId, key] of Object.entries(keys)) { - if (dev.keys[keyId] !== key) { - throw new Error(`Key did not match expected value: expected ${key}, got ${dev.keys[keyId]}`); - } - } - } - verificationStatus = DeviceVerification.VERIFIED; - } else if (verified !== null && verificationStatus == DeviceVerification.VERIFIED) { - verificationStatus = DeviceVerification.UNVERIFIED; - } - - if (blocked) { - verificationStatus = DeviceVerification.BLOCKED; - } else if (blocked !== null && verificationStatus == DeviceVerification.BLOCKED) { - verificationStatus = DeviceVerification.UNVERIFIED; - } - - let knownStatus = dev.known; - if (known !== null) { - knownStatus = known; - } - - if (dev.verified !== verificationStatus || dev.known !== knownStatus) { - dev.verified = verificationStatus; - dev.known = knownStatus; - this.deviceList.storeDevicesForUser(userId, devices); - this.deviceList.saveIfDirty(); - } - - // do cross-signing - if (verified && userId === this.userId) { - logger.info("Own device " + deviceId + " marked verified: signing"); - - // Signing only needed if other device not already signed - let device: ISignedKey | undefined; - const deviceTrust = this.checkDeviceTrust(userId, deviceId); - if (deviceTrust.isCrossSigningVerified()) { - logger.log(`Own device ${deviceId} already cross-signing verified`); - } else { - device = (await this.crossSigningInfo.signDevice(userId, DeviceInfo.fromStorage(dev, deviceId)))!; - } - - if (device) { - const upload = async ({ shouldEmit = false }): Promise => { - logger.info("Uploading signature for " + deviceId); - const response = await this.baseApis.uploadKeySignatures({ - [userId]: { - [deviceId]: device!, - }, - }); - const { failures } = response || {}; - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this.baseApis.emit( - CryptoEvent.KeySignatureUploadFailure, - failures, - "setDeviceVerification", - upload, // continuation - ); - } - throw new KeySignatureUploadError("Key upload failed", { failures }); - } - }; - await upload({ shouldEmit: true }); - // XXX: we'll need to wait for the device list to be updated - } - } - - const deviceObj = DeviceInfo.fromStorage(dev, deviceId); - this.emit(CryptoEvent.DeviceVerificationChanged, userId, deviceId, deviceObj); - return deviceObj; - } - - public findVerificationRequestDMInProgress(roomId: string, userId?: string): VerificationRequest | undefined { - return this.inRoomVerificationRequests.findRequestInProgress(roomId, userId); - } - - public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] { - return this.toDeviceVerificationRequests.getRequestsInProgress(userId); - } - - public requestVerificationDM(userId: string, roomId: string): Promise { - const existingRequest = this.inRoomVerificationRequests.findRequestInProgress(roomId); - if (existingRequest) { - return Promise.resolve(existingRequest); - } - const channel = new InRoomChannel(this.baseApis, roomId, userId); - return this.requestVerificationWithChannel(userId, channel, this.inRoomVerificationRequests); - } - - /** @deprecated Use `requestOwnUserVerificationToDevice` or `requestDeviceVerification` */ - public requestVerification(userId: string, devices?: string[]): Promise { - if (!devices) { - devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(userId)); - } - const existingRequest = this.toDeviceVerificationRequests.findRequestInProgress(userId, devices); - if (existingRequest) { - return Promise.resolve(existingRequest); - } - const channel = new ToDeviceChannel(this.baseApis, userId, devices, ToDeviceChannel.makeTransactionId()); - return this.requestVerificationWithChannel(userId, channel, this.toDeviceVerificationRequests); - } - - public requestOwnUserVerification(): Promise { - return this.requestVerification(this.userId); - } - - public requestDeviceVerification(userId: string, deviceId: string): Promise { - return this.requestVerification(userId, [deviceId]); - } - - private async requestVerificationWithChannel( - userId: string, - channel: IVerificationChannel, - requestsMap: IRequestsMap, - ): Promise { - let request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); - // if transaction id is already known, add request - if (channel.transactionId) { - requestsMap.setRequestByChannel(channel, request); - } - await request.sendRequest(); - // don't replace the request created by a racing remote echo - const racingRequest = requestsMap.getRequestByChannel(channel); - if (racingRequest) { - request = racingRequest; - } else { - logger.log( - `Crypto: adding new request to ` + `requestsByTxnId with id ${channel.transactionId} ${channel.roomId}`, - ); - requestsMap.setRequestByChannel(channel, request); - } - return request; - } - - public beginKeyVerification( - method: string, - userId: string, - deviceId: string, - transactionId: string | null = null, - ): VerificationBase { - let request: Request | undefined; - if (transactionId) { - request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId); - if (!request) { - throw new Error(`No request found for user ${userId} with ` + `transactionId ${transactionId}`); - } - } else { - transactionId = ToDeviceChannel.makeTransactionId(); - const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId); - request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); - this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request); - } - return request.beginKeyVerification(method, { userId, deviceId }); - } - - public async legacyDeviceVerification( - userId: string, - deviceId: string, - method: VerificationMethod, - ): Promise { - const transactionId = ToDeviceChannel.makeTransactionId(); - const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId); - const request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); - this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request); - const verifier = request.beginKeyVerification(method, { userId, deviceId }); - // either reject by an error from verify() while sending .start - // or resolve when the request receives the - // local (fake remote) echo for sending the .start event - await Promise.race([verifier.verify(), request.waitFor((r) => r.started)]); - return request; - } - - /** - * Get information on the active olm sessions with a user - *

- * Returns a map from device id to an object with keys 'deviceIdKey' (the - * device's curve25519 identity key) and 'sessions' (an array of objects in the - * same format as that returned by - * {@link OlmDevice#getSessionInfoForDevice}). - *

- * This method is provided for debugging purposes. - * - * @param userId - id of user to inspect - */ - public async getOlmSessionsForUser(userId: string): Promise> { - const devices = this.getStoredDevicesForUser(userId) || []; - const result: { [deviceId: string]: IUserOlmSession } = {}; - for (const device of devices) { - const deviceKey = device.getIdentityKey(); - const sessions = await this.olmDevice.getSessionInfoForDevice(deviceKey); - - result[device.deviceId] = { - deviceIdKey: deviceKey, - sessions: sessions, - }; - } - return result; - } - - /** - * Get the device which sent an event - * - * @param event - event to be checked - */ - public getEventSenderDeviceInfo(event: MatrixEvent): DeviceInfo | null { - const senderKey = event.getSenderKey(); - const algorithm = event.getWireContent().algorithm; - - if (!senderKey || !algorithm) { - return null; - } - - if (event.isKeySourceUntrusted()) { - // we got the key for this event from a source that we consider untrusted - return null; - } - - // senderKey is the Curve25519 identity key of the device which the event - // was sent from. In the case of Megolm, it's actually the Curve25519 - // identity key of the device which set up the Megolm session. - - const device = this.deviceList.getDeviceByIdentityKey(algorithm, senderKey); - - if (device === null) { - // we haven't downloaded the details of this device yet. - return null; - } - - // so far so good, but now we need to check that the sender of this event - // hadn't advertised someone else's Curve25519 key as their own. We do that - // by checking the Ed25519 claimed by the event (or, in the case of megolm, - // the event which set up the megolm session), to check that it matches the - // fingerprint of the purported sending device. - // - // (see https://github.com/vector-im/vector-web/issues/2215) - - const claimedKey = event.getClaimedEd25519Key(); - if (!claimedKey) { - logger.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device"); - return null; - } - - if (claimedKey !== device.getFingerprint()) { - logger.warn( - "Event " + - event.getId() + - " claims ed25519 key " + - claimedKey + - " but sender device has key " + - device.getFingerprint(), - ); - return null; - } - - return device; - } - - /** - * Get information about the encryption of an event - * - * @param event - event to be checked - * - * @returns An object with the fields: - * - encrypted: whether the event is encrypted (if not encrypted, some of the - * other properties may not be set) - * - senderKey: the sender's key - * - algorithm: the algorithm used to encrypt the event - * - authenticated: whether we can be sure that the owner of the senderKey - * sent the event - * - sender: the sender's device information, if available - * - mismatchedSender: if the event's ed25519 and curve25519 keys don't match - * (only meaningful if `sender` is set) - */ - public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo { - const ret: Partial = {}; - - ret.senderKey = event.getSenderKey() ?? undefined; - ret.algorithm = event.getWireContent().algorithm; - - if (!ret.senderKey || !ret.algorithm) { - ret.encrypted = false; - return ret as IEncryptedEventInfo; - } - ret.encrypted = true; - - if (event.isKeySourceUntrusted()) { - // we got the key this event from somewhere else - // TODO: check if we can trust the forwarders. - ret.authenticated = false; - } else { - ret.authenticated = true; - } - - // senderKey is the Curve25519 identity key of the device which the event - // was sent from. In the case of Megolm, it's actually the Curve25519 - // identity key of the device which set up the Megolm session. - - ret.sender = this.deviceList.getDeviceByIdentityKey(ret.algorithm, ret.senderKey) ?? undefined; - - // so far so good, but now we need to check that the sender of this event - // hadn't advertised someone else's Curve25519 key as their own. We do that - // by checking the Ed25519 claimed by the event (or, in the case of megolm, - // the event which set up the megolm session), to check that it matches the - // fingerprint of the purported sending device. - // - // (see https://github.com/vector-im/vector-web/issues/2215) - - const claimedKey = event.getClaimedEd25519Key(); - if (!claimedKey) { - logger.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device"); - ret.mismatchedSender = true; - } - - if (ret.sender && claimedKey !== ret.sender.getFingerprint()) { - logger.warn( - "Event " + - event.getId() + - " claims ed25519 key " + - claimedKey + - "but sender device has key " + - ret.sender.getFingerprint(), - ); - ret.mismatchedSender = true; - } - - return ret as IEncryptedEventInfo; - } - - /** - * Implementation of {@link Crypto.CryptoApi.getEncryptionInfoForEvent}. - */ - public async getEncryptionInfoForEvent(event: MatrixEvent): Promise { - const encryptionInfo = this.getEventEncryptionInfo(event); - if (!encryptionInfo.encrypted) { - return null; - } - - const senderId = event.getSender(); - if (!senderId || encryptionInfo.mismatchedSender) { - // something definitely wrong is going on here - - // previously: E2EState.Warning -> E2ePadlockUnverified -> Red/"Encrypted by an unverified session" - return { - shieldColour: EventShieldColour.RED, - shieldReason: EventShieldReason.MISMATCHED_SENDER_KEY, - }; - } - - const userTrust = this.checkUserTrust(senderId); - if (!userTrust.isCrossSigningVerified()) { - // If the message is unauthenticated, then display a grey - // shield, otherwise if the user isn't cross-signed then - // nothing's needed - if (!encryptionInfo.authenticated) { - // previously: E2EState.Unauthenticated -> E2ePadlockUnauthenticated -> Grey/"The authenticity of this encrypted message can't be guaranteed on this device." - return { - shieldColour: EventShieldColour.GREY, - shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, - }; - } else { - // previously: E2EState.Normal -> no icon - return { shieldColour: EventShieldColour.NONE, shieldReason: null }; - } - } - - const eventSenderTrust = - senderId && - encryptionInfo.sender && - (await this.getDeviceVerificationStatus(senderId, encryptionInfo.sender.deviceId)); - - if (!eventSenderTrust) { - // previously: E2EState.Unknown -> E2ePadlockUnknown -> Grey/"Encrypted by a deleted session" - return { - shieldColour: EventShieldColour.GREY, - shieldReason: EventShieldReason.UNKNOWN_DEVICE, - }; - } - - if (!eventSenderTrust.isVerified()) { - // previously: E2EState.Warning -> E2ePadlockUnverified -> Red/"Encrypted by an unverified session" - return { - shieldColour: EventShieldColour.RED, - shieldReason: EventShieldReason.UNSIGNED_DEVICE, - }; - } - - if (!encryptionInfo.authenticated) { - // previously: E2EState.Unauthenticated -> E2ePadlockUnauthenticated -> Grey/"The authenticity of this encrypted message can't be guaranteed on this device." - return { - shieldColour: EventShieldColour.GREY, - shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, - }; - } - - // previously: E2EState.Verified -> no icon - return { shieldColour: EventShieldColour.NONE, shieldReason: null }; - } - - /** - * Forces the current outbound group session to be discarded such - * that another one will be created next time an event is sent. - * - * @param roomId - The ID of the room to discard the session for - * - * This should not normally be necessary. - */ - public forceDiscardSession(roomId: string): Promise { - const alg = this.roomEncryptors.get(roomId); - if (alg === undefined) throw new Error("Room not encrypted"); - if (alg.forceDiscardSession === undefined) { - throw new Error("Room encryption algorithm doesn't support session discarding"); - } - alg.forceDiscardSession(); - return Promise.resolve(); - } - - /** - * Configure a room to use encryption (ie, save a flag in the cryptoStore). - * - * @param roomId - The room ID to enable encryption in. - * - * @param config - The encryption config for the room. - * - * @param inhibitDeviceQuery - true to suppress device list query for - * users in the room (for now). In case lazy loading is enabled, - * the device query is always inhibited as the members are not tracked. - * - * @deprecated It is normally incorrect to call this method directly. Encryption - * is enabled by receiving an `m.room.encryption` event (which we may have sent - * previously). - */ - public async setRoomEncryption( - roomId: string, - config: IRoomEncryption, - inhibitDeviceQuery?: boolean, - ): Promise { - const room = this.clientStore.getRoom(roomId); - if (!room) { - throw new Error(`Unable to enable encryption tracking devices in unknown room ${roomId}`); - } - await this.setRoomEncryptionImpl(room, config); - if (!this.lazyLoadMembers && !inhibitDeviceQuery) { - this.deviceList.refreshOutdatedDeviceLists(); - } - } - - /** - * Set up encryption for a room. - * - * This is called when an m.room.encryption event is received. It saves a flag - * for the room in the cryptoStore (if it wasn't already set), sets up an "encryptor" for - * the room, and enables device-list tracking for the room. - * - * It does not initiate a device list query for the room. That is normally - * done once we finish processing the sync, in onSyncCompleted. - * - * @param room - The room to enable encryption in. - * @param config - The encryption config for the room. - */ - private async setRoomEncryptionImpl(room: Room, config: IRoomEncryption): Promise { - const roomId = room.roomId; - - // ignore crypto events with no algorithm defined - // This will happen if a crypto event is redacted before we fetch the room state - // It would otherwise just throw later as an unknown algorithm would, but we may - // as well catch this here - if (!config.algorithm) { - logger.log("Ignoring setRoomEncryption with no algorithm"); - return; - } - - // if state is being replayed from storage, we might already have a configuration - // for this room as they are persisted as well. - // We just need to make sure the algorithm is initialized in this case. - // However, if the new config is different, - // we should bail out as room encryption can't be changed once set. - const existingConfig = this.roomList.getRoomEncryption(roomId); - if (existingConfig) { - if (JSON.stringify(existingConfig) != JSON.stringify(config)) { - logger.error("Ignoring m.room.encryption event which requests " + "a change of config in " + roomId); - return; - } - } - // if we already have encryption in this room, we should ignore this event, - // as it would reset the encryption algorithm. - // This is at least expected to be called twice, as sync calls onCryptoEvent - // for both the timeline and state sections in the /sync response, - // the encryption event would appear in both. - // If it's called more than twice though, - // it signals a bug on client or server. - const existingAlg = this.roomEncryptors.get(roomId); - if (existingAlg) { - return; - } - - // _roomList.getRoomEncryption will not race with _roomList.setRoomEncryption - // because it first stores in memory. We should await the promise only - // after all the in-memory state (roomEncryptors and _roomList) has been updated - // to avoid races when calling this method multiple times. Hence keep a hold of the promise. - let storeConfigPromise: Promise | null = null; - if (!existingConfig) { - storeConfigPromise = this.roomList.setRoomEncryption(roomId, config); - } - - const AlgClass = algorithms.ENCRYPTION_CLASSES.get(config.algorithm); - if (!AlgClass) { - throw new Error("Unable to encrypt with " + config.algorithm); - } - - const alg = new AlgClass({ - userId: this.userId, - deviceId: this.deviceId, - crypto: this, - olmDevice: this.olmDevice, - baseApis: this.baseApis, - roomId, - config, - }); - this.roomEncryptors.set(roomId, alg); - - if (storeConfigPromise) { - await storeConfigPromise; - } - - logger.log(`Enabling encryption in ${roomId}`); - - // we don't want to force a download of the full membership list of this room, but as soon as we have that - // list we can start tracking the device list. - if (room.membersLoaded()) { - await this.trackRoomDevicesImpl(room); - } else { - // wait for the membership list to be loaded - const onState = (_state: RoomState): void => { - room.off(RoomStateEvent.Update, onState); - if (room.membersLoaded()) { - this.trackRoomDevicesImpl(room).catch((e) => { - logger.error(`Error enabling device tracking in ${roomId}`, e); - }); - } - }; - room.on(RoomStateEvent.Update, onState); - } - } - - /** - * Make sure we are tracking the device lists for all users in this room. - * - * @param roomId - The room ID to start tracking devices in. - * @returns when all devices for the room have been fetched and marked to track - * @deprecated there's normally no need to call this function: device list tracking - * will be enabled as soon as we have the full membership list. - */ - public trackRoomDevices(roomId: string): Promise { - const room = this.clientStore.getRoom(roomId); - if (!room) { - throw new Error(`Unable to start tracking devices in unknown room ${roomId}`); - } - return this.trackRoomDevicesImpl(room); - } - - /** - * Make sure we are tracking the device lists for all users in this room. - * - * This is normally called when we are about to send an encrypted event, to make sure - * we have all the devices in the room; but it is also called when processing an - * m.room.encryption state event (if lazy-loading is disabled), or when members are - * loaded (if lazy-loading is enabled), to prepare the device list. - * - * @param room - Room to enable device-list tracking in - */ - private trackRoomDevicesImpl(room: Room): Promise { - const roomId = room.roomId; - const trackMembers = async (): Promise => { - // not an encrypted room - if (!this.roomEncryptors.has(roomId)) { - return; - } - logger.log(`Starting to track devices for room ${roomId} ...`); - const members = await room.getEncryptionTargetMembers(); - members.forEach((m) => { - this.deviceList.startTrackingDeviceList(m.userId); - }); - }; - - let promise = this.roomDeviceTrackingState[roomId]; - if (!promise) { - promise = trackMembers(); - this.roomDeviceTrackingState[roomId] = promise.catch((err) => { - delete this.roomDeviceTrackingState[roomId]; - throw err; - }); - } - return promise; - } - - /** - * Try to make sure we have established olm sessions for all known devices for - * the given users. - * - * @param users - list of user ids - * @param force - If true, force a new Olm session to be created. Default false. - * - * @returns resolves once the sessions are complete, to - * an Object mapping from userId to deviceId to - * `IOlmSessionResult` - */ - public ensureOlmSessionsForUsers( - users: string[], - force?: boolean, - ): Promise>> { - // map user Id → DeviceInfo[] - const devicesByUser: Map = new Map(); - - for (const userId of users) { - const userDevices: DeviceInfo[] = []; - devicesByUser.set(userId, userDevices); - - const devices = this.getStoredDevicesForUser(userId) || []; - for (const deviceInfo of devices) { - const key = deviceInfo.getIdentityKey(); - if (key == this.olmDevice.deviceCurve25519Key) { - // don't bother setting up session to ourself - continue; - } - if (deviceInfo.verified == DeviceVerification.BLOCKED) { - // don't bother setting up sessions with blocked users - continue; - } - - userDevices.push(deviceInfo); - } - } - - return olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, force); - } - - /** - * Get a list containing all of the room keys - * - * @returns a list of session export objects - */ - public async exportRoomKeys(): Promise { - const exportedSessions: IMegolmSessionData[] = []; - await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { - this.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (s) => { - if (s === null) return; - - const sess = this.olmDevice.exportInboundGroupSession(s.senderKey, s.sessionId, s.sessionData!); - delete sess.first_known_index; - sess.algorithm = olmlib.MEGOLM_ALGORITHM; - exportedSessions.push(sess); - }); - }); - - return exportedSessions; - } - - /** - * Get a JSON list containing all of the room keys - * - * @returns a JSON string encoding a list of session - * export objects, each of which is an IMegolmSessionData - */ - public async exportRoomKeysAsJson(): Promise { - return JSON.stringify(await this.exportRoomKeys()); - } - - /** - * Import a list of room keys previously exported by exportRoomKeys - * - * @param keys - a list of session export objects - * @returns a promise which resolves once the keys have been imported - */ - public importRoomKeys(keys: IMegolmSessionData[], opts: ImportRoomKeysOpts = {}): Promise { - let successes = 0; - let failures = 0; - const total = keys.length; - - function updateProgress(): void { - opts.progressCallback?.({ - stage: "load_keys", - successes, - failures, - total, - }); - } - - return Promise.all( - keys.map((key) => { - if (!key.room_id || !key.algorithm) { - logger.warn("ignoring room key entry with missing fields", key); - failures++; - if (opts.progressCallback) { - updateProgress(); - } - return null; - } - - const alg = this.getRoomDecryptor(key.room_id, key.algorithm); - return alg.importRoomKey(key, opts).finally(() => { - successes++; - if (opts.progressCallback) { - updateProgress(); - } - }); - }), - ).then(); - } - - /** - * Import a JSON string encoding a list of room keys previously - * exported by exportRoomKeysAsJson - * - * @param keys - a JSON string encoding a list of session export - * objects, each of which is an IMegolmSessionData - * @param opts - options object - * @returns a promise which resolves once the keys have been imported - */ - public async importRoomKeysAsJson(keys: string, opts?: ImportRoomKeysOpts): Promise { - return await this.importRoomKeys(JSON.parse(keys)); - } - - /** - * Counts the number of end to end session keys that are waiting to be backed up - * @returns Promise which resolves to the number of sessions requiring backup - */ - public countSessionsNeedingBackup(): Promise { - return this.backupManager.countSessionsNeedingBackup(); - } - - /** - * Perform any background tasks that can be done before a message is ready to - * send, in order to speed up sending of the message. - * - * @param room - the room the event is in - */ - public prepareToEncrypt(room: Room): void { - const alg = this.roomEncryptors.get(room.roomId); - if (alg) { - alg.prepareToEncrypt(room); - } - } - - /** - * Encrypt an event according to the configuration of the room. - * - * @param event - event to be sent - * - * @param room - destination room. - * - * @returns Promise which resolves when the event has been - * encrypted, or null if nothing was needed - */ - public async encryptEvent(event: MatrixEvent, room: Room): Promise { - const roomId = event.getRoomId()!; - - const alg = this.roomEncryptors.get(roomId); - if (!alg) { - // MatrixClient has already checked that this room should be encrypted, - // so this is an unexpected situation. - throw new Error( - "Room " + - roomId + - " was previously configured to use encryption, but is " + - "no longer. Perhaps the homeserver is hiding the " + - "configuration event.", - ); - } - - // wait for all the room devices to be loaded - await this.trackRoomDevicesImpl(room); - - let content = event.getContent(); - // If event has an m.relates_to then we need - // to put this on the wrapping event instead - const mRelatesTo = content["m.relates_to"]; - if (mRelatesTo) { - // Clone content here so we don't remove `m.relates_to` from the local-echo - content = Object.assign({}, content); - delete content["m.relates_to"]; - } - - // Treat element's performance metrics the same as `m.relates_to` (when present) - const elementPerfMetrics = content["io.element.performance_metrics"]; - if (elementPerfMetrics) { - content = Object.assign({}, content); - delete content["io.element.performance_metrics"]; - } - - const encryptedContent = (await alg.encryptMessage(room, event.getType(), content)) as IContent; - - if (mRelatesTo) { - encryptedContent["m.relates_to"] = mRelatesTo; - } - if (elementPerfMetrics) { - encryptedContent["io.element.performance_metrics"] = elementPerfMetrics; - } - - event.makeEncrypted( - "m.room.encrypted", - encryptedContent, - this.olmDevice.deviceCurve25519Key!, - this.olmDevice.deviceEd25519Key!, - ); - } - - /** - * Decrypt a received event - * - * - * @returns resolves once we have - * finished decrypting. Rejects with an `algorithms.DecryptionError` if there - * is a problem decrypting the event. - */ - public async decryptEvent(event: MatrixEvent): Promise { - if (event.isRedacted()) { - // Try to decrypt the redaction event, to support encrypted - // redaction reasons. If we can't decrypt, just fall back to using - // the original redacted_because. - const redactionEvent = new MatrixEvent({ - room_id: event.getRoomId(), - ...event.getUnsigned().redacted_because, - }); - let redactedBecause: IEvent = event.getUnsigned().redacted_because!; - if (redactionEvent.isEncrypted()) { - try { - const decryptedEvent = await this.decryptEvent(redactionEvent); - redactedBecause = decryptedEvent.clearEvent as IEvent; - } catch (e) { - logger.warn("Decryption of redaction failed. Falling back to unencrypted event.", e); - } - } - - return { - clearEvent: { - room_id: event.getRoomId(), - type: "m.room.message", - content: {}, - unsigned: { - redacted_because: redactedBecause, - }, - }, - }; - } else { - const content = event.getWireContent(); - const alg = this.getRoomDecryptor(event.getRoomId()!, content.algorithm); - return alg.decryptEvent(event); - } - } - - /** - * Handle the notification from /sync that device lists have - * been changed. - * - * @param deviceLists - device_lists field from /sync - */ - public async processDeviceLists(deviceLists: IDeviceLists): Promise { - // Here, we're relying on the fact that we only ever save the sync data after - // sucessfully saving the device list data, so we're guaranteed that the device - // list store is at least as fresh as the sync token from the sync store, ie. - // any device changes received in sync tokens prior to the 'next' token here - // have been processed and are reflected in the current device list. - // If we didn't make this assumption, we'd have to use the /keys/changes API - // to get key changes between the sync token in the device list and the 'old' - // sync token used here to make sure we didn't miss any. - await this.evalDeviceListChanges(deviceLists); - } - - /** - * Send a request for some room keys, if we have not already done so - * - * @param resend - whether to resend the key request if there is - * already one - * - * @returns a promise that resolves when the key request is queued - */ - public requestRoomKey( - requestBody: IRoomKeyRequestBody, - recipients: IRoomKeyRequestRecipient[], - resend = false, - ): Promise { - return this.outgoingRoomKeyRequestManager - .queueRoomKeyRequest(requestBody, recipients, resend) - .then(() => { - if (this.sendKeyRequestsImmediately) { - this.outgoingRoomKeyRequestManager.sendQueuedRequests(); - } - }) - .catch((e) => { - // this normally means we couldn't talk to the store - logger.error("Error requesting key for event", e); - }); - } - - /** - * Cancel any earlier room key request - * - * @param requestBody - parameters to match for cancellation - */ - public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): void { - this.outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody).catch((e) => { - logger.warn("Error clearing pending room key requests", e); - }); - } - - /** - * Re-send any outgoing key requests, eg after verification - * @returns - */ - public async cancelAndResendAllOutgoingKeyRequests(): Promise { - await this.outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); - } - - /** - * handle an m.room.encryption event - * - * @param room - in which the event was received - * @param event - encryption event to be processed - */ - public async onCryptoEvent(room: Room, event: MatrixEvent): Promise { - const content = event.getContent(); - await this.setRoomEncryptionImpl(room, content); - } - - /** - * Called before the result of a sync is processed - * - * @param syncData - the data from the 'MatrixClient.sync' event - */ - public async onSyncWillProcess(syncData: ISyncStateData): Promise { - if (!syncData.oldSyncToken) { - // If there is no old sync token, we start all our tracking from - // scratch, so mark everything as untracked. onCryptoEvent will - // be called for all e2e rooms during the processing of the sync, - // at which point we'll start tracking all the users of that room. - logger.log("Initial sync performed - resetting device tracking state"); - this.deviceList.stopTrackingAllDeviceLists(); - // we always track our own device list (for key backups etc) - this.deviceList.startTrackingDeviceList(this.userId); - this.roomDeviceTrackingState = {}; - } - - this.sendKeyRequestsImmediately = false; - } - - /** - * handle the completion of a /sync - * - * This is called after the processing of each successful /sync response. - * It is an opportunity to do a batch process on the information received. - * - * @param syncData - the data from the 'MatrixClient.sync' event - */ - public async onSyncCompleted(syncData: OnSyncCompletedData): Promise { - this.deviceList.setSyncToken(syncData.nextSyncToken ?? null); - this.deviceList.saveIfDirty(); - - // we always track our own device list (for key backups etc) - this.deviceList.startTrackingDeviceList(this.userId); - - this.deviceList.refreshOutdatedDeviceLists(); - - // we don't start uploading one-time keys until we've caught up with - // to-device messages, to help us avoid throwing away one-time-keys that we - // are about to receive messages for - // (https://github.com/vector-im/element-web/issues/2782). - if (!syncData.catchingUp) { - this.maybeUploadOneTimeKeys(); - this.processReceivedRoomKeyRequests(); - - // likewise don't start requesting keys until we've caught up - // on to_device messages, otherwise we'll request keys that we're - // just about to get. - this.outgoingRoomKeyRequestManager.sendQueuedRequests(); - - // Sync has finished so send key requests straight away. - this.sendKeyRequestsImmediately = true; - } - } - - /** - * Trigger the appropriate invalidations and removes for a given - * device list - * - * @param deviceLists - device_lists field from /sync, or response from - * /keys/changes - */ - private async evalDeviceListChanges(deviceLists: Required["device_lists"]): Promise { - if (Array.isArray(deviceLists?.changed)) { - deviceLists.changed.forEach((u) => { - this.deviceList.invalidateUserDeviceList(u); - }); - } - - if (Array.isArray(deviceLists?.left) && deviceLists.left.length) { - // Check we really don't share any rooms with these users - // any more: the server isn't required to give us the - // exact correct set. - const e2eUserIds = new Set(await this.getTrackedE2eUsers()); - - deviceLists.left.forEach((u) => { - if (!e2eUserIds.has(u)) { - this.deviceList.stopTrackingDeviceList(u); - } - }); - } - } - - /** - * Get a list of all the IDs of users we share an e2e room with - * for which we are tracking devices already - * - * @returns List of user IDs - */ - private async getTrackedE2eUsers(): Promise { - const e2eUserIds: string[] = []; - for (const room of this.getTrackedE2eRooms()) { - const members = await room.getEncryptionTargetMembers(); - for (const member of members) { - e2eUserIds.push(member.userId); - } - } - return e2eUserIds; - } - - /** - * Get a list of the e2e-enabled rooms we are members of, - * and for which we are already tracking the devices - * - * @returns - */ - private getTrackedE2eRooms(): Room[] { - return this.clientStore.getRooms().filter((room) => { - // check for rooms with encryption enabled - const alg = this.roomEncryptors.get(room.roomId); - if (!alg) { - return false; - } - if (!this.roomDeviceTrackingState[room.roomId]) { - return false; - } - - // ignore any rooms which we have left - const myMembership = room.getMyMembership(); - return myMembership === KnownMembership.Join || myMembership === KnownMembership.Invite; - }); - } - - /** - * Encrypts and sends a given object via Olm to-device messages to a given - * set of devices. - * @param userDeviceInfoArr - the devices to send to - * @param payload - fields to include in the encrypted payload - * @returns Promise which - * resolves once the message has been encrypted and sent to the given - * userDeviceMap, and returns the `{ contentMap, deviceInfoByDeviceId }` - * of the successfully sent messages. - * - * @deprecated Instead use {@link encryptToDeviceMessages} followed by {@link MatrixClient.queueToDevice}. - */ - public async encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice[], payload: object): Promise { - try { - const toDeviceBatch = await this.prepareToDeviceBatch(userDeviceInfoArr, payload); - - try { - await this.baseApis.queueToDevice(toDeviceBatch); - } catch (e) { - logger.error("sendToDevice failed", e); - throw e; - } - } catch (e) { - logger.error("encryptAndSendToDevices promises failed", e); - throw e; - } - } - - private async prepareToDeviceBatch( - userDeviceInfoArr: IOlmDevice[], - payload: object, - ): Promise { - const toDeviceBatch: ToDeviceBatch = { - eventType: EventType.RoomMessageEncrypted, - batch: [], - }; - - await Promise.all( - userDeviceInfoArr.map(async ({ userId, deviceInfo }) => { - const deviceId = deviceInfo.deviceId; - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - - toDeviceBatch.batch.push({ - userId, - deviceId, - payload: encryptedContent, - }); - - await olmlib.ensureOlmSessionsForDevices( - this.olmDevice, - this.baseApis, - new Map([[userId, [deviceInfo]]]), - ); - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this.userId, - this.deviceId, - this.olmDevice, - userId, - deviceInfo, - payload, - ); - }), - ); - - // prune out any devices that encryptMessageForDevice could not encrypt for, - // in which case it will have just not added anything to the ciphertext object. - // There's no point sending messages to devices if we couldn't encrypt to them, - // since that's effectively a blank message. - toDeviceBatch.batch = toDeviceBatch.batch.filter((msg) => { - if (Object.keys(msg.payload.ciphertext).length > 0) { - return true; - } else { - logger.log(`No ciphertext for device ${msg.userId}:${msg.deviceId}: pruning`); - return false; - } - }); - - return toDeviceBatch; - } - - /** - * Implementation of {@link Crypto.CryptoApi#encryptToDeviceMessages}. - */ - public async encryptToDeviceMessages( - eventType: string, - devices: { userId: string; deviceId: string }[], - payload: ToDevicePayload, - ): Promise { - const userIds = new Set(devices.map(({ userId }) => userId)); - const deviceInfoMap = await this.downloadKeys(Array.from(userIds), false); - - const userDeviceInfoArr: IOlmDevice[] = []; - - devices.forEach(({ userId, deviceId }) => { - const devices = deviceInfoMap.get(userId); - if (!devices) { - logger.warn(`No devices found for user ${userId}`); - return; - } - - if (devices.has(deviceId)) { - // Send the message to a specific device - userDeviceInfoArr.push({ userId, deviceInfo: devices.get(deviceId)! }); - } else { - logger.warn(`No device found for user ${userId} with id ${deviceId}`); - } - }); - - return this.prepareToDeviceBatch(userDeviceInfoArr, payload); - } - - private onMembership = (event: MatrixEvent, member: RoomMember, oldMembership?: string): void => { - try { - this.onRoomMembership(event, member, oldMembership); - } catch (e) { - logger.error("Error handling membership change:", e); - } - }; - - public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise { - // all we do here is filter out encrypted to-device messages with the wrong algorithm. Decryption - // happens later in decryptEvent, via the EventMapper - return events.filter((toDevice) => { - if ( - toDevice.type === EventType.RoomMessageEncrypted && - !["m.olm.v1.curve25519-aes-sha2"].includes(toDevice.content?.algorithm) - ) { - logger.log("Ignoring invalid encrypted to-device event from " + toDevice.sender); - return false; - } - return true; - }); - } - - /** - * Stores the current one_time_key count which will be handled later (in a call of - * onSyncCompleted). - * - * @param currentCount - The current count of one_time_keys to be stored - */ - private updateOneTimeKeyCount(currentCount: number): void { - if (isFinite(currentCount)) { - this.oneTimeKeyCount = currentCount; - } else { - throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number"); - } - } - - public processKeyCounts(oneTimeKeysCounts?: Record, unusedFallbackKeys?: string[]): Promise { - if (oneTimeKeysCounts !== undefined) { - this.updateOneTimeKeyCount(oneTimeKeysCounts["signed_curve25519"] || 0); - } - - if (unusedFallbackKeys !== undefined) { - // If `unusedFallbackKeys` is defined, that means `device_unused_fallback_key_types` - // is present in the sync response, which indicates that the server supports fallback keys. - // - // If there's no unused signed_curve25519 fallback key, we need a new one. - this.needsNewFallback = !unusedFallbackKeys.includes("signed_curve25519"); - } - - return Promise.resolve(); - } - - private onToDeviceEvent = (event: MatrixEvent): void => { - try { - logger.log( - `received to-device ${event.getType()} from: ` + - `${event.getSender()} id: ${event.getContent()[ToDeviceMessageId]}`, - ); - - if (event.getType() == "m.room_key" || event.getType() == "m.forwarded_room_key") { - this.onRoomKeyEvent(event); - } else if (event.getType() == "m.room_key_request") { - this.onRoomKeyRequestEvent(event); - } else if (event.getType() === "m.secret.request") { - this.secretStorage.onRequestReceived(event); - } else if (event.getType() === "m.secret.send") { - this.secretStorage.onSecretReceived(event); - } else if (event.getType() === "m.room_key.withheld") { - this.onRoomKeyWithheldEvent(event); - } else if (event.getContent().transaction_id) { - this.onKeyVerificationMessage(event); - } else if (event.getContent().msgtype === "m.bad.encrypted") { - this.onToDeviceBadEncrypted(event); - } else if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { - if (!event.isBeingDecrypted()) { - event.attemptDecryption(this); - } - // once the event has been decrypted, try again - event.once(MatrixEventEvent.Decrypted, (ev) => { - this.onToDeviceEvent(ev); - }); - } - } catch (e) { - logger.error("Error handling toDeviceEvent:", e); - } - }; - - /** - * Handle a key event - * - * @internal - * @param event - key event - */ - private onRoomKeyEvent(event: MatrixEvent): void { - const content = event.getContent(); - - if (!content.room_id || !content.algorithm) { - logger.error("key event is missing fields"); - return; - } - - if (!this.backupManager.checkedForBackup) { - // don't bother awaiting on this - the important thing is that we retry if we - // haven't managed to check before - this.backupManager.checkAndStart(); - } - - const alg = this.getRoomDecryptor(content.room_id, content.algorithm); - alg.onRoomKeyEvent(event); - } - - /** - * Handle a key withheld event - * - * @internal - * @param event - key withheld event - */ - private onRoomKeyWithheldEvent(event: MatrixEvent): void { - const content = event.getContent(); - - if ( - (content.code !== "m.no_olm" && (!content.room_id || !content.session_id)) || - !content.algorithm || - !content.sender_key - ) { - logger.error("key withheld event is missing fields"); - return; - } - - logger.info( - `Got room key withheld event from ${event.getSender()} ` + - `for ${content.algorithm} session ${content.sender_key}|${content.session_id} ` + - `in room ${content.room_id} with code ${content.code} (${content.reason})`, - ); - - const alg = this.getRoomDecryptor(content.room_id, content.algorithm); - if (alg.onRoomKeyWithheldEvent) { - alg.onRoomKeyWithheldEvent(event); - } - if (!content.room_id) { - // retry decryption for all events sent by the sender_key. This will - // update the events to show a message indicating that the olm session was - // wedged. - const roomDecryptors = this.getRoomDecryptors(content.algorithm); - for (const decryptor of roomDecryptors) { - decryptor.retryDecryptionFromSender(content.sender_key); - } - } - } - - /** - * Handle a general key verification event. - * - * @internal - * @param event - verification start event - */ - private onKeyVerificationMessage(event: MatrixEvent): void { - if (!ToDeviceChannel.validateEvent(event, this.baseApis)) { - return; - } - const createRequest = (event: MatrixEvent): VerificationRequest | undefined => { - if (!ToDeviceChannel.canCreateRequest(ToDeviceChannel.getEventType(event))) { - return; - } - const content = event.getContent(); - const deviceId = content && content.from_device; - if (!deviceId) { - return; - } - const userId = event.getSender()!; - const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId]); - return new VerificationRequest(channel, this.verificationMethods, this.baseApis); - }; - this.handleVerificationEvent(event, this.toDeviceVerificationRequests, createRequest); - } - - /** - * Handle key verification requests sent as timeline events - * - * @internal - * @param event - the timeline event - * @param room - not used - * @param atStart - not used - * @param removed - not used - * @param whether - this is a live event - */ - private onTimelineEvent = ( - event: MatrixEvent, - room: Room, - atStart: boolean, - removed: boolean, - { liveEvent = true } = {}, - ): void => { - if (!InRoomChannel.validateEvent(event, this.baseApis)) { - return; - } - const createRequest = (event: MatrixEvent): VerificationRequest => { - const channel = new InRoomChannel(this.baseApis, event.getRoomId()!); - return new VerificationRequest(channel, this.verificationMethods, this.baseApis); - }; - this.handleVerificationEvent(event, this.inRoomVerificationRequests, createRequest, liveEvent); - }; - - private async handleVerificationEvent( - event: MatrixEvent, - requestsMap: IRequestsMap, - createRequest: (event: MatrixEvent) => VerificationRequest | undefined, - isLiveEvent = true, - ): Promise { - // Wait for event to get its final ID with pendingEventOrdering: "chronological", since DM channels depend on it. - if (event.isSending() && event.status != EventStatus.SENT) { - let eventIdListener: () => void; - let statusListener: () => void; - try { - await new Promise((resolve, reject) => { - eventIdListener = resolve; - statusListener = (): void => { - if (event.status == EventStatus.CANCELLED) { - reject(new Error("Event status set to CANCELLED.")); - } - }; - event.once(MatrixEventEvent.LocalEventIdReplaced, eventIdListener); - event.on(MatrixEventEvent.Status, statusListener); - }); - } catch (err) { - logger.error("error while waiting for the verification event to be sent: ", err); - return; - } finally { - event.removeListener(MatrixEventEvent.LocalEventIdReplaced, eventIdListener!); - event.removeListener(MatrixEventEvent.Status, statusListener!); - } - } - let request: VerificationRequest | undefined = requestsMap.getRequest(event); - let isNewRequest = false; - if (!request) { - request = createRequest(event); - // a request could not be made from this event, so ignore event - if (!request) { - logger.log( - `Crypto: could not find VerificationRequest for ` + - `${event.getType()}, and could not create one, so ignoring.`, - ); - return; - } - isNewRequest = true; - requestsMap.setRequest(event, request); - } - event.setVerificationRequest(request); - try { - await request.channel.handleEvent(event, request, isLiveEvent); - } catch (err) { - logger.error("error while handling verification event", err); - } - const shouldEmit = - isNewRequest && - !request.initiatedByMe && - !request.invalid && // check it has enough events to pass the UNSENT stage - !request.observeOnly; - if (shouldEmit) { - this.baseApis.emit(CryptoEvent.VerificationRequest, request); - this.baseApis.emit(CryptoEvent.VerificationRequestReceived, request); - } - } - - /** - * Handle a toDevice event that couldn't be decrypted - * - * @internal - * @param event - undecryptable event - */ - private async onToDeviceBadEncrypted(event: MatrixEvent): Promise { - const content = event.getWireContent(); - const sender = event.getSender(); - const algorithm = content.algorithm; - const deviceKey = content.sender_key; - - this.baseApis.emit(ClientEvent.UndecryptableToDeviceEvent, event); - - // retry decryption for all events sent by the sender_key. This will - // update the events to show a message indicating that the olm session was - // wedged. - const retryDecryption = (): void => { - const roomDecryptors = this.getRoomDecryptors(olmlib.MEGOLM_ALGORITHM); - for (const decryptor of roomDecryptors) { - decryptor.retryDecryptionFromSender(deviceKey); - } - }; - - if (sender === undefined || deviceKey === undefined || deviceKey === undefined) { - return; - } - - // check when we can force a new session with this device: if we've already done so - // recently, don't do it again. - const forceNewSessionRetryTimeDevices = this.forceNewSessionRetryTime.getOrCreate(sender); - const forceNewSessionRetryTime = forceNewSessionRetryTimeDevices.getOrCreate(deviceKey); - if (forceNewSessionRetryTime > Date.now()) { - logger.debug( - `New session already forced with device ${sender}:${deviceKey}: ` + - `not forcing another until at least ${new Date(forceNewSessionRetryTime).toUTCString()}`, - ); - await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true); - retryDecryption(); - return; - } - - // make sure we don't retry to unwedge too soon even if we fail to create a new session - forceNewSessionRetryTimeDevices.set(deviceKey, Date.now() + FORCE_SESSION_RETRY_INTERVAL_MS); - - // establish a new olm session with this device since we're failing to decrypt messages - // on a current session. - // Note that an undecryptable message from another device could easily be spoofed - - // is there anything we can do to mitigate this? - let device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey); - if (!device) { - // if we don't know about the device, fetch the user's devices again - // and retry before giving up - await this.downloadKeys([sender], false); - device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey); - if (!device) { - logger.info("Couldn't find device for identity key " + deviceKey + ": not re-establishing session"); - await this.olmDevice.recordSessionProblem(deviceKey, "wedged", false); - retryDecryption(); - return; - } - } - const devicesByUser = new Map([[sender, [device]]]); - await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, true); - - forceNewSessionRetryTimeDevices.set(deviceKey, Date.now() + MIN_FORCE_SESSION_INTERVAL_MS); - - // Now send a blank message on that session so the other side knows about it. - // (The keyshare request is sent in the clear so that won't do) - // We send this first such that, as long as the toDevice messages arrive in the - // same order we sent them, the other end will get this first, set up the new session, - // then get the keyshare request and send the key over this new session (because it - // is the session it has most recently received a message on). - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this.userId, - this.deviceId, - this.olmDevice, - sender, - device, - { type: "m.dummy" }, - ); - - await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true); - retryDecryption(); - - await this.baseApis.sendToDevice( - "m.room.encrypted", - new Map([[sender, new Map([[device.deviceId, encryptedContent]])]]), - ); - - // Most of the time this probably won't be necessary since we'll have queued up a key request when - // we failed to decrypt the message and will be waiting a bit for the key to arrive before sending - // it. This won't always be the case though so we need to re-send any that have already been sent - // to avoid races. - const requestsToResend = await this.outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest( - sender, - device.deviceId, - ); - for (const keyReq of requestsToResend) { - this.requestRoomKey(keyReq.requestBody, keyReq.recipients, true); - } - } - - /** - * Handle a change in the membership state of a member of a room - * - * @internal - * @param event - event causing the change - * @param member - user whose membership changed - * @param oldMembership - previous membership - */ - private onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void { - // this event handler is registered on the *client* (as opposed to the room - // member itself), which means it is only called on changes to the *live* - // membership state (ie, it is not called when we back-paginate, nor when - // we load the state in the initialsync). - // - // Further, it is automatically registered and called when new members - // arrive in the room. - - const roomId = member.roomId; - - const alg = this.roomEncryptors.get(roomId); - if (!alg) { - // not encrypting in this room - return; - } - // only mark users in this room as tracked if we already started tracking in this room - // this way we don't start device queries after sync on behalf of this room which we won't use - // the result of anyway, as we'll need to do a query again once all the members are fetched - // by calling _trackRoomDevices - if (roomId in this.roomDeviceTrackingState) { - if (member.membership == KnownMembership.Join) { - logger.log("Join event for " + member.userId + " in " + roomId); - // make sure we are tracking the deviceList for this user - this.deviceList.startTrackingDeviceList(member.userId); - } else if ( - member.membership == KnownMembership.Invite && - this.clientStore.getRoom(roomId)?.shouldEncryptForInvitedMembers() - ) { - logger.log("Invite event for " + member.userId + " in " + roomId); - this.deviceList.startTrackingDeviceList(member.userId); - } - } - - alg.onRoomMembership(event, member, oldMembership); - } - - /** - * Called when we get an m.room_key_request event. - * - * @internal - * @param event - key request event - */ - private onRoomKeyRequestEvent(event: MatrixEvent): void { - const content = event.getContent(); - if (content.action === "request") { - // Queue it up for now, because they tend to arrive before the room state - // events at initial sync, and we want to see if we know anything about the - // room before passing them on to the app. - const req = new IncomingRoomKeyRequest(event); - this.receivedRoomKeyRequests.push(req); - } else if (content.action === "request_cancellation") { - const req = new IncomingRoomKeyRequestCancellation(event); - this.receivedRoomKeyRequestCancellations.push(req); - } - } - - /** - * Process any m.room_key_request events which were queued up during the - * current sync. - * - * @internal - */ - private async processReceivedRoomKeyRequests(): Promise { - if (this.processingRoomKeyRequests) { - // we're still processing last time's requests; keep queuing new ones - // up for now. - return; - } - this.processingRoomKeyRequests = true; - - try { - // we need to grab and clear the queues in the synchronous bit of this method, - // so that we don't end up racing with the next /sync. - const requests = this.receivedRoomKeyRequests; - this.receivedRoomKeyRequests = []; - const cancellations = this.receivedRoomKeyRequestCancellations; - this.receivedRoomKeyRequestCancellations = []; - - // Process all of the requests, *then* all of the cancellations. - // - // This makes sure that if we get a request and its cancellation in the - // same /sync result, then we process the request before the - // cancellation (and end up with a cancelled request), rather than the - // cancellation before the request (and end up with an outstanding - // request which should have been cancelled.) - await Promise.all(requests.map((req) => this.processReceivedRoomKeyRequest(req))); - await Promise.all( - cancellations.map((cancellation) => this.processReceivedRoomKeyRequestCancellation(cancellation)), - ); - } catch (e) { - logger.error(`Error processing room key requsts: ${e}`); - } finally { - this.processingRoomKeyRequests = false; - } - } - - /** - * Helper for processReceivedRoomKeyRequests - * - */ - private async processReceivedRoomKeyRequest(req: IncomingRoomKeyRequest): Promise { - const userId = req.userId; - const deviceId = req.deviceId; - - const body = req.requestBody; - const roomId = body.room_id; - const alg = body.algorithm; - - logger.log( - `m.room_key_request from ${userId}:${deviceId}` + - ` for ${roomId} / ${body.session_id} (id ${req.requestId})`, - ); - - if (userId !== this.userId) { - if (!this.roomEncryptors.get(roomId)) { - logger.debug(`room key request for unencrypted room ${roomId}`); - return; - } - const encryptor = this.roomEncryptors.get(roomId)!; - const device = this.deviceList.getStoredDevice(userId, deviceId); - if (!device) { - logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`); - return; - } - - try { - await encryptor.reshareKeyWithDevice!(body.sender_key, body.session_id, userId, device); - } catch (e) { - logger.warn( - "Failed to re-share keys for session " + - body.session_id + - " with device " + - userId + - ":" + - device.deviceId, - e, - ); - } - return; - } - - if (deviceId === this.deviceId) { - // We'll always get these because we send room key requests to - // '*' (ie. 'all devices') which includes the sending device, - // so ignore requests from ourself because apart from it being - // very silly, it won't work because an Olm session cannot send - // messages to itself. - // The log here is probably superfluous since we know this will - // always happen, but let's log anyway for now just in case it - // causes issues. - logger.log("Ignoring room key request from ourselves"); - return; - } - - // todo: should we queue up requests we don't yet have keys for, - // in case they turn up later? - - // if we don't have a decryptor for this room/alg, we don't have - // the keys for the requested events, and can drop the requests. - if (!this.roomDecryptors.has(roomId)) { - logger.log(`room key request for unencrypted room ${roomId}`); - return; - } - - const decryptor = this.roomDecryptors.get(roomId)!.get(alg); - if (!decryptor) { - logger.log(`room key request for unknown alg ${alg} in room ${roomId}`); - return; - } - - if (!(await decryptor.hasKeysForKeyRequest(req))) { - logger.log(`room key request for unknown session ${roomId} / ` + body.session_id); - return; - } - - req.share = (): void => { - decryptor.shareKeysWithDevice(req); - }; - - // if the device is verified already, share the keys - if (this.checkDeviceTrust(userId, deviceId).isVerified()) { - logger.log("device is already verified: sharing keys"); - req.share(); - return; - } - - this.emit(CryptoEvent.RoomKeyRequest, req); - } - - /** - * Helper for processReceivedRoomKeyRequests - * - */ - private async processReceivedRoomKeyRequestCancellation( - cancellation: IncomingRoomKeyRequestCancellation, - ): Promise { - logger.log( - `m.room_key_request cancellation for ${cancellation.userId}:` + - `${cancellation.deviceId} (id ${cancellation.requestId})`, - ); - - // we should probably only notify the app of cancellations we told it - // about, but we don't currently have a record of that, so we just pass - // everything through. - this.emit(CryptoEvent.RoomKeyRequestCancellation, cancellation); - } - - /** - * Get a decryptor for a given room and algorithm. - * - * If we already have a decryptor for the given room and algorithm, return - * it. Otherwise try to instantiate it. - * - * @internal - * - * @param roomId - room id for decryptor. If undefined, a temporary - * decryptor is instantiated. - * - * @param algorithm - crypto algorithm - * - * @throws `DecryptionError` if the algorithm is unknown - */ - public getRoomDecryptor(roomId: string | null, algorithm: string): DecryptionAlgorithm { - let decryptors: Map | undefined; - let alg: DecryptionAlgorithm | undefined; - - if (roomId) { - decryptors = this.roomDecryptors.get(roomId); - if (!decryptors) { - decryptors = new Map(); - this.roomDecryptors.set(roomId, decryptors); - } - - alg = decryptors.get(algorithm); - if (alg) { - return alg; - } - } - - const AlgClass = algorithms.DECRYPTION_CLASSES.get(algorithm); - if (!AlgClass) { - throw new DecryptionError( - DecryptionFailureCode.UNKNOWN_ENCRYPTION_ALGORITHM, - 'Unknown encryption algorithm "' + algorithm + '".', - ); - } - alg = new AlgClass({ - userId: this.userId, - crypto: this, - olmDevice: this.olmDevice, - baseApis: this.baseApis, - roomId: roomId ?? undefined, - }); - - if (decryptors) { - decryptors.set(algorithm, alg); - } - return alg; - } - - /** - * Get all the room decryptors for a given encryption algorithm. - * - * @param algorithm - The encryption algorithm - * - * @returns An array of room decryptors - */ - private getRoomDecryptors(algorithm: string): DecryptionAlgorithm[] { - const decryptors: DecryptionAlgorithm[] = []; - for (const d of this.roomDecryptors.values()) { - if (d.has(algorithm)) { - decryptors.push(d.get(algorithm)!); - } - } - return decryptors; - } - - /** - * sign the given object with our ed25519 key - * - * @param obj - Object to which we will add a 'signatures' property - */ - public async signObject(obj: T): Promise { - const sigs = new Map(Object.entries(obj.signatures || {})); - const unsigned = obj.unsigned; - - delete obj.signatures; - delete obj.unsigned; - - const userSignatures = sigs.get(this.userId) || {}; - sigs.set(this.userId, userSignatures); - userSignatures["ed25519:" + this.deviceId] = await this.olmDevice.sign(anotherjson.stringify(obj)); - obj.signatures = recursiveMapToObject(sigs); - if (unsigned !== undefined) obj.unsigned = unsigned; - } - - /** - * @returns true if the room with the supplied ID is encrypted. False if the - * room is not encrypted, or is unknown to us. - */ - public isRoomEncrypted(roomId: string): boolean { - return this.roomList.isRoomEncrypted(roomId); - } - - /** - * Implementation of {@link Crypto.CryptoApi#isEncryptionEnabledInRoom}. - */ - public async isEncryptionEnabledInRoom(roomId: string): Promise { - return this.isRoomEncrypted(roomId); - } - - /** - * @returns information about the encryption on the room with the supplied - * ID, or null if the room is not encrypted or unknown to us. - */ - public getRoomEncryption(roomId: string): IRoomEncryption | null { - return this.roomList.getRoomEncryption(roomId); - } - - /** - * Returns whether dehydrated devices are supported by the crypto backend - * and by the server. - */ - public async isDehydrationSupported(): Promise { - return false; - } - - /** - * Stub function -- dehydration is not implemented here, so throw error - */ - public async startDehydration(createNewKey?: StartDehydrationOpts | boolean): Promise { - throw new Error("Not implemented"); - } - - /** - * Stub function -- restoreKeyBackup is not implemented here, so throw error - */ - public restoreKeyBackup(opts: KeyBackupRestoreOpts): Promise { - throw new Error("Not implemented"); - } - - /** - * Stub function -- restoreKeyBackupWithPassphrase is not implemented here, so throw error - */ - public restoreKeyBackupWithPassphrase( - passphrase: string, - opts: KeyBackupRestoreOpts, - ): Promise { - throw new Error("Not implemented"); - } - - /** - * Stub function -- resetEncryption is not implemented here, so throw error - */ - public resetEncryption(): Promise { - throw new Error("Not implemented"); - } -} - -/** - * Fix up the backup key, that may be in the wrong format due to a bug in a - * migration step. Some backup keys were stored as a comma-separated list of - * integers, rather than a base64-encoded byte array. If this function is - * passed a string that looks like a list of integers rather than a base64 - * string, it will attempt to convert it to the right format. - * - * @param key - the key to check - * @returns If the key is in the wrong format, then the fixed - * key will be returned. Otherwise null will be returned. - * - */ -export function fixBackupKey(key?: string): string | null { - if (typeof key !== "string" || key.indexOf(",") < 0) { - return null; - } - const fixedKey = Uint8Array.from(key.split(","), (x) => parseInt(x)); - return encodeBase64(fixedKey); -} - -/** - * Represents a received m.room_key_request event - */ -export class IncomingRoomKeyRequest { - /** user requesting the key */ - public readonly userId: string; - /** device requesting the key */ - public readonly deviceId: string; - /** unique id for the request */ - public readonly requestId: string; - public readonly requestBody: IRoomKeyRequestBody; - /** - * callback which, when called, will ask - * the relevant crypto algorithm implementation to share the keys for - * this request. - */ - public share: () => void; - - public constructor(event: MatrixEvent) { - const content = event.getContent(); - - this.userId = event.getSender()!; - this.deviceId = content.requesting_device_id; - this.requestId = content.request_id; - this.requestBody = content.body || {}; - this.share = (): void => { - throw new Error("don't know how to share keys for this request yet"); - }; - } -} - -/** - * Represents a received m.room_key_request cancellation - */ -class IncomingRoomKeyRequestCancellation { - /** user requesting the cancellation */ - public readonly userId: string; - /** device requesting the cancellation */ - public readonly deviceId: string; - /** unique id for the request to be cancelled */ - public readonly requestId: string; - - public constructor(event: MatrixEvent) { - const content = event.getContent(); - - this.userId = event.getSender()!; - this.deviceId = content.requesting_device_id; - this.requestId = content.request_id; - } -} - -// a number of types are re-exported for backwards compatibility, in case any applications are referencing it. -export type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto.ts"; diff --git a/src/crypto/key_passphrase.ts b/src/crypto/key_passphrase.ts deleted file mode 100644 index b60f4f921..000000000 --- a/src/crypto/key_passphrase.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* -Copyright 2018 - 2021 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 { secureRandomString } from "../randomstring.ts"; -import { deriveRecoveryKeyFromPassphrase } from "../crypto-api/index.ts"; - -const DEFAULT_ITERATIONS = 500000; - -interface IKey { - key: Uint8Array; - salt: string; - iterations: number; -} - -/** - * Generate a new recovery key, based on a passphrase. - * @param passphrase - The passphrase to generate the key from - */ -export async function keyFromPassphrase(passphrase: string): Promise { - const salt = secureRandomString(32); - - const key = await deriveRecoveryKeyFromPassphrase(passphrase, salt, DEFAULT_ITERATIONS); - - return { key, salt, iterations: DEFAULT_ITERATIONS }; -} - -// Re-export the key passphrase functions to avoid breaking changes -export { deriveRecoveryKeyFromPassphrase as deriveKey }; -export { keyFromAuthData } from "../common-crypto/key-passphrase.ts"; diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts deleted file mode 100644 index 769436938..000000000 --- a/src/crypto/keybackup.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2021 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. -*/ - -// Export for backward compatibility -import { type ImportRoomKeyProgressData } from "../crypto-api/index.ts"; - -export type { - Curve25519AuthData as ICurve25519AuthData, - Aes256AuthData as IAes256AuthData, - KeyBackupInfo as IKeyBackupInfo, - Curve25519SessionData, - KeyBackupSession as IKeyBackupSession, - KeyBackupRoomSessions as IKeyBackupRoomSessions, -} from "../crypto-api/keybackup.ts"; - -/* eslint-enable camelcase */ - -export interface IKeyBackupPrepareOpts { - /** - * Whether to use Secure Secret Storage to store the key encrypting key backups. - * Optional, defaults to false. - */ - secureSecretStorage: boolean; -} - -export interface IKeyBackupRestoreResult { - total: number; - imported: number; -} - -export interface IKeyBackupRestoreOpts { - cacheCompleteCallback?: () => void; - progressCallback?: (progress: ImportRoomKeyProgressData) => void; -} diff --git a/src/crypto/olmlib.ts b/src/crypto/olmlib.ts deleted file mode 100644 index c908af26e..000000000 --- a/src/crypto/olmlib.ts +++ /dev/null @@ -1,539 +0,0 @@ -/* -Copyright 2016 - 2021 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. -*/ - -/** - * Utilities common to olm encryption algorithms - */ - -import anotherjson from "another-json"; - -import type { PkSigning } from "@matrix-org/olm"; -import type { IOneTimeKey } from "../@types/crypto.ts"; -import { type OlmDevice } from "./OlmDevice.ts"; -import { type DeviceInfo } from "./deviceinfo.ts"; -import { type Logger, logger } from "../logger.ts"; -import { type IClaimOTKsResult, type MatrixClient } from "../client.ts"; -import { type ISignatures } from "../@types/signed.ts"; -import { type MatrixEvent } from "../models/event.ts"; -import { EventType } from "../@types/event.ts"; -import { type IMessage } from "./algorithms/olm.ts"; -import { MapWithDefault } from "../utils.ts"; - -enum Algorithm { - Olm = "m.olm.v1.curve25519-aes-sha2", - Megolm = "m.megolm.v1.aes-sha2", - MegolmBackup = "m.megolm_backup.v1.curve25519-aes-sha2", -} - -/** - * matrix algorithm tag for olm - */ -export const OLM_ALGORITHM = Algorithm.Olm; - -/** - * matrix algorithm tag for megolm - */ -export const MEGOLM_ALGORITHM = Algorithm.Megolm; - -/** - * matrix algorithm tag for megolm backups - */ -export const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup; - -export interface IOlmSessionResult { - /** device info */ - device: DeviceInfo; - /** base64 olm session id; null if no session could be established */ - sessionId: string | null; -} - -/** - * Encrypt an event payload for an Olm device - * - * @param resultsObject - The `ciphertext` property - * of the m.room.encrypted event to which to add our result - * - * @param olmDevice - olm.js wrapper - * @param payloadFields - fields to include in the encrypted payload - * - * Returns a promise which resolves (to undefined) when the payload - * has been encrypted into `resultsObject` - */ -export async function encryptMessageForDevice( - resultsObject: Record, - ourUserId: string, - ourDeviceId: string | undefined, - olmDevice: OlmDevice, - recipientUserId: string, - recipientDevice: DeviceInfo, - payloadFields: Record, -): Promise { - const deviceKey = recipientDevice.getIdentityKey(); - const sessionId = await olmDevice.getSessionIdForDevice(deviceKey); - if (sessionId === null) { - // If we don't have a session for a device then - // we can't encrypt a message for it. - logger.log( - `[olmlib.encryptMessageForDevice] Unable to find Olm session for device ` + - `${recipientUserId}:${recipientDevice.deviceId}`, - ); - return; - } - - logger.log( - `[olmlib.encryptMessageForDevice] Using Olm session ${sessionId} for device ` + - `${recipientUserId}:${recipientDevice.deviceId}`, - ); - - const payload = { - sender: ourUserId, - // TODO this appears to no longer be used whatsoever - sender_device: ourDeviceId, - - // Include the Ed25519 key so that the recipient knows what - // device this message came from. - // We don't need to include the curve25519 key since the - // recipient will already know this from the olm headers. - // When combined with the device keys retrieved from the - // homeserver signed by the ed25519 key this proves that - // the curve25519 key and the ed25519 key are owned by - // the same device. - keys: { - ed25519: olmDevice.deviceEd25519Key, - }, - - // include the recipient device details in the payload, - // to avoid unknown key attacks, per - // https://github.com/vector-im/vector-web/issues/2483 - recipient: recipientUserId, - recipient_keys: { - ed25519: recipientDevice.getFingerprint(), - }, - ...payloadFields, - }; - - // TODO: technically, a bunch of that stuff only needs to be included for - // pre-key messages: after that, both sides know exactly which devices are - // involved in the session. If we're looking to reduce data transfer in the - // future, we could elide them for subsequent messages. - - resultsObject[deviceKey] = await olmDevice.encryptMessage(deviceKey, sessionId, JSON.stringify(payload)); -} - -interface IExistingOlmSession { - device: DeviceInfo; - sessionId: string | null; -} - -/** - * Get the existing olm sessions for the given devices, and the devices that - * don't have olm sessions. - * - * - * - * @param devicesByUser - map from userid to list of devices to ensure sessions for - * - * @returns resolves to an array. The first element of the array is a - * a map of user IDs to arrays of deviceInfo, representing the devices that - * don't have established olm sessions. The second element of the array is - * a map from userId to deviceId to {@link OlmSessionResult} - */ -export async function getExistingOlmSessions( - olmDevice: OlmDevice, - baseApis: MatrixClient, - devicesByUser: Record, -): Promise<[Map, Map>]> { - // map user Id → DeviceInfo[] - const devicesWithoutSession: MapWithDefault = new MapWithDefault(() => []); - // map user Id → device Id → IExistingOlmSession - const sessions: MapWithDefault> = new MapWithDefault(() => new Map()); - - const promises: Promise[] = []; - - for (const [userId, devices] of Object.entries(devicesByUser)) { - for (const deviceInfo of devices) { - const deviceId = deviceInfo.deviceId; - const key = deviceInfo.getIdentityKey(); - promises.push( - (async (): Promise => { - const sessionId = await olmDevice.getSessionIdForDevice(key, true); - if (sessionId === null) { - devicesWithoutSession.getOrCreate(userId).push(deviceInfo); - } else { - sessions.getOrCreate(userId).set(deviceId, { - device: deviceInfo, - sessionId: sessionId, - }); - } - })(), - ); - } - } - - await Promise.all(promises); - - return [devicesWithoutSession, sessions]; -} - -/** - * Try to make sure we have established olm sessions for the given devices. - * - * @param devicesByUser - map from userid to list of devices to ensure sessions for - * - * @param force - If true, establish a new session even if one - * already exists. - * - * @param otkTimeout - The timeout in milliseconds when requesting - * one-time keys for establishing new olm sessions. - * - * @param failedServers - An array to fill with remote servers that - * failed to respond to one-time-key requests. - * - * @param log - A possibly customised log - * - * @returns resolves once the sessions are complete, to - * an Object mapping from userId to deviceId to - * {@link OlmSessionResult} - */ -export async function ensureOlmSessionsForDevices( - olmDevice: OlmDevice, - baseApis: MatrixClient, - devicesByUser: Map, - force = false, - otkTimeout?: number, - failedServers?: string[], - log: Logger = logger, -): Promise>> { - const devicesWithoutSession: [string, string][] = [ - // [userId, deviceId], ... - ]; - // map user Id → device Id → IExistingOlmSession - const result: Map> = new Map(); - // map device key → resolve session fn - const resolveSession: Map void> = new Map(); - - // Mark all sessions this task intends to update as in progress. It is - // important to do this for all devices this task cares about in a single - // synchronous operation, as otherwise it is possible to have deadlocks - // where multiple tasks wait indefinitely on another task to update some set - // of common devices. - for (const devices of devicesByUser.values()) { - for (const deviceInfo of devices) { - const key = deviceInfo.getIdentityKey(); - - if (key === olmDevice.deviceCurve25519Key) { - // We don't start sessions with ourself, so there's no need to - // mark it in progress. - continue; - } - - if (!olmDevice.sessionsInProgress[key]) { - // pre-emptively mark the session as in-progress to avoid race - // conditions. If we find that we already have a session, then - // we'll resolve - olmDevice.sessionsInProgress[key] = new Promise((resolve) => { - resolveSession.set(key, (v: any): void => { - delete olmDevice.sessionsInProgress[key]; - resolve(v); - }); - }); - } - } - } - - for (const [userId, devices] of devicesByUser) { - const resultDevices = new Map(); - result.set(userId, resultDevices); - - for (const deviceInfo of devices) { - const deviceId = deviceInfo.deviceId; - const key = deviceInfo.getIdentityKey(); - - if (key === olmDevice.deviceCurve25519Key) { - // We should never be trying to start a session with ourself. - // Apart from talking to yourself being the first sign of madness, - // olm sessions can't do this because they get confused when - // they get a message and see that the 'other side' has started a - // new chain when this side has an active sender chain. - // If you see this message being logged in the wild, we should find - // the thing that is trying to send Olm messages to itself and fix it. - log.info("Attempted to start session with ourself! Ignoring"); - // We must fill in the section in the return value though, as callers - // expect it to be there. - resultDevices.set(deviceId, { - device: deviceInfo, - sessionId: null, - }); - continue; - } - - const forWhom = `for ${key} (${userId}:${deviceId})`; - const sessionId = await olmDevice.getSessionIdForDevice(key, !!resolveSession.get(key), log); - const resolveSessionFn = resolveSession.get(key); - if (sessionId !== null && resolveSessionFn) { - // we found a session, but we had marked the session as - // in-progress, so resolve it now, which will unmark it and - // unblock anything that was waiting - resolveSessionFn(); - } - if (sessionId === null || force) { - if (force) { - log.info(`Forcing new Olm session ${forWhom}`); - } else { - log.info(`Making new Olm session ${forWhom}`); - } - devicesWithoutSession.push([userId, deviceId]); - } - resultDevices.set(deviceId, { - device: deviceInfo, - sessionId: sessionId, - }); - } - } - - if (devicesWithoutSession.length === 0) { - return result; - } - - const oneTimeKeyAlgorithm = "signed_curve25519"; - let res: IClaimOTKsResult; - let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`; - try { - log.debug(`Claiming ${taskDetail}`); - res = await baseApis.claimOneTimeKeys(devicesWithoutSession, oneTimeKeyAlgorithm, otkTimeout); - log.debug(`Claimed ${taskDetail}`); - } catch (e) { - for (const resolver of resolveSession.values()) { - resolver(); - } - log.debug(`Failed to claim ${taskDetail}`, e, devicesWithoutSession); - throw e; - } - - if (failedServers && "failures" in res) { - failedServers.push(...Object.keys(res.failures)); - } - - const otkResult = res.one_time_keys || ({} as IClaimOTKsResult["one_time_keys"]); - const promises: Promise[] = []; - for (const [userId, devices] of devicesByUser) { - const userRes = otkResult[userId] || {}; - for (const deviceInfo of devices) { - const deviceId = deviceInfo.deviceId; - const key = deviceInfo.getIdentityKey(); - - if (key === olmDevice.deviceCurve25519Key) { - // We've already logged about this above. Skip here too - // otherwise we'll log saying there are no one-time keys - // which will be confusing. - continue; - } - - if (result.get(userId)?.get(deviceId)?.sessionId && !force) { - // we already have a result for this device - continue; - } - - const deviceRes = userRes[deviceId] || {}; - let oneTimeKey: IOneTimeKey | null = null; - for (const keyId in deviceRes) { - if (keyId.indexOf(oneTimeKeyAlgorithm + ":") === 0) { - oneTimeKey = deviceRes[keyId]; - } - } - - if (!oneTimeKey) { - log.warn(`No one-time keys (alg=${oneTimeKeyAlgorithm}) ` + `for device ${userId}:${deviceId}`); - resolveSession.get(key)?.(); - continue; - } - - promises.push( - _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo).then( - (sid) => { - resolveSession.get(key)?.(sid ?? undefined); - const deviceInfo = result.get(userId)?.get(deviceId); - if (deviceInfo) deviceInfo.sessionId = sid; - }, - (e) => { - resolveSession.get(key)?.(); - throw e; - }, - ), - ); - } - } - - taskDetail = `Olm sessions for ${promises.length} devices`; - log.debug(`Starting ${taskDetail}`); - await Promise.all(promises); - log.debug(`Started ${taskDetail}`); - return result; -} - -async function _verifyKeyAndStartSession( - olmDevice: OlmDevice, - oneTimeKey: IOneTimeKey, - userId: string, - deviceInfo: DeviceInfo, -): Promise { - const deviceId = deviceInfo.deviceId; - try { - await verifySignature(olmDevice, oneTimeKey, userId, deviceId, deviceInfo.getFingerprint()); - } catch (e) { - logger.error("Unable to verify signature on one-time key for device " + userId + ":" + deviceId + ":", e); - return null; - } - - let sid; - try { - sid = await olmDevice.createOutboundSession(deviceInfo.getIdentityKey(), oneTimeKey.key); - } catch (e) { - // possibly a bad key - logger.error("Error starting olm session with device " + userId + ":" + deviceId + ": " + e); - return null; - } - - logger.log("Started new olm sessionid " + sid + " for device " + userId + ":" + deviceId); - return sid; -} - -export interface IObject { - unsigned?: object; - signatures?: ISignatures; -} - -/** - * Verify the signature on an object - * - * @param olmDevice - olm wrapper to use for verify op - * - * @param obj - object to check signature on. - * - * @param signingUserId - ID of the user whose signature should be checked - * - * @param signingDeviceId - ID of the device whose signature should be checked - * - * @param signingKey - base64-ed ed25519 public key - * - * Returns a promise which resolves (to undefined) if the the signature is good, - * or rejects with an Error if it is bad. - */ -export async function verifySignature( - olmDevice: OlmDevice, - obj: IOneTimeKey | IObject, - signingUserId: string, - signingDeviceId: string, - signingKey: string, -): Promise { - const signKeyId = "ed25519:" + signingDeviceId; - const signatures = obj.signatures || {}; - const userSigs = signatures[signingUserId] || {}; - const signature = userSigs[signKeyId]; - if (!signature) { - throw Error("No signature"); - } - - // prepare the canonical json: remove unsigned and signatures, and stringify with anotherjson - const mangledObj = Object.assign({}, obj); - if ("unsigned" in mangledObj) { - delete mangledObj.unsigned; - } - delete mangledObj.signatures; - const json = anotherjson.stringify(mangledObj); - - olmDevice.verifySignature(signingKey, json, signature); -} - -/** - * Sign a JSON object using public key cryptography - * @param obj - Object to sign. The object will be modified to include - * the new signature - * @param key - the signing object or the private key - * seed - * @param userId - The user ID who owns the signing key - * @param pubKey - The public key (ignored if key is a seed) - * @returns the signature for the object - */ -export function pkSign(obj: object & IObject, key: Uint8Array | PkSigning, userId: string, pubKey: string): string { - let createdKey = false; - if (key instanceof Uint8Array) { - const keyObj = new globalThis.Olm.PkSigning(); - pubKey = keyObj.init_with_seed(key); - key = keyObj; - createdKey = true; - } - const sigs = obj.signatures || {}; - delete obj.signatures; - const unsigned = obj.unsigned; - if (obj.unsigned) delete obj.unsigned; - try { - const mysigs = sigs[userId] || {}; - sigs[userId] = mysigs; - - return (mysigs["ed25519:" + pubKey] = key.sign(anotherjson.stringify(obj))); - } finally { - obj.signatures = sigs; - if (unsigned) obj.unsigned = unsigned; - if (createdKey) { - key.free(); - } - } -} - -/** - * Verify a signed JSON object - * @param obj - Object to verify - * @param pubKey - The public key to use to verify - * @param userId - The user ID who signed the object - */ -export function pkVerify(obj: IObject, pubKey: string, userId: string): void { - const keyId = "ed25519:" + pubKey; - if (!(obj.signatures && obj.signatures[userId] && obj.signatures[userId][keyId])) { - throw new Error("No signature"); - } - const signature = obj.signatures[userId][keyId]; - const util = new globalThis.Olm.Utility(); - const sigs = obj.signatures; - delete obj.signatures; - const unsigned = obj.unsigned; - if (obj.unsigned) delete obj.unsigned; - try { - util.ed25519_verify(pubKey, anotherjson.stringify(obj), signature); - } finally { - obj.signatures = sigs; - if (unsigned) obj.unsigned = unsigned; - util.free(); - } -} - -/** - * Check that an event was encrypted using olm. - */ -export function isOlmEncrypted(event: MatrixEvent): boolean { - if (!event.getSenderKey()) { - logger.error("Event has no sender key (not encrypted?)"); - return false; - } - if ( - event.getWireType() !== EventType.RoomMessageEncrypted || - !["m.olm.v1.curve25519-aes-sha2"].includes(event.getWireContent().algorithm) - ) { - logger.error("Event was not encrypted using an appropriate algorithm"); - return false; - } - return true; -} diff --git a/src/crypto/recoverykey.ts b/src/crypto/recoverykey.ts deleted file mode 100644 index 6ce5d3f07..000000000 --- a/src/crypto/recoverykey.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -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. -*/ - -// Re-export to avoid breaking changes -export * from "../crypto-api/recovery-key.ts"; diff --git a/src/crypto/store/base.ts b/src/crypto/store/base.ts index ba1a5675b..86e54f497 100644 --- a/src/crypto/store/base.ts +++ b/src/crypto/store/base.ts @@ -14,31 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type IRoomKeyRequestBody, type IRoomKeyRequestRecipient } from "../index.ts"; -import { type RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager.ts"; -import { type IOlmDevice } from "../algorithms/megolm.ts"; -import { type TrackingStatus } from "../DeviceList.ts"; -import { type IRoomEncryption } from "../RoomList.ts"; -import { type IDevice } from "../deviceinfo.ts"; -import { type ICrossSigningInfo } from "../CrossSigning.ts"; import { type Logger } from "../../logger.ts"; -import { type InboundGroupSessionData } from "../OlmDevice.ts"; -import { type MatrixEvent } from "../../models/event.ts"; -import { type DehydrationManager } from "../dehydration.ts"; import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts"; import { type AESEncryptedSecretStoragePayload } from "../../@types/AESEncryptedSecretStoragePayload.ts"; +import { type ISignatures } from "../../@types/signed.ts"; /** * Internal module. Definitions for storage for the crypto module */ export interface SecretStorePrivateKeys { - "dehydration": { - keyInfo: DehydrationManager["keyInfo"]; - key: AESEncryptedSecretStoragePayload; - deviceDisplayName: string; - time: number; - } | null; "m.megolm_backup.v1": AESEncryptedSecretStoragePayload; } @@ -81,22 +66,6 @@ export interface CryptoStore { */ setMigrationState(migrationState: MigrationState): Promise; - getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise; - getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise; - getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise; - getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise; - getOutgoingRoomKeyRequestsByTarget( - userId: string, - deviceId: string, - wantedStates: number[], - ): Promise; - updateOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - updates: Partial, - ): Promise; - deleteOutgoingRoomKeyRequest(requestId: string, expectedState: number): Promise; - // Olm Account getAccount(txn: unknown, func: (accountPickle: string | null) => void): void; storeAccount(txn: unknown, accountPickle: string): void; @@ -106,7 +75,6 @@ export interface CryptoStore { func: (key: SecretStorePrivateKeys[K] | null) => void, type: K, ): void; - storeCrossSigningKeys(txn: unknown, keys: Record): void; storeSecretStorePrivateKey( txn: unknown, type: K, @@ -126,11 +94,8 @@ export interface CryptoStore { txn: unknown, func: (sessions: { [sessionId: string]: ISessionInfo }) => void, ): void; - getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo | null) => void): void; + storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void; - storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise; - getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise; - filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise; /** * Get a batch of end-to-end sessions from the database. @@ -156,25 +121,12 @@ export interface CryptoStore { txn: unknown, func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void, ): void; - getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void; - addEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: unknown, - ): void; storeEndToEndInboundGroupSession( senderCurve25519Key: string, sessionId: string, sessionData: InboundGroupSessionData, txn: unknown, ): void; - storeEndToEndInboundGroupSessionWithheld( - senderCurve25519Key: string, - sessionId: string, - sessionData: IWithheld, - txn: unknown, - ): void; /** * Count the number of Megolm sessions in the database. @@ -201,21 +153,8 @@ export interface CryptoStore { deleteEndToEndInboundGroupSessionsBatch(sessions: { senderKey: string; sessionId: string }[]): Promise; // Device Data - getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void; - storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void; - storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void; getEndToEndRooms(txn: unknown, func: (rooms: Record) => void): void; - getSessionsNeedingBackup(limit: number): Promise; - countSessionsNeedingBackup(txn?: unknown): Promise; - unmarkSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise; markSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise; - addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string, txn?: unknown): void; - getSharedHistoryInboundGroupSessions( - roomId: string, - txn?: unknown, - ): Promise<[senderKey: string, sessionId: string][]>; - addParkedSharedHistory(roomId: string, data: ParkedSharedHistory, txn?: unknown): void; - takeParkedSharedHistory(roomId: string, txn?: unknown): Promise; // Session key backups doTxn(mode: Mode, stores: Iterable, func: (txn: unknown) => T, log?: Logger): Promise; @@ -256,12 +195,6 @@ export interface IDeviceData { syncToken?: string; } -export interface IProblem { - type: string; - fixed: boolean; - time: number; -} - export interface IWithheld { // eslint-disable-next-line camelcase room_id: string; @@ -297,15 +230,6 @@ export interface OutgoingRoomKeyRequest { state: RoomKeyRequestState; } -export interface ParkedSharedHistory { - senderId: string; - senderKey: string; - sessionId: string; - sessionKey: string; - keysClaimed: ReturnType; // XXX: Less type dependence on MatrixEvent - forwardingCurve25519KeyChain: string[]; -} - /** * Keys for the `account` object store to store the migration state. * Values are defined in `MigrationState`. @@ -346,3 +270,119 @@ export enum MigrationState { * {@link CryptoStore#getEndToEndInboundGroupSessionsBatch}. */ export const SESSION_BATCH_SIZE = 50; + +export interface InboundGroupSessionData { + room_id: string; // eslint-disable-line camelcase + /** pickled Olm.InboundGroupSession */ + session: string; + keysClaimed?: Record; + /** Devices involved in forwarding this session to us (normally empty). */ + forwardingCurve25519KeyChain: string[]; + /** whether this session is untrusted. */ + untrusted?: boolean; + /** whether this session exists during the room being set to shared history. */ + sharedHistory?: boolean; +} + +export interface ICrossSigningInfo { + keys: Record; + firstUse: boolean; + crossSigningVerifiedBefore: boolean; +} + +/* eslint-disable camelcase */ +export interface IRoomEncryption { + algorithm: string; + rotation_period_ms?: number; + rotation_period_msgs?: number; +} +/* eslint-enable camelcase */ + +export enum TrackingStatus { + NotTracked, + PendingDownload, + DownloadInProgress, + UpToDate, +} + +/** + * possible states for a room key request + * + * The state machine looks like: + * ``` + * + * | (cancellation sent) + * | .-------------------------------------------------. + * | | | + * V V (cancellation requested) | + * UNSENT -----------------------------+ | + * | | | + * | | | + * | (send successful) | CANCELLATION_PENDING_AND_WILL_RESEND + * V | Λ + * SENT | | + * |-------------------------------- | --------------' + * | | (cancellation requested with intent + * | | to resend the original request) + * | | + * | (cancellation requested) | + * V | + * CANCELLATION_PENDING | + * | | + * | (cancellation sent) | + * V | + * (deleted) <---------------------------+ + * ``` + */ +export enum RoomKeyRequestState { + /** request not yet sent */ + Unsent, + /** request sent, awaiting reply */ + Sent, + /** reply received, cancellation not yet sent */ + CancellationPending, + /** + * Cancellation not yet sent and will transition to UNSENT instead of + * being deleted once the cancellation has been sent. + */ + CancellationPendingAndWillResend, +} + +/* eslint-disable camelcase */ +interface IRoomKey { + room_id: string; + algorithm: string; +} + +/** + * The parameters of a room key request. The details of the request may + * vary with the crypto algorithm, but the management and storage layers for + * outgoing requests expect it to have 'room_id' and 'session_id' properties. + */ +export interface IRoomKeyRequestBody extends IRoomKey { + session_id: string; + sender_key: string; +} + +/* eslint-enable camelcase */ + +export interface IRoomKeyRequestRecipient { + userId: string; + deviceId: string; +} + +interface IDevice { + keys: Record; + algorithms: string[]; + verified: DeviceVerification; + known: boolean; + unsigned?: Record; + signatures?: ISignatures; +} + +/** State of the verification of the device. */ +export enum DeviceVerification { + Blocked = -1, + Unverified = 0, + Verified = 1, +} diff --git a/src/crypto/store/indexeddb-crypto-store-backend.ts b/src/crypto/store/indexeddb-crypto-store-backend.ts index 6e0300397..a3079ac30 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.ts +++ b/src/crypto/store/indexeddb-crypto-store-backend.ts @@ -15,27 +15,21 @@ limitations under the License. */ import { type Logger, logger } from "../../logger.ts"; -import { deepCompare } from "../../utils.ts"; import { type CryptoStore, type IDeviceData, - type IProblem, type ISession, type SessionExtended, type ISessionInfo, type IWithheld, MigrationState, type Mode, - type OutgoingRoomKeyRequest, - type ParkedSharedHistory, type SecretStorePrivateKeys, SESSION_BATCH_SIZE, ACCOUNT_OBJECT_KEY_MIGRATION_STATE, + type InboundGroupSessionData, + type IRoomEncryption, } from "./base.ts"; -import { type IRoomKeyRequestBody, type IRoomKeyRequestRecipient } from "../index.ts"; -import { type IOlmDevice } from "../algorithms/megolm.ts"; -import { type IRoomEncryption } from "../RoomList.ts"; -import { type InboundGroupSessionData } from "../OlmDevice.ts"; import { IndexedDBCryptoStore } from "./indexeddb-crypto-store.ts"; import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts"; @@ -106,297 +100,6 @@ export class Backend implements CryptoStore { }); } - /** - * Look for an existing outgoing room key request, and if none is found, - * add a new one - * - * - * @returns resolves to - * {@link OutgoingRoomKeyRequest}: either the - * same instance as passed in, or the existing one. - */ - public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise { - const requestBody = request.requestBody; - - return new Promise((resolve, reject) => { - const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite"); - txn.onerror = reject; - - // first see if we already have an entry for this request. - this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => { - if (existing) { - // this entry matches the request - return it. - logger.log( - `already have key request outstanding for ` + - `${requestBody.room_id} / ${requestBody.session_id}: ` + - `not sending another`, - ); - resolve(existing); - return; - } - - // we got to the end of the list without finding a match - // - add the new request. - logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id); - txn.oncomplete = (): void => { - resolve(request); - }; - const store = txn.objectStore("outgoingRoomKeyRequests"); - store.add(request); - }); - }); - } - - /** - * Look for an existing room key request - * - * @param requestBody - existing request to look for - * - * @returns resolves to the matching - * {@link OutgoingRoomKeyRequest}, or null if - * not found - */ - public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise { - return new Promise((resolve, reject) => { - const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly"); - txn.onerror = reject; - - this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => { - resolve(existing); - }); - }); - } - - /** - * look for an existing room key request in the db - * - * @internal - * @param txn - database transaction - * @param requestBody - existing request to look for - * @param callback - function to call with the results of the - * search. Either passed a matching - * {@link OutgoingRoomKeyRequest}, or null if - * not found. - */ - // eslint-disable-next-line @typescript-eslint/naming-convention - private _getOutgoingRoomKeyRequest( - txn: IDBTransaction, - requestBody: IRoomKeyRequestBody, - callback: (req: OutgoingRoomKeyRequest | null) => void, - ): void { - const store = txn.objectStore("outgoingRoomKeyRequests"); - - const idx = store.index("session"); - const cursorReq = idx.openCursor([requestBody.room_id, requestBody.session_id]); - - cursorReq.onsuccess = (): void => { - const cursor = cursorReq.result; - if (!cursor) { - // no match found - callback(null); - return; - } - - const existing = cursor.value; - - if (deepCompare(existing.requestBody, requestBody)) { - // got a match - callback(existing); - return; - } - - // look at the next entry in the index - cursor.continue(); - }; - } - - /** - * Look for room key requests by state - * - * @param wantedStates - list of acceptable states - * - * @returns resolves to the a - * {@link OutgoingRoomKeyRequest}, or null if - * there are no pending requests in those states. If there are multiple - * requests in those states, an arbitrary one is chosen. - */ - public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise { - if (wantedStates.length === 0) { - return Promise.resolve(null); - } - - // this is a bit tortuous because we need to make sure we do the lookup - // in a single transaction, to avoid having a race with the insertion - // code. - - // index into the wantedStates array - let stateIndex = 0; - let result: OutgoingRoomKeyRequest; - - function onsuccess(this: IDBRequest): void { - const cursor = this.result; - if (cursor) { - // got a match - result = cursor.value; - return; - } - - // try the next state in the list - stateIndex++; - if (stateIndex >= wantedStates.length) { - // no matches - return; - } - - const wantedState = wantedStates[stateIndex]; - const cursorReq = (this.source as IDBIndex).openCursor(wantedState); - cursorReq.onsuccess = onsuccess; - } - - const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly"); - const store = txn.objectStore("outgoingRoomKeyRequests"); - - const wantedState = wantedStates[stateIndex]; - const cursorReq = store.index("state").openCursor(wantedState); - cursorReq.onsuccess = onsuccess; - - return promiseifyTxn(txn).then(() => result); - } - - /** - * - * @returns All elements in a given state - */ - public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise { - return new Promise((resolve, reject) => { - const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly"); - const store = txn.objectStore("outgoingRoomKeyRequests"); - const index = store.index("state"); - const request = index.getAll(wantedState); - - request.onsuccess = (): void => resolve(request.result); - request.onerror = (): void => reject(request.error); - }); - } - - public getOutgoingRoomKeyRequestsByTarget( - userId: string, - deviceId: string, - wantedStates: number[], - ): Promise { - let stateIndex = 0; - const results: OutgoingRoomKeyRequest[] = []; - - function onsuccess(this: IDBRequest): void { - const cursor = this.result; - if (cursor) { - const keyReq = cursor.value; - if ( - keyReq.recipients.some( - (recipient: IRoomKeyRequestRecipient) => - recipient.userId === userId && recipient.deviceId === deviceId, - ) - ) { - results.push(keyReq); - } - cursor.continue(); - } else { - // try the next state in the list - stateIndex++; - if (stateIndex >= wantedStates.length) { - // no matches - return; - } - - const wantedState = wantedStates[stateIndex]; - const cursorReq = (this.source as IDBIndex).openCursor(wantedState); - cursorReq.onsuccess = onsuccess; - } - } - - const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly"); - const store = txn.objectStore("outgoingRoomKeyRequests"); - - const wantedState = wantedStates[stateIndex]; - const cursorReq = store.index("state").openCursor(wantedState); - cursorReq.onsuccess = onsuccess; - - return promiseifyTxn(txn).then(() => results); - } - - /** - * Look for an existing room key request by id and state, and update it if - * found - * - * @param requestId - ID of request to update - * @param expectedState - state we expect to find the request in - * @param updates - name/value map of updates to apply - * - * @returns resolves to - * {@link OutgoingRoomKeyRequest} - * updated request, or null if no matching row was found - */ - public updateOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - updates: Partial, - ): Promise { - let result: OutgoingRoomKeyRequest | null = null; - - function onsuccess(this: IDBRequest): void { - const cursor = this.result; - if (!cursor) { - return; - } - const data = cursor.value; - if (data.state != expectedState) { - logger.warn( - `Cannot update room key request from ${expectedState} ` + - `as it was already updated to ${data.state}`, - ); - return; - } - Object.assign(data, updates); - cursor.update(data); - result = data; - } - - const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite"); - const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId); - cursorReq.onsuccess = onsuccess; - return promiseifyTxn(txn).then(() => result); - } - - /** - * Look for an existing room key request by id and state, and delete it if - * found - * - * @param requestId - ID of request to update - * @param expectedState - state we expect to find the request in - * - * @returns resolves once the operation is completed - */ - public deleteOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - ): Promise { - const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite"); - const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId); - cursorReq.onsuccess = (): void => { - const cursor = cursorReq.result; - if (!cursor) { - return; - } - const data = cursor.value; - if (data.state != expectedState) { - logger.warn(`Cannot delete room key request in state ${data.state} ` + `(expected ${expectedState})`); - return; - } - cursor.delete(); - }; - return promiseifyTxn(txn); - } - // Olm Account public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void { @@ -447,11 +150,6 @@ export class Backend implements CryptoStore { }; } - public storeCrossSigningKeys(txn: IDBTransaction, keys: Record): void { - const objectStore = txn.objectStore("account"); - objectStore.put(keys, "crossSigningKeys"); - } - public storeSecretStorePrivateKey( txn: IDBTransaction, type: K, @@ -526,24 +224,6 @@ export class Backend implements CryptoStore { }; } - public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void { - const objectStore = txn.objectStore("sessions"); - const getReq = objectStore.openCursor(); - getReq.onsuccess = function (): void { - try { - const cursor = getReq.result; - if (cursor) { - func(cursor.value); - cursor.continue(); - } else { - func(null); - } - } catch (e) { - abortWithException(txn, e); - } - }; - } - public storeEndToEndSession( deviceKey: string, sessionId: string, @@ -559,76 +239,6 @@ export class Backend implements CryptoStore { }); } - public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise { - const txn = this.db.transaction("session_problems", "readwrite"); - const objectStore = txn.objectStore("session_problems"); - objectStore.put({ - deviceKey, - type, - fixed, - time: Date.now(), - }); - await promiseifyTxn(txn); - } - - public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise { - let result: IProblem | null = null; - const txn = this.db.transaction("session_problems", "readwrite"); - const objectStore = txn.objectStore("session_problems"); - const index = objectStore.index("deviceKey"); - const req = index.getAll(deviceKey); - req.onsuccess = (): void => { - const problems = req.result; - if (!problems.length) { - result = null; - return; - } - problems.sort((a, b) => { - return a.time - b.time; - }); - const lastProblem = problems[problems.length - 1]; - for (const problem of problems) { - if (problem.time > timestamp) { - result = Object.assign({}, problem, { fixed: lastProblem.fixed }); - return; - } - } - if (lastProblem.fixed) { - result = null; - } else { - result = lastProblem; - } - }; - await promiseifyTxn(txn); - return result; - } - - // FIXME: we should probably prune this when devices get deleted - public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise { - const txn = this.db.transaction("notified_error_devices", "readwrite"); - const objectStore = txn.objectStore("notified_error_devices"); - - const ret: IOlmDevice[] = []; - - await Promise.all( - devices.map((device) => { - return new Promise((resolve) => { - const { userId, deviceInfo } = device; - const getReq = objectStore.get([userId, deviceInfo.deviceId]); - getReq.onsuccess = function (): void { - if (!getReq.result) { - objectStore.put({ userId, deviceId: deviceInfo.deviceId }); - ret.push(device); - } - resolve(); - }; - }); - }), - ); - - return ret; - } - /** * Fetch a batch of Olm sessions from the database. * @@ -730,57 +340,6 @@ export class Backend implements CryptoStore { }; } - public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void { - const objectStore = txn.objectStore("inbound_group_sessions"); - const getReq = objectStore.openCursor(); - getReq.onsuccess = function (): void { - const cursor = getReq.result; - if (cursor) { - try { - func({ - senderKey: cursor.value.senderCurve25519Key, - sessionId: cursor.value.sessionId, - sessionData: cursor.value.session, - }); - } catch (e) { - abortWithException(txn, e); - } - cursor.continue(); - } else { - try { - func(null); - } catch (e) { - abortWithException(txn, e); - } - } - }; - } - - public addEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: IDBTransaction, - ): void { - const objectStore = txn.objectStore("inbound_group_sessions"); - const addReq = objectStore.add({ - senderCurve25519Key, - sessionId, - session: sessionData, - }); - addReq.onerror = (ev): void => { - if (addReq.error?.name === "ConstraintError") { - // This stops the error from triggering the txn's onerror - ev.stopPropagation(); - // ...and this stops it from aborting the transaction - ev.preventDefault(); - logger.log("Ignoring duplicate inbound group session: " + senderCurve25519Key + " / " + sessionId); - } else { - abortWithException(txn, new Error("Failed to add inbound group session: " + addReq.error?.name)); - } - }; - } - public storeEndToEndInboundGroupSession( senderCurve25519Key: string, sessionId: string, @@ -795,20 +354,6 @@ export class Backend implements CryptoStore { }); } - public storeEndToEndInboundGroupSessionWithheld( - senderCurve25519Key: string, - sessionId: string, - sessionData: IWithheld, - txn: IDBTransaction, - ): void { - const objectStore = txn.objectStore("inbound_group_sessions_withheld"); - objectStore.put({ - senderCurve25519Key, - sessionId, - session: sessionData, - }); - } - /** * Count the number of Megolm sessions in the database. * @@ -912,16 +457,6 @@ export class Backend implements CryptoStore { }; } - public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void { - const objectStore = txn.objectStore("device_data"); - objectStore.put(deviceData, "-"); - } - - public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void { - const objectStore = txn.objectStore("rooms"); - objectStore.put(roomInfo, roomId); - } - public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record) => void): void { const rooms: Parameters[1]>[0] = {}; const objectStore = txn.objectStore("rooms"); @@ -941,67 +476,6 @@ export class Backend implements CryptoStore { }; } - // session backups - - public getSessionsNeedingBackup(limit: number): Promise { - return new Promise((resolve, reject) => { - const sessions: ISession[] = []; - - const txn = this.db.transaction(["sessions_needing_backup", "inbound_group_sessions"], "readonly"); - txn.onerror = reject; - txn.oncomplete = function (): void { - resolve(sessions); - }; - const objectStore = txn.objectStore("sessions_needing_backup"); - const sessionStore = txn.objectStore("inbound_group_sessions"); - const getReq = objectStore.openCursor(); - getReq.onsuccess = function (): void { - const cursor = getReq.result; - if (cursor) { - const sessionGetReq = sessionStore.get(cursor.key); - sessionGetReq.onsuccess = function (): void { - sessions.push({ - senderKey: sessionGetReq.result.senderCurve25519Key, - sessionId: sessionGetReq.result.sessionId, - sessionData: sessionGetReq.result.session, - }); - }; - if (!limit || sessions.length < limit) { - cursor.continue(); - } - } - }; - }); - } - - public countSessionsNeedingBackup(txn?: IDBTransaction): Promise { - if (!txn) { - txn = this.db.transaction("sessions_needing_backup", "readonly"); - } - const objectStore = txn.objectStore("sessions_needing_backup"); - return new Promise((resolve, reject) => { - const req = objectStore.count(); - req.onerror = reject; - req.onsuccess = (): void => resolve(req.result); - }); - } - - public async unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise { - if (!txn) { - txn = this.db.transaction("sessions_needing_backup", "readwrite"); - } - const objectStore = txn.objectStore("sessions_needing_backup"); - await Promise.all( - sessions.map((session) => { - return new Promise((resolve, reject) => { - const req = objectStore.delete([session.senderKey, session.sessionId]); - req.onsuccess = resolve; - req.onerror = reject; - }); - }), - ); - } - public async markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise { if (!txn) { txn = this.db.transaction("sessions_needing_backup", "readwrite"); @@ -1021,75 +495,6 @@ export class Backend implements CryptoStore { ); } - public addSharedHistoryInboundGroupSession( - roomId: string, - senderKey: string, - sessionId: string, - txn?: IDBTransaction, - ): void { - if (!txn) { - txn = this.db.transaction("shared_history_inbound_group_sessions", "readwrite"); - } - const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); - const req = objectStore.get([roomId]); - req.onsuccess = (): void => { - const { sessions } = req.result || { sessions: [] }; - sessions.push([senderKey, sessionId]); - objectStore.put({ roomId, sessions }); - }; - } - - public getSharedHistoryInboundGroupSessions( - roomId: string, - txn?: IDBTransaction, - ): Promise<[senderKey: string, sessionId: string][]> { - if (!txn) { - txn = this.db.transaction("shared_history_inbound_group_sessions", "readonly"); - } - const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); - const req = objectStore.get([roomId]); - return new Promise((resolve, reject) => { - req.onsuccess = (): void => { - const { sessions } = req.result || { sessions: [] }; - resolve(sessions); - }; - req.onerror = reject; - }); - } - - public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void { - if (!txn) { - txn = this.db.transaction("parked_shared_history", "readwrite"); - } - const objectStore = txn.objectStore("parked_shared_history"); - const req = objectStore.get([roomId]); - req.onsuccess = (): void => { - const { parked } = req.result || { parked: [] }; - parked.push(parkedData); - objectStore.put({ roomId, parked }); - }; - } - - public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise { - if (!txn) { - txn = this.db.transaction("parked_shared_history", "readwrite"); - } - const cursorReq = txn.objectStore("parked_shared_history").openCursor(roomId); - return new Promise((resolve, reject) => { - cursorReq.onsuccess = (): void => { - const cursor = cursorReq.result; - if (!cursor) { - resolve([]); - return; - } - const data = cursor.value; - cursor.delete(); - resolve(data); - }; - cursorReq.onerror = reject; - }); - } - public doTxn( mode: Mode, stores: string | string[], diff --git a/src/crypto/store/indexeddb-crypto-store.ts b/src/crypto/store/indexeddb-crypto-store.ts index b5c7dc651..824ad0050 100644 --- a/src/crypto/store/indexeddb-crypto-store.ts +++ b/src/crypto/store/indexeddb-crypto-store.ts @@ -22,23 +22,17 @@ import { InvalidCryptoStoreError, InvalidCryptoStoreState } from "../../errors.t import * as IndexedDBHelpers from "../../indexeddb-helpers.ts"; import { type CryptoStore, - type IDeviceData, - type IProblem, type ISession, type SessionExtended, type ISessionInfo, type IWithheld, MigrationState, type Mode, - type OutgoingRoomKeyRequest, - type ParkedSharedHistory, type SecretStorePrivateKeys, ACCOUNT_OBJECT_KEY_MIGRATION_STATE, + type InboundGroupSessionData, + type IRoomEncryption, } from "./base.ts"; -import { type IRoomKeyRequestBody } from "../index.ts"; -import { type IOlmDevice } from "../algorithms/megolm.ts"; -import { type IRoomEncryption } from "../RoomList.ts"; -import { type InboundGroupSessionData } from "../OlmDevice.ts"; import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts"; /* @@ -282,110 +276,6 @@ export class IndexedDBCryptoStore implements CryptoStore { return this.backend!.setMigrationState(migrationState); } - /** - * Look for an existing outgoing room key request, and if none is found, - * add a new one - * - * - * @returns resolves to - * {@link OutgoingRoomKeyRequest}: either the - * same instance as passed in, or the existing one. - */ - public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise { - return this.backend!.getOrAddOutgoingRoomKeyRequest(request); - } - - /** - * Look for an existing room key request - * - * @param requestBody - existing request to look for - * - * @returns resolves to the matching - * {@link OutgoingRoomKeyRequest}, or null if - * not found - */ - public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise { - return this.backend!.getOutgoingRoomKeyRequest(requestBody); - } - - /** - * Look for room key requests by state - * - * @param wantedStates - list of acceptable states - * - * @returns resolves to the a - * {@link OutgoingRoomKeyRequest}, or null if - * there are no pending requests in those states. If there are multiple - * requests in those states, an arbitrary one is chosen. - */ - public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise { - return this.backend!.getOutgoingRoomKeyRequestByState(wantedStates); - } - - /** - * Look for room key requests by state – - * unlike above, return a list of all entries in one state. - * - * @returns Returns an array of requests in the given state - */ - public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise { - return this.backend!.getAllOutgoingRoomKeyRequestsByState(wantedState); - } - - /** - * Look for room key requests by target device and state - * - * @param userId - Target user ID - * @param deviceId - Target device ID - * @param wantedStates - list of acceptable states - * - * @returns resolves to a list of all the - * {@link OutgoingRoomKeyRequest} - */ - public getOutgoingRoomKeyRequestsByTarget( - userId: string, - deviceId: string, - wantedStates: number[], - ): Promise { - return this.backend!.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates); - } - - /** - * Look for an existing room key request by id and state, and update it if - * found - * - * @param requestId - ID of request to update - * @param expectedState - state we expect to find the request in - * @param updates - name/value map of updates to apply - * - * @returns resolves to - * {@link OutgoingRoomKeyRequest} - * updated request, or null if no matching row was found - */ - public updateOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - updates: Partial, - ): Promise { - return this.backend!.updateOutgoingRoomKeyRequest(requestId, expectedState, updates); - } - - /** - * Look for an existing room key request by id and state, and delete it if - * found - * - * @param requestId - ID of request to update - * @param expectedState - state we expect to find the request in - * - * @returns resolves once the operation is completed - */ - public deleteOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - ): Promise { - return this.backend!.deleteOutgoingRoomKeyRequest(requestId, expectedState); - } - // Olm Account /* @@ -438,16 +328,6 @@ export class IndexedDBCryptoStore implements CryptoStore { this.backend!.getSecretStorePrivateKey(txn, func, type); } - /** - * Write the cross-signing keys back to the store - * - * @param txn - An active transaction. See doTxn(). - * @param keys - keys object as getCrossSigningKeys() - */ - public storeCrossSigningKeys(txn: IDBTransaction, keys: Record): void { - this.backend!.storeCrossSigningKeys(txn, keys); - } - /** * Write the cross-signing private keys back to the store * @@ -514,17 +394,6 @@ export class IndexedDBCryptoStore implements CryptoStore { this.backend!.getEndToEndSessions(deviceKey, txn, func); } - /** - * Retrieve all end-to-end sessions - * @param txn - An active transaction. See doTxn(). - * @param func - Called one for each session with - * an object with, deviceKey, lastReceivedMessageTs, sessionId - * and session keys. - */ - public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void { - this.backend!.getAllEndToEndSessions(txn, func); - } - /** * Store a session between the logged-in user and another device * @param deviceKey - The public key of the other device. @@ -541,18 +410,6 @@ export class IndexedDBCryptoStore implements CryptoStore { this.backend!.storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn); } - public storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise { - return this.backend!.storeEndToEndSessionProblem(deviceKey, type, fixed); - } - - public getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise { - return this.backend!.getEndToEndSessionProblem(deviceKey, timestamp); - } - - public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise { - return this.backend!.filterOutNotifiedErrorDevices(devices); - } - /** * Count the number of Megolm sessions in the database. * @@ -606,35 +463,6 @@ export class IndexedDBCryptoStore implements CryptoStore { this.backend!.getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func); } - /** - * Fetches all inbound group sessions in the store - * @param txn - An active transaction. See doTxn(). - * @param func - Called once for each group session - * in the store with an object having keys `{senderKey, sessionId, sessionData}`, - * then once with null to indicate the end of the list. - */ - public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void { - this.backend!.getAllEndToEndInboundGroupSessions(txn, func); - } - - /** - * Adds an end-to-end inbound group session to the store. - * If there already exists an inbound group session with the same - * senderCurve25519Key and sessionID, the session will not be added. - * @param senderCurve25519Key - The sender's curve 25519 key - * @param sessionId - The ID of the session - * @param sessionData - The session data structure - * @param txn - An active transaction. See doTxn(). - */ - public addEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: IDBTransaction, - ): void { - this.backend!.addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn); - } - /** * Writes an end-to-end inbound group session to the store. * If there already exists an inbound group session with the same @@ -653,15 +481,6 @@ export class IndexedDBCryptoStore implements CryptoStore { this.backend!.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn); } - public storeEndToEndInboundGroupSessionWithheld( - senderCurve25519Key: string, - sessionId: string, - sessionData: IWithheld, - txn: IDBTransaction, - ): void { - this.backend!.storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn); - } - /** * Fetch a batch of Megolm sessions from the database. * @@ -686,44 +505,6 @@ export class IndexedDBCryptoStore implements CryptoStore { return this.backend!.deleteEndToEndInboundGroupSessionsBatch(sessions); } - // End-to-end device tracking - - /** - * Store the state of all tracked devices - * This contains devices for each user, a tracking state for each user - * and a sync token matching the point in time the snapshot represents. - * These all need to be written out in full each time such that the snapshot - * is always consistent, so they are stored in one object. - * - * @param txn - An active transaction. See doTxn(). - */ - public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void { - this.backend!.storeEndToEndDeviceData(deviceData, txn); - } - - /** - * Get the state of all tracked devices - * - * @param txn - An active transaction. See doTxn(). - * @param func - Function called with the - * device data - */ - public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void { - this.backend!.getEndToEndDeviceData(txn, func); - } - - // End to End Rooms - - /** - * Store the end-to-end state for a room. - * @param roomId - The room's ID. - * @param roomInfo - The end-to-end info for the room. - * @param txn - An active transaction. See doTxn(). - */ - public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void { - this.backend!.storeEndToEndRoom(roomId, roomInfo, txn); - } - /** * Get an object of `roomId->roomInfo` for all e2e rooms in the store * @param txn - An active transaction. See doTxn(). @@ -733,37 +514,6 @@ export class IndexedDBCryptoStore implements CryptoStore { this.backend!.getEndToEndRooms(txn, func); } - // session backups - - /** - * Get the inbound group sessions that need to be backed up. - * @param limit - The maximum number of sessions to retrieve. 0 - * for no limit. - * @returns resolves to an array of inbound group sessions - */ - public getSessionsNeedingBackup(limit: number): Promise { - return this.backend!.getSessionsNeedingBackup(limit); - } - - /** - * Count the inbound group sessions that need to be backed up. - * @param txn - An active transaction. See doTxn(). (optional) - * @returns resolves to the number of sessions - */ - public countSessionsNeedingBackup(txn?: IDBTransaction): Promise { - return this.backend!.countSessionsNeedingBackup(txn); - } - - /** - * Unmark sessions as needing to be backed up. - * @param sessions - The sessions that need to be backed up. - * @param txn - An active transaction. See doTxn(). (optional) - * @returns resolves when the sessions are unmarked - */ - public unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise { - return this.backend!.unmarkSessionsNeedingBackup(sessions, txn); - } - /** * Mark sessions as needing to be backed up. * @param sessions - The sessions that need to be backed up. @@ -774,49 +524,6 @@ export class IndexedDBCryptoStore implements CryptoStore { return this.backend!.markSessionsNeedingBackup(sessions, txn); } - /** - * Add a shared-history group session for a room. - * @param roomId - The room that the key belongs to - * @param senderKey - The sender's curve 25519 key - * @param sessionId - The ID of the session - * @param txn - An active transaction. See doTxn(). (optional) - */ - public addSharedHistoryInboundGroupSession( - roomId: string, - senderKey: string, - sessionId: string, - txn?: IDBTransaction, - ): void { - this.backend!.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn); - } - - /** - * Get the shared-history group session for a room. - * @param roomId - The room that the key belongs to - * @param txn - An active transaction. See doTxn(). (optional) - * @returns Promise which resolves to an array of [senderKey, sessionId] - */ - public getSharedHistoryInboundGroupSessions( - roomId: string, - txn?: IDBTransaction, - ): Promise<[senderKey: string, sessionId: string][]> { - return this.backend!.getSharedHistoryInboundGroupSessions(roomId, txn); - } - - /** - * Park a shared-history group session for a room we may be invited to later. - */ - public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void { - this.backend!.addParkedSharedHistory(roomId, parkedData, txn); - } - - /** - * Pop out all shared-history group sessions for a room. - */ - public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise { - return this.backend!.takeParkedSharedHistory(roomId, txn); - } - /** * Perform a transaction on the crypto store. Any store methods * that require a transaction (txn) object to be passed in may diff --git a/src/crypto/store/localStorage-crypto-store.ts b/src/crypto/store/localStorage-crypto-store.ts index f3333083d..ac52c40d0 100644 --- a/src/crypto/store/localStorage-crypto-store.ts +++ b/src/crypto/store/localStorage-crypto-store.ts @@ -18,8 +18,6 @@ import { logger } from "../../logger.ts"; import { MemoryCryptoStore } from "./memory-crypto-store.ts"; import { type CryptoStore, - type IDeviceData, - type IProblem, type ISession, type SessionExtended, type ISessionInfo, @@ -28,11 +26,9 @@ import { type Mode, type SecretStorePrivateKeys, SESSION_BATCH_SIZE, + type InboundGroupSessionData, + type IRoomEncryption, } from "./base.ts"; -import { type IOlmDevice } from "../algorithms/megolm.ts"; -import { type IRoomEncryption } from "../RoomList.ts"; -import { type InboundGroupSessionData } from "../OlmDevice.ts"; -import { safeSet } from "../../utils.ts"; import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts"; /** @@ -47,8 +43,6 @@ const E2E_PREFIX = "crypto."; const KEY_END_TO_END_MIGRATION_STATE = E2E_PREFIX + "migration"; const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; const KEY_CROSS_SIGNING_KEYS = E2E_PREFIX + "cross_signing_keys"; -const KEY_NOTIFIED_ERROR_DEVICES = E2E_PREFIX + "notified_error_devices"; -const KEY_DEVICE_DATA = E2E_PREFIX + "device_data"; const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/"; const KEY_INBOUND_SESSION_WITHHELD_PREFIX = E2E_PREFIX + "inboundgroupsessions.withheld/"; const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/"; @@ -58,10 +52,6 @@ function keyEndToEndSessions(deviceKey: string): string { return E2E_PREFIX + "sessions/" + deviceKey; } -function keyEndToEndSessionProblems(deviceKey: string): string { - return E2E_PREFIX + "session.problems/" + deviceKey; -} - function keyEndToEndInboundGroupSession(senderKey: string, sessionId: string): string { return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId; } @@ -173,75 +163,12 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto func(this._getEndToEndSessions(deviceKey) ?? {}); } - public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void { - for (let i = 0; i < this.store.length; ++i) { - if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) { - const deviceKey = this.store.key(i)!.split("/")[1]; - for (const sess of Object.values(this._getEndToEndSessions(deviceKey))) { - func(sess); - } - } - } - } - public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void { const sessions = this._getEndToEndSessions(deviceKey) || {}; sessions[sessionId] = sessionInfo; setJsonItem(this.store, keyEndToEndSessions(deviceKey), sessions); } - public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise { - const key = keyEndToEndSessionProblems(deviceKey); - const problems = getJsonItem(this.store, key) || []; - problems.push({ type, fixed, time: Date.now() }); - problems.sort((a, b) => { - return a.time - b.time; - }); - setJsonItem(this.store, key, problems); - } - - public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise { - const key = keyEndToEndSessionProblems(deviceKey); - const problems = getJsonItem(this.store, key) || []; - if (!problems.length) { - return null; - } - const lastProblem = problems[problems.length - 1]; - for (const problem of problems) { - if (problem.time > timestamp) { - return Object.assign({}, problem, { fixed: lastProblem.fixed }); - } - } - if (lastProblem.fixed) { - return null; - } else { - return lastProblem; - } - } - - public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise { - const notifiedErrorDevices = - getJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES) || {}; - const ret: IOlmDevice[] = []; - - for (const device of devices) { - const { userId, deviceInfo } = device; - if (userId in notifiedErrorDevices) { - if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) { - ret.push(device); - safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true); - } - } else { - ret.push(device); - safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true }); - } - } - - setJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES, notifiedErrorDevices); - - return ret; - } - /** * Fetch a batch of Olm sessions from the database. * @@ -306,37 +233,6 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto ); } - public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void { - for (let i = 0; i < this.store.length; ++i) { - const key = this.store.key(i); - if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) { - // we can't use split, as the components we are trying to split out - // might themselves contain '/' characters. We rely on the - // senderKey being a (32-byte) curve25519 key, base64-encoded - // (hence 43 characters long). - - func({ - senderKey: key.slice(KEY_INBOUND_SESSION_PREFIX.length, KEY_INBOUND_SESSION_PREFIX.length + 43), - sessionId: key.slice(KEY_INBOUND_SESSION_PREFIX.length + 44), - sessionData: getJsonItem(this.store, key)!, - }); - } - } - func(null); - } - - public addEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: unknown, - ): void { - const existing = getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)); - if (!existing) { - this.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn); - } - } - public storeEndToEndInboundGroupSession( senderCurve25519Key: string, sessionId: string, @@ -346,15 +242,6 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto setJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), sessionData); } - public storeEndToEndInboundGroupSessionWithheld( - senderCurve25519Key: string, - sessionId: string, - sessionData: IWithheld, - txn: unknown, - ): void { - setJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), sessionData); - } - /** * Count the number of Megolm sessions in the database. * @@ -431,18 +318,6 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto } } - public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void { - func(getJsonItem(this.store, KEY_DEVICE_DATA)); - } - - public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void { - setJsonItem(this.store, KEY_DEVICE_DATA, deviceData); - } - - public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void { - setJsonItem(this.store, keyEndToEndRoomsPrefix(roomId), roomInfo); - } - public getEndToEndRooms(txn: unknown, func: (rooms: Record) => void): void { const result: Record = {}; const prefix = keyEndToEndRoomsPrefix(""); @@ -457,47 +332,6 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto func(result); } - public getSessionsNeedingBackup(limit: number): Promise { - const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; - const sessions: ISession[] = []; - - for (const session in sessionsNeedingBackup) { - if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) { - // see getAllEndToEndInboundGroupSessions for the magic number explanations - const senderKey = session.slice(0, 43); - const sessionId = session.slice(44); - this.getEndToEndInboundGroupSession(senderKey, sessionId, null, (sessionData) => { - sessions.push({ - senderKey: senderKey, - sessionId: sessionId, - sessionData: sessionData!, - }); - }); - if (limit && sessions.length >= limit) { - break; - } - } - } - return Promise.resolve(sessions); - } - - public countSessionsNeedingBackup(): Promise { - const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; - return Promise.resolve(Object.keys(sessionsNeedingBackup).length); - } - - public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise { - const sessionsNeedingBackup = - getJsonItem<{ - [senderKeySessionId: string]: string; - }>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; - for (const session of sessions) { - delete sessionsNeedingBackup[session.senderKey + "/" + session.sessionId]; - } - setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup); - return Promise.resolve(); - } - public markSessionsNeedingBackup(sessions: ISession[]): Promise { const sessionsNeedingBackup = getJsonItem<{ @@ -545,10 +379,6 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto func(key); } - public storeCrossSigningKeys(txn: unknown, keys: Record): void { - setJsonItem(this.store, KEY_CROSS_SIGNING_KEYS, keys); - } - public storeSecretStorePrivateKey( txn: unknown, type: K, diff --git a/src/crypto/store/memory-crypto-store.ts b/src/crypto/store/memory-crypto-store.ts index cf8aaa205..9c42378c5 100644 --- a/src/crypto/store/memory-crypto-store.ts +++ b/src/crypto/store/memory-crypto-store.ts @@ -14,27 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { logger } from "../../logger.ts"; -import { deepCompare, promiseTry, safeSet } from "../../utils.ts"; +import { safeSet } from "../../utils.ts"; import { type CryptoStore, - type IDeviceData, - type IProblem, type ISession, type SessionExtended, type ISessionInfo, type IWithheld, MigrationState, type Mode, - type OutgoingRoomKeyRequest, - type ParkedSharedHistory, type SecretStorePrivateKeys, SESSION_BATCH_SIZE, + type InboundGroupSessionData, + type IRoomEncryption, } from "./base.ts"; -import { type IRoomKeyRequestBody } from "../index.ts"; -import { type IOlmDevice } from "../algorithms/megolm.ts"; -import { type IRoomEncryption } from "../RoomList.ts"; -import { type InboundGroupSessionData } from "../OlmDevice.ts"; import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts"; function encodeSessionKey(senderCurve25519Key: string, sessionId: string): string { @@ -54,22 +47,16 @@ function decodeSessionKey(key: string): { senderKey: string; sessionId: string } export class MemoryCryptoStore implements CryptoStore { private migrationState: MigrationState = MigrationState.NOT_STARTED; - private outgoingRoomKeyRequests: OutgoingRoomKeyRequest[] = []; private account: string | null = null; private crossSigningKeys: Record | null = null; private privateKeys: Partial = {}; private sessions: { [deviceKey: string]: { [sessionId: string]: ISessionInfo } } = {}; - private sessionProblems: { [deviceKey: string]: IProblem[] } = {}; - private notifiedErrorDevices: { [userId: string]: { [deviceId: string]: boolean } } = {}; private inboundGroupSessions: { [sessionKey: string]: InboundGroupSessionData } = {}; private inboundGroupSessionsWithheld: Record = {}; // Opaque device data object - private deviceData: IDeviceData | null = null; private rooms: { [roomId: string]: IRoomEncryption } = {}; private sessionsNeedingBackup: { [sessionKey: string]: boolean } = {}; - private sharedHistoryInboundGroupSessions: { [roomId: string]: [senderKey: string, sessionId: string][] } = {}; - private parkedSharedHistory = new Map(); // keyed by room ID /** * Returns true if this CryptoStore has ever been initialised (ie, it might contain data). @@ -126,189 +113,6 @@ export class MemoryCryptoStore implements CryptoStore { this.migrationState = migrationState; } - /** - * Look for an existing outgoing room key request, and if none is found, - * add a new one - * - * - * @returns resolves to - * {@link OutgoingRoomKeyRequest}: either the - * same instance as passed in, or the existing one. - */ - public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise { - const requestBody = request.requestBody; - - return promiseTry(() => { - // first see if we already have an entry for this request. - const existing = this._getOutgoingRoomKeyRequest(requestBody); - - if (existing) { - // this entry matches the request - return it. - logger.log( - `already have key request outstanding for ` + - `${requestBody.room_id} / ${requestBody.session_id}: ` + - `not sending another`, - ); - return existing; - } - - // we got to the end of the list without finding a match - // - add the new request. - logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id); - this.outgoingRoomKeyRequests.push(request); - return request; - }); - } - - /** - * Look for an existing room key request - * - * @param requestBody - existing request to look for - * - * @returns resolves to the matching - * {@link OutgoingRoomKeyRequest}, or null if - * not found - */ - public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise { - return Promise.resolve(this._getOutgoingRoomKeyRequest(requestBody)); - } - - /** - * Looks for existing room key request, and returns the result synchronously. - * - * @internal - * - * @param requestBody - existing request to look for - * - * @returns - * the matching request, or null if not found - */ - // eslint-disable-next-line @typescript-eslint/naming-convention - private _getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): OutgoingRoomKeyRequest | null { - for (const existing of this.outgoingRoomKeyRequests) { - if (deepCompare(existing.requestBody, requestBody)) { - return existing; - } - } - return null; - } - - /** - * Look for room key requests by state - * - * @param wantedStates - list of acceptable states - * - * @returns resolves to the a - * {@link OutgoingRoomKeyRequest}, or null if - * there are no pending requests in those states - */ - public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise { - for (const req of this.outgoingRoomKeyRequests) { - for (const state of wantedStates) { - if (req.state === state) { - return Promise.resolve(req); - } - } - } - return Promise.resolve(null); - } - - /** - * - * @returns All OutgoingRoomKeyRequests in state - */ - public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise { - return Promise.resolve(this.outgoingRoomKeyRequests.filter((r) => r.state == wantedState)); - } - - public getOutgoingRoomKeyRequestsByTarget( - userId: string, - deviceId: string, - wantedStates: number[], - ): Promise { - const results: OutgoingRoomKeyRequest[] = []; - - for (const req of this.outgoingRoomKeyRequests) { - for (const state of wantedStates) { - if ( - req.state === state && - req.recipients.some((recipient) => recipient.userId === userId && recipient.deviceId === deviceId) - ) { - results.push(req); - } - } - } - return Promise.resolve(results); - } - - /** - * Look for an existing room key request by id and state, and update it if - * found - * - * @param requestId - ID of request to update - * @param expectedState - state we expect to find the request in - * @param updates - name/value map of updates to apply - * - * @returns resolves to - * {@link OutgoingRoomKeyRequest} - * updated request, or null if no matching row was found - */ - public updateOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - updates: Partial, - ): Promise { - for (const req of this.outgoingRoomKeyRequests) { - if (req.requestId !== requestId) { - continue; - } - - if (req.state !== expectedState) { - logger.warn( - `Cannot update room key request from ${expectedState} ` + - `as it was already updated to ${req.state}`, - ); - return Promise.resolve(null); - } - Object.assign(req, updates); - return Promise.resolve(req); - } - - return Promise.resolve(null); - } - - /** - * Look for an existing room key request by id and state, and delete it if - * found - * - * @param requestId - ID of request to update - * @param expectedState - state we expect to find the request in - * - * @returns resolves once the operation is completed - */ - public deleteOutgoingRoomKeyRequest( - requestId: string, - expectedState: number, - ): Promise { - for (let i = 0; i < this.outgoingRoomKeyRequests.length; i++) { - const req = this.outgoingRoomKeyRequests[i]; - - if (req.requestId !== requestId) { - continue; - } - - if (req.state != expectedState) { - logger.warn(`Cannot delete room key request in state ${req.state} ` + `(expected ${expectedState})`); - return Promise.resolve(null); - } - - this.outgoingRoomKeyRequests.splice(i, 1); - return Promise.resolve(req); - } - - return Promise.resolve(null); - } - // Olm Account public getAccount(txn: unknown, func: (accountPickle: string | null) => void): void { @@ -332,10 +136,6 @@ export class MemoryCryptoStore implements CryptoStore { func(result || null); } - public storeCrossSigningKeys(txn: unknown, keys: Record): void { - this.crossSigningKeys = keys; - } - public storeSecretStorePrivateKey( txn: unknown, type: K, @@ -372,18 +172,6 @@ export class MemoryCryptoStore implements CryptoStore { func(this.sessions[deviceKey] || {}); } - public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void { - Object.entries(this.sessions).forEach(([deviceKey, deviceSessions]) => { - Object.entries(deviceSessions).forEach(([sessionId, session]) => { - func({ - ...session, - deviceKey, - sessionId, - }); - }); - }); - } - public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void { let deviceSessions = this.sessions[deviceKey]; if (deviceSessions === undefined) { @@ -393,52 +181,6 @@ export class MemoryCryptoStore implements CryptoStore { safeSet(deviceSessions, sessionId, sessionInfo); } - public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise { - const problems = (this.sessionProblems[deviceKey] = this.sessionProblems[deviceKey] || []); - problems.push({ type, fixed, time: Date.now() }); - problems.sort((a, b) => { - return a.time - b.time; - }); - } - - public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise { - const problems = this.sessionProblems[deviceKey] || []; - if (!problems.length) { - return null; - } - const lastProblem = problems[problems.length - 1]; - for (const problem of problems) { - if (problem.time > timestamp) { - return Object.assign({}, problem, { fixed: lastProblem.fixed }); - } - } - if (lastProblem.fixed) { - return null; - } else { - return lastProblem; - } - } - - public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise { - const notifiedErrorDevices = this.notifiedErrorDevices; - const ret: IOlmDevice[] = []; - - for (const device of devices) { - const { userId, deviceInfo } = device; - if (userId in notifiedErrorDevices) { - if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) { - ret.push(device); - safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true); - } - } else { - ret.push(device); - safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true }); - } - } - - return ret; - } - /** * Fetch a batch of Olm sessions from the database. * @@ -496,28 +238,6 @@ export class MemoryCryptoStore implements CryptoStore { func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null); } - public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void { - for (const key of Object.keys(this.inboundGroupSessions)) { - func({ - ...decodeSessionKey(key), - sessionData: this.inboundGroupSessions[key], - }); - } - func(null); - } - - public addEndToEndInboundGroupSession( - senderCurve25519Key: string, - sessionId: string, - sessionData: InboundGroupSessionData, - txn: unknown, - ): void { - const k = encodeSessionKey(senderCurve25519Key, sessionId); - if (this.inboundGroupSessions[k] === undefined) { - this.inboundGroupSessions[k] = sessionData; - } - } - public storeEndToEndInboundGroupSession( senderCurve25519Key: string, sessionId: string, @@ -528,16 +248,6 @@ export class MemoryCryptoStore implements CryptoStore { this.inboundGroupSessions[k] = sessionData; } - public storeEndToEndInboundGroupSessionWithheld( - senderCurve25519Key: string, - sessionId: string, - sessionData: IWithheld, - txn: unknown, - ): void { - const k = encodeSessionKey(senderCurve25519Key, sessionId); - this.inboundGroupSessionsWithheld[k] = sessionData; - } - /** * Count the number of Megolm sessions in the database. * @@ -594,54 +304,12 @@ export class MemoryCryptoStore implements CryptoStore { } } - // Device Data - - public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void { - func(this.deviceData); - } - - public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void { - this.deviceData = deviceData; - } - // E2E rooms - public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void { - this.rooms[roomId] = roomInfo; - } - public getEndToEndRooms(txn: unknown, func: (rooms: Record) => void): void { func(this.rooms); } - public getSessionsNeedingBackup(limit: number): Promise { - const sessions: ISession[] = []; - for (const session in this.sessionsNeedingBackup) { - if (this.inboundGroupSessions[session]) { - sessions.push({ - ...decodeSessionKey(session), - sessionData: this.inboundGroupSessions[session], - }); - if (limit && session.length >= limit) { - break; - } - } - } - return Promise.resolve(sessions); - } - - public countSessionsNeedingBackup(): Promise { - return Promise.resolve(Object.keys(this.sessionsNeedingBackup).length); - } - - public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise { - for (const session of sessions) { - const sessionKey = encodeSessionKey(session.senderKey, session.sessionId); - delete this.sessionsNeedingBackup[sessionKey]; - } - return Promise.resolve(); - } - public markSessionsNeedingBackup(sessions: ISession[]): Promise { for (const session of sessions) { const sessionKey = encodeSessionKey(session.senderKey, session.sessionId); @@ -650,28 +318,6 @@ export class MemoryCryptoStore implements CryptoStore { return Promise.resolve(); } - public addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string): void { - const sessions = this.sharedHistoryInboundGroupSessions[roomId] || []; - sessions.push([senderKey, sessionId]); - this.sharedHistoryInboundGroupSessions[roomId] = sessions; - } - - public getSharedHistoryInboundGroupSessions(roomId: string): Promise<[senderKey: string, sessionId: string][]> { - return Promise.resolve(this.sharedHistoryInboundGroupSessions[roomId] || []); - } - - public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory): void { - const parked = this.parkedSharedHistory.get(roomId) ?? []; - parked.push(parkedData); - this.parkedSharedHistory.set(roomId, parked); - } - - public takeParkedSharedHistory(roomId: string): Promise { - const parked = this.parkedSharedHistory.get(roomId) ?? []; - this.parkedSharedHistory.delete(roomId); - return Promise.resolve(parked); - } - // Session key backups public doTxn(mode: Mode, stores: Iterable, func: (txn?: unknown) => T): Promise { diff --git a/src/crypto/verification/Base.ts b/src/crypto/verification/Base.ts deleted file mode 100644 index 028a6c3f9..000000000 --- a/src/crypto/verification/Base.ts +++ /dev/null @@ -1,409 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2020 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. -*/ - -/** - * Base class for verification methods. - */ - -import { MatrixEvent } from "../../models/event.ts"; -import { EventType } from "../../@types/event.ts"; -import { logger } from "../../logger.ts"; -import { DeviceInfo } from "../deviceinfo.ts"; -import { newTimeoutError } from "./Error.ts"; -import { type KeysDuringVerification, requestKeysDuringVerification } from "../CrossSigning.ts"; -import { type IVerificationChannel } from "./request/Channel.ts"; -import { type MatrixClient } from "../../client.ts"; -import { type VerificationRequest } from "./request/VerificationRequest.ts"; -import { TypedEventEmitter } from "../../models/typed-event-emitter.ts"; -import { - type ShowQrCodeCallbacks, - type ShowSasCallbacks, - type Verifier, - VerifierEvent, - type VerifierEventHandlerMap, -} from "../../crypto-api/verification.ts"; - -const timeoutException = new Error("Verification timed out"); - -export class SwitchStartEventError extends Error { - public constructor(public readonly startEvent: MatrixEvent | null) { - super(); - } -} - -export type KeyVerifier = (keyId: string, device: DeviceInfo, keyInfo: string) => void; - -/** @deprecated use VerifierEvent */ -export type VerificationEvent = VerifierEvent; -/** @deprecated use VerifierEvent */ -export const VerificationEvent = VerifierEvent; - -/** @deprecated use VerifierEventHandlerMap */ -export type VerificationEventHandlerMap = { - [VerificationEvent.Cancel]: (e: Error | MatrixEvent) => void; -}; - -/** @deprecated Avoid referencing this class directly; instead use {@link Crypto.Verifier}. */ -// The type parameters of VerificationBase are no longer used, but we need some placeholders to maintain -// backwards compatibility with applications that reference the class. -export class VerificationBase< - // eslint-disable-next-line @typescript-eslint/no-unused-vars - Events extends string = VerifierEvent, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - Arguments = VerifierEventHandlerMap, - > - extends TypedEventEmitter - implements Verifier -{ - private cancelled = false; - private _done = false; - private promise: Promise | null = null; - private transactionTimeoutTimer: ReturnType | null = null; - protected expectedEvent?: string; - private resolve?: () => void; - private reject?: (e: Error | MatrixEvent) => void; - private resolveEvent?: (e: MatrixEvent) => void; - private rejectEvent?: (e: Error) => void; - private started?: boolean; - - /** - * Base class for verification methods. - * - *

Once a verifier object is created, the verification can be started by - * calling the verify() method, which will return a promise that will - * resolve when the verification is completed, or reject if it could not - * complete.

- * - *

Subclasses must have a NAME class property.

- * - * @param channel - the verification channel to send verification messages over. - * TODO: Channel types - * - * @param baseApis - base matrix api interface - * - * @param userId - the user ID that is being verified - * - * @param deviceId - the device ID that is being verified - * - * @param startEvent - the m.key.verification.start event that - * initiated this verification, if any - * - * @param request - the key verification request object related to - * this verification, if any - */ - public constructor( - public readonly channel: IVerificationChannel, - public readonly baseApis: MatrixClient, - public readonly userId: string, - public readonly deviceId: string, - public startEvent: MatrixEvent | null, - public readonly request: VerificationRequest, - ) { - super(); - } - - public get initiatedByMe(): boolean { - // if there is no start event yet, - // we probably want to send it, - // which happens if we initiate - if (!this.startEvent) { - return true; - } - const sender = this.startEvent.getSender(); - const content = this.startEvent.getContent(); - return sender === this.baseApis.getUserId() && content.from_device === this.baseApis.getDeviceId(); - } - - public get hasBeenCancelled(): boolean { - return this.cancelled; - } - - private resetTimer(): void { - logger.info("Refreshing/starting the verification transaction timeout timer"); - if (this.transactionTimeoutTimer !== null) { - clearTimeout(this.transactionTimeoutTimer); - } - this.transactionTimeoutTimer = setTimeout( - () => { - if (!this._done && !this.cancelled) { - logger.info("Triggering verification timeout"); - this.cancel(timeoutException); - } - }, - 10 * 60 * 1000, - ); // 10 minutes - } - - private endTimer(): void { - if (this.transactionTimeoutTimer !== null) { - clearTimeout(this.transactionTimeoutTimer); - this.transactionTimeoutTimer = null; - } - } - - protected send(type: string, uncompletedContent: Record): Promise { - return this.channel.send(type, uncompletedContent); - } - - protected waitForEvent(type: string): Promise { - if (this._done) { - return Promise.reject(new Error("Verification is already done")); - } - const existingEvent = this.request.getEventFromOtherParty(type); - if (existingEvent) { - return Promise.resolve(existingEvent); - } - - this.expectedEvent = type; - return new Promise((resolve, reject) => { - this.resolveEvent = resolve; - this.rejectEvent = reject; - }); - } - - public canSwitchStartEvent(event: MatrixEvent): boolean { - return false; - } - - public switchStartEvent(event: MatrixEvent): void { - if (this.canSwitchStartEvent(event)) { - logger.log("Verification Base: switching verification start event", { restartingFlow: !!this.rejectEvent }); - if (this.rejectEvent) { - const reject = this.rejectEvent; - this.rejectEvent = undefined; - reject(new SwitchStartEventError(event)); - } else { - this.startEvent = event; - } - } - } - - public handleEvent(e: MatrixEvent): void { - if (this._done) { - return; - } else if (e.getType() === this.expectedEvent) { - // if we receive an expected m.key.verification.done, then just - // ignore it, since we don't need to do anything about it - if (this.expectedEvent !== EventType.KeyVerificationDone) { - this.expectedEvent = undefined; - this.rejectEvent = undefined; - this.resetTimer(); - this.resolveEvent?.(e); - } - } else if (e.getType() === EventType.KeyVerificationCancel) { - const reject = this.reject; - this.reject = undefined; - // there is only promise to reject if verify has been called - if (reject) { - const content = e.getContent(); - const { reason, code } = content; - reject(new Error(`Other side cancelled verification ` + `because ${reason} (${code})`)); - } - } else if (this.expectedEvent) { - // only cancel if there is an event expected. - // if there is no event expected, it means verify() wasn't called - // and we're just replaying the timeline events when syncing - // after a refresh when the events haven't been stored in the cache yet. - const exception = new Error( - "Unexpected message: expecting " + this.expectedEvent + " but got " + e.getType(), - ); - this.expectedEvent = undefined; - if (this.rejectEvent) { - const reject = this.rejectEvent; - this.rejectEvent = undefined; - reject(exception); - } - this.cancel(exception); - } - } - - public async done(): Promise { - this.endTimer(); // always kill the activity timer - if (!this._done) { - this.request.onVerifierFinished(); - this.resolve?.(); - return requestKeysDuringVerification(this.baseApis, this.userId, this.deviceId); - } - } - - public cancel(e: Error | MatrixEvent): void { - this.endTimer(); // always kill the activity timer - if (!this._done) { - this.cancelled = true; - this.request.onVerifierCancelled(); - if (this.userId && this.deviceId) { - // send a cancellation to the other user (if it wasn't - // cancelled by the other user) - if (e === timeoutException) { - const timeoutEvent = newTimeoutError(); - this.send(timeoutEvent.getType(), timeoutEvent.getContent()); - } else if (e instanceof MatrixEvent) { - const sender = e.getSender(); - if (sender !== this.userId) { - const content = e.getContent(); - if (e.getType() === EventType.KeyVerificationCancel) { - content.code = content.code || "m.unknown"; - content.reason = content.reason || content.body || "Unknown reason"; - this.send(EventType.KeyVerificationCancel, content); - } else { - this.send(EventType.KeyVerificationCancel, { - code: "m.unknown", - reason: content.body || "Unknown reason", - }); - } - } - } else { - this.send(EventType.KeyVerificationCancel, { - code: "m.unknown", - reason: e.toString(), - }); - } - } - if (this.promise !== null) { - // when we cancel without a promise, we end up with a promise - // but no reject function. If cancel is called again, we'd error. - if (this.reject) this.reject(e); - } else { - // FIXME: this causes an "Uncaught promise" console message - // if nothing ends up chaining this promise. - this.promise = Promise.reject(e); - } - // Also emit a 'cancel' event that the app can listen for to detect cancellation - // before calling verify() - this.emit(VerificationEvent.Cancel, e); - } - } - - /** - * Begin the key verification - * - * @returns Promise which resolves when the verification has - * completed. - */ - public verify(): Promise { - if (this.promise) return this.promise; - - this.promise = new Promise((resolve, reject) => { - this.resolve = (...args): void => { - this._done = true; - this.endTimer(); - resolve(...args); - }; - this.reject = (e: Error | MatrixEvent): void => { - this._done = true; - this.endTimer(); - reject(e); - }; - }); - if (this.doVerification && !this.started) { - this.started = true; - this.resetTimer(); // restart the timeout - new Promise((resolve, reject) => { - const crossSignId = this.baseApis.crypto!.deviceList.getStoredCrossSigningForUser(this.userId)?.getId(); - if (crossSignId === this.deviceId) { - reject(new Error("Device ID is the same as the cross-signing ID")); - } - resolve(); - }) - .then(() => this.doVerification!()) - .then(this.done.bind(this), this.cancel.bind(this)); - } - return this.promise; - } - - protected doVerification?: () => Promise; - - protected async verifyKeys(userId: string, keys: Record, verifier: KeyVerifier): Promise { - // we try to verify all the keys that we're told about, but we might - // not know about all of them, so keep track of the keys that we know - // about, and ignore the rest - const verifiedDevices: [string, string, string][] = []; - - for (const [keyId, keyInfo] of Object.entries(keys)) { - const deviceId = keyId.split(":", 2)[1]; - const device = this.baseApis.getStoredDevice(userId, deviceId); - if (device) { - verifier(keyId, device, keyInfo); - verifiedDevices.push([deviceId, keyId, device.keys[keyId]]); - } else { - const crossSigningInfo = this.baseApis.crypto!.deviceList.getStoredCrossSigningForUser(userId); - if (crossSigningInfo && crossSigningInfo.getId() === deviceId) { - verifier( - keyId, - DeviceInfo.fromStorage( - { - keys: { - [keyId]: deviceId, - }, - }, - deviceId, - ), - keyInfo, - ); - verifiedDevices.push([deviceId, keyId, deviceId]); - } else { - logger.warn(`verification: Could not find device ${deviceId} to verify`); - } - } - } - - // if none of the keys could be verified, then error because the app - // should be informed about that - if (!verifiedDevices.length) { - throw new Error("No devices could be verified"); - } - - logger.info("Verification completed! Marking devices verified: ", verifiedDevices); - // TODO: There should probably be a batch version of this, otherwise it's going - // to upload each signature in a separate API call which is silly because the - // API supports as many signatures as you like. - for (const [deviceId, keyId, key] of verifiedDevices) { - await this.baseApis.crypto!.setDeviceVerification(userId, deviceId, true, null, null, { [keyId]: key }); - } - - // if one of the user's own devices is being marked as verified / unverified, - // check the key backup status, since whether or not we use this depends on - // whether it has a signature from a verified device - if (userId == this.baseApis.credentials.userId) { - await this.baseApis.checkKeyBackup(); - } - } - - public get events(): string[] | undefined { - return undefined; - } - - /** - * Get the details for an SAS verification, if one is in progress - * - * Returns `null`, unless this verifier is for a SAS-based verification and we are waiting for the user to confirm - * the SAS matches. - */ - public getShowSasCallbacks(): ShowSasCallbacks | null { - return null; - } - - /** - * Get the details for reciprocating QR code verification, if one is in progress - * - * Returns `null`, unless this verifier is for reciprocating a QR-code-based verification (ie, the other user has - * already scanned our QR code), and we are waiting for the user to confirm. - */ - public getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null { - return null; - } -} diff --git a/src/crypto/verification/Error.ts b/src/crypto/verification/Error.ts deleted file mode 100644 index 4f609db3a..000000000 --- a/src/crypto/verification/Error.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* -Copyright 2018 - 2021 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. -*/ - -/** - * Error messages. - */ - -import { MatrixEvent } from "../../models/event.ts"; -import { EventType } from "../../@types/event.ts"; - -export function newVerificationError(code: string, reason: string, extraData?: Record): MatrixEvent { - const content = Object.assign({}, { code, reason }, extraData); - return new MatrixEvent({ - type: EventType.KeyVerificationCancel, - content, - }); -} - -export function errorFactory(code: string, reason: string): (extraData?: Record) => MatrixEvent { - return function (extraData?: Record) { - return newVerificationError(code, reason, extraData); - }; -} - -/** - * The verification was cancelled by the user. - */ -export const newUserCancelledError = errorFactory("m.user", "Cancelled by user"); - -/** - * The verification timed out. - */ -export const newTimeoutError = errorFactory("m.timeout", "Timed out"); - -/** - * An unknown method was selected. - */ -export const newUnknownMethodError = errorFactory("m.unknown_method", "Unknown method"); - -/** - * An unexpected message was sent. - */ -export const newUnexpectedMessageError = errorFactory("m.unexpected_message", "Unexpected message"); - -/** - * The key does not match. - */ -export const newKeyMismatchError = errorFactory("m.key_mismatch", "Key mismatch"); - -/** - * An invalid message was sent. - */ -export const newInvalidMessageError = errorFactory("m.invalid_message", "Invalid message"); - -export function errorFromEvent(event: MatrixEvent): { code: string; reason: string } { - const content = event.getContent(); - if (content) { - const { code, reason } = content; - return { code, reason }; - } else { - return { code: "Unknown error", reason: "m.unknown" }; - } -} diff --git a/src/crypto/verification/IllegalMethod.ts b/src/crypto/verification/IllegalMethod.ts deleted file mode 100644 index 2b5a88597..000000000 --- a/src/crypto/verification/IllegalMethod.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* -Copyright 2020 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. -*/ - -/** - * Verification method that is illegal to have (cannot possibly - * do verification with this method). - */ - -import { VerificationBase as Base, type VerificationEvent, type VerificationEventHandlerMap } from "./Base.ts"; -import { type IVerificationChannel } from "./request/Channel.ts"; -import { type MatrixClient } from "../../client.ts"; -import { type MatrixEvent } from "../../models/event.ts"; -import { type VerificationRequest } from "./request/VerificationRequest.ts"; - -export class IllegalMethod extends Base { - public static factory( - channel: IVerificationChannel, - baseApis: MatrixClient, - userId: string, - deviceId: string, - startEvent: MatrixEvent, - request: VerificationRequest, - ): IllegalMethod { - return new IllegalMethod(channel, baseApis, userId, deviceId, startEvent, request); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - public static get NAME(): string { - // Typically the name will be something else, but to complete - // the contract we offer a default one here. - return "org.matrix.illegal_method"; - } - - protected doVerification = async (): Promise => { - throw new Error("Verification is not possible with this method"); - }; -} diff --git a/src/crypto/verification/QRCode.ts b/src/crypto/verification/QRCode.ts deleted file mode 100644 index b4cb17136..000000000 --- a/src/crypto/verification/QRCode.ts +++ /dev/null @@ -1,310 +0,0 @@ -/* -Copyright 2018 - 2021 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. -*/ - -/** - * QR code key verification. - */ - -import { VerificationBase as Base } from "./Base.ts"; -import { newKeyMismatchError, newUserCancelledError } from "./Error.ts"; -import { decodeBase64, encodeUnpaddedBase64 } from "../../base64.ts"; -import { logger } from "../../logger.ts"; -import { type VerificationRequest } from "./request/VerificationRequest.ts"; -import { type MatrixClient } from "../../client.ts"; -import { type IVerificationChannel } from "./request/Channel.ts"; -import { type MatrixEvent } from "../../models/event.ts"; -import { type ShowQrCodeCallbacks, VerifierEvent } from "../../crypto-api/verification.ts"; -import { VerificationMethod } from "../../types.ts"; - -export const SHOW_QR_CODE_METHOD = VerificationMethod.ShowQrCode; -export const SCAN_QR_CODE_METHOD = VerificationMethod.ScanQrCode; - -/** @deprecated use VerifierEvent */ -export type QrCodeEvent = VerifierEvent; -/** @deprecated use VerifierEvent */ -export const QrCodeEvent = VerifierEvent; - -/** @deprecated Avoid referencing this class directly; instead use {@link Crypto.Verifier}. */ -export class ReciprocateQRCode extends Base { - public reciprocateQREvent?: ShowQrCodeCallbacks; - - public static factory( - channel: IVerificationChannel, - baseApis: MatrixClient, - userId: string, - deviceId: string, - startEvent: MatrixEvent, - request: VerificationRequest, - ): ReciprocateQRCode { - return new ReciprocateQRCode(channel, baseApis, userId, deviceId, startEvent, request); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - public static get NAME(): string { - return "m.reciprocate.v1"; - } - - protected doVerification = async (): Promise => { - if (!this.startEvent) { - // TODO: Support scanning QR codes - throw new Error("It is not currently possible to start verification" + "with this method yet."); - } - - const { qrCodeData } = this.request; - // 1. check the secret - if (this.startEvent.getContent()["secret"] !== qrCodeData?.encodedSharedSecret) { - throw newKeyMismatchError(); - } - - // 2. ask if other user shows shield as well - await new Promise((resolve, reject) => { - this.reciprocateQREvent = { - confirm: resolve, - cancel: (): void => reject(newUserCancelledError()), - }; - this.emit(QrCodeEvent.ShowReciprocateQr, this.reciprocateQREvent); - }); - - // 3. determine key to sign / mark as trusted - const keys: Record = {}; - - switch (qrCodeData?.mode) { - case Mode.VerifyOtherUser: { - // add master key to keys to be signed, only if we're not doing self-verification - const masterKey = qrCodeData.otherUserMasterKey; - keys[`ed25519:${masterKey}`] = masterKey!; - break; - } - case Mode.VerifySelfTrusted: { - const deviceId = this.request.targetDevice.deviceId; - keys[`ed25519:${deviceId}`] = qrCodeData.otherDeviceKey!; - break; - } - case Mode.VerifySelfUntrusted: { - const masterKey = qrCodeData.myMasterKey; - keys[`ed25519:${masterKey}`] = masterKey!; - break; - } - } - - // 4. sign the key (or mark own MSK as verified in case of MODE_VERIFY_SELF_TRUSTED) - await this.verifyKeys(this.userId, keys, (keyId, device, keyInfo) => { - // make sure the device has the expected keys - const targetKey = keys[keyId]; - if (!targetKey) throw newKeyMismatchError(); - - if (keyInfo !== targetKey) { - logger.error("key ID from key info does not match"); - throw newKeyMismatchError(); - } - for (const deviceKeyId in device.keys) { - if (!deviceKeyId.startsWith("ed25519")) continue; - const deviceTargetKey = keys[deviceKeyId]; - if (!deviceTargetKey) throw newKeyMismatchError(); - if (device.keys[deviceKeyId] !== deviceTargetKey) { - logger.error("master key does not match"); - throw newKeyMismatchError(); - } - } - }); - }; - - public getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null { - return this.reciprocateQREvent ?? null; - } -} - -const CODE_VERSION = 0x02; // the version of binary QR codes we support -const BINARY_PREFIX = "MATRIX"; // ASCII, used to prefix the binary format - -enum Mode { - VerifyOtherUser = 0x00, // Verifying someone who isn't us - VerifySelfTrusted = 0x01, // We trust the master key - VerifySelfUntrusted = 0x02, // We do not trust the master key -} - -interface IQrData { - prefix: string; - version: number; - mode: Mode; - transactionId?: string; - firstKeyB64: string; - secondKeyB64: string; - secretB64: string; -} - -export class QRCodeData { - public constructor( - public readonly mode: Mode, - private readonly sharedSecret: string, - // only set when mode is MODE_VERIFY_OTHER_USER, master key of other party at time of generating QR code - public readonly otherUserMasterKey: string | null, - // only set when mode is MODE_VERIFY_SELF_TRUSTED, device key of other party at time of generating QR code - public readonly otherDeviceKey: string | null, - // only set when mode is MODE_VERIFY_SELF_UNTRUSTED, own master key at time of generating QR code - public readonly myMasterKey: string | null, - private readonly buffer: Buffer, - ) {} - - public static async create(request: VerificationRequest, client: MatrixClient): Promise { - const sharedSecret = QRCodeData.generateSharedSecret(); - const mode = QRCodeData.determineMode(request, client); - let otherUserMasterKey: string | null = null; - let otherDeviceKey: string | null = null; - let myMasterKey: string | null = null; - if (mode === Mode.VerifyOtherUser) { - const otherUserCrossSigningInfo = client.getStoredCrossSigningForUser(request.otherUserId); - otherUserMasterKey = otherUserCrossSigningInfo!.getId("master"); - } else if (mode === Mode.VerifySelfTrusted) { - otherDeviceKey = await QRCodeData.getOtherDeviceKey(request, client); - } else if (mode === Mode.VerifySelfUntrusted) { - const myUserId = client.getUserId()!; - const myCrossSigningInfo = client.getStoredCrossSigningForUser(myUserId); - myMasterKey = myCrossSigningInfo!.getId("master"); - } - const qrData = QRCodeData.generateQrData( - request, - client, - mode, - sharedSecret, - otherUserMasterKey!, - otherDeviceKey!, - myMasterKey!, - ); - const buffer = QRCodeData.generateBuffer(qrData); - return new QRCodeData(mode, sharedSecret, otherUserMasterKey, otherDeviceKey, myMasterKey, buffer); - } - - /** - * The unpadded base64 encoded shared secret. - */ - public get encodedSharedSecret(): string { - return this.sharedSecret; - } - - public getBuffer(): Buffer { - return this.buffer; - } - - private static generateSharedSecret(): string { - const secretBytes = new Uint8Array(11); - globalThis.crypto.getRandomValues(secretBytes); - return encodeUnpaddedBase64(secretBytes); - } - - private static async getOtherDeviceKey(request: VerificationRequest, client: MatrixClient): Promise { - const myUserId = client.getUserId()!; - const otherDevice = request.targetDevice; - const device = otherDevice.deviceId ? client.getStoredDevice(myUserId, otherDevice.deviceId) : undefined; - if (!device) { - throw new Error("could not find device " + otherDevice?.deviceId); - } - return device.getFingerprint(); - } - - private static determineMode(request: VerificationRequest, client: MatrixClient): Mode { - const myUserId = client.getUserId(); - const otherUserId = request.otherUserId; - - let mode = Mode.VerifyOtherUser; - if (myUserId === otherUserId) { - // Mode changes depending on whether or not we trust the master cross signing key - const myTrust = client.checkUserTrust(myUserId); - if (myTrust.isCrossSigningVerified()) { - mode = Mode.VerifySelfTrusted; - } else { - mode = Mode.VerifySelfUntrusted; - } - } - return mode; - } - - private static generateQrData( - request: VerificationRequest, - client: MatrixClient, - mode: Mode, - encodedSharedSecret: string, - otherUserMasterKey?: string, - otherDeviceKey?: string, - myMasterKey?: string, - ): IQrData { - const myUserId = client.getUserId()!; - const transactionId = request.channel.transactionId; - const qrData: IQrData = { - prefix: BINARY_PREFIX, - version: CODE_VERSION, - mode, - transactionId, - firstKeyB64: "", // worked out shortly - secondKeyB64: "", // worked out shortly - secretB64: encodedSharedSecret, - }; - - const myCrossSigningInfo = client.getStoredCrossSigningForUser(myUserId); - - if (mode === Mode.VerifyOtherUser) { - // First key is our master cross signing key - qrData.firstKeyB64 = myCrossSigningInfo!.getId("master")!; - // Second key is the other user's master cross signing key - qrData.secondKeyB64 = otherUserMasterKey!; - } else if (mode === Mode.VerifySelfTrusted) { - // First key is our master cross signing key - qrData.firstKeyB64 = myCrossSigningInfo!.getId("master")!; - qrData.secondKeyB64 = otherDeviceKey!; - } else if (mode === Mode.VerifySelfUntrusted) { - // First key is our device's key - qrData.firstKeyB64 = client.getDeviceEd25519Key()!; - // Second key is what we think our master cross signing key is - qrData.secondKeyB64 = myMasterKey!; - } - return qrData; - } - - private static generateBuffer(qrData: IQrData): Buffer { - let buf = Buffer.alloc(0); // we'll concat our way through life - - const appendByte = (b: number): void => { - const tmpBuf = Buffer.from([b]); - buf = Buffer.concat([buf, tmpBuf]); - }; - const appendInt = (i: number): void => { - const tmpBuf = Buffer.alloc(2); - tmpBuf.writeInt16BE(i, 0); - buf = Buffer.concat([buf, tmpBuf]); - }; - const appendStr = (s: string, enc: BufferEncoding, withLengthPrefix = true): void => { - const tmpBuf = Buffer.from(s, enc); - if (withLengthPrefix) appendInt(tmpBuf.byteLength); - buf = Buffer.concat([buf, tmpBuf]); - }; - const appendEncBase64 = (b64: string): void => { - const b = decodeBase64(b64); - const tmpBuf = Buffer.from(b); - buf = Buffer.concat([buf, tmpBuf]); - }; - - // Actually build the buffer for the QR code - appendStr(qrData.prefix, "ascii", false); - appendByte(qrData.version); - appendByte(qrData.mode); - appendStr(qrData.transactionId!, "utf-8"); - appendEncBase64(qrData.firstKeyB64); - appendEncBase64(qrData.secondKeyB64); - appendEncBase64(qrData.secretB64); - - return buf; - } -} diff --git a/src/crypto/verification/SAS.ts b/src/crypto/verification/SAS.ts deleted file mode 100644 index f956b5756..000000000 --- a/src/crypto/verification/SAS.ts +++ /dev/null @@ -1,499 +0,0 @@ -/* -Copyright 2018 - 2021 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. -*/ - -/** - * Short Authentication String (SAS) verification. - */ - -import anotherjson from "another-json"; -import { type Utility, type SAS as OlmSAS } from "@matrix-org/olm"; - -import { VerificationBase as Base, SwitchStartEventError } from "./Base.ts"; -import { - errorFactory, - newInvalidMessageError, - newKeyMismatchError, - newUnknownMethodError, - newUserCancelledError, -} from "./Error.ts"; -import { logger } from "../../logger.ts"; -import { type IContent, type MatrixEvent } from "../../models/event.ts"; -import { generateDecimalSas } from "./SASDecimal.ts"; -import { EventType } from "../../@types/event.ts"; -import { - type EmojiMapping, - type GeneratedSas, - type ShowSasCallbacks, - VerifierEvent, -} from "../../crypto-api/verification.ts"; -import { VerificationMethod } from "../../types.ts"; - -// backwards-compatibility exports -export type { - ShowSasCallbacks as ISasEvent, - GeneratedSas as IGeneratedSas, - EmojiMapping, -} from "../../crypto-api/verification.ts"; - -const START_TYPE = EventType.KeyVerificationStart; - -const EVENTS = [EventType.KeyVerificationAccept, EventType.KeyVerificationKey, EventType.KeyVerificationMac]; - -let olmutil: Utility; - -const newMismatchedSASError = errorFactory("m.mismatched_sas", "Mismatched short authentication string"); - -const newMismatchedCommitmentError = errorFactory("m.mismatched_commitment", "Mismatched commitment"); - -// This list was generated from the data in the Matrix specification [1] with the following command: -// -// jq -r '.[] | " [\"" + .emoji + "\", \"" + (.description|ascii_downcase) + "\"], // " + (.number|tostring)' sas-emoji.json -// -// [1]: https://github.com/matrix-org/matrix-spec/blob/main/data-definitions/sas-emoji.json -const emojiMapping: EmojiMapping[] = [ - ["🐶", "dog"], // 0 - ["🐱", "cat"], // 1 - ["🦁", "lion"], // 2 - ["🐎", "horse"], // 3 - ["🦄", "unicorn"], // 4 - ["🐷", "pig"], // 5 - ["🐘", "elephant"], // 6 - ["🐰", "rabbit"], // 7 - ["🐼", "panda"], // 8 - ["🐓", "rooster"], // 9 - ["🐧", "penguin"], // 10 - ["🐢", "turtle"], // 11 - ["🐟", "fish"], // 12 - ["🐙", "octopus"], // 13 - ["🦋", "butterfly"], // 14 - ["🌷", "flower"], // 15 - ["🌳", "tree"], // 16 - ["🌵", "cactus"], // 17 - ["🍄", "mushroom"], // 18 - ["🌏", "globe"], // 19 - ["🌙", "moon"], // 20 - ["☁️", "cloud"], // 21 - ["🔥", "fire"], // 22 - ["🍌", "banana"], // 23 - ["🍎", "apple"], // 24 - ["🍓", "strawberry"], // 25 - ["🌽", "corn"], // 26 - ["🍕", "pizza"], // 27 - ["🎂", "cake"], // 28 - ["❤️", "heart"], // 29 - ["😀", "smiley"], // 30 - ["🤖", "robot"], // 31 - ["🎩", "hat"], // 32 - ["👓", "glasses"], // 33 - ["🔧", "spanner"], // 34 - ["🎅", "santa"], // 35 - ["👍", "thumbs up"], // 36 - ["☂️", "umbrella"], // 37 - ["⌛", "hourglass"], // 38 - ["⏰", "clock"], // 39 - ["🎁", "gift"], // 40 - ["💡", "light bulb"], // 41 - ["📕", "book"], // 42 - ["✏️", "pencil"], // 43 - ["📎", "paperclip"], // 44 - ["✂️", "scissors"], // 45 - ["🔒", "lock"], // 46 - ["🔑", "key"], // 47 - ["🔨", "hammer"], // 48 - ["☎️", "telephone"], // 49 - ["🏁", "flag"], // 50 - ["🚂", "train"], // 51 - ["🚲", "bicycle"], // 52 - ["✈️", "aeroplane"], // 53 - ["🚀", "rocket"], // 54 - ["🏆", "trophy"], // 55 - ["⚽", "ball"], // 56 - ["🎸", "guitar"], // 57 - ["🎺", "trumpet"], // 58 - ["🔔", "bell"], // 59 - ["⚓", "anchor"], // 60 - ["🎧", "headphones"], // 61 - ["📁", "folder"], // 62 - ["📌", "pin"], // 63 -]; - -function generateEmojiSas(sasBytes: number[]): EmojiMapping[] { - const emojis = [ - // just like base64 encoding - sasBytes[0] >> 2, - ((sasBytes[0] & 0x3) << 4) | (sasBytes[1] >> 4), - ((sasBytes[1] & 0xf) << 2) | (sasBytes[2] >> 6), - sasBytes[2] & 0x3f, - sasBytes[3] >> 2, - ((sasBytes[3] & 0x3) << 4) | (sasBytes[4] >> 4), - ((sasBytes[4] & 0xf) << 2) | (sasBytes[5] >> 6), - ]; - - return emojis.map((num) => emojiMapping[num]); -} - -const sasGenerators = { - decimal: generateDecimalSas, - emoji: generateEmojiSas, -} as const; - -function generateSas(sasBytes: Uint8Array, methods: string[]): GeneratedSas { - const sas: GeneratedSas = {}; - for (const method of methods) { - if (method in sasGenerators) { - // @ts-ignore - ts doesn't like us mixing types like this - sas[method] = sasGenerators[method](Array.from(sasBytes)); - } - } - return sas; -} - -const macMethods = { - "hkdf-hmac-sha256": "calculate_mac", - "org.matrix.msc3783.hkdf-hmac-sha256": "calculate_mac_fixed_base64", - "hkdf-hmac-sha256.v2": "calculate_mac_fixed_base64", - "hmac-sha256": "calculate_mac_long_kdf", -} as const; - -type MacMethod = keyof typeof macMethods; - -function calculateMAC(olmSAS: OlmSAS, method: MacMethod) { - return function (input: string, info: string): string { - const mac = olmSAS[macMethods[method]](input, info); - logger.log("SAS calculateMAC:", method, [input, info], mac); - return mac; - }; -} - -const calculateKeyAgreement = { - // eslint-disable-next-line @typescript-eslint/naming-convention - "curve25519-hkdf-sha256": function (sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array { - const ourInfo = `${sas.baseApis.getUserId()}|${sas.baseApis.deviceId}|` + `${sas.ourSASPubKey}|`; - const theirInfo = `${sas.userId}|${sas.deviceId}|${sas.theirSASPubKey}|`; - const sasInfo = - "MATRIX_KEY_VERIFICATION_SAS|" + - (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) + - sas.channel.transactionId; - return olmSAS.generate_bytes(sasInfo, bytes); - }, - "curve25519": function (sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array { - const ourInfo = `${sas.baseApis.getUserId()}${sas.baseApis.deviceId}`; - const theirInfo = `${sas.userId}${sas.deviceId}`; - const sasInfo = - "MATRIX_KEY_VERIFICATION_SAS" + - (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) + - sas.channel.transactionId; - return olmSAS.generate_bytes(sasInfo, bytes); - }, -} as const; - -type KeyAgreement = keyof typeof calculateKeyAgreement; - -/* lists of algorithms/methods that are supported. The key agreement, hashes, - * and MAC lists should be sorted in order of preference (most preferred - * first). - */ -const KEY_AGREEMENT_LIST: KeyAgreement[] = ["curve25519-hkdf-sha256", "curve25519"]; -const HASHES_LIST = ["sha256"]; -const MAC_LIST: MacMethod[] = [ - "hkdf-hmac-sha256.v2", - "org.matrix.msc3783.hkdf-hmac-sha256", - "hkdf-hmac-sha256", - "hmac-sha256", -]; -const SAS_LIST = Object.keys(sasGenerators); - -const KEY_AGREEMENT_SET = new Set(KEY_AGREEMENT_LIST); -const HASHES_SET = new Set(HASHES_LIST); -const MAC_SET = new Set(MAC_LIST); -const SAS_SET = new Set(SAS_LIST); - -function intersection(anArray: T[], aSet: Set): T[] { - return Array.isArray(anArray) ? anArray.filter((x) => aSet.has(x)) : []; -} - -/** @deprecated use VerifierEvent */ -export type SasEvent = VerifierEvent; -/** @deprecated use VerifierEvent */ -export const SasEvent = VerifierEvent; - -/** @deprecated Avoid referencing this class directly; instead use {@link Crypto.Verifier}. */ -export class SAS extends Base { - private waitingForAccept?: boolean; - public ourSASPubKey?: string; - public theirSASPubKey?: string; - public sasEvent?: ShowSasCallbacks; - - // eslint-disable-next-line @typescript-eslint/naming-convention - public static get NAME(): string { - return VerificationMethod.Sas; - } - - public get events(): string[] { - return EVENTS; - } - - protected doVerification = async (): Promise => { - await globalThis.Olm.init(); - olmutil = olmutil || new globalThis.Olm.Utility(); - - // make sure user's keys are downloaded - await this.baseApis.downloadKeys([this.userId]); - - let retry = false; - do { - try { - if (this.initiatedByMe) { - return await this.doSendVerification(); - } else { - return await this.doRespondVerification(); - } - } catch (err) { - if (err instanceof SwitchStartEventError) { - // this changes what initiatedByMe returns - this.startEvent = err.startEvent; - retry = true; - } else { - throw err; - } - } - } while (retry); - }; - - public canSwitchStartEvent(event: MatrixEvent): boolean { - if (event.getType() !== START_TYPE) { - return false; - } - const content = event.getContent(); - return content?.method === SAS.NAME && !!this.waitingForAccept; - } - - private async sendStart(): Promise> { - const startContent = this.channel.completeContent(START_TYPE, { - method: SAS.NAME, - from_device: this.baseApis.deviceId, - key_agreement_protocols: KEY_AGREEMENT_LIST, - hashes: HASHES_LIST, - message_authentication_codes: MAC_LIST, - // FIXME: allow app to specify what SAS methods can be used - short_authentication_string: SAS_LIST, - }); - await this.channel.sendCompleted(START_TYPE, startContent); - return startContent; - } - - private async verifyAndCheckMAC( - keyAgreement: KeyAgreement, - sasMethods: string[], - olmSAS: OlmSAS, - macMethod: MacMethod, - ): Promise { - const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6); - const verifySAS = new Promise((resolve, reject) => { - this.sasEvent = { - sas: generateSas(sasBytes, sasMethods), - confirm: async (): Promise => { - try { - await this.sendMAC(olmSAS, macMethod); - resolve(); - } catch (err) { - reject(err); - } - }, - cancel: (): void => reject(newUserCancelledError()), - mismatch: (): void => reject(newMismatchedSASError()), - }; - this.emit(SasEvent.ShowSas, this.sasEvent); - }); - - const [e] = await Promise.all([ - this.waitForEvent(EventType.KeyVerificationMac).then((e) => { - // we don't expect any more messages from the other - // party, and they may send a m.key.verification.done - // when they're done on their end - this.expectedEvent = EventType.KeyVerificationDone; - return e; - }), - verifySAS, - ]); - const content = e.getContent(); - await this.checkMAC(olmSAS, content, macMethod); - } - - private async doSendVerification(): Promise { - this.waitingForAccept = true; - let startContent; - if (this.startEvent) { - startContent = this.channel.completedContentFromEvent(this.startEvent); - } else { - startContent = await this.sendStart(); - } - - // we might have switched to a different start event, - // but was we didn't call _waitForEvent there was no - // call that could throw yet. So check manually that - // we're still on the initiator side - if (!this.initiatedByMe) { - throw new SwitchStartEventError(this.startEvent); - } - - let e: MatrixEvent; - try { - e = await this.waitForEvent(EventType.KeyVerificationAccept); - } finally { - this.waitingForAccept = false; - } - let content = e.getContent(); - const sasMethods = intersection(content.short_authentication_string, SAS_SET); - if ( - !( - KEY_AGREEMENT_SET.has(content.key_agreement_protocol) && - HASHES_SET.has(content.hash) && - MAC_SET.has(content.message_authentication_code) && - sasMethods.length - ) - ) { - throw newUnknownMethodError(); - } - if (typeof content.commitment !== "string") { - throw newInvalidMessageError(); - } - const keyAgreement = content.key_agreement_protocol; - const macMethod = content.message_authentication_code; - const hashCommitment = content.commitment; - const olmSAS = new globalThis.Olm.SAS(); - try { - this.ourSASPubKey = olmSAS.get_pubkey(); - await this.send(EventType.KeyVerificationKey, { - key: this.ourSASPubKey, - }); - - e = await this.waitForEvent(EventType.KeyVerificationKey); - // FIXME: make sure event is properly formed - content = e.getContent(); - const commitmentStr = content.key + anotherjson.stringify(startContent); - // TODO: use selected hash function (when we support multiple) - if (olmutil.sha256(commitmentStr) !== hashCommitment) { - throw newMismatchedCommitmentError(); - } - this.theirSASPubKey = content.key; - olmSAS.set_their_key(content.key); - - await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod); - } finally { - olmSAS.free(); - } - } - - private async doRespondVerification(): Promise { - // as m.related_to is not included in the encrypted content in e2e rooms, - // we need to make sure it is added - let content = this.channel.completedContentFromEvent(this.startEvent!); - - // Note: we intersect using our pre-made lists, rather than the sets, - // so that the result will be in our order of preference. Then - // fetching the first element from the array will give our preferred - // method out of the ones offered by the other party. - const keyAgreement = intersection(KEY_AGREEMENT_LIST, new Set(content.key_agreement_protocols))[0]; - const hashMethod = intersection(HASHES_LIST, new Set(content.hashes))[0]; - const macMethod = intersection(MAC_LIST, new Set(content.message_authentication_codes))[0]; - // FIXME: allow app to specify what SAS methods can be used - const sasMethods = intersection(content.short_authentication_string, SAS_SET); - if (!(keyAgreement !== undefined && hashMethod !== undefined && macMethod !== undefined && sasMethods.length)) { - throw newUnknownMethodError(); - } - - const olmSAS = new globalThis.Olm.SAS(); - try { - const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(content); - await this.send(EventType.KeyVerificationAccept, { - key_agreement_protocol: keyAgreement, - hash: hashMethod, - message_authentication_code: macMethod, - short_authentication_string: sasMethods, - // TODO: use selected hash function (when we support multiple) - commitment: olmutil.sha256(commitmentStr), - }); - - const e = await this.waitForEvent(EventType.KeyVerificationKey); - // FIXME: make sure event is properly formed - content = e.getContent(); - this.theirSASPubKey = content.key; - olmSAS.set_their_key(content.key); - this.ourSASPubKey = olmSAS.get_pubkey(); - await this.send(EventType.KeyVerificationKey, { - key: this.ourSASPubKey, - }); - - await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod); - } finally { - olmSAS.free(); - } - } - - private sendMAC(olmSAS: OlmSAS, method: MacMethod): Promise { - const mac: Record = {}; - const keyList: string[] = []; - const baseInfo = - "MATRIX_KEY_VERIFICATION_MAC" + - this.baseApis.getUserId() + - this.baseApis.deviceId + - this.userId + - this.deviceId + - this.channel.transactionId; - - const deviceKeyId = `ed25519:${this.baseApis.deviceId}`; - mac[deviceKeyId] = calculateMAC(olmSAS, method)(this.baseApis.getDeviceEd25519Key()!, baseInfo + deviceKeyId); - keyList.push(deviceKeyId); - - const crossSigningId = this.baseApis.getCrossSigningId(); - if (crossSigningId) { - const crossSigningKeyId = `ed25519:${crossSigningId}`; - mac[crossSigningKeyId] = calculateMAC(olmSAS, method)(crossSigningId, baseInfo + crossSigningKeyId); - keyList.push(crossSigningKeyId); - } - - const keys = calculateMAC(olmSAS, method)(keyList.sort().join(","), baseInfo + "KEY_IDS"); - return this.send(EventType.KeyVerificationMac, { mac, keys }); - } - - private async checkMAC(olmSAS: OlmSAS, content: IContent, method: MacMethod): Promise { - const baseInfo = - "MATRIX_KEY_VERIFICATION_MAC" + - this.userId + - this.deviceId + - this.baseApis.getUserId() + - this.baseApis.deviceId + - this.channel.transactionId; - - if ( - content.keys !== - calculateMAC(olmSAS, method)(Object.keys(content.mac).sort().join(","), baseInfo + "KEY_IDS") - ) { - throw newKeyMismatchError(); - } - - await this.verifyKeys(this.userId, content.mac, (keyId, device, keyInfo) => { - if (keyInfo !== calculateMAC(olmSAS, method)(device.keys[keyId], baseInfo + keyId)) { - throw newKeyMismatchError(); - } - }); - } - - public getShowSasCallbacks(): ShowSasCallbacks | null { - return this.sasEvent ?? null; - } -} diff --git a/src/crypto/verification/SASDecimal.ts b/src/crypto/verification/SASDecimal.ts deleted file mode 100644 index 0cb4630c2..000000000 --- a/src/crypto/verification/SASDecimal.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2018 - 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. -*/ - -/** - * Implementation of decimal encoding of SAS as per: - * https://spec.matrix.org/v1.4/client-server-api/#sas-method-decimal - * @param sasBytes - the five bytes generated by HKDF - * @returns the derived three numbers between 1000 and 9191 inclusive - */ -export function generateDecimalSas(sasBytes: number[]): [number, number, number] { - /* - * +--------+--------+--------+--------+--------+ - * | Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | - * +--------+--------+--------+--------+--------+ - * bits: 87654321 87654321 87654321 87654321 87654321 - * \____________/\_____________/\____________/ - * 1st number 2nd number 3rd number - */ - return [ - ((sasBytes[0] << 5) | (sasBytes[1] >> 3)) + 1000, - (((sasBytes[1] & 0x7) << 10) | (sasBytes[2] << 2) | (sasBytes[3] >> 6)) + 1000, - (((sasBytes[3] & 0x3f) << 7) | (sasBytes[4] >> 1)) + 1000, - ]; -} diff --git a/src/crypto/verification/request/Channel.ts b/src/crypto/verification/request/Channel.ts deleted file mode 100644 index 3b3a6385f..000000000 --- a/src/crypto/verification/request/Channel.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* -Copyright 2021 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 MatrixEvent } from "../../../models/event.ts"; -import { type VerificationRequest } from "./VerificationRequest.ts"; - -export interface IVerificationChannel { - request?: VerificationRequest; - readonly userId?: string; - readonly roomId?: string; - readonly deviceId?: string; - readonly transactionId?: string; - readonly receiveStartFromOtherDevices?: boolean; - getTimestamp(event: MatrixEvent): number; - send(type: string, uncompletedContent: Record): Promise; - completeContent(type: string, content: Record): Record; - sendCompleted(type: string, content: Record): Promise; - completedContentFromEvent(event: MatrixEvent): Record; - canCreateRequest(type: string): boolean; - handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent: boolean): Promise; -} diff --git a/src/crypto/verification/request/InRoomChannel.ts b/src/crypto/verification/request/InRoomChannel.ts deleted file mode 100644 index bbb8bcbca..000000000 --- a/src/crypto/verification/request/InRoomChannel.ts +++ /dev/null @@ -1,371 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 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 { VerificationRequest, REQUEST_TYPE, READY_TYPE, START_TYPE } from "./VerificationRequest.ts"; -import { logger } from "../../../logger.ts"; -import { type IVerificationChannel } from "./Channel.ts"; -import { EventType, type TimelineEvents } from "../../../@types/event.ts"; -import { type MatrixClient } from "../../../client.ts"; -import { type MatrixEvent } from "../../../models/event.ts"; -import { type IRequestsMap } from "../../index.ts"; - -const MESSAGE_TYPE = EventType.RoomMessage; -const M_REFERENCE = "m.reference"; -const M_RELATES_TO = "m.relates_to"; - -/** - * A key verification channel that sends verification events in the timeline of a room. - * Uses the event id of the initial m.key.verification.request event as a transaction id. - */ -export class InRoomChannel implements IVerificationChannel { - private requestEventId?: string; - - /** - * @param client - the matrix client, to send messages with and get current user & device from. - * @param roomId - id of the room where verification events should be posted in, should be a DM with the given user. - * @param userId - id of user that the verification request is directed at, should be present in the room. - */ - public constructor( - private readonly client: MatrixClient, - public readonly roomId: string, - public userId?: string, - ) {} - - public get receiveStartFromOtherDevices(): boolean { - return true; - } - - /** The transaction id generated/used by this verification channel */ - public get transactionId(): string | undefined { - return this.requestEventId; - } - - public static getOtherPartyUserId(event: MatrixEvent, client: MatrixClient): string | undefined { - const type = InRoomChannel.getEventType(event); - if (type !== REQUEST_TYPE) { - return; - } - const ownUserId = client.getUserId(); - const sender = event.getSender(); - const content = event.getContent(); - const receiver = content.to; - - if (sender === ownUserId) { - return receiver; - } else if (receiver === ownUserId) { - return sender; - } - } - - /** - * @param event - the event to get the timestamp of - * @returns the timestamp when the event was sent - */ - public getTimestamp(event: MatrixEvent): number { - return event.getTs(); - } - - /** - * Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel - * @param type - the event type to check - * @returns boolean flag - */ - public static canCreateRequest(type: string): boolean { - return type === REQUEST_TYPE; - } - - public canCreateRequest(type: string): boolean { - return InRoomChannel.canCreateRequest(type); - } - - /** - * Extract the transaction id used by a given key verification event, if any - * @param event - the event - * @returns the transaction id - */ - public static getTransactionId(event: MatrixEvent): string | undefined { - if (InRoomChannel.getEventType(event) === REQUEST_TYPE) { - return event.getId(); - } else { - const relation = event.getRelation(); - if (relation?.rel_type === M_REFERENCE) { - return relation.event_id; - } - } - } - - /** - * Checks whether this event is a well-formed key verification event. - * This only does checks that don't rely on the current state of a potentially already channel - * so we can prevent channels being created by invalid events. - * `handleEvent` can do more checks and choose to ignore invalid events. - * @param event - the event to validate - * @param client - the client to get the current user and device id from - * @returns whether the event is valid and should be passed to handleEvent - */ - public static validateEvent(event: MatrixEvent, client: MatrixClient): boolean { - const txnId = InRoomChannel.getTransactionId(event); - if (typeof txnId !== "string" || txnId.length === 0) { - return false; - } - const type = InRoomChannel.getEventType(event); - const content = event.getContent(); - - // from here on we're fairly sure that this is supposed to be - // part of a verification request, so be noisy when rejecting something - if (type === REQUEST_TYPE) { - if (!content || typeof content.to !== "string" || !content.to.length) { - logger.log("InRoomChannel: validateEvent: " + "no valid to " + content.to); - return false; - } - - // ignore requests that are not direct to or sent by the syncing user - if (!InRoomChannel.getOtherPartyUserId(event, client)) { - logger.log( - "InRoomChannel: validateEvent: " + - `not directed to or sent by me: ${event.getSender()}` + - `, ${content.to}`, - ); - return false; - } - } - - return VerificationRequest.validateEvent(type, event, client); - } - - /** - * As m.key.verification.request events are as m.room.message events with the InRoomChannel - * to have a fallback message in non-supporting clients, we map the real event type - * to the symbolic one to keep things in unison with ToDeviceChannel - * @param event - the event to get the type of - * @returns the "symbolic" event type - */ - public static getEventType(event: MatrixEvent): string { - const type = event.getType(); - if (type === MESSAGE_TYPE) { - const content = event.getContent(); - if (content) { - const { msgtype } = content; - if (msgtype === REQUEST_TYPE) { - return REQUEST_TYPE; - } - } - } - if (type && type !== REQUEST_TYPE) { - return type; - } else { - return ""; - } - } - - /** - * Changes the state of the channel, request, and verifier in response to a key verification event. - * @param event - to handle - * @param request - the request to forward handling to - * @param isLiveEvent - whether this is an even received through sync or not - * @returns a promise that resolves when any requests as an answer to the passed-in event are sent. - */ - public async handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent = false): Promise { - // prevent processing the same event multiple times, as under - // some circumstances Room.timeline can get emitted twice for the same event - if (request.hasEventId(event.getId()!)) { - return; - } - const type = InRoomChannel.getEventType(event); - // do validations that need state (roomId, userId), - // ignore if invalid - - if (event.getRoomId() !== this.roomId) { - return; - } - // set userId if not set already - if (!this.userId) { - const userId = InRoomChannel.getOtherPartyUserId(event, this.client); - if (userId) { - this.userId = userId; - } - } - // ignore events not sent by us or the other party - const ownUserId = this.client.getUserId(); - const sender = event.getSender(); - if (this.userId) { - if (sender !== ownUserId && sender !== this.userId) { - logger.log(`InRoomChannel: ignoring verification event from non-participating sender ${sender}`); - return; - } - } - if (!this.requestEventId) { - this.requestEventId = InRoomChannel.getTransactionId(event); - } - - // With pendingEventOrdering: "chronological", we will see events that have been sent but not yet reflected - // back via /sync. These are "local echoes" and are identifiable by their txnId - const isLocalEcho = !!event.getTxnId(); - - // Alternatively, we may see an event that we sent that is reflected back via /sync. These are "remote echoes" - // and have a transaction ID in the "unsigned" data - const isRemoteEcho = !!event.getUnsigned().transaction_id; - - const isSentByUs = event.getSender() === this.client.getUserId(); - - return request.handleEvent(type, event, isLiveEvent, isLocalEcho || isRemoteEcho, isSentByUs); - } - - /** - * Adds the transaction id (relation) back to a received event - * so it has the same format as returned by `completeContent` before sending. - * The relation can not appear on the event content because of encryption, - * relations are excluded from encryption. - * @param event - the received event - * @returns the content object with the relation added again - */ - public completedContentFromEvent(event: MatrixEvent): Record { - // ensure m.related_to is included in e2ee rooms - // as the field is excluded from encryption - const content = Object.assign({}, event.getContent()); - content[M_RELATES_TO] = event.getRelation()!; - return content; - } - - /** - * Add all the fields to content needed for sending it over this channel. - * This is public so verification methods (SAS uses this) can get the exact - * content that will be sent independent of the used channel, - * as they need to calculate the hash of it. - * @param type - the event type - * @param content - the (incomplete) content - * @returns the complete content, as it will be sent. - */ - public completeContent(type: string, content: Record): Record { - content = Object.assign({}, content); - if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { - content.from_device = this.client.getDeviceId(); - } - if (type === REQUEST_TYPE) { - // type is mapped to m.room.message in the send method - content = { - body: - this.client.getUserId() + - " is requesting to verify " + - "your key, but your client does not support in-chat key " + - "verification. You will need to use legacy key " + - "verification to verify keys.", - msgtype: REQUEST_TYPE, - to: this.userId, - from_device: content.from_device, - methods: content.methods, - }; - } else { - content[M_RELATES_TO] = { - rel_type: M_REFERENCE, - event_id: this.transactionId, - }; - } - return content; - } - - /** - * Send an event over the channel with the content not having gone through `completeContent`. - * @param type - the event type - * @param uncompletedContent - the (incomplete) content - * @returns the promise of the request - */ - public send(type: string, uncompletedContent: Record): Promise { - const content = this.completeContent(type, uncompletedContent); - return this.sendCompleted(type, content); - } - - /** - * Send an event over the channel with the content having gone through `completeContent` already. - * @param type - the event type - * @returns the promise of the request - */ - public async sendCompleted(type: string, content: Record): Promise { - let sendType = type; - if (type === REQUEST_TYPE) { - sendType = MESSAGE_TYPE; - } - const response = await this.client.sendEvent( - this.roomId, - sendType as keyof TimelineEvents, - content as TimelineEvents[keyof TimelineEvents], - ); - if (type === REQUEST_TYPE) { - this.requestEventId = response.event_id; - } - } -} - -export class InRoomRequests implements IRequestsMap { - private requestsByRoomId = new Map>(); - - public getRequest(event: MatrixEvent): VerificationRequest | undefined { - const roomId = event.getRoomId()!; - const txnId = InRoomChannel.getTransactionId(event)!; - return this.getRequestByTxnId(roomId, txnId); - } - - public getRequestByChannel(channel: InRoomChannel): VerificationRequest | undefined { - return this.getRequestByTxnId(channel.roomId, channel.transactionId!); - } - - private getRequestByTxnId(roomId: string, txnId: string): VerificationRequest | undefined { - const requestsByTxnId = this.requestsByRoomId.get(roomId); - if (requestsByTxnId) { - return requestsByTxnId.get(txnId); - } - } - - public setRequest(event: MatrixEvent, request: VerificationRequest): void { - this.doSetRequest(event.getRoomId()!, InRoomChannel.getTransactionId(event)!, request); - } - - public setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void { - this.doSetRequest(channel.roomId!, channel.transactionId!, request); - } - - private doSetRequest(roomId: string, txnId: string, request: VerificationRequest): void { - let requestsByTxnId = this.requestsByRoomId.get(roomId); - if (!requestsByTxnId) { - requestsByTxnId = new Map(); - this.requestsByRoomId.set(roomId, requestsByTxnId); - } - requestsByTxnId.set(txnId, request); - } - - public removeRequest(event: MatrixEvent): void { - const roomId = event.getRoomId()!; - const requestsByTxnId = this.requestsByRoomId.get(roomId); - if (requestsByTxnId) { - requestsByTxnId.delete(InRoomChannel.getTransactionId(event)!); - if (requestsByTxnId.size === 0) { - this.requestsByRoomId.delete(roomId); - } - } - } - - public findRequestInProgress(roomId: string, userId?: string): VerificationRequest | undefined { - const requestsByTxnId = this.requestsByRoomId.get(roomId); - if (requestsByTxnId) { - for (const request of requestsByTxnId.values()) { - if (request.pending && (userId === undefined || request.requestingUserId === userId)) { - return request; - } - } - } - } -} diff --git a/src/crypto/verification/request/ToDeviceChannel.ts b/src/crypto/verification/request/ToDeviceChannel.ts deleted file mode 100644 index 3083f472a..000000000 --- a/src/crypto/verification/request/ToDeviceChannel.ts +++ /dev/null @@ -1,354 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 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 { secureRandomString } from "../../../randomstring.ts"; -import { logger } from "../../../logger.ts"; -import { - CANCEL_TYPE, - PHASE_STARTED, - PHASE_READY, - REQUEST_TYPE, - READY_TYPE, - START_TYPE, - VerificationRequest, -} from "./VerificationRequest.ts"; -import { errorFromEvent, newUnexpectedMessageError } from "../Error.ts"; -import { MatrixEvent } from "../../../models/event.ts"; -import { type IVerificationChannel } from "./Channel.ts"; -import { type MatrixClient } from "../../../client.ts"; -import { type IRequestsMap } from "../../index.ts"; - -export type Request = VerificationRequest; - -/** - * A key verification channel that sends verification events over to_device messages. - * Generates its own transaction ids. - */ -export class ToDeviceChannel implements IVerificationChannel { - public request?: VerificationRequest; - - // userId and devices of user we're about to verify - public constructor( - private readonly client: MatrixClient, - public readonly userId: string, - private readonly devices: string[], - public transactionId?: string, - public deviceId?: string, - ) {} - - public isToDevices(devices: string[]): boolean { - if (devices.length === this.devices.length) { - for (const device of devices) { - if (!this.devices.includes(device)) { - return false; - } - } - return true; - } else { - return false; - } - } - - public static getEventType(event: MatrixEvent): string { - return event.getType(); - } - - /** - * Extract the transaction id used by a given key verification event, if any - * @param event - the event - * @returns the transaction id - */ - public static getTransactionId(event: MatrixEvent): string { - const content = event.getContent(); - return content && content.transaction_id; - } - - /** - * Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel - * @param type - the event type to check - * @returns boolean flag - */ - public static canCreateRequest(type: string): boolean { - return type === REQUEST_TYPE || type === START_TYPE; - } - - public canCreateRequest(type: string): boolean { - return ToDeviceChannel.canCreateRequest(type); - } - - /** - * Checks whether this event is a well-formed key verification event. - * This only does checks that don't rely on the current state of a potentially already channel - * so we can prevent channels being created by invalid events. - * `handleEvent` can do more checks and choose to ignore invalid events. - * @param event - the event to validate - * @param client - the client to get the current user and device id from - * @returns whether the event is valid and should be passed to handleEvent - */ - public static validateEvent(event: MatrixEvent, client: MatrixClient): boolean { - if (event.isCancelled()) { - logger.warn("Ignoring flagged verification request from " + event.getSender()); - return false; - } - const content = event.getContent(); - if (!content) { - logger.warn("ToDeviceChannel.validateEvent: invalid: no content"); - return false; - } - - if (!content.transaction_id) { - logger.warn("ToDeviceChannel.validateEvent: invalid: no transaction_id"); - return false; - } - - const type = event.getType(); - - if (type === REQUEST_TYPE) { - if (!Number.isFinite(content.timestamp)) { - logger.warn("ToDeviceChannel.validateEvent: invalid: no timestamp"); - return false; - } - if (event.getSender() === client.getUserId() && content.from_device == client.getDeviceId()) { - // ignore requests from ourselves, because it doesn't make sense for a - // device to verify itself - logger.warn("ToDeviceChannel.validateEvent: invalid: from own device"); - return false; - } - } - - return VerificationRequest.validateEvent(type, event, client); - } - - /** - * @param event - the event to get the timestamp of - * @returns the timestamp when the event was sent - */ - public getTimestamp(event: MatrixEvent): number { - const content = event.getContent(); - return content && content.timestamp; - } - - /** - * Changes the state of the channel, request, and verifier in response to a key verification event. - * @param event - to handle - * @param request - the request to forward handling to - * @param isLiveEvent - whether this is an even received through sync or not - * @returns a promise that resolves when any requests as an answer to the passed-in event are sent. - */ - public async handleEvent(event: MatrixEvent, request: Request, isLiveEvent = false): Promise { - const type = event.getType(); - const content = event.getContent(); - if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { - if (!this.transactionId) { - this.transactionId = content.transaction_id; - } - const deviceId = content.from_device; - // adopt deviceId if not set before and valid - if (!this.deviceId && this.devices.includes(deviceId)) { - this.deviceId = deviceId; - } - // if no device id or different from adopted one, cancel with sender - if (!this.deviceId || this.deviceId !== deviceId) { - // also check that message came from the device we sent the request to earlier on - // and do send a cancel message to that device - // (but don't cancel the request for the device we should be talking to) - const cancelContent = this.completeContent(CANCEL_TYPE, errorFromEvent(newUnexpectedMessageError())); - return this.sendToDevices(CANCEL_TYPE, cancelContent, [deviceId]); - } - } - const wasStarted = request.phase === PHASE_STARTED || request.phase === PHASE_READY; - - await request.handleEvent(event.getType(), event, isLiveEvent, false, false); - - const isStarted = request.phase === PHASE_STARTED || request.phase === PHASE_READY; - - const isAcceptingEvent = type === START_TYPE || type === READY_TYPE; - // the request has picked a ready or start event, tell the other devices about it - if (isAcceptingEvent && !wasStarted && isStarted && this.deviceId) { - const nonChosenDevices = this.devices.filter((d) => d !== this.deviceId && d !== this.client.getDeviceId()); - if (nonChosenDevices.length) { - const message = this.completeContent(CANCEL_TYPE, { - code: "m.accepted", - reason: "Verification request accepted by another device", - }); - await this.sendToDevices(CANCEL_TYPE, message, nonChosenDevices); - } - } - } - - /** - * See {@link InRoomChannel#completedContentFromEvent} for why this is needed. - * @param event - the received event - * @returns the content object - */ - public completedContentFromEvent(event: MatrixEvent): Record { - return event.getContent(); - } - - /** - * Add all the fields to content needed for sending it over this channel. - * This is public so verification methods (SAS uses this) can get the exact - * content that will be sent independent of the used channel, - * as they need to calculate the hash of it. - * @param type - the event type - * @param content - the (incomplete) content - * @returns the complete content, as it will be sent. - */ - public completeContent(type: string, content: Record): Record { - // make a copy - content = Object.assign({}, content); - if (this.transactionId) { - content.transaction_id = this.transactionId; - } - if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { - content.from_device = this.client.getDeviceId(); - } - if (type === REQUEST_TYPE) { - content.timestamp = Date.now(); - } - return content; - } - - /** - * Send an event over the channel with the content not having gone through `completeContent`. - * @param type - the event type - * @param uncompletedContent - the (incomplete) content - * @returns the promise of the request - */ - public send(type: string, uncompletedContent: Record = {}): Promise { - // create transaction id when sending request - if ((type === REQUEST_TYPE || type === START_TYPE) && !this.transactionId) { - this.transactionId = ToDeviceChannel.makeTransactionId(); - } - const content = this.completeContent(type, uncompletedContent); - return this.sendCompleted(type, content); - } - - /** - * Send an event over the channel with the content having gone through `completeContent` already. - * @param type - the event type - * @returns the promise of the request - */ - public async sendCompleted(type: string, content: Record): Promise { - let result; - if (type === REQUEST_TYPE || (type === CANCEL_TYPE && !this.deviceId)) { - result = await this.sendToDevices(type, content, this.devices); - } else { - result = await this.sendToDevices(type, content, [this.deviceId!]); - } - // the VerificationRequest state machine requires remote echos of the event - // the client sends itself, so we fake this for to_device messages - const remoteEchoEvent = new MatrixEvent({ - sender: this.client.getUserId()!, - content, - type, - }); - await this.request!.handleEvent( - type, - remoteEchoEvent, - /*isLiveEvent=*/ true, - /*isRemoteEcho=*/ true, - /*isSentByUs=*/ true, - ); - return result; - } - - private async sendToDevices(type: string, content: Record, devices: string[]): Promise { - if (devices.length) { - const deviceMessages: Map> = new Map(); - for (const deviceId of devices) { - deviceMessages.set(deviceId, content); - } - - await this.client.sendToDevice(type, new Map([[this.userId, deviceMessages]])); - } - } - - /** - * Allow Crypto module to create and know the transaction id before the .start event gets sent. - * @returns the transaction id - */ - public static makeTransactionId(): string { - return secureRandomString(32); - } -} - -export class ToDeviceRequests implements IRequestsMap { - private requestsByUserId = new Map>(); - - public getRequest(event: MatrixEvent): Request | undefined { - return this.getRequestBySenderAndTxnId(event.getSender()!, ToDeviceChannel.getTransactionId(event)); - } - - public getRequestByChannel(channel: ToDeviceChannel): Request | undefined { - return this.getRequestBySenderAndTxnId(channel.userId, channel.transactionId!); - } - - public getRequestBySenderAndTxnId(sender: string, txnId: string): Request | undefined { - const requestsByTxnId = this.requestsByUserId.get(sender); - if (requestsByTxnId) { - return requestsByTxnId.get(txnId); - } - } - - public setRequest(event: MatrixEvent, request: Request): void { - this.setRequestBySenderAndTxnId(event.getSender()!, ToDeviceChannel.getTransactionId(event), request); - } - - public setRequestByChannel(channel: ToDeviceChannel, request: Request): void { - this.setRequestBySenderAndTxnId(channel.userId, channel.transactionId!, request); - } - - public setRequestBySenderAndTxnId(sender: string, txnId: string, request: Request): void { - let requestsByTxnId = this.requestsByUserId.get(sender); - if (!requestsByTxnId) { - requestsByTxnId = new Map(); - this.requestsByUserId.set(sender, requestsByTxnId); - } - requestsByTxnId.set(txnId, request); - } - - public removeRequest(event: MatrixEvent): void { - const userId = event.getSender()!; - const requestsByTxnId = this.requestsByUserId.get(userId); - if (requestsByTxnId) { - requestsByTxnId.delete(ToDeviceChannel.getTransactionId(event)); - if (requestsByTxnId.size === 0) { - this.requestsByUserId.delete(userId); - } - } - } - - public findRequestInProgress(userId: string, devices: string[]): Request | undefined { - const requestsByTxnId = this.requestsByUserId.get(userId); - if (requestsByTxnId) { - for (const request of requestsByTxnId.values()) { - if (request.pending && request.channel.isToDevices(devices)) { - return request; - } - } - } - } - - public getRequestsInProgress(userId: string): Request[] { - const requestsByTxnId = this.requestsByUserId.get(userId); - if (requestsByTxnId) { - return Array.from(requestsByTxnId.values()).filter((r) => r.pending); - } - return []; - } -} diff --git a/src/crypto/verification/request/VerificationRequest.ts b/src/crypto/verification/request/VerificationRequest.ts deleted file mode 100644 index 08a0c3602..000000000 --- a/src/crypto/verification/request/VerificationRequest.ts +++ /dev/null @@ -1,977 +0,0 @@ -/* -Copyright 2018 - 2021 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 { logger } from "../../../logger.ts"; -import { errorFactory, errorFromEvent, newUnexpectedMessageError, newUnknownMethodError } from "../Error.ts"; -import { QRCodeData, SCAN_QR_CODE_METHOD } from "../QRCode.ts"; -import { type IVerificationChannel } from "./Channel.ts"; -import { type MatrixClient } from "../../../client.ts"; -import { type MatrixEvent } from "../../../models/event.ts"; -import { EventType } from "../../../@types/event.ts"; -import { type VerificationBase } from "../Base.ts"; -import { type VerificationMethod } from "../../index.ts"; -import { TypedEventEmitter } from "../../../models/typed-event-emitter.ts"; -import { - canAcceptVerificationRequest, - VerificationPhase as Phase, - type VerificationRequest as IVerificationRequest, - VerificationRequestEvent, - type VerificationRequestEventHandlerMap, - type Verifier, -} from "../../../crypto-api/verification.ts"; - -// backwards-compatibility exports -export { VerificationPhase as Phase, VerificationRequestEvent } from "../../../crypto-api/verification.ts"; - -// How long after the event's timestamp that the request times out -const TIMEOUT_FROM_EVENT_TS = 10 * 60 * 1000; // 10 minutes - -// How long after we receive the event that the request times out -const TIMEOUT_FROM_EVENT_RECEIPT = 2 * 60 * 1000; // 2 minutes - -// to avoid almost expired verification notifications -// from showing a notification and almost immediately -// disappearing, also ignore verification requests that -// are this amount of time away from expiring. -const VERIFICATION_REQUEST_MARGIN = 3 * 1000; // 3 seconds - -export const EVENT_PREFIX = "m.key.verification."; -export const REQUEST_TYPE = EVENT_PREFIX + "request"; -export const START_TYPE = EVENT_PREFIX + "start"; -export const CANCEL_TYPE = EVENT_PREFIX + "cancel"; -export const DONE_TYPE = EVENT_PREFIX + "done"; -export const READY_TYPE = EVENT_PREFIX + "ready"; - -// Legacy export fields -export const PHASE_UNSENT = Phase.Unsent; -export const PHASE_REQUESTED = Phase.Requested; -export const PHASE_READY = Phase.Ready; -export const PHASE_STARTED = Phase.Started; -export const PHASE_CANCELLED = Phase.Cancelled; -export const PHASE_DONE = Phase.Done; - -interface ITargetDevice { - userId?: string; - deviceId?: string; -} - -interface ITransition { - phase: Phase; - event?: MatrixEvent; -} - -/** - * State machine for verification requests. - * Things that differ based on what channel is used to - * send and receive verification events are put in `InRoomChannel` or `ToDeviceChannel`. - * - * @deprecated Avoid direct references: instead prefer {@link Crypto.VerificationRequest}. - */ -export class VerificationRequest - extends TypedEventEmitter - implements IVerificationRequest -{ - private eventsByUs = new Map(); - private eventsByThem = new Map(); - private _observeOnly = false; - private timeoutTimer: ReturnType | null = null; - private _accepting = false; - private _declining = false; - private verifierHasFinished = false; - private _cancelled = false; - private _chosenMethod: VerificationMethod | null = null; - // we keep a copy of the QR Code data (including other user master key) around - // for QR reciprocate verification, to protect against - // cross-signing identity reset between the .ready and .start event - // and signing the wrong key after .start - private _qrCodeData: QRCodeData | null = null; - - // The timestamp when we received the request event from the other side - private requestReceivedAt: number | null = null; - - private commonMethods: VerificationMethod[] = []; - private _phase!: Phase; - public _cancellingUserId?: string; // Used in tests only - private _verifier?: VerificationBase; - - public constructor( - public readonly channel: C, - private readonly verificationMethods: Map, - private readonly client: MatrixClient, - ) { - super(); - this.channel.request = this; - this.setPhase(PHASE_UNSENT, false); - } - - /** - * Stateless validation logic not specific to the channel. - * Invoked by the same static method in either channel. - * @param type - the "symbolic" event type, as returned by the `getEventType` function on the channel. - * @param event - the event to validate. Don't call getType() on it but use the `type` parameter instead. - * @param client - the client to get the current user and device id from - * @returns whether the event is valid and should be passed to handleEvent - */ - public static validateEvent(type: string, event: MatrixEvent, client: MatrixClient): boolean { - const content = event.getContent(); - - if (!type || !type.startsWith(EVENT_PREFIX)) { - return false; - } - - // from here on we're fairly sure that this is supposed to be - // part of a verification request, so be noisy when rejecting something - if (!content) { - logger.log("VerificationRequest: validateEvent: no content"); - return false; - } - - if (type === REQUEST_TYPE || type === READY_TYPE) { - if (!Array.isArray(content.methods)) { - logger.log("VerificationRequest: validateEvent: " + "fail because methods"); - return false; - } - } - - if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { - if (typeof content.from_device !== "string" || content.from_device.length === 0) { - logger.log("VerificationRequest: validateEvent: " + "fail because from_device"); - return false; - } - } - - return true; - } - - /** - * Unique ID for this verification request. - * - * An ID isn't assigned until the first message is sent, so this may be `undefined` in the early phases. - */ - public get transactionId(): string | undefined { - return this.channel.transactionId; - } - - /** - * For an in-room verification, the ID of the room. - */ - public get roomId(): string | undefined { - return this.channel.roomId; - } - - public get invalid(): boolean { - return this.phase === PHASE_UNSENT; - } - - /** returns whether the phase is PHASE_REQUESTED */ - public get requested(): boolean { - return this.phase === PHASE_REQUESTED; - } - - /** returns whether the phase is PHASE_CANCELLED */ - public get cancelled(): boolean { - return this.phase === PHASE_CANCELLED; - } - - /** returns whether the phase is PHASE_READY */ - public get ready(): boolean { - return this.phase === PHASE_READY; - } - - /** returns whether the phase is PHASE_STARTED */ - public get started(): boolean { - return this.phase === PHASE_STARTED; - } - - /** returns whether the phase is PHASE_DONE */ - public get done(): boolean { - return this.phase === PHASE_DONE; - } - - /** once the phase is PHASE_STARTED (and !initiatedByMe) or PHASE_READY: common methods supported by both sides */ - public get methods(): VerificationMethod[] { - return this.commonMethods; - } - - /** the method picked in the .start event */ - public get chosenMethod(): VerificationMethod | null { - return this._chosenMethod; - } - - public calculateEventTimeout(event: MatrixEvent): number { - let effectiveExpiresAt = this.channel.getTimestamp(event) + TIMEOUT_FROM_EVENT_TS; - - if (this.requestReceivedAt && !this.initiatedByMe && this.phase <= PHASE_REQUESTED) { - const expiresAtByReceipt = this.requestReceivedAt + TIMEOUT_FROM_EVENT_RECEIPT; - effectiveExpiresAt = Math.min(effectiveExpiresAt, expiresAtByReceipt); - } - - return Math.max(0, effectiveExpiresAt - Date.now()); - } - - /** The current remaining amount of ms before the request should be automatically cancelled */ - public get timeout(): number { - const requestEvent = this.getEventByEither(REQUEST_TYPE); - if (requestEvent) { - return this.calculateEventTimeout(requestEvent); - } - return 0; - } - - /** - * The key verification request event. - * @returns The request event, or falsey if not found. - */ - public get requestEvent(): MatrixEvent | undefined { - return this.getEventByEither(REQUEST_TYPE); - } - - /** current phase of the request. Some properties might only be defined in a current phase. */ - public get phase(): Phase { - return this._phase; - } - - /** The verifier to do the actual verification, once the method has been established. Only defined when the `phase` is PHASE_STARTED. */ - public get verifier(): VerificationBase | undefined { - return this._verifier; - } - - public get canAccept(): boolean { - return canAcceptVerificationRequest(this); - } - - public get accepting(): boolean { - return this._accepting; - } - - public get declining(): boolean { - return this._declining; - } - - /** whether this request has sent it's initial event and needs more events to complete */ - public get pending(): boolean { - return !this.observeOnly && this._phase !== PHASE_DONE && this._phase !== PHASE_CANCELLED; - } - - /** Only set after a .ready if the other party can scan a QR code - * - * @deprecated Prefer `generateQRCode`. - */ - public get qrCodeData(): QRCodeData | null { - return this._qrCodeData; - } - - /** - * Get the data for a QR code allowing the other device to verify this one, if it supports it. - * - * Only set after a .ready if the other party can scan a QR code, otherwise undefined. - * - * @deprecated Prefer `generateQRCode`. - */ - public getQRCodeBytes(): Uint8ClampedArray | undefined { - if (!this._qrCodeData) return; - return new Uint8ClampedArray(this._qrCodeData.getBuffer()); - } - - /** - * Generate the data for a QR code allowing the other device to verify this one, if it supports it. - * - * Only returns data once `phase` is `Ready` and the other party can scan a QR code; - * otherwise returns `undefined`. - */ - public async generateQRCode(): Promise { - return this.getQRCodeBytes(); - } - - /** Checks whether the other party supports a given verification method. - * This is useful when setting up the QR code UI, as it is somewhat asymmetrical: - * if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa. - * For methods that need to be supported by both ends, use the `methods` property. - * @param method - the method to check - * @param force - to check even if the phase is not ready or started yet, internal usage - * @returns whether or not the other party said the supported the method */ - public otherPartySupportsMethod(method: string, force = false): boolean { - if (!force && !this.ready && !this.started) { - return false; - } - const theirMethodEvent = this.eventsByThem.get(REQUEST_TYPE) || this.eventsByThem.get(READY_TYPE); - if (!theirMethodEvent) { - // if we started straight away with .start event, - // we are assuming that the other side will support the - // chosen method, so return true for that. - if (this.started && this.initiatedByMe) { - const myStartEvent = this.eventsByUs.get(START_TYPE); - const content = myStartEvent && myStartEvent.getContent(); - const myStartMethod = content && content.method; - return method == myStartMethod; - } - return false; - } - const content = theirMethodEvent.getContent(); - if (!content) { - return false; - } - const { methods } = content; - if (!Array.isArray(methods)) { - return false; - } - - return methods.includes(method); - } - - /** Whether this request was initiated by the syncing user. - * For InRoomChannel, this is who sent the .request event. - * For ToDeviceChannel, this is who sent the .start event - */ - public get initiatedByMe(): boolean { - // event created by us but no remote echo has been received yet - const noEventsYet = this.eventsByUs.size + this.eventsByThem.size === 0; - if (this._phase === PHASE_UNSENT && noEventsYet) { - return true; - } - const hasMyRequest = this.eventsByUs.has(REQUEST_TYPE); - const hasTheirRequest = this.eventsByThem.has(REQUEST_TYPE); - if (hasMyRequest && !hasTheirRequest) { - return true; - } - if (!hasMyRequest && hasTheirRequest) { - return false; - } - const hasMyStart = this.eventsByUs.has(START_TYPE); - const hasTheirStart = this.eventsByThem.has(START_TYPE); - if (hasMyStart && !hasTheirStart) { - return true; - } - return false; - } - - /** The id of the user that initiated the request */ - public get requestingUserId(): string { - if (this.initiatedByMe) { - return this.client.getUserId()!; - } else { - return this.otherUserId; - } - } - - /** The id of the user that (will) receive(d) the request */ - public get receivingUserId(): string { - if (this.initiatedByMe) { - return this.otherUserId; - } else { - return this.client.getUserId()!; - } - } - - /** The user id of the other party in this request */ - public get otherUserId(): string { - return this.channel.userId!; - } - - /** The device id of the other party in this request, for requests happening over to-device messages only. */ - public get otherDeviceId(): string | undefined { - return this.channel.deviceId; - } - - public get isSelfVerification(): boolean { - return this.client.getUserId() === this.otherUserId; - } - - /** - * The id of the user that cancelled the request, - * only defined when phase is PHASE_CANCELLED - */ - public get cancellingUserId(): string | undefined { - const myCancel = this.eventsByUs.get(CANCEL_TYPE); - const theirCancel = this.eventsByThem.get(CANCEL_TYPE); - - if (myCancel && (!theirCancel || myCancel.getId()! < theirCancel.getId()!)) { - return myCancel.getSender(); - } - if (theirCancel) { - return theirCancel.getSender(); - } - return undefined; - } - - /** - * The cancellation code e.g m.user which is responsible for cancelling this verification - */ - public get cancellationCode(): string { - const ev = this.getEventByEither(CANCEL_TYPE); - return ev ? ev.getContent().code : null; - } - - public get observeOnly(): boolean { - return this._observeOnly; - } - - /** - * Gets which device the verification should be started with - * given the events sent so far in the verification. This is the - * same algorithm used to determine which device to send the - * verification to when no specific device is specified. - * @returns The device information - */ - public get targetDevice(): ITargetDevice { - const theirFirstEvent = - this.eventsByThem.get(REQUEST_TYPE) || - this.eventsByThem.get(READY_TYPE) || - this.eventsByThem.get(START_TYPE); - const theirFirstContent = theirFirstEvent?.getContent(); - const fromDevice = theirFirstContent?.from_device; - return { - userId: this.otherUserId, - deviceId: fromDevice, - }; - } - - /* Start the key verification, creating a verifier and sending a .start event. - * If no previous events have been sent, pass in `targetDevice` to set who to direct this request to. - * @param method - the name of the verification method to use. - * @param targetDevice.userId the id of the user to direct this request to - * @param targetDevice.deviceId the id of the device to direct this request to - * @returns the verifier of the given method - */ - public beginKeyVerification( - method: VerificationMethod, - targetDevice: ITargetDevice | null = null, - ): VerificationBase { - // need to allow also when unsent in case of to_device - if (!this.observeOnly && !this._verifier) { - const validStartPhase = - this.phase === PHASE_REQUESTED || - this.phase === PHASE_READY || - (this.phase === PHASE_UNSENT && this.channel.canCreateRequest(START_TYPE)); - if (validStartPhase) { - // when called on a request that was initiated with .request event - // check the method is supported by both sides - if (this.commonMethods.length && !this.commonMethods.includes(method)) { - throw newUnknownMethodError(); - } - this._verifier = this.createVerifier(method, null, targetDevice); - if (!this._verifier) { - throw newUnknownMethodError(); - } - this._chosenMethod = method; - } - } - return this._verifier!; - } - - public async startVerification(method: string): Promise { - const verifier = this.beginKeyVerification(method); - // kick off the verification in the background, but *don't* wait for to complete: we need to return the `Verifier`. - verifier.verify(); - return verifier; - } - - public scanQRCode(qrCodeData: Uint8ClampedArray): Promise { - throw new Error("QR code scanning not supported by legacy crypto"); - } - - /** - * sends the initial .request event. - * @returns resolves when the event has been sent. - */ - public async sendRequest(): Promise { - if (!this.observeOnly && this._phase === PHASE_UNSENT) { - const methods = [...this.verificationMethods.keys()]; - await this.channel.send(REQUEST_TYPE, { methods }); - } - } - - /** - * Cancels the request, sending a cancellation to the other party - * @param params - * @param params.reason - the error reason to send the cancellation with - * @param params.code - the error code to send the cancellation with - * @returns resolves when the event has been sent. - */ - public async cancel({ reason = "User declined", code = "m.user" } = {}): Promise { - if (!this.observeOnly && this._phase !== PHASE_CANCELLED) { - this._declining = true; - this.emit(VerificationRequestEvent.Change); - if (this._verifier) { - return this._verifier.cancel(errorFactory(code, reason)()); - } else { - this._cancellingUserId = this.client.getUserId()!; - await this.channel.send(CANCEL_TYPE, { code, reason }); - } - } - } - - /** - * Accepts the request, sending a .ready event to the other party - * @returns resolves when the event has been sent. - */ - public async accept(): Promise { - if (!this.observeOnly && this.phase === PHASE_REQUESTED && !this.initiatedByMe) { - const methods = [...this.verificationMethods.keys()]; - this._accepting = true; - this.emit(VerificationRequestEvent.Change); - await this.channel.send(READY_TYPE, { methods }); - } - } - - /** - * Can be used to listen for state changes until the callback returns true. - * @param fn - callback to evaluate whether the request is in the desired state. - * Takes the request as an argument. - * @returns that resolves once the callback returns true - * @throws Error when the request is cancelled - */ - public waitFor(fn: (request: VerificationRequest) => boolean): Promise { - return new Promise((resolve, reject) => { - const check = (): boolean => { - let handled = false; - if (fn(this)) { - resolve(this); - handled = true; - } else if (this.cancelled) { - reject(new Error("cancelled")); - handled = true; - } - if (handled) { - this.off(VerificationRequestEvent.Change, check); - } - return handled; - }; - if (!check()) { - this.on(VerificationRequestEvent.Change, check); - } - }); - } - - private setPhase(phase: Phase, notify = true): void { - this._phase = phase; - if (notify) { - this.emit(VerificationRequestEvent.Change); - } - } - - private getEventByEither(type: string): MatrixEvent | undefined { - return this.eventsByThem.get(type) || this.eventsByUs.get(type); - } - - private getEventBy(type: string, byThem = false): MatrixEvent | undefined { - if (byThem) { - return this.eventsByThem.get(type); - } else { - return this.eventsByUs.get(type); - } - } - - private calculatePhaseTransitions(): ITransition[] { - const transitions: ITransition[] = [{ phase: PHASE_UNSENT }]; - const phase = (): Phase => transitions[transitions.length - 1].phase; - - // always pass by .request first to be sure channel.userId has been set - const hasRequestByThem = this.eventsByThem.has(REQUEST_TYPE); - const requestEvent = this.getEventBy(REQUEST_TYPE, hasRequestByThem); - if (requestEvent) { - transitions.push({ phase: PHASE_REQUESTED, event: requestEvent }); - } - - const readyEvent = requestEvent && this.getEventBy(READY_TYPE, !hasRequestByThem); - if (readyEvent && phase() === PHASE_REQUESTED) { - transitions.push({ phase: PHASE_READY, event: readyEvent }); - } - - let startEvent: MatrixEvent | undefined; - if (readyEvent || !requestEvent) { - const theirStartEvent = this.eventsByThem.get(START_TYPE); - const ourStartEvent = this.eventsByUs.get(START_TYPE); - // any party can send .start after a .ready or unsent - if (theirStartEvent && ourStartEvent) { - startEvent = - theirStartEvent.getSender()! < ourStartEvent.getSender()! ? theirStartEvent : ourStartEvent; - } else { - startEvent = theirStartEvent ? theirStartEvent : ourStartEvent; - } - } else { - startEvent = this.getEventBy(START_TYPE, !hasRequestByThem); - } - if (startEvent) { - const fromRequestPhase = - phase() === PHASE_REQUESTED && requestEvent?.getSender() !== startEvent.getSender(); - const fromUnsentPhase = phase() === PHASE_UNSENT && this.channel.canCreateRequest(START_TYPE); - if (fromRequestPhase || phase() === PHASE_READY || fromUnsentPhase) { - transitions.push({ phase: PHASE_STARTED, event: startEvent }); - } - } - - const ourDoneEvent = this.eventsByUs.get(DONE_TYPE); - if (this.verifierHasFinished || (ourDoneEvent && phase() === PHASE_STARTED)) { - transitions.push({ phase: PHASE_DONE }); - } - - const cancelEvent = this.getEventByEither(CANCEL_TYPE); - if ((this._cancelled || cancelEvent) && phase() !== PHASE_DONE) { - transitions.push({ phase: PHASE_CANCELLED, event: cancelEvent }); - return transitions; - } - - return transitions; - } - - private transitionToPhase(transition: ITransition): void { - const { phase, event } = transition; - // get common methods - if (phase === PHASE_REQUESTED || phase === PHASE_READY) { - if (!this.wasSentByOwnDevice(event)) { - const content = event!.getContent<{ - methods: string[]; - }>(); - this.commonMethods = content.methods.filter((m) => this.verificationMethods.has(m)); - } - } - // detect if we're not a party in the request, and we should just observe - if (!this.observeOnly) { - // if requested or accepted by one of my other devices - if (phase === PHASE_REQUESTED || phase === PHASE_STARTED || phase === PHASE_READY) { - if ( - this.channel.receiveStartFromOtherDevices && - this.wasSentByOwnUser(event) && - !this.wasSentByOwnDevice(event) - ) { - this._observeOnly = true; - } - } - } - // create verifier - if (phase === PHASE_STARTED) { - const { method } = event!.getContent(); - if (!this._verifier && !this.observeOnly) { - this._verifier = this.createVerifier(method, event); - if (!this._verifier) { - this.cancel({ - code: "m.unknown_method", - reason: `Unknown method: ${method}`, - }); - } else { - this._chosenMethod = method; - } - } - } - } - - private applyPhaseTransitions(): ITransition[] { - const transitions = this.calculatePhaseTransitions(); - const existingIdx = transitions.findIndex((t) => t.phase === this.phase); - // trim off phases we already went through, if any - const newTransitions = transitions.slice(existingIdx + 1); - // transition to all new phases - for (const transition of newTransitions) { - this.transitionToPhase(transition); - } - return newTransitions; - } - - private isWinningStartRace(newEvent: MatrixEvent): boolean { - if (newEvent.getType() !== START_TYPE) { - return false; - } - const oldEvent = this._verifier!.startEvent; - - let oldRaceIdentifier; - if (this.isSelfVerification) { - // if the verifier does not have a startEvent, - // it is because it's still sending and we are on the initator side - // we know we are sending a .start event because we already - // have a verifier (checked in calling method) - if (oldEvent) { - const oldContent = oldEvent.getContent(); - oldRaceIdentifier = oldContent && oldContent.from_device; - } else { - oldRaceIdentifier = this.client.getDeviceId(); - } - } else { - if (oldEvent) { - oldRaceIdentifier = oldEvent.getSender(); - } else { - oldRaceIdentifier = this.client.getUserId(); - } - } - - let newRaceIdentifier; - if (this.isSelfVerification) { - const newContent = newEvent.getContent(); - newRaceIdentifier = newContent && newContent.from_device; - } else { - newRaceIdentifier = newEvent.getSender(); - } - return newRaceIdentifier < oldRaceIdentifier; - } - - public hasEventId(eventId: string): boolean { - for (const event of this.eventsByUs.values()) { - if (event.getId() === eventId) { - return true; - } - } - for (const event of this.eventsByThem.values()) { - if (event.getId() === eventId) { - return true; - } - } - return false; - } - - /** - * Changes the state of the request and verifier in response to a key verification event. - * @param type - the "symbolic" event type, as returned by the `getEventType` function on the channel. - * @param event - the event to handle. Don't call getType() on it but use the `type` parameter instead. - * @param isLiveEvent - whether this is an even received through sync or not - * @param isRemoteEcho - whether this is the remote echo of an event sent by the same device - * @param isSentByUs - whether this event is sent by a party that can accept and/or observe the request like one of our peers. - * For InRoomChannel this means any device for the syncing user. For ToDeviceChannel, just the syncing device. - * @returns a promise that resolves when any requests as an answer to the passed-in event are sent. - */ - public async handleEvent( - type: string, - event: MatrixEvent, - isLiveEvent: boolean, - isRemoteEcho: boolean, - isSentByUs: boolean, - ): Promise { - // if reached phase cancelled or done, ignore anything else that comes - if (this.done || this.cancelled) { - return; - } - const wasObserveOnly = this._observeOnly; - - this.adjustObserveOnly(event, isLiveEvent); - - if (!this.observeOnly && !isRemoteEcho) { - if (await this.cancelOnError(type, event)) { - return; - } - } - - // This assumes verification won't need to send an event with - // the same type for the same party twice. - // This is true for QR and SAS verification, and was - // added here to prevent verification getting cancelled - // when the server duplicates an event (https://github.com/matrix-org/synapse/issues/3365) - const isDuplicateEvent = isSentByUs ? this.eventsByUs.has(type) : this.eventsByThem.has(type); - if (isDuplicateEvent) { - return; - } - - const oldPhase = this.phase; - this.addEvent(type, event, isSentByUs); - - // this will create if needed the verifier so needs to happen before calling it - const newTransitions = this.applyPhaseTransitions(); - try { - // only pass events from the other side to the verifier, - // no remote echos of our own events - if (this._verifier && !this.observeOnly) { - const newEventWinsRace = this.isWinningStartRace(event); - if (this._verifier.canSwitchStartEvent(event) && newEventWinsRace) { - this._verifier.switchStartEvent(event); - } else if (!isRemoteEcho) { - if (type === CANCEL_TYPE || this._verifier.events?.includes(type)) { - this._verifier.handleEvent(event); - } - } - } - - if (newTransitions.length) { - // create QRCodeData if the other side can scan - // important this happens before emitting a phase change, - // so listeners can rely on it being there already - // We only do this for live events because it is important that - // we sign the keys that were in the QR code, and not the keys - // we happen to have at some later point in time. - if (isLiveEvent && newTransitions.some((t) => t.phase === PHASE_READY)) { - const shouldGenerateQrCode = this.otherPartySupportsMethod(SCAN_QR_CODE_METHOD, true); - if (shouldGenerateQrCode) { - this._qrCodeData = await QRCodeData.create(this, this.client); - } - } - - const lastTransition = newTransitions[newTransitions.length - 1]; - const { phase } = lastTransition; - - this.setupTimeout(phase); - // set phase as last thing as this emits the "change" event - this.setPhase(phase); - } else if (this._observeOnly !== wasObserveOnly) { - this.emit(VerificationRequestEvent.Change); - } - } finally { - // log events we processed so we can see from rageshakes what events were added to a request - logger.log( - `Verification request ${this.channel.transactionId}: ` + - `${type} event with id:${event.getId()}, ` + - `content:${JSON.stringify(event.getContent())} ` + - `deviceId:${this.channel.deviceId}, ` + - `sender:${event.getSender()}, isSentByUs:${isSentByUs}, ` + - `isLiveEvent:${isLiveEvent}, isRemoteEcho:${isRemoteEcho}, ` + - `phase:${oldPhase}=>${this.phase}, ` + - `observeOnly:${wasObserveOnly}=>${this._observeOnly}`, - ); - } - } - - private setupTimeout(phase: Phase): void { - const shouldTimeout = !this.timeoutTimer && !this.observeOnly && phase === PHASE_REQUESTED; - - if (shouldTimeout) { - this.timeoutTimer = setTimeout(this.cancelOnTimeout, this.timeout); - } - if (this.timeoutTimer) { - const shouldClear = - phase === PHASE_STARTED || phase === PHASE_READY || phase === PHASE_DONE || phase === PHASE_CANCELLED; - if (shouldClear) { - clearTimeout(this.timeoutTimer); - this.timeoutTimer = null; - } - } - } - - private cancelOnTimeout = async (): Promise => { - try { - if (this.initiatedByMe) { - await this.cancel({ - reason: "Other party didn't accept in time", - code: "m.timeout", - }); - } else { - await this.cancel({ - reason: "User didn't accept in time", - code: "m.timeout", - }); - } - } catch (err) { - logger.error("Error while cancelling verification request", err); - } - }; - - private async cancelOnError(type: string, event: MatrixEvent): Promise { - if (type === START_TYPE) { - const method = event.getContent().method; - if (!this.verificationMethods.has(method)) { - await this.cancel(errorFromEvent(newUnknownMethodError())); - return true; - } - } - - const isUnexpectedRequest = type === REQUEST_TYPE && this.phase !== PHASE_UNSENT; - const isUnexpectedReady = type === READY_TYPE && this.phase !== PHASE_REQUESTED && this.phase !== PHASE_STARTED; - // only if phase has passed from PHASE_UNSENT should we cancel, because events - // are allowed to come in in any order (at least with InRoomChannel). So we only know - // we're dealing with a valid request we should participate in once we've moved to PHASE_REQUESTED. - // Before that, we could be looking at somebody else's verification request and we just - // happen to be in the room - if (this.phase !== PHASE_UNSENT && (isUnexpectedRequest || isUnexpectedReady)) { - logger.warn(`Cancelling, unexpected ${type} verification ` + `event from ${event.getSender()}`); - const reason = `Unexpected ${type} event in phase ${this.phase}`; - await this.cancel(errorFromEvent(newUnexpectedMessageError({ reason }))); - return true; - } - return false; - } - - private adjustObserveOnly(event: MatrixEvent, isLiveEvent = false): void { - // don't send out events for historical requests - if (!isLiveEvent) { - this._observeOnly = true; - } - if (this.calculateEventTimeout(event) < VERIFICATION_REQUEST_MARGIN) { - this._observeOnly = true; - } - } - - private addEvent(type: string, event: MatrixEvent, isSentByUs = false): void { - if (isSentByUs) { - this.eventsByUs.set(type, event); - } else { - this.eventsByThem.set(type, event); - } - - // once we know the userId of the other party (from the .request event) - // see if any event by anyone else crept into this.eventsByThem - if (type === REQUEST_TYPE) { - for (const [type, event] of this.eventsByThem.entries()) { - if (event.getSender() !== this.otherUserId) { - this.eventsByThem.delete(type); - } - } - // also remember when we received the request event - this.requestReceivedAt = Date.now(); - } - } - - private createVerifier( - method: VerificationMethod, - startEvent: MatrixEvent | null = null, - targetDevice: ITargetDevice | null = null, - ): VerificationBase | undefined { - if (!targetDevice) { - targetDevice = this.targetDevice; - } - const { userId, deviceId } = targetDevice; - - const VerifierCtor = this.verificationMethods.get(method); - if (!VerifierCtor) { - logger.warn("could not find verifier constructor for method", method); - return; - } - return new VerifierCtor(this.channel, this.client, userId!, deviceId!, startEvent, this); - } - - private wasSentByOwnUser(event?: MatrixEvent): boolean { - return event?.getSender() === this.client.getUserId(); - } - - // only for .request, .ready or .start - private wasSentByOwnDevice(event?: MatrixEvent): boolean { - if (!this.wasSentByOwnUser(event)) { - return false; - } - const content = event!.getContent(); - if (!content || content.from_device !== this.client.getDeviceId()) { - return false; - } - return true; - } - - public onVerifierCancelled(): void { - this._cancelled = true; - // move to cancelled phase - const newTransitions = this.applyPhaseTransitions(); - if (newTransitions.length) { - this.setPhase(newTransitions[newTransitions.length - 1].phase); - } - } - - public onVerifierFinished(): void { - this.channel.send(EventType.KeyVerificationDone, {}); - this.verifierHasFinished = true; - // move to .done phase - const newTransitions = this.applyPhaseTransitions(); - if (newTransitions.length) { - this.setPhase(newTransitions[newTransitions.length - 1].phase); - } - } - - public getEventFromOtherParty(type: string): MatrixEvent | undefined { - return this.eventsByThem.get(type); - } -} diff --git a/src/embedded.ts b/src/embedded.ts index b62373ae0..7dc617af1 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -54,8 +54,6 @@ import { MatrixError } from "./http-api/errors.ts"; import { User } from "./models/user.ts"; import { type Room } from "./models/room.ts"; import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts"; -import { type DeviceInfo } from "./crypto/deviceinfo.ts"; -import { type IOlmDevice } from "./crypto/algorithms/megolm.ts"; import { MapWithDefault, recursiveMapToObject } from "./utils.ts"; import { type EmptyObject, TypedEventEmitter } from "./matrix.ts"; @@ -64,6 +62,17 @@ interface IStateEventRequest { stateKey?: string; } +export interface OlmDevice { + /** + * The user ID of the device owner. + */ + userId: string; + /** + * The device ID of the device. + */ + deviceId: string; +} + export interface ICapabilities { /** * Event types that this client expects to send. @@ -128,6 +137,7 @@ export enum RoomWidgetClientEvent { PendingEventsChanged = "PendingEvent.pendingEventsChanged", } export type EventHandlerMap = { [RoomWidgetClientEvent.PendingEventsChanged]: () => void }; + /** * A MatrixClient that routes its requests through the widget API instead of the * real CS API. @@ -467,13 +477,10 @@ export class RoomWidgetClient extends MatrixClient { await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap)); } - public async encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice[], payload: object): Promise { + public async encryptAndSendToDevices(userDeviceInfoArr: OlmDevice[], payload: object): Promise { // map: user Id → device Id → payload const contentMap: MapWithDefault> = new MapWithDefault(() => new Map()); - for (const { - userId, - deviceInfo: { deviceId }, - } of userDeviceInfoArr) { + for (const { userId, deviceId } of userDeviceInfoArr) { contentMap.getOrCreate(userId).set(deviceId, payload); } diff --git a/src/matrix.ts b/src/matrix.ts index c0c5f1307..419442187 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -85,7 +85,6 @@ export * from "./models/related-relations.ts"; export type { RoomSummary } from "./client.ts"; export * as ContentHelpers from "./content-helpers.ts"; export * as SecretStorage from "./secret-storage.ts"; -export type { ICryptoCallbacks } from "./crypto/index.ts"; // used to be located here export { createNewMatrixCall, CallEvent } from "./webrtc/call.ts"; export type { MatrixCall } from "./webrtc/call.ts"; export { @@ -97,10 +96,6 @@ export { GroupCallStatsReportEvent, } from "./webrtc/groupCall.ts"; -export { - /** @deprecated Use {@link Crypto.CryptoEvent} instead */ - CryptoEvent, -} from "./crypto/index.ts"; export { SyncState, SetPresence } from "./sync.ts"; export type { ISyncStateData as SyncStateData } from "./sync.ts"; export { SlidingSyncEvent } from "./sliding-sync.ts"; @@ -115,9 +110,6 @@ export type { ISSOFlow as SSOFlow, LoginFlow } from "./@types/auth.ts"; export type { IHierarchyRelation as HierarchyRelation, IHierarchyRoom as HierarchyRoom } from "./@types/spaces.ts"; export { LocationAssetType } from "./@types/location.ts"; -/** @deprecated Backwards-compatibility re-export. Import from `crypto-api` directly. */ -export * as Crypto from "./crypto-api/index.ts"; - let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore(); /** diff --git a/src/models/device.ts b/src/models/device.ts index 8498b5515..5716f3e1f 100644 --- a/src/models/device.ts +++ b/src/models/device.ts @@ -27,7 +27,7 @@ export type DeviceMap = Map>; type DeviceParameters = Pick & Partial; /** - * Information on a user's device, as returned by {@link Crypto.CryptoApi.getUserDeviceInfo}. + * Information on a user's device, as returned by {@link crypto-api!CryptoApi.getUserDeviceInfo}. */ export class Device { /** id of the device */ diff --git a/src/models/event.ts b/src/models/event.ts index 548dc0df0..f3b2c4b71 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -23,7 +23,6 @@ import { type ExtensibleEvent, ExtensibleEvents, type Optional } from "matrix-ev import type { IEventDecryptionResult } from "../@types/crypto.ts"; import { logger } from "../logger.ts"; -import { type VerificationRequest } from "../crypto/verification/request/VerificationRequest.ts"; import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, @@ -33,7 +32,6 @@ import { UNSIGNED_THREAD_ID_FIELD, UNSIGNED_MEMBERSHIP_FIELD, } from "../@types/event.ts"; -import { type Crypto } from "../crypto/index.ts"; import { deepSortedObjectEntries, internaliseString } from "../utils.ts"; import { type RoomMember } from "./room-member.ts"; import { type Thread, THREAD_RELATION_TYPE, ThreadEvent, type ThreadEventHandlerMap } from "./thread.ts"; @@ -407,15 +405,6 @@ export class MatrixEvent extends TypedEventEmitter; /** @@ -893,30 +882,6 @@ export class MatrixEvent extends TypedEventEmitter { - const wireContent = this.getWireContent(); - return crypto.requestRoomKey( - { - algorithm: wireContent.algorithm, - room_id: this.getRoomId()!, - session_id: wireContent.session_id, - sender_key: wireContent.sender_key, - }, - this.getKeyRequestRecipients(userId), - true, - ); - } - /** * Calculate the recipients for keyshare requests. * @@ -1114,7 +1079,7 @@ export class MatrixEvent extends TypedEventEmitter { const signatureVerification: SignatureVerification = await this.olmMachine.verifyBackup(info); diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 8f41c4b6c..95dc4846b 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -20,7 +20,6 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm"; import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto.ts"; import { KnownMembership } from "../@types/membership.ts"; import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts"; -import type { IEncryptedEventInfo } from "../crypto/api.ts"; import type { ToDevicePayload, ToDeviceBatch } from "../models/ToDeviceMessage.ts"; import { type MatrixEvent, MatrixEventEvent } from "../models/event.ts"; import { type Room } from "../models/room.ts"; @@ -285,64 +284,6 @@ export class RustCrypto extends TypedEventEmitter = {}; - - ret.senderKey = event.getSenderKey() ?? undefined; - ret.algorithm = event.getWireContent().algorithm; - - if (!ret.senderKey || !ret.algorithm) { - ret.encrypted = false; - return ret as IEncryptedEventInfo; - } - ret.encrypted = true; - ret.authenticated = true; - ret.mismatchedSender = true; - return ret as IEncryptedEventInfo; - } - - /** - * Implementation of {@link CryptoBackend#checkUserTrust}. - * - * Stub for backwards compatibility. - * - */ - public checkUserTrust(userId: string): UserVerificationStatus { - return new UserVerificationStatus(false, false, false); - } - - /** - * Get the cross signing information for a given user. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @param userId - the user ID to get the cross-signing info for. - * - * @returns the cross signing information for the user. - */ - public getStoredCrossSigningForUser(userId: string): null { - // TODO - return null; - } - - /** - * This function is unneeded for the rust-crypto. - * The cross signing key import and the device verification are done in {@link CryptoApi#bootstrapCrossSigning} - * - * The function is stub to keep the compatibility with the old crypto. - * More information: https://github.com/vector-im/element-web/issues/25648 - * - * Implementation of {@link CryptoBackend#checkOwnCrossSigningTrust} - */ - public async checkOwnCrossSigningTrust(): Promise { - return; - } - /** * Implementation of {@link CryptoBackend#getBackupDecryptor}. */ diff --git a/src/sync.ts b/src/sync.ts index 16a61d33c..e69e21f1d 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -65,7 +65,6 @@ import { BeaconEvent } from "./models/beacon.ts"; import { type IEventsResponse } from "./@types/requests.ts"; import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts"; import { Feature, ServerSupport } from "./feature.ts"; -import { type Crypto } from "./crypto/index.ts"; import { KnownMembership } from "./@types/membership.ts"; const DEBUG = true; @@ -122,13 +121,6 @@ function debuglog(...params: any[]): void { * Options passed into the constructor of SyncApi by MatrixClient */ export interface SyncApiOptions { - /** - * Crypto manager - * - * @deprecated in favour of cryptoCallbacks - */ - crypto?: Crypto; - /** * If crypto is enabled on our client, callbacks into the crypto module */ @@ -648,9 +640,6 @@ export class SyncApi { } this.opts.filter.setLazyLoadMembers(true); } - if (this.opts.lazyLoadMembers) { - this.syncOpts.crypto?.enableLazyLoading(); - } }; private storeClientOptions = async (): Promise => { @@ -886,12 +875,6 @@ export class SyncApi { catchingUp: this.catchingUp, }; - if (this.syncOpts.crypto) { - // tell the crypto module we're about to process a sync - // response - await this.syncOpts.crypto.onSyncWillProcess(syncEventData); - } - try { await this.processSyncResponse(syncEventData, data); } catch (e) { @@ -926,15 +909,6 @@ export class SyncApi { this.updateSyncState(SyncState.Syncing, syncEventData); if (this.client.store.wantsSave()) { - // We always save the device list (if it's dirty) before saving the sync data: - // this means we know the saved device list data is at least as fresh as the - // stored sync data which means we don't have to worry that we may have missed - // device changes. We can also skip the delay since we're not calling this very - // frequently (and we don't really want to delay the sync for it). - if (this.syncOpts.crypto) { - await this.syncOpts.crypto.saveDeviceList(0); - } - // tell databases that everything is now in a consistent state and can be saved. await this.client.store.save(); } @@ -1254,27 +1228,6 @@ export class SyncApi { await this.injectRoomEvents(room, stateEvents, undefined); - const inviter = room.currentState.getStateEvents(EventType.RoomMember, client.getUserId()!)?.getSender(); - - const crypto = client.crypto; - if (crypto) { - const parkedHistory = await crypto.cryptoStore.takeParkedSharedHistory(room.roomId); - for (const parked of parkedHistory) { - if (parked.senderId === inviter) { - await crypto.olmDevice.addInboundGroupSession( - room.roomId, - parked.senderKey, - parked.forwardingCurve25519KeyChain, - parked.sessionId, - parked.sessionKey, - parked.keysClaimed, - true, - { sharedHistory: true, untrusted: true }, - ); - } - } - } - if (inviteObj.isBrandNewRoom) { room.recalculate(); client.store.storeRoom(room); diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 18703fbd8..f07a91ee1 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -48,7 +48,6 @@ import { import { CallFeed } from "./callFeed.ts"; import { type MatrixClient } from "../client.ts"; import { EventEmitterEvents, TypedEventEmitter } from "../models/typed-event-emitter.ts"; -import { DeviceInfo } from "../crypto/deviceinfo.ts"; import { GroupCallUnknownDeviceError } from "./groupCall.ts"; import { type IScreensharingOpts } from "./mediaHandler.ts"; import { MatrixError } from "../http-api/index.ts"; @@ -426,7 +425,7 @@ export class MatrixCall extends TypedEventEmitter