/* Copyright 2024 New Vector Ltd. Copyright 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { mocked, Mocked } from "jest-mock"; import { MatrixClient, Device } from "matrix-js-sdk/src/matrix"; import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorage } from "matrix-js-sdk/src/secret-storage"; import { BootstrapCrossSigningOpts, CryptoApi, DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import { SdkContextClass } from "../../../src/contexts/SDKContext"; import { accessSecretStorage } from "../../../src/SecurityManager"; import { SetupEncryptionStore } from "../../../src/stores/SetupEncryptionStore"; import { emitPromise, stubClient } from "../../test-utils"; jest.mock("../../../src/SecurityManager", () => ({ accessSecretStorage: jest.fn(), })); describe("SetupEncryptionStore", () => { const cachedPassword = "p4assword"; let client: Mocked; let mockCrypto: Mocked; let mockSecretStorage: Mocked; let setupEncryptionStore: SetupEncryptionStore; beforeEach(() => { client = mocked(stubClient()); mockCrypto = { bootstrapCrossSigning: jest.fn(), getCrossSigningKeyId: jest.fn(), getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]), getUserDeviceInfo: jest.fn(), getDeviceVerificationStatus: jest.fn(), isDehydrationSupported: jest.fn().mockResolvedValue(false), startDehydration: jest.fn(), } as unknown as Mocked; client.getCrypto.mockReturnValue(mockCrypto); mockSecretStorage = { isStored: jest.fn(), } as unknown as Mocked; Object.defineProperty(client, "secretStorage", { value: mockSecretStorage }); setupEncryptionStore = new SetupEncryptionStore(); SdkContextClass.instance.accountPasswordStore.setPassword(cachedPassword); }); afterEach(() => { SdkContextClass.instance.accountPasswordStore.clearPassword(); }); describe("start", () => { it("should fetch cross-signing and device info", async () => { const fakeKey = {} as SecretStorageKeyDescriptionAesV1; mockSecretStorage.isStored.mockResolvedValue({ sskeyid: fakeKey }); const fakeDevice = new Device({ deviceId: "deviceId", userId: "", algorithms: [], keys: new Map() }); mockCrypto.getUserDeviceInfo.mockResolvedValue( new Map([[client.getSafeUserId(), new Map([[fakeDevice.deviceId, fakeDevice]])]]), ); setupEncryptionStore.start(); await emitPromise(setupEncryptionStore, "update"); // our fake device is not signed, so we can't verify against it expect(setupEncryptionStore.hasDevicesToVerifyAgainst).toBe(false); expect(setupEncryptionStore.keyId).toEqual("sskeyid"); expect(setupEncryptionStore.keyInfo).toBe(fakeKey); }); it("should spot a signed device", async () => { mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 }); const fakeDevice = new Device({ deviceId: "deviceId", userId: "", algorithms: [], keys: new Map([["curve25519:deviceId", "identityKey"]]), }); mockCrypto.getUserDeviceInfo.mockResolvedValue( new Map([[client.getSafeUserId(), new Map([[fakeDevice.deviceId, fakeDevice]])]]), ); mockCrypto.getDeviceVerificationStatus.mockResolvedValue( new DeviceVerificationStatus({ signedByOwner: true }), ); setupEncryptionStore.start(); await emitPromise(setupEncryptionStore, "update"); expect(setupEncryptionStore.hasDevicesToVerifyAgainst).toBe(true); }); it("should ignore the MSC3812 dehydrated device", async () => { mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 }); const fakeDevice = new Device({ deviceId: "dehydrated", userId: "", algorithms: [], keys: new Map([["curve25519:dehydrated", "identityKey"]]), dehydrated: true, }); mockCrypto.getUserDeviceInfo.mockResolvedValue( new Map([[client.getSafeUserId(), new Map([[fakeDevice.deviceId, fakeDevice]])]]), ); setupEncryptionStore.start(); await emitPromise(setupEncryptionStore, "update"); expect(setupEncryptionStore.hasDevicesToVerifyAgainst).toBe(false); expect(mockCrypto.getDeviceVerificationStatus).not.toHaveBeenCalled(); }); it("should correctly handle getUserDeviceInfo() returning an empty map", async () => { mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 }); mockCrypto.getUserDeviceInfo.mockResolvedValue(new Map()); setupEncryptionStore.start(); await emitPromise(setupEncryptionStore, "update"); expect(setupEncryptionStore.hasDevicesToVerifyAgainst).toBe(false); }); }); describe("usePassPhrase", () => { it("should use dehydration when enabled", async () => { // mocks for cross-signing and secret storage mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 }); mockCrypto.getUserDeviceInfo.mockResolvedValue(new Map()); mockCrypto.getDeviceVerificationStatus.mockResolvedValue( new DeviceVerificationStatus({ signedByOwner: true }), ); mocked(accessSecretStorage).mockImplementation(async (func?: () => Promise) => { await func!(); }); // mocks for dehydration mockCrypto.isDehydrationSupported.mockResolvedValue(true); const dehydrationPromise = new Promise((resolve) => { // Dehydration gets processed in the background, after // `usePassPhrase` returns, so we need to use a promise to make // sure that it is called. mockCrypto.startDehydration.mockImplementation(async () => { resolve(); }); }); client.waitForClientWellKnown.mockResolvedValue({ "org.matrix.msc3814": true }); setupEncryptionStore.start(); await emitPromise(setupEncryptionStore, "update"); await setupEncryptionStore.usePassPhrase(); await dehydrationPromise; }); }); it("resetConfirm should work with a cached account password", async () => { const makeRequest = jest.fn(); mockCrypto.bootstrapCrossSigning.mockImplementation(async (opts: BootstrapCrossSigningOpts) => { await opts?.authUploadDeviceSigningKeys?.(makeRequest); }); mocked(accessSecretStorage).mockImplementation(async (func?: () => Promise) => { await func!(); }); await setupEncryptionStore.resetConfirm(); expect(mocked(accessSecretStorage)).toHaveBeenCalledWith(expect.any(Function), { accountPassword: cachedPassword, forceReset: true, resetCrossSigning: true, }); }); });