diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 1be7fd0cab..b954854c81 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -1,6 +1,6 @@ /* Copyright 2018, 2019 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020, 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. @@ -18,12 +18,11 @@ limitations under the License. import React, { createRef } from "react"; import FileSaver from "file-saver"; import { logger } from "matrix-js-sdk/src/logger"; -import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; -import { TrustInfo } from "matrix-js-sdk/src/crypto/backup"; -import { CrossSigningKeys, IAuthDict, MatrixError, UIAFlow, UIAResponse } from "matrix-js-sdk/src/matrix"; +import { AuthDict, CrossSigningKeys, MatrixError, UIAFlow, UIAResponse } from "matrix-js-sdk/src/matrix"; import { IRecoveryKey } from "matrix-js-sdk/src/crypto/api"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import classNames from "classnames"; +import { BackupTrustInfo, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { _t, _td } from "../../../../languageHandler"; @@ -81,8 +80,25 @@ interface IState { copied: boolean; downloaded: boolean; setPassphrase: boolean; - backupInfo: IKeyBackupInfo | null; - backupSigStatus: TrustInfo | null; + + /** Information on the current key backup version, as returned by the server. + * + * `null` could mean any of: + * * we haven't yet requested the data from the server. + * * we were unable to reach the server. + * * the server returned key backup version data we didn't understand or was malformed. + * * there is actually no backup on the server. + */ + backupInfo: KeyBackupInfo | null; + + /** + * Information on whether the backup in `backupInfo` is correctly signed, and whether we have the right key to + * decrypt it. + * + * `undefined` if `backupInfo` is null, or if crypto is not enabled in the client. + */ + backupTrustInfo: BackupTrustInfo | undefined; + // does the server offer a UI auth flow with just m.login.password // for /keys/device_signing/upload? canUploadKeysWithPasswordOnly: boolean | null; @@ -144,7 +160,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { + /** + * Attempt to get information on the current backup from the server, and update the state. + * + * Updates {@link IState.backupInfo} and {@link IState.backupTrustInfo}, and picks an appropriate phase for + * {@link IState.phase}. + * + * @returns If the backup data was retrieved successfully, the trust info for the backup. Otherwise, undefined. + */ + private async fetchBackupInfo(): Promise { try { const cli = MatrixClientPeg.safeGet(); const backupInfo = await cli.getKeyBackupVersion(); - const backupSigStatus = + const backupTrustInfo = // we may not have started crypto yet, in which case we definitely don't trust the backup - backupInfo && cli.isCryptoEnabled() ? await cli.isKeyBackupTrusted(backupInfo) : null; + backupInfo ? await cli.getCrypto()?.isKeyBackupTrusted(backupInfo) : undefined; const { forceReset } = this.props; const phase = backupInfo && !forceReset ? Phase.Migrate : Phase.ChooseKeyPassphrase; @@ -191,16 +215,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent => { if (this.state.passPhraseKeySelected === SecureBackupSetupMethod.Key) { - this.recoveryKey = await MatrixClientPeg.safeGet().createRecoveryKeyFromPassphrase(); + this.recoveryKey = await MatrixClientPeg.safeGet().getCrypto()!.createRecoveryKeyFromPassphrase(); this.setState({ copied: false, downloaded: false, @@ -255,7 +277,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { e.preventDefault(); - if (this.state.backupSigStatus?.usable) { + if (this.state.backupTrustInfo?.trusted) { this.bootstrapSecretStorage(); } else { this.restoreBackup(); @@ -284,7 +306,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent Promise>, + makeRequest: (authData: AuthDict) => Promise>, ): Promise => { if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { await makeRequest({ @@ -337,13 +359,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent this.recoveryKey!, setupNewKeyBackup: true, setupNewSecretStorage: true, @@ -356,10 +379,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent this.recoveryKey!, keyBackupInfo: this.state.backupInfo!, setupNewKeyBackup: !this.state.backupInfo, @@ -420,8 +443,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent{_t("Enter your account password to confirm the upgrade:")}
); - } else if (!this.state.backupSigStatus?.usable) { + } else if (!this.state.backupTrustInfo?.trusted) { authPrompt = (
{_t("Restore your key backup to upgrade your encryption")}
diff --git a/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx b/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx new file mode 100644 index 0000000000..fac786b3c2 --- /dev/null +++ b/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx @@ -0,0 +1,224 @@ +/* +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 { render, RenderResult, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { mocked, MockedObject } from "jest-mock"; +import { CryptoApi, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; +import { defer, IDeferred, sleep } from "matrix-js-sdk/src/utils"; +import { BackupTrustInfo, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; + +import { + filterConsole, + flushPromises, + getMockClientWithEventEmitter, + mockClientMethodsCrypto, + mockClientMethodsServer, +} from "../../../../test-utils"; +import CreateSecretStorageDialog from "../../../../../src/async-components/views/dialogs/security/CreateSecretStorageDialog"; +import Modal from "../../../../../src/Modal"; +import RestoreKeyBackupDialog from "../../../../../src/components/views/dialogs/security/RestoreKeyBackupDialog"; + +describe("CreateSecretStorageDialog", () => { + let mockClient: MockedObject; + let mockCrypto: MockedObject; + + beforeEach(() => { + mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsServer(), + ...mockClientMethodsCrypto(), + uploadDeviceSigningKeys: jest.fn().mockImplementation(async () => { + await sleep(0); // CreateSecretStorageDialog doesn't expect this to resolve immediately + throw new MatrixError({ flows: [] }); + }), + }); + + mockCrypto = mocked(mockClient.getCrypto()!); + Object.assign(mockCrypto, { + isKeyBackupTrusted: jest.fn(), + bootstrapCrossSigning: jest.fn(), + bootstrapSecretStorage: jest.fn(), + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function renderComponent( + props: Partial> = {}, + ): RenderResult { + const onFinished = jest.fn(); + return render(); + } + + it("shows a loading spinner initially", async () => { + const { container } = renderComponent(); + expect(screen.getByTestId("spinner")).toBeDefined(); + expect(container).toMatchSnapshot(); + await flushPromises(); + }); + + describe("when there is an error fetching the backup version", () => { + filterConsole("Error fetching backup data from server"); + + it("shows an error", async () => { + mockClient.getKeyBackupVersion.mockImplementation(async () => { + throw new Error("bleh bleh"); + }); + + const result = renderComponent(); + // XXX the error message is... misleading. + await result.findByText("Unable to query secret storage status"); + expect(result.container).toMatchSnapshot(); + }); + }); + + it("shows 'Generate a Security Key' text if no key backup is present", async () => { + const result = renderComponent(); + await flushPromises(); + expect(result.container).toMatchSnapshot(); + result.getByText("Generate a Security Key"); + }); + + describe("when canUploadKeysWithPasswordOnly", () => { + // spy on Modal.createDialog + let modalSpy: jest.SpyInstance; + + // deferred which should be resolved to indicate that the created dialog has completed + let restoreDialogFinishedDefer: IDeferred<[done?: boolean]>; + + beforeEach(() => { + mockClient.getKeyBackupVersion.mockResolvedValue({} as KeyBackupInfo); + mockClient.uploadDeviceSigningKeys.mockImplementation(async () => { + await sleep(0); + throw new MatrixError({ + flows: [{ stages: ["m.login.password"] }], + }); + }); + + restoreDialogFinishedDefer = defer<[done?: boolean]>(); + modalSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ + finished: restoreDialogFinishedDefer.promise, + close: jest.fn(), + }); + }); + + it("prompts for a password and then shows RestoreKeyBackupDialog", async () => { + const result = renderComponent(); + await result.findByText(/Enter your account password to confirm the upgrade/); + expect(result.container).toMatchSnapshot(); + + await userEvent.type(result.getByPlaceholderText("Password"), "my pass"); + result.getByRole("button", { name: "Next" }).click(); + + expect(modalSpy).toHaveBeenCalledWith( + RestoreKeyBackupDialog, + { + keyCallback: expect.any(Function), + showSummary: false, + }, + undefined, + false, + false, + ); + + restoreDialogFinishedDefer.resolve([]); + }); + + it("calls bootstrapSecretStorage once keys are restored if the backup is now trusted", async () => { + mockClient.isCryptoEnabled.mockReturnValue(true); + + const result = renderComponent(); + await result.findByText(/Enter your account password to confirm the upgrade/); + expect(result.container).toMatchSnapshot(); + + await userEvent.type(result.getByPlaceholderText("Password"), "my pass"); + result.getByRole("button", { name: "Next" }).click(); + + expect(modalSpy).toHaveBeenCalled(); + + // While we restore the key backup, its signature becomes accepted + mockCrypto.isKeyBackupTrusted.mockResolvedValue({ trusted: true } as BackupTrustInfo); + + restoreDialogFinishedDefer.resolve([]); + await flushPromises(); + + // XXX no idea why this is a sensible thing to do. I just work here. + expect(mockCrypto.bootstrapCrossSigning).toHaveBeenCalled(); + expect(mockCrypto.bootstrapSecretStorage).toHaveBeenCalled(); + + await result.findByText("Your keys are now being backed up from this device."); + }); + + describe("when there is an error fetching the backup version after RestoreKeyBackupDialog", () => { + filterConsole("Error fetching backup data from server"); + + it("handles the error sensibly", async () => { + const result = renderComponent(); + await result.findByText(/Enter your account password to confirm the upgrade/); + expect(result.container).toMatchSnapshot(); + + await userEvent.type(result.getByPlaceholderText("Password"), "my pass"); + result.getByRole("button", { name: "Next" }).click(); + + expect(modalSpy).toHaveBeenCalled(); + + mockClient.getKeyBackupVersion.mockImplementation(async () => { + throw new Error("bleh bleh"); + }); + restoreDialogFinishedDefer.resolve([]); + await result.findByText("Unable to query secret storage status"); + }); + }); + }); + + describe("when backup is present but not trusted", () => { + beforeEach(() => { + mockClient.getKeyBackupVersion.mockResolvedValue({} as KeyBackupInfo); + }); + + it("shows migrate text, then 'RestoreKeyBackupDialog' if 'Restore' is clicked", async () => { + const result = renderComponent(); + await result.findByText("Restore your key backup to upgrade your encryption"); + expect(result.container).toMatchSnapshot(); + + // before we click "Restore", set up a spy on createDialog + const restoreDialogFinishedDefer = defer<[done?: boolean]>(); + const modalSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ + finished: restoreDialogFinishedDefer.promise, + close: jest.fn(), + }); + + result.getByRole("button", { name: "Restore" }).click(); + + expect(modalSpy).toHaveBeenCalledWith( + RestoreKeyBackupDialog, + { + keyCallback: expect.any(Function), + showSummary: false, + }, + undefined, + false, + false, + ); + + // simulate RestoreKeyBackupDialog completing, to run that code path + restoreDialogFinishedDefer.resolve([]); + }); + }); +}); diff --git a/test/components/views/dialogs/security/__snapshots__/CreateSecretStorageDialog-test.tsx.snap b/test/components/views/dialogs/security/__snapshots__/CreateSecretStorageDialog-test.tsx.snap new file mode 100644 index 0000000000..2b3d041284 --- /dev/null +++ b/test/components/views/dialogs/security/__snapshots__/CreateSecretStorageDialog-test.tsx.snap @@ -0,0 +1,551 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreateSecretStorageDialog shows 'Generate a Security Key' text if no key backup is present 1`] = ` +
+
+