Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Recovery section in the new user settings Encryption tab #28673

Open
wants to merge 40 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3e77b3d
Refine `SettingsSection` & `SettingsTab`
florianduros Dec 4, 2024
ae623f8
Add encryption tab
florianduros Dec 4, 2024
f9e48b4
Add recovery section
florianduros Dec 6, 2024
0057f57
Add device verification
florianduros Dec 12, 2024
bb507b0
Rename `Panel` into `State`
florianduros Dec 13, 2024
1aace3f
Update & add tests to user settings common
florianduros Dec 13, 2024
70c084e
Add tests to `RecoveryPanel`
florianduros Dec 18, 2024
7193998
Add tests to `ChangeRecoveryKey`
florianduros Dec 18, 2024
fec324e
Update CreateSecretStorageDialog-test snapshot
florianduros Dec 16, 2024
44c6bce
Add tests to `EncryptionUserSettingsTab`
florianduros Dec 16, 2024
075f6dc
Update existing screenshots of e2e tests
florianduros Dec 16, 2024
895ad88
Add new encryption tab ownership to `@element-hq/element-crypto-web-r…
florianduros Dec 17, 2024
ba032a7
Add e2e tests
florianduros Dec 18, 2024
7909ac9
Fix monospace font and add figma link to hardcoded value
florianduros Dec 19, 2024
618557c
Add unit to Icon
florianduros Dec 19, 2024
c805cd8
Merge branch 'develop' into florianduros/encryption-tab
florianduros Dec 19, 2024
7a372f7
Merge branch 'develop' into florianduros/encryption-tab
florianduros Dec 23, 2024
b20579d
Improve e2e doc
florianduros Dec 23, 2024
24c537c
Assert that the crypto module is defined
florianduros Jan 6, 2025
72adfa5
Add classname doc
florianduros Jan 6, 2025
36c7e0e
Fix typo
florianduros Jan 6, 2025
52076f1
Use `good` state instead of default
florianduros Jan 6, 2025
a0d904e
Rename `ChangeRecoveryKey.isSetupFlow` into `ChangeRecoveryKey.userHa…
florianduros Jan 6, 2025
1a0e6dc
Move `deleteCachedSecrets` fixture in `recovery.spec.ts`
florianduros Jan 6, 2025
0b254e5
Use one callback instead of two in `RecoveryPanel`
florianduros Jan 6, 2025
6f236bd
Fix docs and naming of `utils.createBot`
florianduros Jan 8, 2025
82bf2cc
Fix typo in `RecoveryPanel`
florianduros Jan 8, 2025
84d11f8
Add more doc to the state of the `EncryptionUserSettingsTab`
florianduros Jan 8, 2025
2fe5555
Rename `verification_required` into `set_up_encryption`
florianduros Jan 8, 2025
4b365ba
Merge branch 'develop' into florianduros/encryption-tab
florianduros Jan 8, 2025
8a9291a
Update test
florianduros Jan 8, 2025
1c00502
ADd new license
florianduros Jan 8, 2025
dc940f5
Update comments and doc
florianduros Jan 9, 2025
521cebf
Assert that `recoveryKey.encodedPrivateKey` is always defined
florianduros Jan 9, 2025
7af44cc
Add comments to explain how the secrets could be uncached
florianduros Jan 9, 2025
8bd5d6a
Use `matrixClient.secretStorage.getDefaultKeyId` instead of `matrixCl…
florianduros Jan 9, 2025
086f28e
Merge branch 'develop' into florianduros/encryption-tab
florianduros Jan 9, 2025
0c18708
Update existing screenshot to add encryption tab.
florianduros Jan 9, 2025
0a52b7c
Update tests
florianduros Jan 9, 2025
e5dea48
Use new labels when changing the recovery key
florianduros Jan 10, 2025
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
18 changes: 11 additions & 7 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
/package.json @element-hq/element-web-team
/yarn.lock @element-hq/element-web-team

/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers
/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers
/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers
/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers
/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
/src/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers

# Ignore translations as those will be updated by GHA for Localazy download
/src/i18n/strings
Expand Down
27 changes: 4 additions & 23 deletions playwright/e2e/crypto/device-verification.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
awaitVerifier,
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
createBot,
doTwoWaySasVerification,
logIntoElement,
waitForVerificationRequest,
Expand All @@ -28,29 +29,9 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
let expectedBackupVersion: string;

test.beforeEach(async ({ page, homeserver, credentials }) => {
// Visit the login page of the app, to load the matrix sdk
await page.goto("/#/login");

// wait for the page to load
await page.waitForSelector(".mx_AuthPage", { timeout: 30000 });

// Create a new device for alice
aliceBotClient = new Bot(page, homeserver, {
bootstrapCrossSigning: true,
bootstrapSecretStorage: true,
});
aliceBotClient.setCredentials(credentials);

// Backup is prepared in the background. Poll until it is ready.
const botClientHandle = await aliceBotClient.prepareClient();
await expect
.poll(async () => {
expectedBackupVersion = await botClientHandle.evaluate((cli) =>
cli.getCrypto()!.getActiveSessionBackupVersion(),
);
return expectedBackupVersion;
})
.not.toBe(null);
const res = await createBot(page, homeserver, credentials);
aliceBotClient = res.botClient;
expectedBackupVersion = res.expectedBackupVersion;
});

// Click the "Verify with another device" button, and have the bot client auto-accept it.
Expand Down
42 changes: 42 additions & 0 deletions playwright/e2e/crypto/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
import type {
CryptoEvent,
EmojiMapping,
GeneratedSecretStorageKey,
ShowSasCallbacks,
VerificationRequest,
Verifier,
Expand All @@ -22,6 +23,47 @@ import { Client } from "../../pages/client";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { Bot } from "../../pages/bot";

/**
* Create a bot client and wait for the key backup to be ready.
* This function will wait for the key backup to be ready.
florianduros marked this conversation as resolved.
Show resolved Hide resolved
* @param page - the page to use
florianduros marked this conversation as resolved.
Show resolved Hide resolved
* @param homeserver - the homeserver to use
* @param credentials - the credentials to use for the bot client
*/
export async function createBot(
page: Page,
homeserver: HomeserverInstance,
credentials: Credentials,
): Promise<{ botClient: Bot; recoveryKey: GeneratedSecretStorageKey; expectedBackupVersion: string }> {
// Visit the login page of the app, to load the matrix sdk
await page.goto("/#/login");

// wait for the page to load
await page.waitForSelector(".mx_AuthPage", { timeout: 30000 });

// Create a new bot client
const botClient = new Bot(page, homeserver, {
bootstrapCrossSigning: true,
bootstrapSecretStorage: true,
});
botClient.setCredentials(credentials);
// Backup is prepared in the background. Poll until it is ready.
const botClientHandle = await botClient.prepareClient();
let expectedBackupVersion: string;
await expect
.poll(async () => {
expectedBackupVersion = await botClientHandle.evaluate((cli) =>
cli.getCrypto()!.getActiveSessionBackupVersion(),
);
return expectedBackupVersion;
})
.not.toBe(null);

const recoveryKey = await botClient.getRecoveryKey();

return { botClient, recoveryKey, expectedBackupVersion };
}

/**
* wait for the given client to receive an incoming verification request, and automatically accept it
*
Expand Down
97 changes: 97 additions & 0 deletions playwright/e2e/settings/encryption-user-tab/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import { Page } from "@playwright/test";
import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";

import { ElementAppPage } from "../../../pages/ElementAppPage";
import { test as base, expect } from "../../../element-web-test";
export { expect };

/**
* Set up for the encryption tab test
*/
export const test = base.extend<{
util: Helpers;
}>({
util: async ({ page, app, bot }, use) => {
await use(new Helpers(page, app));
},
});

class Helpers {
constructor(
private page: Page,
private app: ElementAppPage,
) {}

/**
* Open the encryption tab
*/
openEncryptionTab() {
return this.app.settings.openUserSettings("Encryption");
}

/**
* Go through the device verification flow using the recovery key.
*/
async verifyDevice(recoveryKey: GeneratedSecretStorageKey) {
// Select the security phrase
await this.page.getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
await this.enterRecoveryKey(recoveryKey);
await this.page.getByRole("button", { name: "Done" }).click();
}

/**
* Fill the recovery key in the dialog
* @param recoveryKey
*/
async enterRecoveryKey(recoveryKey: GeneratedSecretStorageKey) {
// Select to use recovery key
await this.page.getByRole("button", { name: "use your Security Key" }).click();

// Fill the recovery key
const dialog = this.page.locator(".mx_Dialog");
await dialog.getByRole("textbox").fill(recoveryKey.encodedPrivateKey);
await dialog.getByRole("button", { name: "Continue" }).click();
}

/**
* Get the encryption tab content
*/
getEncryptionTabContent() {
return this.page.getByTestId("encryptionTab");
}

/**
* Delete the key backup for the given version
* @param backupVersion
*/
async deleteKeyBackup(backupVersion: string) {
const client = await this.app.client.prepareClient();
await client.evaluate(async (client, backupVersion) => {
await client.getCrypto()?.deleteKeyBackupVersion(backupVersion);
}, backupVersion);
}

/**
* Get the security key from the clipboard and fill in the input field
* Then click on the finish button
* @param screenshot
*/
async confirmRecoveryKey(screenshot: `${string}.png`) {
const dialog = this.getEncryptionTabContent();
await expect(dialog.getByText("Enter your recovery key to confirm")).toBeVisible();
await expect(dialog).toMatchScreenshot(screenshot);

const handle = await this.page.evaluateHandle(() => navigator.clipboard.readText());
const clipboardContent = await handle.jsonValue();
await dialog.getByRole("textbox").fill(clipboardContent);
await dialog.getByRole("button", { name: "Finish set up" }).click();
await expect(dialog).toMatchScreenshot("default-recovery.png");
}
}
163 changes: 163 additions & 0 deletions playwright/e2e/settings/encryption-user-tab/recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { Page } from "@playwright/test";

import { test, expect } from ".";
import {
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
createBot,
verifySession,
} from "../../crypto/utils";

test.describe("Recovery section in Encryption tab", () => {
test.use({
displayName: "Alice",
});

let recoveryKey: GeneratedSecretStorageKey;
let expectedBackupVersion: string;

test.beforeEach(async ({ page, homeserver, credentials }) => {
const res = await createBot(page, homeserver, credentials);
recoveryKey = res.recoveryKey;
expectedBackupVersion = res.expectedBackupVersion;
});

test("should verify the device", { tag: "@screenshot" }, async ({ page, app, util }) => {
const dialog = await util.openEncryptionTab();

// The user's device is in an unverified state, therefore the only option available to them here is to verify it
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
await expect(verifyButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("verify-device-encryption-tab.png");
await verifyButton.click();

await util.verifyDevice(recoveryKey);
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");

// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);

// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await app.closeDialog();
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
});

test(
"should change the recovery key",
{ tag: "@screenshot" },
async ({ page, app, homeserver, credentials, util, context }) => {
await verifySession(app, "new passphrase");
const dialog = await util.openEncryptionTab();

// The user can only change the recovery key
const changeButton = dialog.getByRole("button", { name: "Change recovery key" });
await expect(changeButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
await changeButton.click();

// Display the new recovery key and click on the copy button
await expect(dialog.getByText("Change recovery key?")).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("change-key-1-encryption-tab.png", {
mask: [dialog.getByTestId("recoveryKey")],
});
await dialog.getByRole("button", { name: "Copy" }).click();
await dialog.getByRole("button", { name: "Continue" }).click();

// Confirm the recovery key
await util.confirmRecoveryKey("change-key-2-encryption-tab.png");
},
);

test("should setup the recovery key", { tag: "@screenshot" }, async ({ page, app, util }) => {
florianduros marked this conversation as resolved.
Show resolved Hide resolved
await verifySession(app, "new passphrase");
await util.deleteKeyBackup(expectedBackupVersion);

// The key backup is deleted and the user needs to set it up
const dialog = await util.openEncryptionTab();
const setupButton = dialog.getByRole("button", { name: "Set up recovery" });
await expect(setupButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-recovery.png");
await setupButton.click();

// Display an informative panel about the recovery key
await expect(dialog.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-key-1-encryption-tab.png");
await dialog.getByRole("button", { name: "Continue" }).click();

// Display the new recovery key and click on the copy button
await expect(dialog.getByText("Save your recovery key somewhere safe")).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-key-2-encryption-tab.png", {
mask: [dialog.getByTestId("recoveryKey")],
});
await dialog.getByRole("button", { name: "Copy" }).click();
await dialog.getByRole("button", { name: "Continue" }).click();

// Confirm the recovery key
await util.confirmRecoveryKey("set-up-key-3-encryption-tab.png");

await app.closeDialog();
// Check that the current device is connected to key backup and the backup version is the expected one
await checkDeviceIsConnectedKeyBackup(page, "2", true);
});

test(
"should enter the recovery key when the secrets are not cached",
{ tag: "@screenshot" },
async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
// We need to delete the cached secrets
await deleteCachedSecrets(page);

await util.openEncryptionTab();
// We ask the user to enter the recovery key
const dialog = util.getEncryptionTabContent();
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
await expect(enterKeyButton).toBeVisible();
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
await enterKeyButton.click();

// Fill the recovery key
await util.enterRecoveryKey(recoveryKey);
await expect(dialog).toMatchScreenshot("default-recovery.png");

// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);

// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await app.closeDialog();
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
},
);
});

/**
* Remove the cached secrets from the indexedDB
* This is a workaround to simulate the case where the secrets are not cached.
Copy link
Member

Choose a reason for hiding this comment

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

ok, but do we know what could cause this to happen in practice?

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't know. I just know that EX is checking it and we need to do it too, cf this discussion.

(Why I'm doing it that way)

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

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

Added comments

*/
async function deleteCachedSecrets(page: Page) {
await page.evaluate(async () => {
const removeCachedSecrets = new Promise((resolve) => {
const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto");
request.onsuccess = async (event: Event & { target: { result: IDBDatabase } }) => {
const db = event.target.result;
const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity");
request.onsuccess = () => {
db.close();
resolve(undefined);
};
};
});
await removeCachedSecrets;
});
await page.reload();
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading