diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0f781215ce3..1ab3fe44b08 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 e3bf79204a0..274a7f9deac 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 1d242a027a4..7662b57c659 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 8797f6a3afc..84210901ca4 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 102bb2b164b..24d03321a44 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 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("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()); - 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 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); + }); - expect(decryptedEvent.isDecryptionFailure()).toBe(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(await aliceClient.getCrypto()!.getEncryptionInfoForEvent(decryptedEvent)).toEqual({ - shieldColour: EventShieldColour.RED, - shieldReason: EventShieldReason.UNKNOWN_DEVICE, - }); - }, - ); + expect(decryptedEvent.isDecryptionFailure()).toBe(false); + + 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); @@ -1445,360 +1177,40 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // Change history visibility in sync response const syncResponse = getSyncResponse([]); - syncResponse.rooms[Category.Join][ROOM_ID].timeline.events.push( - mkEventCustom({ - sender: TEST_USER_ID, - type: "m.room.history_visibility", - state_key: "", - content: { - history_visibility: HistoryVisibility.Invited, - }, - }), - ); - - // Update the new visibility - syncResponder.sendOrQueueSyncResponse(syncResponse); - await syncPromise(aliceClient); - - // Resend a message to bob and get the new session id - [, , encryptedMessage] = await Promise.all([ - aliceClient.sendTextMessage(ROOM_ID, "test"), - expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession), - expectEncryptedSendMessage(), - ]); - - // Check that the new session id exists - const newSessionId = encryptedMessage.session_id; - expect(newSessionId).toBeDefined(); - - // Check that the session id has changed - 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] } } }, - }, - }; + syncResponse.rooms[Category.Join][ROOM_ID].timeline.events.push( + mkEventCustom({ + sender: TEST_USER_ID, + type: "m.room.history_visibility", + state_key: "", + content: { + history_visibility: HistoryVisibility.Invited, + }, + }), + ); + // Update the new visibility 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 }); + // Resend a message to bob and get the new session id + [, , encryptedMessage] = await Promise.all([ + aliceClient.sendTextMessage(ROOM_ID, "test"), + expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession), + expectEncryptedSendMessage(), + ]); - const event1 = testUtils.mkEvent({ - event: true, - ...rawEvent, - room: ROOM_ID, - }); - await event1.attemptDecryption(testClient.client.crypto!, { isRetry: true }); - expect(event1.isKeySourceUntrusted()).toBeTruthy(); + // Check that the new session id exists + const newSessionId = encryptedMessage.session_id; + expect(newSessionId).toBeDefined(); - 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(); + // Check that the session id has changed + expect(sessionId).not.toEqual(newSessionId); }); 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"); - - expect(await aliceClient.getCrypto()!.isSecretStorageReady()).toStrictEqual(false); - }, - ); + 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); + }); 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 4236ae2970c..68428db94ac 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 b209cb291ca..e0a2c790dfd 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, - ); - - 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); - }, - ); + 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); - 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 419a19e1c26..00000000000 --- 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 5aee7e83582..2394d9a3ef9 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 3801e934fbf..90ce5edc6e8 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 b2c0019385f..426a9c3ce57 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 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 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]); }); /** @@ -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 ce741d8dc39..00000000000 --- 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 abd55185aec..a28ec25d2d4 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 78738b46aa3..816472aa98b 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 a60107d1180..de45eeef82a 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 15abab550fb..00000000000 --- 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 e99ceb2739b..00000000000 --- 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 429f15ebe34..00000000000 --- 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 4a2035383ae..00000000000 --- 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 a061756499f..00000000000 --- 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 a11f73dfda5..00000000000 --- 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 011c0d5e3aa..00000000000 --- 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 3ef2b50509f..00000000000 --- 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 d9a0dac895e..00000000000 --- 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 d54f8f4e7ca..00000000000 --- 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 b0d421f1393..00000000000 --- 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 477441ea330..00000000000 --- 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 28a88c50b80..00000000000 --- 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 0f8fdcba591..00000000000 --- 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 c3b45b7b813..00000000000 --- 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 ced5f08631c..00000000000 --- 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 515a15d4075..00000000000 --- 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 cf3cf0c49dc..00000000000 --- 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 5752fa4a266..00000000000 --- 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 35bdc4dff06..fc430678de6 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 5b00a3f9e56..17951cc6e53 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 e539e15a8e3..2dc02ed4335 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 f7a694c9114..88f1361a139 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 d124d12bf34..21569870d5f 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 4157b146e40..982432fcdcb 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 54389e33603..fa8dd228e8a 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 8c7e490ca79..8f6fc0408c3 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 @@ -2127,2069 +1837,225 @@ export class MatrixClient extends TypedEventEmitter { - const caps = this.serverCapabilitiesService.getCachedCapabilities(); - if (caps) return caps; - return this.serverCapabilitiesService.fetchCapabilities(); - } - - /** - * Gets the cached capabilities of the homeserver. If none have been fetched yet, - * return undefined. - * - * @returns The capabilities of the homeserver - */ - public getCachedCapabilities(): Capabilities | undefined { - return this.serverCapabilitiesService.getCachedCapabilities(); - } - - /** - * Fetches the latest capabilities from the homeserver, ignoring any cached - * versions. The newly returned version is cached. - * - * @returns A promise which resolves to the capabilities of the homeserver - */ - public fetchCapabilities(): Promise { - return this.serverCapabilitiesService.fetchCapabilities(); - } - - /** - * Initialise support for end-to-end encryption in this client, using libolm. - * - * You should call this method after creating the matrixclient, but *before* - * calling `startClient`, if you want to support end-to-end encryption. - * - * It will return a Promise which will resolve when the crypto layer has been - * successfully initialised. - * - * @deprecated libolm is deprecated. Prefer {@link initRustCrypto}. - */ - public async initLegacyCrypto(): Promise { - 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. - * - * @param args.useIndexedDB - True to use an indexeddb store, false to use an in-memory store. Defaults to 'true'. - * @param args.storageKey - A key with which to encrypt the indexeddb store. If provided, it must be exactly - * 32 bytes of data, and must be the same each time the client is initialised for a given device. - * If both this and `storagePassword` are unspecified, the store will be unencrypted. - * @param args.storagePassword - An alternative to `storageKey`. A password which will be used to derive a key to - * encrypt the store with. Deriving a key from a password is (deliberately) a slow operation, so prefer - * to pass a `storageKey` directly where possible. - * - * @returns a Promise which will resolve when the crypto layer has been - * successfully initialised. - */ - public async initRustCrypto( - args: { - useIndexedDB?: boolean; - storageKey?: Uint8Array; - storagePassword?: string; - } = {}, - ): Promise { - if (this.cryptoBackend) { - this.logger.warn("Attempt to re-initialise e2e encryption on MatrixClient"); - return; - } - - const userId = this.getUserId(); - if (userId === null) { - throw new Error( - `Cannot enable encryption on MatrixClient with unknown userId: ` + - `ensure userId is passed in createClient().`, - ); - } - const deviceId = this.getDeviceId(); - if (deviceId === null) { - throw new Error( - `Cannot enable encryption on MatrixClient with unknown deviceId: ` + - `ensure deviceId is passed in createClient().`, - ); - } - - // importing rust-crypto will download the webassembly, so we delay it until we know it will be - // needed. - this.logger.debug("Downloading Rust crypto library"); - const RustCrypto = await import("./rust-crypto/index.ts"); - - const rustCrypto = await RustCrypto.initRustCrypto({ - logger: this.logger, - http: this.http, - userId: userId, - deviceId: deviceId, - secretStorage: this.secretStorage, - cryptoCallbacks: this.cryptoCallbacks, - storePrefix: args.useIndexedDB === false ? null : RUST_SDK_STORE_PREFIX, - storeKey: args.storageKey, - storePassphrase: args.storagePassword, - - legacyCryptoStore: this.cryptoStore, - legacyPickleKey: this.pickleKey ?? "DEFAULT_KEY", - legacyMigrationProgressListener: (progress: number, total: number): void => { - this.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, progress, total); - }, - }); - - rustCrypto.setSupportedVerificationMethods(this.verificationMethods); - - this.cryptoBackend = rustCrypto; - - // attach the event listeners needed by RustCrypto - this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto)); - this.on(ClientEvent.Event, (event) => { - rustCrypto.onLiveEventFromSync(event); - }); - - // re-emit the events emitted by the crypto impl - this.reEmitter.reEmit(rustCrypto, [ - CryptoEvent.VerificationRequestReceived, - CryptoEvent.UserTrustStatusChanged, - CryptoEvent.KeyBackupStatus, - CryptoEvent.KeyBackupSessionsRemaining, - CryptoEvent.KeyBackupFailed, - CryptoEvent.KeyBackupDecryptionKeyCached, - CryptoEvent.KeysChanged, - CryptoEvent.DevicesUpdated, - CryptoEvent.WillUpdateDevices, - CryptoEvent.DehydratedDeviceCreated, - CryptoEvent.DehydratedDeviceUploaded, - CryptoEvent.RehydrationStarted, - CryptoEvent.RehydrationProgress, - CryptoEvent.RehydrationCompleted, - CryptoEvent.RehydrationError, - CryptoEvent.DehydrationKeyCached, - CryptoEvent.DehydratedDeviceRotationError, - ]); - } - - /** - * Access the server-side secret storage API for this client. - */ - public get secretStorage(): ServerSideSecretStorage { - return this._secretStorage; - } - - /** - * Access the crypto API for this client. - * - * If end-to-end encryption has been enabled for this client (via {@link initLegacyCrypto} or {@link initRustCrypto}), - * returns an object giving access to the crypto API. Otherwise, returns `undefined`. - */ - public getCrypto(): CryptoApi | undefined { - return this.cryptoBackend; - } - - /** - * Is end-to-end crypto enabled for this client. - * @returns True if end-to-end is enabled. - * @deprecated prefer {@link getCrypto} - */ - public isCryptoEnabled(): boolean { - return !!this.cryptoBackend; - } - - /** - * Get the Ed25519 key for this device - * - * @returns base64-encoded ed25519 key. Null if crypto is - * disabled. - * - * @deprecated Not supported for Rust Cryptography.Prefer {@link CryptoApi.getOwnDeviceKeys} - */ - public getDeviceEd25519Key(): string | null { - return this.crypto?.getDeviceEd25519Key() ?? null; - } - - /** - * Get the Curve25519 key for this device - * - * @returns base64-encoded curve25519 key. Null if crypto is - * disabled. - * - * @deprecated Not supported for Rust Cryptography. Use {@link CryptoApi.getOwnDeviceKeys} - */ - public getDeviceCurve25519Key(): string | null { - return this.crypto?.getDeviceCurve25519Key() ?? null; - } - - /** - * @deprecated Does nothing. - */ - public async uploadKeys(): Promise { - 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. - * @returns whether encryption is enabled. - * - * @deprecated Not correctly supported for Rust Cryptography. Use {@link CryptoApi.isEncryptionEnabledInRoom} and/or - * {@link Room.hasEncryptionStateEvent}. - */ - public isRoomEncrypted(roomId: string): boolean { - const room = this.getRoom(roomId); - if (!room) { - // we don't know about this room, so can't determine if it should be - // encrypted. Let's assume not. - return false; - } - - // if there is an 'm.room.encryption' event in this room, it should be - // encrypted (independently of whether we actually support encryption) - if (room.hasEncryptionStateEvent()) { - return true; - } - - // we don't have an m.room.encrypted event, but that might be because - // the server is hiding it from us. Check the store to see if it was - // previously encrypted. - return this.crypto?.isRoomEncrypted(roomId) ?? false; - } - - /** - * Encrypts and sends a given object via Olm to-device messages to a given - * set of devices. - * - * @param userDeviceInfoArr - list of deviceInfo objects representing 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 CryptoApi.encryptToDeviceMessages} followed by {@link queueToDevice}. - */ - public encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice[], 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 */ - }; - } - - /** - * Check whether the key backup private key is stored in secret storage. - * @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 isKeyBackupKeyStored(): Promise | null> { - return Promise.resolve(this.secretStorage.isStored("m.megolm_backup.v1")); - } - - /** - * Create a new key backup version and enable it, using the information return - * from prepareKeyBackupVersion. - * - * @param info - Info object from prepareKeyBackupVersion - * @returns Object with 'version' param indicating the version created - * - * @deprecated Use {@link Crypto.CryptoApi.resetKeyBackup | `CryptoApi.resetKeyBackup`}. - */ - public async createKeyBackupVersion(info: IKeyBackupInfo): Promise { - 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) { - path = utils.encodeUri("/room_keys/keys/$roomId/$sessionId", { - $roomId: roomId!, - $sessionId: sessionId, - }); - } else if (roomId !== undefined) { - path = utils.encodeUri("/room_keys/keys/$roomId", { - $roomId: roomId, - }); - } else { - path = "/room_keys/keys"; - } - const queryData = version === undefined ? undefined : { version }; - return { path, queryData }; - } - - /** - * Back up session keys to the homeserver. - * @param roomId - ID of the room that the keys are for Optional. - * @param sessionId - ID of the session that the keys are for Optional. - * @param version - backup version Optional. - * @param data - Object keys to send - * @returns a promise that will resolve when the keys - * are uploaded - * - * @deprecated Not supported for Rust Cryptography. - */ - public sendKeyBackup( - roomId: undefined, - sessionId: undefined, - version: string | undefined, - data: IKeyBackup, - ): Promise; - 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(); + public retryImmediately(): boolean { + // don't await for this promise: we just want to kick it off + this.toDeviceMessageQueue.sendQueue(); + return this.syncApi?.retryImmediately() ?? false; } /** - * 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.) + * Return the global notification EventTimelineSet, if any * - * @returns Promise which resolves to the number of sessions requiring a backup. - * @deprecated Not supported for Rust Cryptography. + * @returns the globl notification EventTimelineSet */ - public flagAllGroupSessionsForBackup(): Promise { - if (!this.crypto) { - throw new Error("End-to-end encryption disabled"); - } - - return this.crypto.backupManager.flagAllGroupSessionsForBackup(); + public getNotifTimelineSet(): EventTimelineSet | null { + return this.notifTimelineSet; } /** - * 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 + * Set the global notification EventTimelineSet + * */ - public isValidRecoveryKey(recoveryKey: string): boolean { - try { - decodeRecoveryKey(recoveryKey); - return true; - } catch { - return false; - } + public setNotifTimelineSet(set: EventTimelineSet): void { + this.notifTimelineSet = set; } /** - * 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. + * Gets the cached capabilities of the homeserver, returning cached ones if available. + * If there are no cached capabilities and none can be fetched, throw an exception. * - * @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. + * @returns Promise resolving with The capabilities of the homeserver */ - public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupInfo): Promise { - return keyFromAuthData(backupInfo.auth_data, password); + public async getCapabilities(): Promise { + const caps = this.serverCapabilitiesService.getCachedCapabilities(); + if (caps) return caps; + return this.serverCapabilitiesService.fetchCapabilities(); } /** - * 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. + * Gets the cached capabilities of the homeserver. If none have been fetched yet, + * return undefined. * - * @param recoveryKey - The recovery key - * @returns key backup key - * @deprecated Use {@link decodeRecoveryKey} directly + * @returns The capabilities of the homeserver */ - public keyBackupKeyFromRecoveryKey(recoveryKey: string): Uint8Array { - return decodeRecoveryKey(recoveryKey); + public getCachedCapabilities(): Capabilities | undefined { + return this.serverCapabilitiesService.getCachedCapabilities(); } /** - * 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. + * Fetches the latest capabilities from the homeserver, ignoring any cached + * versions. The newly returned version is cached. * - * @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`}. + * @returns A promise which resolves to the capabilities of the homeserver */ - 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); + public fetchCapabilities(): Promise { + return this.serverCapabilitiesService.fetchCapabilities(); } /** - * Restore from an existing key backup via an encoded recovery key. + * Initialise support for end-to-end encryption in this client, using the rust matrix-sdk-crypto. * - * @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. + * **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. * - * @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 + * @param args.useIndexedDB - True to use an indexeddb store, false to use an in-memory store. Defaults to 'true'. + * @param args.storageKey - A key with which to encrypt the indexeddb store. If provided, it must be exactly + * 32 bytes of data, and must be the same each time the client is initialised for a given device. + * If both this and `storagePassword` are unspecified, the store will be unencrypted. + * @param args.storagePassword - An alternative to `storageKey`. A password which will be used to derive a key to + * encrypt the store with. Deriving a key from a password is (deliberately) a slow operation, so prefer + * to pass a `storageKey` directly where possible. * - * @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`}. + * @returns a Promise which will resolve when the crypto layer has been + * successfully initialised. */ - 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"); + public async initRustCrypto( + args: { + useIndexedDB?: boolean; + storageKey?: Uint8Array; + storagePassword?: string; + } = {}, + ): Promise { + if (this.cryptoBackend) { + this.logger.warn("Attempt to re-initialise e2e encryption on MatrixClient"); + return; } - if (!backupInfo.version) { - throw new Error("Backup version must be defined"); + const userId = this.getUserId(); + if (userId === null) { + throw new Error( + `Cannot enable encryption on MatrixClient with unknown userId: ` + + `ensure userId is passed in createClient().`, + ); + } + const deviceId = this.getDeviceId(); + if (deviceId === null) { + throw new Error( + `Cannot enable encryption on MatrixClient with unknown deviceId: ` + + `ensure deviceId is passed in createClient().`, + ); } - 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", - }); - } + // importing rust-crypto will download the webassembly, so we delay it until we know it will be + // needed. + this.logger.debug("Downloading Rust crypto library"); + const RustCrypto = await import("./rust-crypto/index.ts"); - const res = await this.http.authedRequest( - Method.Get, - path.path, - path.queryData, - undefined, - { prefix: ClientPrefix.V3 }, - ); + const rustCrypto = await RustCrypto.initRustCrypto({ + logger: this.logger, + http: this.http, + userId: userId, + deviceId: deviceId, + secretStorage: this.secretStorage, + cryptoCallbacks: this.cryptoCallbacks, + storePrefix: args.useIndexedDB === false ? null : RUST_SDK_STORE_PREFIX, + storeKey: args.storageKey, + storePassphrase: args.storagePassword, - // We have finished fetching the backup, go to next step - if (progressCallback) { - progressCallback({ - stage: "load_keys", - }); - } + legacyCryptoStore: this.legacyCryptoStore, + legacyPickleKey: this.legacyPickleKey ?? "DEFAULT_KEY", + legacyMigrationProgressListener: (progress: number, total: number): void => { + this.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, progress, total); + }, + }); - 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); - } + rustCrypto.setSupportedVerificationMethods(this.verificationMethods); - 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!; + this.cryptoBackend = rustCrypto; - 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(); - } + // attach the event listeners needed by RustCrypto + this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto)); + this.on(ClientEvent.Event, (event) => { + rustCrypto.onLiveEventFromSync(event); + }); - /// in case entering the passphrase would add a new signature? - await this.cryptoBackend.checkKeyBackupAndEnable(); + // re-emit the events emitted by the crypto impl + this.reEmitter.reEmit(rustCrypto, [ + CryptoEvent.VerificationRequestReceived, + CryptoEvent.UserTrustStatusChanged, + CryptoEvent.KeyBackupStatus, + CryptoEvent.KeyBackupSessionsRemaining, + CryptoEvent.KeyBackupFailed, + CryptoEvent.KeyBackupDecryptionKeyCached, + CryptoEvent.KeysChanged, + CryptoEvent.DevicesUpdated, + CryptoEvent.WillUpdateDevices, + CryptoEvent.DehydratedDeviceCreated, + CryptoEvent.DehydratedDeviceUploaded, + CryptoEvent.RehydrationStarted, + CryptoEvent.RehydrationProgress, + CryptoEvent.RehydrationCompleted, + CryptoEvent.RehydrationError, + CryptoEvent.DehydrationKeyCached, + CryptoEvent.DehydratedDeviceRotationError, + ]); + } - return { total: totalKeyCount, imported: totalImported }; + /** + * Access the server-side secret storage API for this client. + */ + public get secretStorage(): ServerSideSecretStorage { + return this._secretStorage; } /** - * 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. + * Access the crypto API for this client. * - * @returns The total number of keys in the backup. + * If end-to-end encryption has been enabled for this client (via {@link initRustCrypto}), + * returns an object giving access to the crypto API. Otherwise, returns `undefined`. */ - 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; + public getCrypto(): CryptoApi | undefined { + return this.cryptoBackend; } /** - * 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. + * Whether encryption is enabled for a room. + * @param roomId - the room id to query. + * @returns whether encryption is enabled. * - * @returns A promise that resolves when the decryption is complete. + * @deprecated Not correctly supported for Rust Cryptography. Use {@link CryptoApi.isEncryptionEnabledInRoom} and/or + * {@link Room.hasEncryptionStateEvent}. */ - 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; - } - } + public isRoomEncrypted(roomId: string): boolean { + const room = this.getRoom(roomId); + if (!room) { + // we don't know about this room, so can't determine if it should be + // encrypted. Let's assume not. + return false; } - // Handle remaining chunk if needed - if (groupChunkCount > 0) { - await handleChunkCallback(chunkGroupByRoom); + // if there is an 'm.room.encryption' event in this room, it should be + // encrypted (independently of whether we actually support encryption) + return room.hasEncryptionStateEvent(); + } + + /** + * Check whether the key backup private key is stored in secret storage. + * @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 isKeyBackupKeyStored(): Promise | null> { + return Promise.resolve(this.secretStorage.isStored("m.megolm_backup.v1")); + } + + private makeKeyBackupPath(roomId?: string, sessionId?: string, version?: string): IKeyBackupPath { + let path: string; + if (sessionId !== undefined) { + path = utils.encodeUri("/room_keys/keys/$roomId/$sessionId", { + $roomId: roomId!, + $sessionId: sessionId, + }); + } else if (roomId !== undefined) { + path = utils.encodeUri("/room_keys/keys/$roomId", { + $roomId: roomId, + }); + } else { + path = "/room_keys/keys"; } + const queryData = version === undefined ? undefined : { version }; + return { path, queryData }; } public deleteKeysFromBackup(roomId: undefined, sessionId: undefined, 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 53351d6e47d..0d52d0a0671 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 008fc2eac6f..55b5da8ad7a 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 12eaddd6d05..63bfed81b3b 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 175e8f8a4ac..00000000000 --- 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 c7c3896788e..00000000000 --- 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 6ff27ddc46b..00000000000 --- 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 0db4aa742f4..00000000000 --- 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 59bff25925b..00000000000 --- 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 84a23106955..00000000000 --- 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 c2705f81b89..00000000000 --- 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 9052b804b50..00000000000 --- 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 450e1756a14..00000000000 --- 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 f13522fd7e6..00000000000 --- 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 947c6e0ea2b..00000000000 --- 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 d3057e248c3..00000000000 --- 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 60a8f28a3d9..00000000000 --- 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 b1fd010e3ed..00000000000 --- 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 c328c2c4fdc..00000000000 --- 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 4aea59de52b..00000000000 --- 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 a995611f7b5..00000000000 --- 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 c1ffe42c603..00000000000 --- 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 9e130c674fd..00000000000 --- 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 dd499d7306e..00000000000 --- 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 b60f4f92175..00000000000 --- 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 76943693848..00000000000 --- 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 c908af26ea6..00000000000 --- 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 6ce5d3f07e6..00000000000 --- 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 ba1a5675b56..86e54f497cf 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 6e0300397a6..a3079ac30f5 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 b5c7dc651b1..824ad00504c 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 f3333083d05..ac52c40d011 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 cf8aaa2059b..9c42378c5c9 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 028a6c3f9b4..00000000000 --- 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 4f609db3a8e..00000000000 --- 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 2b5a88597b8..00000000000 --- 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 b4cb171368f..00000000000 --- 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 f956b575644..00000000000 --- 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 0cb4630c2a8..00000000000 --- 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 3b3a6385ff1..00000000000 --- 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 bbb8bcbca68..00000000000 --- 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 3083f472a7c..00000000000 --- 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 08a0c36022b..00000000000 --- 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 b62373ae03f..7dc617af119 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 c0c5f130767..41944218750 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 8498b55157f..5716f3e1fbf 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 548dc0df0da..f3b2c4b7123 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 8f41c4b6c17..95dc4846b92 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 16a61d33c0a..e69e21f1d18 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 18703fbd873..f07a91ee1c6 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