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 (2nd take) #12098

Merged
merged 24 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c263b17
Ensure backup settings in playwright
BillCarsonFr Jan 2, 2024
c0cef0b
Fix verification by pass causing backup reset
BillCarsonFr Jan 2, 2024
1bc286c
fix force backup setup by default
BillCarsonFr Jan 3, 2024
70091ad
fix test
BillCarsonFr Jan 3, 2024
795e214
clarify when we need to bootstrap
BillCarsonFr Jan 3, 2024
d5a227c
jslint
BillCarsonFr Jan 3, 2024
befa0f9
Merge branch 'develop' into valere/fix_reset_backup_after_verif
BillCarsonFr Jan 4, 2024
3a1305e
post merge fix
BillCarsonFr Jan 4, 2024
8c087e0
post rebase missing files
BillCarsonFr Jan 4, 2024
602d2b2
fix bad merge
BillCarsonFr Jan 5, 2024
45c9f25
update test
BillCarsonFr Jan 5, 2024
5e5d5ef
Fix import
BillCarsonFr Jan 5, 2024
043f728
Merge branch 'develop' into valere/fix_reset_backup_after_verif
BillCarsonFr Jan 5, 2024
08da26f
test user forgot passkey
BillCarsonFr Jan 5, 2024
6fe0940
better usage of locator
BillCarsonFr Jan 5, 2024
a726c5d
fix snapshot
BillCarsonFr Jan 8, 2024
9abb2d9
Merge branch 'develop' into valere/fix_reset_backup_after_verif
BillCarsonFr Jan 8, 2024
71f00af
remove getDialogByTitle
BillCarsonFr Jan 9, 2024
5114eb4
Update src/async-components/views/dialogs/security/CreateKeyBackupDia…
BillCarsonFr Jan 9, 2024
f1e89cc
unneeded permission
BillCarsonFr Jan 9, 2024
9b2a902
Merge branch 'develop' into valere/fix_reset_backup_after_verif
BillCarsonFr Jan 9, 2024
0b82647
code review
BillCarsonFr Jan 9, 2024
2ae60bc
cleaning
BillCarsonFr Jan 9, 2024
5c9494e
Merge branch 'develop' into valere/fix_reset_backup_after_verif
BillCarsonFr Jan 9, 2024
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
86 changes: 86 additions & 0 deletions playwright/e2e/crypto/backups.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
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 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();

// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(tab.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");

// Delete it
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();

// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(tab.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");

await page.pause();
});
});
4 changes: 4 additions & 0 deletions playwright/element-web-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,7 @@ export const expect = baseExpect.extend({
return { pass: true, message: () => "", name: "toMatchScreenshot" };
},
});

test.use({
permissions: ["clipboard-read"],
});
BillCarsonFr marked this conversation as resolved.
Show resolved Hide resolved
13 changes: 13 additions & 0 deletions playwright/pages/ElementAppPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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
11 changes: 8 additions & 3 deletions src/SecurityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ export async function accessSecretStorage(func = async (): Promise<void> => {},
}

/** 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 {
const cli = MatrixClientPeg.safeGet();
if (!(await cli.hasSecretStorageKey()) || forceReset) {
Expand Down Expand Up @@ -378,7 +378,12 @@ export async function doAccessSecretStorage(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 @@ -391,7 +396,7 @@ export async function doAccessSecretStorage(func = async (): Promise<void> => {}
}
},
});
await cli.bootstrapSecretStorage({
await crypto.bootstrapSecretStorage({
getKeyBackupPassphrase: promptForBackupPassphrase,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,10 @@ 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";
import { accessSecretStorage } from "../../../../SecurityManager";
import { accessSecretStorage, withSecretStorageKeyCache } from "../../../../SecurityManager";
import Spinner from "../../../../components/views/elements/Spinner";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
Expand Down Expand Up @@ -75,24 +74,30 @@ 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,
// Check if 4S already set up
const secretStorageAlreadySetup = await cli.hasSecretStorageKey();
BillCarsonFr marked this conversation as resolved.
Show resolved Hide resolved

if (!secretStorageAlreadySetup) {
// bootstrap secret storage, that will also create a backup version
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Suggested change
// bootstrap secret storage, that will also create a backup version
// bootstrap secret storage; that will also create a backup version

await accessSecretStorage(async (): Promise<void> => {
// do nothing, all is now setup correctly
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Suggested change
// do nothing, all is now setup correctly
// do nothing, all is now set up correctly

});
info = await cli.createKeyBackupVersion(info);
});
await cli.scheduleAllGroupSessionsForBackup();
} else {
// Secret storage exists, we need to ensure that we can write to it before
// we create a new backup version. It ensures that we can write to it and keep it in sync.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, only just seeing this now. It rather duplicates the comment below.

Suggested change
// Secret storage exists, we need to ensure that we can write to it before
// we create a new backup version. It ensures that we can write to it and keep it in sync.

await withSecretStorageKeyCache(async () => {
// this is the secret that will need to be updated, ensure that we can access it.
// This will ask the user to enter their passphrase/key.
await cli.secretStorage.get("m.megolm_backup.v1");
// if we get here, we can access the secret storage, so we can create a backup version.
// We don't reset if we can't access the secret storage, as the secret storage will then
// have an outdated secret for backup that future sessions will not be able to use.
await cli.getCrypto()?.resetKeyBackup();
});
}

this.setState({
phase: Phase.Done,
});
Expand All @@ -102,9 +107,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 @@ -39,9 +39,12 @@ describe("CreateKeyBackupDialog", () => {
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();
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;

const { asFragment } = render(<CreateKeyBackupDialog onFinished={jest.fn()} />);
Expand All @@ -51,6 +54,18 @@ describe("CreateKeyBackupDialog", () => {
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 () => {
const onFinished = jest.fn();
const { asFragment } = render(<CreateKeyBackupDialog onFinished={onFinished} />);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,65 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`CreateKeyBackupDialog should display an error message when backup creation failed 1`] = `
<DocumentFragment>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_CreateKeyBackupDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header"
>
<h2
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Starting backup…
</h2>
</div>
<div>
<div>
<p>
Unable to create key backup
</p>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-testid="dialog-cancel-button"
type="button"
>
Cancel
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
type="button"
>
Retry
</button>
</span>
</div>
</div>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</DocumentFragment>
`;

exports[`CreateKeyBackupDialog should display the error message when backup creation failed 1`] = `
<DocumentFragment>
<div
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