Set up key backup using non-deprecated APIs (2nd take) (#12098)

* Ensure backup settings in playwright

* Fix verification by pass causing backup reset

* fix force backup setup by default

* fix test

* clarify when we need to bootstrap

* jslint

* post merge fix

* post rebase missing files

* fix bad merge

* update test

* Fix import

* test user forgot passkey

* better usage of locator

* fix snapshot

* remove getDialogByTitle

* Update src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* unneeded permission

* code review

* cleaning

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
pull/28788/head^2
Valere 2024-01-10 11:34:03 +01:00 committed by GitHub
parent d82c3eea4f
commit 0337bd1b0a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 246 additions and 26 deletions

View File

@ -0,0 +1,120 @@
/*
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 { type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
async function expectBackupVersionToBe(page: Page, version: string) {
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
version + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
);
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version);
}
test.describe("Backups", () => {
test.use({
displayName: "Hanako",
});
test("Create, delete and recreate a keys backup", async ({ page, user, app }, workerInfo) => {
// Create a backup
const securityTab = await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
// It's the first time and secure storage is not set up, so it will create one
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
// copy the recovery key to use it later
const securityKey = await app.getClipboard();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "1");
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Delete it
await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup"
// Create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
await currentDialogLocator.getByLabel("Security Key").fill(securityKey);
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
// Should be successful
await expect(currentDialogLocator.getByRole("heading", { name: "Success!" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "OK", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "2");
// ==
// Ensure that if you don't have the secret storage passphrase the backup won't be created
// ==
// First delete version 2
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Click "Delete Backup"
await currentDialogLocator.getByTestId("dialog-primary-button").click();
// Try to create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
// But cancel the security key dialog, to simulate not having the secret storage passphrase
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible();
// check that it failed
await expect(currentDialogLocator.getByText("Unable to create key backup")).toBeVisible();
// cancel
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
// go back to the settings to check that no backup was created (the setup button should still be there)
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("button", { name: "Set up", exact: true })).toBeVisible();
});
});

View File

@ -51,6 +51,10 @@ export class ElementAppPage {
return this.settings.closeDialog(); return this.settings.closeDialog();
} }
public async getClipboard(): Promise<string> {
return await this.page.evaluate(() => navigator.clipboard.readText());
}
/** /**
* Opens the given room by name. The room must be visible in the * 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 * room list, but the room list may be folded horizontally, and the

View File

@ -347,7 +347,7 @@ export async function accessSecretStorage(func = async (): Promise<void> => {},
} }
/** Helper for {@link #accessSecretStorage} */ /** Helper for {@link #accessSecretStorage} */
export async function doAccessSecretStorage(func = async (): Promise<void> => {}, forceReset = false): Promise<void> { async function doAccessSecretStorage(func: () => Promise<void>, forceReset: boolean): Promise<void> {
try { try {
const cli = MatrixClientPeg.safeGet(); const cli = MatrixClientPeg.safeGet();
if (!(await cli.hasSecretStorageKey()) || forceReset) { if (!(await cli.hasSecretStorageKey()) || forceReset) {
@ -378,7 +378,12 @@ export async function doAccessSecretStorage(func = async (): Promise<void> => {}
throw new Error("Secret storage creation canceled"); throw new Error("Secret storage creation canceled");
} }
} else { } 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<void> => { authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
const { finished } = Modal.createDialog(InteractiveAuthDialog, { const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"), title: _t("encryption|bootstrap_title"),
@ -391,7 +396,7 @@ export async function doAccessSecretStorage(func = async (): Promise<void> => {}
} }
}, },
}); });
await cli.bootstrapSecretStorage({ await crypto.bootstrapSecretStorage({
getKeyBackupPassphrase: promptForBackupPassphrase, getKeyBackupPassphrase: promptForBackupPassphrase,
}); });

View File

@ -17,11 +17,10 @@ limitations under the License.
import React from "react"; import React from "react";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
import { accessSecretStorage } from "../../../../SecurityManager"; import { accessSecretStorage, withSecretStorageKeyCache } from "../../../../SecurityManager";
import Spinner from "../../../../components/views/elements/Spinner"; import Spinner from "../../../../components/views/elements/Spinner";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import DialogButtons from "../../../../components/views/elements/DialogButtons"; import DialogButtons from "../../../../components/views/elements/DialogButtons";
@ -75,24 +74,36 @@ export default class CreateKeyBackupDialog extends React.PureComponent<IProps, I
this.setState({ this.setState({
error: undefined, error: undefined,
}); });
let info: IKeyBackupInfo | undefined;
const cli = MatrixClientPeg.safeGet(); const cli = MatrixClientPeg.safeGet();
try { try {
await accessSecretStorage(async (): Promise<void> => { // Check if 4S already set up
// `accessSecretStorage` will have bootstrapped secret storage if necessary, so we can now const secretStorageAlreadySetup = await cli.hasSecretStorageKey();
// set up key backup.
// if (!secretStorageAlreadySetup) {
// XXX: `bootstrapSecretStorage` also sets up key backup as a side effect, so there is a 90% chance // bootstrap secret storage; that will also create a backup version
// this is actually redundant. await accessSecretStorage(async (): Promise<void> => {
// // do nothing, all is now set up correctly
// 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); } else {
}); await withSecretStorageKeyCache(async () => {
await cli.scheduleAllGroupSessionsForBackup(); const crypto = cli.getCrypto();
if (!crypto) {
throw new Error("End-to-end encryption is disabled - unable to create backup.");
}
// Before we reset the backup, let's make sure we can access secret storage, to
// reduce the chance of us getting into a broken state where we have an outdated
// secret in secret storage.
// `SecretStorage.get` will ask the user to enter their passphrase/key if necessary;
// it will then be cached for the actual backup reset operation.
await cli.secretStorage.get("m.megolm_backup.v1");
// We now know we can store the new backup key in secret storage, so it is safe to
// go ahead with the reset.
await crypto.resetKeyBackup();
});
}
this.setState({ this.setState({
phase: Phase.Done, phase: Phase.Done,
}); });
@ -102,9 +113,6 @@ export default class CreateKeyBackupDialog extends React.PureComponent<IProps, I
// delete the version, disable backup, or do nothing? If we just // delete the version, disable backup, or do nothing? If we just
// disable without deleting, we'll enable on next app reload since // disable without deleting, we'll enable on next app reload since
// it is trusted. // it is trusted.
if (info?.version) {
cli.deleteKeyBackupVersion(info.version);
}
this.setState({ this.setState({
error: true, error: true,
}); });

View File

@ -0,0 +1,62 @@
/*
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 { mocked } from "jest-mock";
import { CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { accessSecretStorage } from "../src/SecurityManager";
import { filterConsole, stubClient } from "./test-utils";
describe("SecurityManager", () => {
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");
});
});
});
});

View File

@ -24,6 +24,7 @@ import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
jest.mock("../../../../../src/SecurityManager", () => ({ jest.mock("../../../../../src/SecurityManager", () => ({
accessSecretStorage: jest.fn().mockResolvedValue(undefined), accessSecretStorage: jest.fn().mockResolvedValue(undefined),
withSecretStorageKeyCache: jest.fn().mockImplementation((fn) => fn()),
})); }));
describe("CreateKeyBackupDialog", () => { describe("CreateKeyBackupDialog", () => {
@ -39,9 +40,12 @@ describe("CreateKeyBackupDialog", () => {
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
it("should display the error message when backup creation failed", async () => { it("should display an error message when backup creation failed", async () => {
const matrixClient = createTestClient(); const matrixClient = createTestClient();
mocked(matrixClient.scheduleAllGroupSessionsForBackup).mockRejectedValue("my error"); mocked(matrixClient.hasSecretStorageKey).mockResolvedValue(true);
mocked(matrixClient.getCrypto()!.resetKeyBackup).mockImplementation(() => {
throw new Error("failed");
});
MatrixClientPeg.safeGet = MatrixClientPeg.get = () => matrixClient; MatrixClientPeg.safeGet = MatrixClientPeg.get = () => matrixClient;
const { asFragment } = render(<CreateKeyBackupDialog onFinished={jest.fn()} />); const { asFragment } = render(<CreateKeyBackupDialog onFinished={jest.fn()} />);
@ -51,6 +55,18 @@ describe("CreateKeyBackupDialog", () => {
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
it("should display an error message when there is no Crypto available", async () => {
const matrixClient = createTestClient();
mocked(matrixClient.hasSecretStorageKey).mockResolvedValue(true);
mocked(matrixClient.getCrypto).mockReturnValue(undefined);
MatrixClientPeg.safeGet = MatrixClientPeg.get = () => matrixClient;
render(<CreateKeyBackupDialog onFinished={jest.fn()} />);
// 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 () => { it("should display the success dialog when the key backup is finished", async () => {
const onFinished = jest.fn(); const onFinished = jest.fn();
const { asFragment } = render(<CreateKeyBackupDialog onFinished={onFinished} />); const { asFragment } = render(<CreateKeyBackupDialog onFinished={onFinished} />);

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreateKeyBackupDialog should display the error message when backup creation failed 1`] = ` exports[`CreateKeyBackupDialog should display an error message when backup creation failed 1`] = `
<DocumentFragment> <DocumentFragment>
<div <div
data-focus-guard="true" data-focus-guard="true"

View File

@ -116,6 +116,10 @@ export function createTestClient(): MatrixClient {
bootstrapCrossSigning: jest.fn(), bootstrapCrossSigning: jest.fn(),
hasSecretStorageKey: jest.fn(), hasSecretStorageKey: jest.fn(),
secretStorage: {
get: jest.fn(),
},
store: { store: {
getPendingEvents: jest.fn().mockResolvedValue([]), getPendingEvents: jest.fn().mockResolvedValue([]),
setPendingEvents: jest.fn().mockResolvedValue(undefined), setPendingEvents: jest.fn().mockResolvedValue(undefined),
@ -132,6 +136,7 @@ export function createTestClient(): MatrixClient {
getUserDeviceInfo: jest.fn(), getUserDeviceInfo: jest.fn(),
getUserVerificationStatus: jest.fn(), getUserVerificationStatus: jest.fn(),
getDeviceVerificationStatus: jest.fn(), getDeviceVerificationStatus: jest.fn(),
resetKeyBackup: jest.fn(),
}), }),
getPushActionsForEvent: jest.fn(), getPushActionsForEvent: jest.fn(),