diff --git a/package.json b/package.json index 9c69bbb..6d56f82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matrix-org/react-sdk-module-api", - "version": "2.3.0", + "version": "2.4.0", "description": "Module API surface for matrix-react-sdk", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/RuntimeModule.ts b/src/RuntimeModule.ts index 704561f..86945f1 100644 --- a/src/RuntimeModule.ts +++ b/src/RuntimeModule.ts @@ -18,6 +18,7 @@ import { EventEmitter } from "events"; import { ModuleApi } from "./ModuleApi"; import { PlainSubstitution } from "./types/translations"; +import { AllExtensions } from "./types/extensions"; // TODO: Type the event emitter with AnyLifecycle (extract TypedEventEmitter from js-sdk somehow?) // See https://github.com/matrix-org/matrix-react-sdk-module-api/issues/4 @@ -27,6 +28,9 @@ import { PlainSubstitution } from "./types/translations"; * will be provided information about the application state and can react to it. */ export abstract class RuntimeModule extends EventEmitter { + public extensions?: AllExtensions; + public moduleName: string = RuntimeModule.name; + protected constructor(protected readonly moduleApi: ModuleApi) { super(); } diff --git a/src/lifecycles/CryptoSetupExtensions.ts b/src/lifecycles/CryptoSetupExtensions.ts new file mode 100644 index 0000000..92c1b26 --- /dev/null +++ b/src/lifecycles/CryptoSetupExtensions.ts @@ -0,0 +1,184 @@ +/* +Copyright 2023 Verji Tech AS +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. +*/ + +/* + * Types copied (and renamed) from matrix-js-sdk + */ + +/** + * Copied from https://github.com/matrix-org/matrix-js-sdk/blob/2337d5a7af6265bbcabbd42c1594cd8b1829b00b/src/secret-storage.ts#L39-L50 + */ +export interface SecretStorageKeyDescriptionCommon { + /** A human-readable name for this key. */ + // XXX: according to the spec, this is optional + name: string; + + /** The encryption algorithm used with this key. */ + algorithm: string; + + /** Information for deriving this key from a passphrase. */ + // XXX: according to the spec, this is optional + passphrase: PassphraseInfo; +} + +/** + * Copied from https://github.com/matrix-org/matrix-js-sdk/blob/2337d5a7af6265bbcabbd42c1594cd8b1829b00b/src/secret-storage.ts#L59-L71 + */ +export interface SecretStorageKeyDescriptionAesV1 extends SecretStorageKeyDescriptionCommon { + // XXX: strictly speaking, we should be able to enforce the algorithm here. But + // this interface ends up being incorrectly used where other algorithms are in use (notably + // in device-dehydration support), and unpicking that is too much like hard work + // at the moment. + // algorithm: "m.secret_storage.v1.aes-hmac-sha2"; + + /** The 16-byte AES initialization vector, encoded as base64. */ + iv: string; + + /** The MAC of the result of encrypting 32 bytes of 0, encoded as base64. */ + mac: string; +} + +/** + * Copied from https://github.com/matrix-org/matrix-js-sdk/blob/2337d5a7af6265bbcabbd42c1594cd8b1829b00b/src/secret-storage.ts#L78 + */ +export type SecretStorageKeyDescription = SecretStorageKeyDescriptionAesV1; + +/** + * Copied from https://github.com/matrix-org/matrix-js-sdk/blob/2337d5a7af6265bbcabbd42c1594cd8b1829b00b/src/secret-storage.ts#L85-L97 + */ +export interface PassphraseInfo { + /** The algorithm to be used to derive the key. */ + algorithm: "m.pbkdf2"; + + /** The number of PBKDF2 iterations to use. */ + iterations: number; + + /** The salt to be used for PBKDF2. */ + salt: string; + + /** The number of bits to generate. Defaults to 256. */ + bits?: number; +} + +/* + * Copied from https://github.com/matrix-org/matrix-react-sdk/blob/11096b207a1510569f5c54182e328f6148a6475c/src/MatrixClientPeg.ts#L57-L67 + */ +export interface ExamineLoginResponseCreds { + homeserverUrl: string; + identityServerUrl?: string; + userId: string; + deviceId?: string; + accessToken: string; + refreshToken?: string; + guest?: boolean; + pickleKey?: string; + freshLogin?: boolean; +} + +/** + * Copied from https://github.com/matrix-org/matrix-react-sdk/blob/11096b207a1510569f5c54182e328f6148a6475c/src/toasts/SetupEncryptionToast.ts#L71-L75 + */ +export enum SetupEncryptionKind { + SetUpEncryption = "set_up_encryption", + UpgradeEncryption = "upgrade_encryption", + VerifyThisSessions = "verify_this_session", +} + +export interface ExtendedMatrixClientCreds extends ExamineLoginResponseCreds { + secureBackupKey?: string; +} + +export interface ProvideCryptoSetupStore { + getInstance: () => SetupEncryptionStoreProjection; +} + +export interface SetupEncryptionStoreProjection { + usePassPhrase(): Promise; +} + +export interface ProvideCryptoSetupExtensions { + examineLoginResponse(response: any, credentials: ExtendedMatrixClientCreds): void; + persistCredentials(credentials: ExtendedMatrixClientCreds): void; + getSecretStorageKey(): Uint8Array | null; + createSecretStorageKey(): Uint8Array | null; + catchAccessSecretStorageError(e: Error): void; + setupEncryptionNeeded: (args: CryptoSetupArgs) => boolean; + getDehydrationKeyCallback(): + | ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise) + | null; + SHOW_ENCRYPTION_SETUP_UI: boolean; +} + +export abstract class CryptoSetupExtensionsBase implements ProvideCryptoSetupExtensions { + public abstract examineLoginResponse(response: any, credentials: ExtendedMatrixClientCreds): void; + public abstract persistCredentials(credentials: ExtendedMatrixClientCreds): void; + public abstract getSecretStorageKey(): Uint8Array | null; + public abstract createSecretStorageKey(): Uint8Array | null; + public abstract catchAccessSecretStorageError(e: Error): void; + public abstract setupEncryptionNeeded(args: CryptoSetupArgs): boolean; + public abstract getDehydrationKeyCallback(): + | ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise) + | null; + public abstract SHOW_ENCRYPTION_SETUP_UI: boolean; +} + +/* Define an interface for setupEncryptionNeeded to help enforce mandatory arguments */ +export interface CryptoSetupArgs { + kind: SetupEncryptionKind; + storeProvider: ProvideCryptoSetupStore; +} + +/** + * + * The default/empty crypto-extensions + * Can (and will) be used if none of the modules has an implementaion of IProvideCryptoSetupExtensions + * + * */ +export class DefaultCryptoSetupExtensions extends CryptoSetupExtensionsBase { + public SHOW_ENCRYPTION_SETUP_UI = true; + + public examineLoginResponse(response: any, credentials: ExtendedMatrixClientCreds): void { + console.log("Default empty examineLoginResponse() => void"); + } + public persistCredentials(credentials: ExtendedMatrixClientCreds): void { + console.log("Default empty persistCredentials() => void"); + } + + public getSecretStorageKey(): Uint8Array | null { + console.log("Default empty getSecretStorageKey() => null"); + return null; + } + + public createSecretStorageKey(): Uint8Array | null { + console.log("Default empty createSecretStorageKey() => null"); + return null; + } + + public catchAccessSecretStorageError(e: Error): void { + console.log("Default catchAccessSecretStorageError() => void"); + } + + public setupEncryptionNeeded(args: CryptoSetupArgs): boolean { + console.log("Default setupEncryptionNeeded() => false"); + return false; + } + + public getDehydrationKeyCallback(): + | ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise) + | null { + console.log("Default empty getDehydrationKeyCallback() => null"); + return null; + } +} diff --git a/src/lifecycles/ExperimentalExtensions.ts b/src/lifecycles/ExperimentalExtensions.ts new file mode 100644 index 0000000..fcf02e0 --- /dev/null +++ b/src/lifecycles/ExperimentalExtensions.ts @@ -0,0 +1,32 @@ +/* +Copyright 2023 Verji Tech AS +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. +*/ + +/** + * Mostly for test. To ensure we handle more than one module having extensions + * Can possibly also be useful for PoC development + */ +export interface ProvideExperimentalExtensions { + experimentalMethod(args?: any): any; +} + +export abstract class ExperimentalExtensionsBase implements ProvideExperimentalExtensions { + public abstract experimentalMethod(args?: any): any; +} + +export class DefaultExperimentalExtensions extends ExperimentalExtensionsBase { + public experimentalMethod(args?: any): any { + return null; + } +} diff --git a/src/types/extensions.ts b/src/types/extensions.ts new file mode 100644 index 0000000..2fcbdcc --- /dev/null +++ b/src/types/extensions.ts @@ -0,0 +1,22 @@ +/* +Copyright 2023 Verji Tech AS +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 { ProvideCryptoSetupExtensions } from "../lifecycles/CryptoSetupExtensions"; +import { ProvideExperimentalExtensions } from "../lifecycles/ExperimentalExtensions"; + +export type AllExtensions = { + cryptoSetup?: ProvideCryptoSetupExtensions; + experimental?: ProvideExperimentalExtensions; +}; diff --git a/test/lifecycles/CryptoSetupExtensions.test.tsx b/test/lifecycles/CryptoSetupExtensions.test.tsx new file mode 100644 index 0000000..15c6a40 --- /dev/null +++ b/test/lifecycles/CryptoSetupExtensions.test.tsx @@ -0,0 +1,182 @@ +/* +Copyright 2023 Verji Tech AS + +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 { RuntimeModule } from "../../src"; +import { + ExtendedMatrixClientCreds, + CryptoSetupExtensionsBase, + SecretStorageKeyDescription, + CryptoSetupArgs, + ProvideCryptoSetupExtensions, + DefaultCryptoSetupExtensions, +} from "../../src/lifecycles/CryptoSetupExtensions"; +import { DefaultExperimentalExtensions, ExperimentalExtensionsBase } from "../../src/lifecycles/ExperimentalExtensions"; + +describe("Defaults", () => { + let module: RuntimeModule; + + beforeAll(() => { + module = new (class extends RuntimeModule { + public constructor() { + super(undefined as any); + + this.extensions = { + cryptoSetup: new DefaultCryptoSetupExtensions(), + experimental: new DefaultExperimentalExtensions(), + }; + } + })(); + }); + + it("returns default value for SHOW_ENCRYPTION_SETUP_UI", () => { + let result = module.extensions!.cryptoSetup!.SHOW_ENCRYPTION_SETUP_UI; + expect(result).toBeTruthy(); + }); + + it("returns default value for getSecretStorageKey()", () => { + let result = module.extensions!.cryptoSetup!.getSecretStorageKey(); + expect(result).toEqual(null); + }); + + it("returns default value of null instead of callback", async () => { + let callback = module.extensions!.cryptoSetup!.getDehydrationKeyCallback() as any; + expect(callback).toBeNull(); + }); + + it("must not throw when calling default examineLoginResponse()", () => { + var credentials = new (class implements ExtendedMatrixClientCreds { + identityServerUrl?: string | undefined; + userId: string = ""; + deviceId?: string | undefined; + accessToken: string = ""; + refreshToken?: string | undefined; + guest?: boolean | undefined; + pickleKey?: string | undefined; + freshLogin?: boolean | undefined; + homeserverUrl: string = ""; + secureBackupKey?: string | undefined = ""; + })(); + + let t = () => module.extensions!.cryptoSetup?.examineLoginResponse({ secureBackupKey: "my key" }, credentials); + expect(t).not.toThrow(); + }); +}); + +describe("Custom CryptoSetupExtensions", () => { + let module: RuntimeModule; + + beforeAll(() => { + module = new (class extends RuntimeModule { + public constructor() { + super(undefined as any); + + this.extensions = { + cryptoSetup: new (class extends CryptoSetupExtensionsBase { + persistCredentials(credentials: ExtendedMatrixClientCreds): void {} + catchAccessSecretStorageError(e: Error): void {} + setupEncryptionNeeded(args: CryptoSetupArgs): boolean { + return true; + } + getSecretStorageKey(): Uint8Array | null { + return new Uint8Array([0xaa, 0xbb, 0xbb, 0xaa]); + } + createSecretStorageKey(): Uint8Array | null { + return new Uint8Array([0xaa, 0xbb, 0xbb, 0xaa, 0xaa, 0xbb, 0xbb, 0xaa]); + } + getDehydrationKeyCallback(): + | (( + keyInfo: SecretStorageKeyDescription, + checkFunc: (key: Uint8Array) => void, + ) => Promise) + | null { + return (_, __) => Promise.resolve(new Uint8Array([0x0, 0x1, 0x2, 0x3])); + } + examineLoginResponse(response: any, credentials: ExtendedMatrixClientCreds): void { + credentials.secureBackupKey = "my secure backup key"; + } + SHOW_ENCRYPTION_SETUP_UI: boolean = false; + })(), + }; + } + })(); + }); + + it("overrides SHOW_ENCRYPTION_SETUP_UI custom setting from base class", () => { + let result = module.extensions!.cryptoSetup!.SHOW_ENCRYPTION_SETUP_UI; + expect(result).toBeFalsy(); + }); + + it("returns custom value when calling getSecretStorageKey", () => { + let result = module.extensions!.cryptoSetup!.getSecretStorageKey(); + expect(result).toEqual(Uint8Array.from([0xaa, 0xbb, 0xbb, 0xaa])); + }); + + it("returns callback which resolves to custom value when calling getDehydrationKeyCallback", async () => { + let callback = module.extensions!.cryptoSetup!.getDehydrationKeyCallback() as any; + const result = await callback({} as SecretStorageKeyDescription, () => {}); + const expected = Uint8Array.from([0x0, 0x1, 0x2, 0x3]); + expect(result).toEqual(expected); + }); + + it("must allow adding secure backup key to login response", () => { + var credentials = new (class implements ExtendedMatrixClientCreds { + identityServerUrl?: string | undefined; + userId: string = ""; + deviceId?: string | undefined; + accessToken: string = ""; + refreshToken?: string | undefined; + guest?: boolean | undefined; + pickleKey?: string | undefined; + freshLogin?: boolean | undefined; + homeserverUrl: string = ""; + secureBackupKey?: string | undefined = ""; + })(); + + module.extensions!.cryptoSetup?.examineLoginResponse({ secureBackupKey: "my key" }, credentials); + expect(credentials.secureBackupKey).toEqual("my secure backup key"); + }); +}); + +describe("Custom ExperimentalExtensions", () => { + let module: RuntimeModule; + + beforeAll(() => { + module = new (class extends RuntimeModule { + public constructor() { + super(undefined as any); + + this.extensions = { + cryptoSetup: {} as ProvideCryptoSetupExtensions, + experimental: new (class extends ExperimentalExtensionsBase { + experimentalMethod(args?: any) { + return "test 123"; + } + })(), + }; + } + })(); + }); + + it("must not throw calling experimentalMethod without arguments", () => { + let t = () => module.extensions!.experimental!.experimentalMethod(); + expect(t).not.toThrow(); + }); + + it("must return correct custom value for experimentalMethod", () => { + let result = module.extensions!.experimental!.experimentalMethod("test 123"); + expect(result).toEqual("test 123"); + }); +});