diff --git a/package.json b/package.json index 8c3e95d92c3..42a48823f72 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "@matrix-org/emojibase-bindings": "^1.1.2", "@matrix-org/matrix-wysiwyg": "2.17.0", "@matrix-org/olm": "3.2.15", - "@matrix-org/react-sdk-module-api": "^2.3.0", + "@matrix-org/react-sdk-module-api": "^2.4.0", "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^7.0.0", "@testing-library/react-hooks": "^8.0.1", diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 3e87541ddd6..61097c13c21 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -24,7 +24,7 @@ import { QueryDict } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg"; -import SecurityCustomisations from "./customisations/Security"; +import { ModuleRunner } from "./modules/ModuleRunner"; import EventIndexPeg from "./indexing/EventIndexPeg"; import createMatrixClient from "./utils/createMatrixClient"; import Notifier from "./Notifier"; @@ -863,7 +863,7 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise void, ): Promise { - const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); + const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey(); if (keyFromCustomisations) { - logger.log("Using key from security customisations (dehydration)"); + logger.log("CryptoSetupExtension: Using key from extension (dehydration)"); return keyFromCustomisations; } @@ -430,7 +430,7 @@ async function doAccessSecretStorage(func: () => Promise, forceReset: bool // inner operation completes. return await func(); } catch (e) { - SecurityCustomisations.catchAccessSecretStorageError?.(e); + ModuleRunner.instance.extensions.cryptoSetup.catchAccessSecretStorageError(e as Error); logger.error(e); // Re-throw so that higher level logic can abort as needed throw e; diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 3fe64499caa..be49b43851e 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -40,7 +40,7 @@ import { isSecureBackupRequired, SecureBackupSetupMethod, } from "../../../../utils/WellKnownUtils"; -import SecurityCustomisations from "../../../../customisations/Security"; +import { ModuleRunner } from "../../../../modules/ModuleRunner"; import Field from "../../../../components/views/elements/Field"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import Spinner from "../../../../components/views/elements/Spinner"; @@ -180,9 +180,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { if (crossSigningIsSetUp) { // if the user has previously set up cross-signing, verify this device so we can fetch the // private keys. - if (SecurityCustomisations.SHOW_ENCRYPTION_SETUP_UI === false) { + + const cryptoExtension = ModuleRunner.instance.extensions.cryptoSetup; + if (cryptoExtension.SHOW_ENCRYPTION_SETUP_UI == false) { this.onLoggedIn(); } else { this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); diff --git a/src/modules/ModuleRunner.ts b/src/modules/ModuleRunner.ts index 1a94eb16b38..0c609f6f66a 100644 --- a/src/modules/ModuleRunner.ts +++ b/src/modules/ModuleRunner.ts @@ -17,18 +17,108 @@ limitations under the License. import { safeSet } from "matrix-js-sdk/src/utils"; import { TranslationStringsObject } from "@matrix-org/react-sdk-module-api/lib/types/translations"; import { AnyLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/types"; +import { + DefaultCryptoSetupExtensions, + ProvideCryptoSetupExtensions, +} from "@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions"; +import { + DefaultExperimentalExtensions, + ProvideExperimentalExtensions, +} from "@matrix-org/react-sdk-module-api/lib/lifecycles/ExperimentalExtensions"; import { AppModule } from "./AppModule"; import { ModuleFactory } from "./ModuleFactory"; import "./ModuleComponents"; +/** + * Handles and manages extensions provided by modules. + */ +class ExtensionsManager { + // Private backing fields for extensions + private cryptoSetupExtension: ProvideCryptoSetupExtensions; + private experimentalExtension: ProvideExperimentalExtensions; + + /** `true` if `cryptoSetupExtension` is the default implementation; `false` if it is implemented by a module. */ + private hasDefaultCryptoSetupExtension = true; + + /** `true` if `experimentalExtension` is the default implementation; `false` if it is implemented by a module. */ + private hasDefaultExperimentalExtension = true; + + /** + * Create a new instance. + */ + public constructor() { + // Set up defaults + this.cryptoSetupExtension = new DefaultCryptoSetupExtensions(); + this.experimentalExtension = new DefaultExperimentalExtensions(); + } + + /** + * Provides a crypto setup extension. + * + * @returns The registered extension. If no module provides this extension, a default implementation is returned. + */ + public get cryptoSetup(): ProvideCryptoSetupExtensions { + return this.cryptoSetupExtension; + } + + /** + * Provides an experimental extension. + * + * @remarks + * This method extension is provided to simplify experimentation and development, and is not intended for production code. + * + * @returns The registered extension. If no module provides this extension, a default implementation is returned. + */ + public get experimental(): ProvideExperimentalExtensions { + return this.experimentalExtension; + } + + /** + * Add any extensions provided by the module. + * + * @param module - The appModule to check for extensions. + * + * @throws if an extension is provided by more than one module. + */ + public addExtensions(module: AppModule): void { + const runtimeModule = module.module; + + /* Add the cryptoSetup extension if any */ + if (runtimeModule.extensions?.cryptoSetup) { + if (this.hasDefaultCryptoSetupExtension) { + this.cryptoSetupExtension = runtimeModule.extensions?.cryptoSetup; + this.hasDefaultCryptoSetupExtension = false; + } else { + throw new Error( + `adding cryptoSetup extension implementation from module ${runtimeModule.moduleName} but an implementation was already provided.`, + ); + } + } + + /* Add the experimental extension if any */ + if (runtimeModule.extensions?.experimental) { + if (this.hasDefaultExperimentalExtension) { + this.experimentalExtension = runtimeModule.extensions?.experimental; + this.hasDefaultExperimentalExtension = false; + } else { + throw new Error( + `adding experimental extension implementation from module ${runtimeModule.moduleName} but an implementation was already provided.`, + ); + } + } + } +} + /** * Handles and coordinates the operation of modules. */ export class ModuleRunner { public static readonly instance = new ModuleRunner(); + private extensionsManager = new ExtensionsManager(); + private modules: AppModule[] = []; private constructor() { @@ -36,12 +126,22 @@ export class ModuleRunner { } /** - * Resets the runner, clearing all known modules. + * Exposes all extensions which may be overridden/provided by modules. + * + * @returns An `ExtensionsManager` which exposes the extensions. + */ + public get extensions(): ExtensionsManager { + return this.extensionsManager; + } + + /** + * Resets the runner, clearing all known modules, and all extensions * * Intended for test usage only. */ public reset(): void { this.modules = []; + this.extensionsManager = new ExtensionsManager(); } /** @@ -72,7 +172,12 @@ export class ModuleRunner { * @param factory The module factory. */ public registerModule(factory: ModuleFactory): void { - this.modules.push(new AppModule(factory)); + const appModule = new AppModule(factory); + + this.modules.push(appModule); + + // Check if the new module provides any extensions, and also ensure a given extension is only provided by a single runtime module. + this.extensionsManager.addExtensions(appModule); } /** diff --git a/src/toasts/SetupEncryptionToast.ts b/src/toasts/SetupEncryptionToast.ts index e55e665a27f..3f78ad0c925 100644 --- a/src/toasts/SetupEncryptionToast.ts +++ b/src/toasts/SetupEncryptionToast.ts @@ -21,7 +21,8 @@ import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEnc import { accessSecretStorage } from "../SecurityManager"; import ToastStore from "../stores/ToastStore"; import GenericToast from "../components/views/toasts/GenericToast"; -import SecurityCustomisations from "../customisations/Security"; +import { ModuleRunner } from "../modules/ModuleRunner"; +import { SetupEncryptionStore } from "../stores/SetupEncryptionStore"; import Spinner from "../components/views/elements/Spinner"; const TOAST_KEY = "setupencryption"; @@ -79,7 +80,12 @@ const onReject = (): void => { }; export const showToast = (kind: Kind): void => { - if (SecurityCustomisations.setupEncryptionNeeded?.(kind)) { + if ( + ModuleRunner.instance.extensions.cryptoSetup.setupEncryptionNeeded({ + kind: kind as any, + storeProvider: { getInstance: () => SetupEncryptionStore.sharedInstance() }, + }) + ) { return; } diff --git a/test/MatrixClientPeg-test.ts b/test/MatrixClientPeg-test.ts index b94ec7cb018..2ed08e0a21f 100644 --- a/test/MatrixClientPeg-test.ts +++ b/test/MatrixClientPeg-test.ts @@ -17,6 +17,10 @@ limitations under the License. import { logger } from "matrix-js-sdk/src/logger"; import fetchMockJest from "fetch-mock-jest"; import EventEmitter from "events"; +import { + ProvideCryptoSetupExtensions, + SecretStorageKeyDescription, +} from "@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions"; import { advanceDateAndTime, stubClient } from "./test-utils"; import { IMatrixClientPeg, MatrixClientPeg as peg } from "../src/MatrixClientPeg"; @@ -25,6 +29,7 @@ import Modal from "../src/Modal"; import PlatformPeg from "../src/PlatformPeg"; import { SettingLevel } from "../src/settings/SettingLevel"; import { Features } from "../src/settings/Settings"; +import { ModuleRunner } from "../src/modules/ModuleRunner"; jest.useFakeTimers(); @@ -77,6 +82,78 @@ describe("MatrixClientPeg", () => { expect(peg.userRegisteredWithinLastHours(24)).toBe(false); }); + describe(".start extensions", () => { + let testPeg: IMatrixClientPeg; + + beforeEach(() => { + // instantiate a MatrixClientPegClass instance, with a new MatrixClient + testPeg = new PegClass(); + fetchMockJest.get("http://example.com/_matrix/client/versions", {}); + }); + + describe("cryptoSetup extension", () => { + it("should call default cryptoSetup.getDehydrationKeyCallback", async () => { + const mockCryptoSetup = { + SHOW_ENCRYPTION_SETUP_UI: true, + examineLoginResponse: jest.fn(), + persistCredentials: jest.fn(), + getSecretStorageKey: jest.fn(), + createSecretStorageKey: jest.fn(), + catchAccessSecretStorageError: jest.fn(), + setupEncryptionNeeded: jest.fn(), + getDehydrationKeyCallback: jest.fn().mockReturnValue(null), + } as ProvideCryptoSetupExtensions; + + // Ensure we have an instance before we set up spies + const instance = ModuleRunner.instance; + jest.spyOn(instance.extensions, "cryptoSetup", "get").mockReturnValue(mockCryptoSetup); + + testPeg.replaceUsingCreds({ + accessToken: "SEKRET", + homeserverUrl: "http://example.com", + userId: "@user:example.com", + deviceId: "TEST_DEVICE_ID", + }); + + expect(mockCryptoSetup.getDehydrationKeyCallback).toHaveBeenCalledTimes(1); + }); + + it("should call overridden cryptoSetup.getDehydrationKeyCallback", async () => { + const mockDehydrationKeyCallback = () => Uint8Array.from([0x11, 0x22, 0x33]); + + const mockCryptoSetup = { + SHOW_ENCRYPTION_SETUP_UI: true, + examineLoginResponse: jest.fn(), + persistCredentials: jest.fn(), + getSecretStorageKey: jest.fn(), + createSecretStorageKey: jest.fn(), + catchAccessSecretStorageError: jest.fn(), + setupEncryptionNeeded: jest.fn(), + getDehydrationKeyCallback: jest.fn().mockReturnValue(mockDehydrationKeyCallback), + } as ProvideCryptoSetupExtensions; + + // Ensure we have an instance before we set up spies + const instance = ModuleRunner.instance; + jest.spyOn(instance.extensions, "cryptoSetup", "get").mockReturnValue(mockCryptoSetup); + + testPeg.replaceUsingCreds({ + accessToken: "SEKRET", + homeserverUrl: "http://example.com", + userId: "@user:example.com", + deviceId: "TEST_DEVICE_ID", + }); + expect(mockCryptoSetup.getDehydrationKeyCallback).toHaveBeenCalledTimes(1); + + const client = testPeg.get(); + const dehydrationKey = await client?.cryptoCallbacks.getDehydrationKey!( + {} as SecretStorageKeyDescription, + (key: Uint8Array) => true, + ); + expect(dehydrationKey).toEqual(Uint8Array.from([0x11, 0x22, 0x33])); + }); + }); + }); + describe(".start", () => { let testPeg: IMatrixClientPeg; diff --git a/test/modules/MockModule.ts b/test/modules/MockModule.ts index ab29b025086..67c2aeeb7b9 100644 --- a/test/modules/MockModule.ts +++ b/test/modules/MockModule.ts @@ -16,6 +16,9 @@ limitations under the License. import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule"; import { ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi"; +import { AllExtensions } from "@matrix-org/react-sdk-module-api/lib/types/extensions"; +import { ProvideCryptoSetupExtensions } from "@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions"; +import { ProvideExperimentalExtensions } from "@matrix-org/react-sdk-module-api/lib/lifecycles/ExperimentalExtensions"; import { ModuleRunner } from "../../src/modules/ModuleRunner"; @@ -29,6 +32,11 @@ export class MockModule extends RuntimeModule { } } +/** + * Register a mock module + * + * @returns The registered module. + */ export function registerMockModule(): MockModule { let module: MockModule | undefined; ModuleRunner.instance.registerModule((api) => { @@ -43,3 +51,88 @@ export function registerMockModule(): MockModule { } return module; } + +class MockModuleWithCryptoSetupExtension extends RuntimeModule { + public get apiInstance(): ModuleApi { + return this.moduleApi; + } + + moduleName: string = MockModuleWithCryptoSetupExtension.name; + + extensions: AllExtensions = { + cryptoSetup: { + SHOW_ENCRYPTION_SETUP_UI: true, + examineLoginResponse: jest.fn(), + persistCredentials: jest.fn(), + getSecretStorageKey: jest.fn().mockReturnValue(Uint8Array.from([0x11, 0x22, 0x99])), + createSecretStorageKey: jest.fn(), + catchAccessSecretStorageError: jest.fn(), + setupEncryptionNeeded: jest.fn(), + getDehydrationKeyCallback: jest.fn(), + } as ProvideCryptoSetupExtensions, + }; + + public constructor(moduleApi: ModuleApi) { + super(moduleApi); + } +} + +class MockModuleWithExperimentalExtension extends RuntimeModule { + public get apiInstance(): ModuleApi { + return this.moduleApi; + } + + moduleName: string = MockModuleWithExperimentalExtension.name; + + extensions: AllExtensions = { + experimental: { + experimentalMethod: jest.fn().mockReturnValue(Uint8Array.from([0x22, 0x44, 0x88])), + } as ProvideExperimentalExtensions, + }; + + public constructor(moduleApi: ModuleApi) { + super(moduleApi); + } +} + +/** + * Register a mock module which implements the cryptoSetup extension. + * + * @returns The registered module. + */ +export function registerMockModuleWithCryptoSetupExtension(): MockModuleWithCryptoSetupExtension { + let module: MockModuleWithCryptoSetupExtension | undefined; + + ModuleRunner.instance.registerModule((api) => { + if (module) { + throw new Error("State machine error: ModuleRunner created the module twice"); + } + module = new MockModuleWithCryptoSetupExtension(api); + return module; + }); + if (!module) { + throw new Error("State machine error: ModuleRunner did not create module"); + } + return module; +} + +/** + * Register a mock module which implements the experimental extension. + * + * @returns The registered module. + */ +export function registerMockModuleWithExperimentalExtension(): MockModuleWithExperimentalExtension { + let module: MockModuleWithExperimentalExtension | undefined; + + ModuleRunner.instance.registerModule((api) => { + if (module) { + throw new Error("State machine error: ModuleRunner created the module twice"); + } + module = new MockModuleWithExperimentalExtension(api); + return module; + }); + if (!module) { + throw new Error("State machine error: ModuleRunner did not create module"); + } + return module; +} diff --git a/test/modules/ModuleRunner-test.ts b/test/modules/ModuleRunner-test.ts index 175c62c9e6b..27358ff88af 100644 --- a/test/modules/ModuleRunner-test.ts +++ b/test/modules/ModuleRunner-test.ts @@ -16,7 +16,12 @@ limitations under the License. import { RoomPreviewOpts, RoomViewLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; -import { MockModule, registerMockModule } from "./MockModule"; +import { + MockModule, + registerMockModule, + registerMockModuleWithCryptoSetupExtension, + registerMockModuleWithExperimentalExtension, +} from "./MockModule"; import { ModuleRunner } from "../../src/modules/ModuleRunner"; describe("ModuleRunner", () => { @@ -49,4 +54,48 @@ describe("ModuleRunner", () => { ]); }); }); + + describe("extensions", () => { + it("should return default values when no crypto-setup extensions are provided by a registered module", async () => { + registerMockModule(); + const result = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey(); + expect(result).toBeNull(); + }); + + it("should return default values when no experimental extensions are provided by a registered module", async () => { + registerMockModule(); + const result = ModuleRunner.instance.extensions?.experimental.experimentalMethod(); + expect(result).toBeNull(); + }); + + it("should return value from crypto-setup-extensions provided by a registered module", async () => { + registerMockModuleWithCryptoSetupExtension(); + const result = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey(); + expect(result).toEqual(Uint8Array.from([0x11, 0x22, 0x99])); + }); + + it("should return value from experimental-extensions provided by a registered module", async () => { + registerMockModuleWithExperimentalExtension(); + const result = ModuleRunner.instance.extensions.experimental.experimentalMethod(); + expect(result).toEqual(Uint8Array.from([0x22, 0x44, 0x88])); + }); + + it("must not allow multiple modules to provide cryptoSetup extension", async () => { + registerMockModuleWithCryptoSetupExtension(); + const t = () => registerMockModuleWithCryptoSetupExtension(); + expect(t).toThrow(Error); + expect(t).toThrow( + "adding cryptoSetup extension implementation from module MockModuleWithCryptoSetupExtension but an implementation was already provided", + ); + }); + + it("must not allow multiple modules to provide experimental extension", async () => { + registerMockModuleWithExperimentalExtension(); + const t = () => registerMockModuleWithExperimentalExtension(); + expect(t).toThrow(Error); + expect(t).toThrow( + "adding experimental extension implementation from module MockModuleWithExperimentalExtension but an implementation was already provided", + ); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 51ab9eac8ca..eb58c723099 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1877,10 +1877,10 @@ resolved "https://registry.yarnpkg.com/@matrix-org/olm/-/olm-3.2.15.tgz#55f3c1b70a21bbee3f9195cecd6846b1083451ec" integrity sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q== -"@matrix-org/react-sdk-module-api@^2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.3.0.tgz#85be5cfc73be0494c13d4dc9050cb70c58d7a08b" - integrity sha512-x/ie44yaXNtE5AKcmQiW5yINVEIJ7IjjEc35vj6j52fM8tZ9XbJx9PANKSWsdd0NJp3OqyaeHftmN6ESfx4YoQ== +"@matrix-org/react-sdk-module-api@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.4.0.tgz#5e4552acbe728141f42c1d54d75dcb4efea9301c" + integrity sha512-cPb+YaqllfJkRX0ofcG/0YdHxCvcMAvUbdNMO2olpGL8vwbBP6mHdhbZ87z9pgsRIVOqfFuLUE3WeW0hxWrklQ== dependencies: "@babel/runtime" "^7.17.9"