diff --git a/playwright/e2e/crypto/backups.spec.ts b/playwright/e2e/crypto/backups.spec.ts new file mode 100644 index 0000000000..847784bff2 --- /dev/null +++ b/playwright/e2e/crypto/backups.spec.ts @@ -0,0 +1,57 @@ +/* +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 { test, expect } from "../../element-web-test"; + +test.describe("Backups", () => { + test.use({ + displayName: "Hanako", + }); + + test("Create, delete and recreate a keys backup", async ({ page, user, app }, workerInfo) => { + // skipIfLegacyCrypto + test.skip( + workerInfo.project.name === "Legacy Crypto", + "This test only works with Rust crypto. Deleting the backup seems to fail with legacy crypto.", + ); + + // Create a backup + const tab = await app.settings.openUserSettings("Security & Privacy"); + await expect(tab.getByRole("heading", { name: "Secure Backup" })).toBeVisible(); + await tab.getByRole("button", { name: "Set up", exact: true }).click(); + const dialog = await app.getDialogByTitle("Set up Secure Backup", 60000); + await dialog.getByRole("button", { name: "Continue", exact: true }).click(); + await expect(dialog.getByRole("heading", { name: "Save your Security Key" })).toBeVisible(); + await dialog.getByRole("button", { name: "Copy", exact: true }).click(); + const securityKey = await app.getClipboard(); + await dialog.getByRole("button", { name: "Continue", exact: true }).click(); + await expect(dialog.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible(); + await dialog.getByRole("button", { name: "Done", exact: true }).click(); + + // Delete it + await app.settings.openUserSettings("Security & Privacy"); + await expect(tab.getByRole("heading", { name: "Secure Backup" })).toBeVisible(); + await tab.getByRole("button", { name: "Delete Backup", exact: true }).click(); + await dialog.getByTestId("dialog-primary-button").click(); // Click "Delete Backup" + + // Create another + await tab.getByRole("button", { name: "Set up", exact: true }).click(); + dialog.getByLabel("Security Key").fill(securityKey); + await dialog.getByRole("button", { name: "Continue", exact: true }).click(); + await expect(dialog.getByRole("heading", { name: "Success!" })).toBeVisible(); + await dialog.getByRole("button", { name: "OK", exact: true }).click(); + }); +}); diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index a551ec593d..e2e1729a4b 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -238,3 +238,7 @@ export const expect = baseExpect.extend({ return { pass: true, message: () => "", name: "toMatchScreenshot" }; }, }); + +test.use({ + permissions: ["clipboard-read"], +}); diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index 0d8a5213fc..8d5b43f1d8 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -50,6 +50,19 @@ export class ElementAppPage { return this.settings.closeDialog(); } + public async getClipboard(): Promise { + return await this.page.evaluate(() => navigator.clipboard.readText()); + } + + /** + * Find an open dialog by its title + */ + public async getDialogByTitle(title: string, timeout = 5000): Promise { + const dialog = this.page.locator(".mx_Dialog"); + await dialog.getByRole("heading", { name: title }).waitFor({ timeout }); + return dialog; + } + /** * Opens the given room by name. The room must be visible in the * room list, but the room list may be folded horizontally, and the diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 10848e7afb..056be7a2a9 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -319,8 +319,13 @@ export async function promptForBackupPassphrase(): Promise { * @param {Function} [func] An operation to perform once secret storage has been * bootstrapped. Optional. * @param {bool} [forceReset] Reset secret storage even if it's already set up + * @param {bool} [setupNewKeyBackup] Reset secret storage even if it's already set up */ -export async function accessSecretStorage(func = async (): Promise => {}, forceReset = false): Promise { +export async function accessSecretStorage( + func = async (): Promise => {}, + forceReset = false, + setupNewKeyBackup = true, +): Promise { secretStorageBeingAccessed = true; try { const cli = MatrixClientPeg.safeGet(); @@ -352,7 +357,12 @@ export async function accessSecretStorage(func = async (): Promise => {}, throw new Error("Secret storage creation canceled"); } } else { - await cli.bootstrapCrossSigning({ + const crypto = cli.getCrypto(); + if (!crypto) { + throw new Error("End-to-end encryption is disabled - unable to access secret storage."); + } + + await crypto.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async (makeRequest): Promise => { const { finished } = Modal.createDialog(InteractiveAuthDialog, { title: _t("encryption|bootstrap_title"), @@ -365,8 +375,9 @@ export async function accessSecretStorage(func = async (): Promise => {}, } }, }); - await cli.bootstrapSecretStorage({ + await crypto.bootstrapSecretStorage({ getKeyBackupPassphrase: promptForBackupPassphrase, + setupNewKeyBackup, }); const keyId = Object.keys(secretStorageKeys)[0]; diff --git a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx index 453a9dc84c..5a7e317dd2 100644 --- a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx @@ -17,7 +17,6 @@ limitations under the License. import React from "react"; import { logger } from "matrix-js-sdk/src/logger"; -import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { _t } from "../../../../languageHandler"; @@ -75,24 +74,25 @@ export default class CreateKeyBackupDialog extends React.PureComponent => { - // `accessSecretStorage` will have bootstrapped secret storage if necessary, so we can now - // set up key backup. - // - // XXX: `bootstrapSecretStorage` also sets up key backup as a side effect, so there is a 90% chance - // this is actually redundant. - // - // The only time it would *not* be redundant would be if, for some reason, we had working 4S but no - // working key backup. (For example, if the user clicked "Delete Backup".) - info = await cli.prepareKeyBackupVersion(null /* random key */, { - secureSecretStorage: true, - }); - info = await cli.createKeyBackupVersion(info); - }); - await cli.scheduleAllGroupSessionsForBackup(); + // We don't want accessSecretStorage to create a backup for us - we + // will create one ourselves in the closure we pass in by calling + // resetKeyBackup. + const setupNewKeyBackup = false; + const forceReset = false; + + await accessSecretStorage( + async (): Promise => { + const crypto = cli.getCrypto(); + if (!crypto) { + throw new Error("End-to-end encryption is disabled - unable to create backup."); + } + await crypto.resetKeyBackup(); + }, + forceReset, + setupNewKeyBackup, + ); this.setState({ phase: Phase.Done, }); @@ -102,9 +102,6 @@ export default class CreateKeyBackupDialog extends React.PureComponent { + describe("accessSecretStorage", () => { + filterConsole("Not setting dehydration key: no SSSS key found"); + + it("runs the function passed in", async () => { + // Given a client + const crypto = { + bootstrapCrossSigning: () => {}, + bootstrapSecretStorage: () => {}, + } as unknown as CryptoApi; + const client = stubClient(); + mocked(client.hasSecretStorageKey).mockResolvedValue(true); + mocked(client.getCrypto).mockReturnValue(crypto); + + // When I run accessSecretStorage + const func = jest.fn(); + await accessSecretStorage(func); + + // Then we call the passed-in function + expect(func).toHaveBeenCalledTimes(1); + }); + + describe("expecting errors", () => { + filterConsole("End-to-end encryption is disabled - unable to access secret storage"); + + it("throws if crypto is unavailable", async () => { + // Given a client with no crypto + const client = stubClient(); + mocked(client.hasSecretStorageKey).mockResolvedValue(true); + mocked(client.getCrypto).mockReturnValue(undefined); + + // When I run accessSecretStorage + // Then we throw an error + await expect(async () => { + await accessSecretStorage(jest.fn()); + }).rejects.toThrow("End-to-end encryption is disabled - unable to access secret storage"); + }); + }); + }); +}); diff --git a/test/components/views/dialogs/security/CreateKeyBackupDialog-test.tsx b/test/components/views/dialogs/security/CreateKeyBackupDialog-test.tsx index b0d461b8f8..faa466e3f5 100644 --- a/test/components/views/dialogs/security/CreateKeyBackupDialog-test.tsx +++ b/test/components/views/dialogs/security/CreateKeyBackupDialog-test.tsx @@ -19,11 +19,13 @@ import React from "react"; import { mocked } from "jest-mock"; import CreateKeyBackupDialog from "../../../../../src/async-components/views/dialogs/security/CreateKeyBackupDialog"; -import { createTestClient } from "../../../../test-utils"; +import { createTestClient, filterConsole } from "../../../../test-utils"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; jest.mock("../../../../../src/SecurityManager", () => ({ - accessSecretStorage: jest.fn().mockResolvedValue(undefined), + accessSecretStorage: async (func = async () => Promise) => { + await func(); + }, })); describe("CreateKeyBackupDialog", () => { @@ -39,16 +41,33 @@ describe("CreateKeyBackupDialog", () => { expect(asFragment()).toMatchSnapshot(); }); - it("should display the error message when backup creation failed", async () => { - const matrixClient = createTestClient(); - mocked(matrixClient.scheduleAllGroupSessionsForBackup).mockRejectedValue("my error"); - MatrixClientPeg.safeGet = MatrixClientPeg.get = () => matrixClient; + describe("expecting failure", () => { + filterConsole("Error creating key backup"); - const { asFragment } = render(); + it("should display an error message when backup creation failed", async () => { + const matrixClient = createTestClient(); + mocked(matrixClient.getCrypto()!.resetKeyBackup).mockImplementation(() => { + throw new Error("failed"); + }); + MatrixClientPeg.safeGet = MatrixClientPeg.get = () => matrixClient; - // Check if the error message is displayed - await waitFor(() => expect(screen.getByText("Unable to create key backup")).toBeDefined()); - expect(asFragment()).toMatchSnapshot(); + const { asFragment } = render(); + + // Check if the error message is displayed + await waitFor(() => expect(screen.getByText("Unable to create key backup")).toBeDefined()); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should display an error message when there is no Crypto available", async () => { + const matrixClient = createTestClient(); + mocked(matrixClient.getCrypto).mockReturnValue(undefined); + MatrixClientPeg.safeGet = MatrixClientPeg.get = () => matrixClient; + + render(); + + // Check if the error message is displayed + await waitFor(() => expect(screen.getByText("Unable to create key backup")).toBeDefined()); + }); }); it("should display the success dialog when the key backup is finished", async () => { diff --git a/test/components/views/dialogs/security/__snapshots__/CreateKeyBackupDialog-test.tsx.snap b/test/components/views/dialogs/security/__snapshots__/CreateKeyBackupDialog-test.tsx.snap index 9d62384e3c..dd82b840f1 100644 --- a/test/components/views/dialogs/security/__snapshots__/CreateKeyBackupDialog-test.tsx.snap +++ b/test/components/views/dialogs/security/__snapshots__/CreateKeyBackupDialog-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`CreateKeyBackupDialog should display the error message when backup creation failed 1`] = ` +exports[`CreateKeyBackupDialog expecting failure should display an error message when backup creation failed 1`] = `