Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Set up key backup using non-deprecated APIs #12005

Merged
merged 1 commit into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions playwright/e2e/crypto/backups.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 4 additions & 0 deletions playwright/element-web-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,7 @@ export const expect = baseExpect.extend({
return { pass: true, message: () => "", name: "toMatchScreenshot" };
},
});

test.use({
permissions: ["clipboard-read"],
});
13 changes: 13 additions & 0 deletions playwright/pages/ElementAppPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ export class ElementAppPage {
return this.settings.closeDialog();
}

public async getClipboard(): Promise<string> {
return await this.page.evaluate(() => navigator.clipboard.readText());
}

/**
* Find an open dialog by its title
*/
public async getDialogByTitle(title: string, timeout = 5000): Promise<Locator> {
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
Expand Down
17 changes: 14 additions & 3 deletions src/SecurityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,13 @@ export async function promptForBackupPassphrase(): Promise<Uint8Array> {
* @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<void> => {}, forceReset = false): Promise<void> {
export async function accessSecretStorage(
func = async (): Promise<void> => {},
forceReset = false,
setupNewKeyBackup = true,
): Promise<void> {
secretStorageBeingAccessed = true;
try {
const cli = MatrixClientPeg.safeGet();
Expand Down Expand Up @@ -352,7 +357,12 @@ export async function accessSecretStorage(func = async (): Promise<void> => {},
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<void> => {
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
Expand All @@ -365,8 +375,9 @@ export async function accessSecretStorage(func = async (): Promise<void> => {},
}
},
});
await cli.bootstrapSecretStorage({
await crypto.bootstrapSecretStorage({
getKeyBackupPassphrase: promptForBackupPassphrase,
setupNewKeyBackup,
});

const keyId = Object.keys(secretStorageKeys)[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -75,24 +74,25 @@ export default class CreateKeyBackupDialog extends React.PureComponent<IProps, I
this.setState({
error: undefined,
});
let info: IKeyBackupInfo | undefined;
const cli = MatrixClientPeg.safeGet();
try {
await accessSecretStorage(async (): Promise<void> => {
// `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<void> => {
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,
});
Expand All @@ -102,9 +102,6 @@ export default class CreateKeyBackupDialog extends React.PureComponent<IProps, I
// delete the version, disable backup, or do nothing? If we just
// disable without deleting, we'll enable on next app reload since
// it is trusted.
if (info?.version) {
cli.deleteKeyBackupVersion(info.version);
}
this.setState({
error: true,
});
Expand Down
62 changes: 62 additions & 0 deletions test/SecurityManager-test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>) => {
await func();
},
}));

describe("CreateKeyBackupDialog", () => {
Expand All @@ -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(<CreateKeyBackupDialog onFinished={jest.fn()} />);
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(<CreateKeyBackupDialog onFinished={jest.fn()} />);

// 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(<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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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`] = `
<DocumentFragment>
<div
data-focus-guard="true"
Expand Down
1 change: 1 addition & 0 deletions test/test-utils/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export function createTestClient(): MatrixClient {
getUserDeviceInfo: jest.fn(),
getUserVerificationStatus: jest.fn(),
getDeviceVerificationStatus: jest.fn(),
resetKeyBackup: jest.fn(),
}),

getPushActionsForEvent: jest.fn(),
Expand Down
Loading