Skip to content

Commit 13913ba

Browse files
authored
Add Recovery section in the new user settings Encryption tab (#28673)
* Refine `SettingsSection` & `SettingsTab` * Add encryption tab * Add recovery section * Add device verification * Rename `Panel` into `State` * Update & add tests to user settings common * Add tests to `RecoveryPanel` * Add tests to `ChangeRecoveryKey` * Update CreateSecretStorageDialog-test snapshot * Add tests to `EncryptionUserSettingsTab` * Update existing screenshots of e2e tests * Add new encryption tab ownership to `@element-hq/element-crypto-web-reviewers` * Add e2e tests * Fix monospace font and add figma link to hardcoded value * Add unit to Icon * Improve e2e doc * Assert that the crypto module is defined * Add classname doc * Fix typo * Use `good` state instead of default * Rename `ChangeRecoveryKey.isSetupFlow` into `ChangeRecoveryKey.userHasKeyBackup` * Move `deleteCachedSecrets` fixture in `recovery.spec.ts` * Use one callback instead of two in `RecoveryPanel` * Fix docs and naming of `utils.createBot` * Fix typo in `RecoveryPanel` * Add more doc to the state of the `EncryptionUserSettingsTab` * Rename `verification_required` into `set_up_encryption` * Update test * ADd new license * Update comments and doc * Assert that `recoveryKey.encodedPrivateKey` is always defined * Add comments to explain how the secrets could be uncached * Use `matrixClient.secretStorage.getDefaultKeyId` instead of `matrixClient.getCrypto().checkKeyBackupAndEnable` to know if we need to set up a recovery key * Update existing screenshot to add encryption tab. * Update tests * Use new labels when changing the recovery key * Fix docs * Don't reset key backup when creating a recovery key * Fix doc
1 parent 03a1b48 commit 13913ba

File tree

53 files changed

+2931
-49
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+2931
-49
lines changed

.github/CODEOWNERS

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
/package.json @element-hq/element-web-team
44
/yarn.lock @element-hq/element-web-team
55

6-
/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers
7-
/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers
8-
/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
9-
/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
10-
/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
11-
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
12-
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
6+
/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers
7+
/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers
8+
/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
9+
/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
10+
/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
11+
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
12+
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
13+
/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
14+
/src/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
15+
/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
16+
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
1317

1418
# Ignore translations as those will be updated by GHA for Localazy download
1519
/src/i18n/strings

playwright/e2e/crypto/device-verification.spec.ts

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
awaitVerifier,
1616
checkDeviceIsConnectedKeyBackup,
1717
checkDeviceIsCrossSigned,
18+
createBot,
1819
doTwoWaySasVerification,
1920
logIntoElement,
2021
waitForVerificationRequest,
@@ -28,29 +29,9 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
2829
let expectedBackupVersion: string;
2930

3031
test.beforeEach(async ({ page, homeserver, credentials }) => {
31-
// Visit the login page of the app, to load the matrix sdk
32-
await page.goto("/#/login");
33-
34-
// wait for the page to load
35-
await page.waitForSelector(".mx_AuthPage", { timeout: 30000 });
36-
37-
// Create a new device for alice
38-
aliceBotClient = new Bot(page, homeserver, {
39-
bootstrapCrossSigning: true,
40-
bootstrapSecretStorage: true,
41-
});
42-
aliceBotClient.setCredentials(credentials);
43-
44-
// Backup is prepared in the background. Poll until it is ready.
45-
const botClientHandle = await aliceBotClient.prepareClient();
46-
await expect
47-
.poll(async () => {
48-
expectedBackupVersion = await botClientHandle.evaluate((cli) =>
49-
cli.getCrypto()!.getActiveSessionBackupVersion(),
50-
);
51-
return expectedBackupVersion;
52-
})
53-
.not.toBe(null);
32+
const res = await createBot(page, homeserver, credentials);
33+
aliceBotClient = res.botClient;
34+
expectedBackupVersion = res.expectedBackupVersion;
5435
});
5536

5637
// Click the "Verify with another device" button, and have the bot client auto-accept it.

playwright/e2e/crypto/utils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
1212
import type {
1313
CryptoEvent,
1414
EmojiMapping,
15+
GeneratedSecretStorageKey,
1516
ShowSasCallbacks,
1617
VerificationRequest,
1718
Verifier,
@@ -22,6 +23,46 @@ import { Client } from "../../pages/client";
2223
import { ElementAppPage } from "../../pages/ElementAppPage";
2324
import { Bot } from "../../pages/bot";
2425

26+
/**
27+
* Create a bot client using the supplied credentials, and wait for the key backup to be ready.
28+
* @param page - the playwright `page` fixture
29+
* @param homeserver - the homeserver to use
30+
* @param credentials - the credentials to use for the bot client
31+
*/
32+
export async function createBot(
33+
page: Page,
34+
homeserver: HomeserverInstance,
35+
credentials: Credentials,
36+
): Promise<{ botClient: Bot; recoveryKey: GeneratedSecretStorageKey; expectedBackupVersion: string }> {
37+
// Visit the login page of the app, to load the matrix sdk
38+
await page.goto("/#/login");
39+
40+
// wait for the page to load
41+
await page.waitForSelector(".mx_AuthPage", { timeout: 30000 });
42+
43+
// Create a new bot client
44+
const botClient = new Bot(page, homeserver, {
45+
bootstrapCrossSigning: true,
46+
bootstrapSecretStorage: true,
47+
});
48+
botClient.setCredentials(credentials);
49+
// Backup is prepared in the background. Poll until it is ready.
50+
const botClientHandle = await botClient.prepareClient();
51+
let expectedBackupVersion: string;
52+
await expect
53+
.poll(async () => {
54+
expectedBackupVersion = await botClientHandle.evaluate((cli) =>
55+
cli.getCrypto()!.getActiveSessionBackupVersion(),
56+
);
57+
return expectedBackupVersion;
58+
})
59+
.not.toBe(null);
60+
61+
const recoveryKey = await botClient.getRecoveryKey();
62+
63+
return { botClient, recoveryKey, expectedBackupVersion };
64+
}
65+
2566
/**
2667
* wait for the given client to receive an incoming verification request, and automatically accept it
2768
*
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright 2024 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { Page } from "@playwright/test";
9+
import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
10+
11+
import { ElementAppPage } from "../../../pages/ElementAppPage";
12+
import { test as base, expect } from "../../../element-web-test";
13+
export { expect };
14+
15+
/**
16+
* Set up for the encryption tab test
17+
*/
18+
export const test = base.extend<{
19+
util: Helpers;
20+
}>({
21+
util: async ({ page, app, bot }, use) => {
22+
await use(new Helpers(page, app));
23+
},
24+
});
25+
26+
class Helpers {
27+
constructor(
28+
private page: Page,
29+
private app: ElementAppPage,
30+
) {}
31+
32+
/**
33+
* Open the encryption tab
34+
*/
35+
openEncryptionTab() {
36+
return this.app.settings.openUserSettings("Encryption");
37+
}
38+
39+
/**
40+
* Go through the device verification flow using the recovery key.
41+
*/
42+
async verifyDevice(recoveryKey: GeneratedSecretStorageKey) {
43+
// Select the security phrase
44+
await this.page.getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
45+
await this.enterRecoveryKey(recoveryKey);
46+
await this.page.getByRole("button", { name: "Done" }).click();
47+
}
48+
49+
/**
50+
* Fill the recovery key in the dialog
51+
* @param recoveryKey
52+
*/
53+
async enterRecoveryKey(recoveryKey: GeneratedSecretStorageKey) {
54+
// Select to use recovery key
55+
await this.page.getByRole("button", { name: "use your Security Key" }).click();
56+
57+
// Fill the recovery key
58+
const dialog = this.page.locator(".mx_Dialog");
59+
await dialog.getByRole("textbox").fill(recoveryKey.encodedPrivateKey);
60+
await dialog.getByRole("button", { name: "Continue" }).click();
61+
}
62+
63+
/**
64+
* Get the encryption tab content
65+
*/
66+
getEncryptionTabContent() {
67+
return this.page.getByTestId("encryptionTab");
68+
}
69+
70+
/**
71+
* Set the default key id of the secret storage to `null`
72+
*/
73+
async removeSecretStorageDefaultKeyId() {
74+
const client = await this.app.client.prepareClient();
75+
await client.evaluate(async (client) => {
76+
await client.secretStorage.setDefaultKeyId(null);
77+
});
78+
}
79+
80+
/**
81+
* Get the security key from the clipboard and fill in the input field
82+
* Then click on the finish button
83+
* @param title - The title of the dialog
84+
* @param confirmButtonLabel - The label of the confirm button
85+
* @param screenshot
86+
*/
87+
async confirmRecoveryKey(title: string, confirmButtonLabel: string, screenshot: `${string}.png`) {
88+
const dialog = this.getEncryptionTabContent();
89+
await expect(dialog.getByText(title, { exact: true })).toBeVisible();
90+
await expect(dialog).toMatchScreenshot(screenshot);
91+
92+
const handle = await this.page.evaluateHandle(() => navigator.clipboard.readText());
93+
const clipboardContent = await handle.jsonValue();
94+
await dialog.getByRole("textbox").fill(clipboardContent);
95+
await dialog.getByRole("button", { name: confirmButtonLabel }).click();
96+
await expect(dialog).toMatchScreenshot("default-recovery.png");
97+
}
98+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Copyright 2024 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
9+
import { Page } from "@playwright/test";
10+
11+
import { test, expect } from ".";
12+
import {
13+
checkDeviceIsConnectedKeyBackup,
14+
checkDeviceIsCrossSigned,
15+
createBot,
16+
verifySession,
17+
} from "../../crypto/utils";
18+
19+
test.describe("Recovery section in Encryption tab", () => {
20+
test.use({
21+
displayName: "Alice",
22+
});
23+
24+
let recoveryKey: GeneratedSecretStorageKey;
25+
let expectedBackupVersion: string;
26+
27+
test.beforeEach(async ({ page, homeserver, credentials }) => {
28+
const res = await createBot(page, homeserver, credentials);
29+
recoveryKey = res.recoveryKey;
30+
expectedBackupVersion = res.expectedBackupVersion;
31+
});
32+
33+
test("should verify the device", { tag: "@screenshot" }, async ({ page, app, util }) => {
34+
const dialog = await util.openEncryptionTab();
35+
36+
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
37+
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
38+
await expect(verifyButton).toBeVisible();
39+
await expect(util.getEncryptionTabContent()).toMatchScreenshot("verify-device-encryption-tab.png");
40+
await verifyButton.click();
41+
42+
await util.verifyDevice(recoveryKey);
43+
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
44+
45+
// Check that our device is now cross-signed
46+
await checkDeviceIsCrossSigned(app);
47+
48+
// Check that the current device is connected to key backup
49+
// The backup decryption key should be in cache also, as we got it directly from the 4S
50+
await app.closeDialog();
51+
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
52+
});
53+
54+
test(
55+
"should change the recovery key",
56+
{ tag: "@screenshot" },
57+
async ({ page, app, homeserver, credentials, util, context }) => {
58+
await verifySession(app, "new passphrase");
59+
const dialog = await util.openEncryptionTab();
60+
61+
// The user can only change the recovery key
62+
const changeButton = dialog.getByRole("button", { name: "Change recovery key" });
63+
await expect(changeButton).toBeVisible();
64+
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
65+
await changeButton.click();
66+
67+
// Display the new recovery key and click on the copy button
68+
await expect(dialog.getByText("Change recovery key?")).toBeVisible();
69+
await expect(util.getEncryptionTabContent()).toMatchScreenshot("change-key-1-encryption-tab.png", {
70+
mask: [dialog.getByTestId("recoveryKey")],
71+
});
72+
await dialog.getByRole("button", { name: "Copy" }).click();
73+
await dialog.getByRole("button", { name: "Continue" }).click();
74+
75+
// Confirm the recovery key
76+
await util.confirmRecoveryKey(
77+
"Enter your new recovery key",
78+
"Confirm new recovery key",
79+
"change-key-2-encryption-tab.png",
80+
);
81+
},
82+
);
83+
84+
test("should setup the recovery key", { tag: "@screenshot" }, async ({ page, app, util }) => {
85+
await verifySession(app, "new passphrase");
86+
await util.removeSecretStorageDefaultKeyId();
87+
88+
// The key backup is deleted and the user needs to set it up
89+
const dialog = await util.openEncryptionTab();
90+
const setupButton = dialog.getByRole("button", { name: "Set up recovery" });
91+
await expect(setupButton).toBeVisible();
92+
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-recovery.png");
93+
await setupButton.click();
94+
95+
// Display an informative panel about the recovery key
96+
await expect(dialog.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
97+
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-key-1-encryption-tab.png");
98+
await dialog.getByRole("button", { name: "Continue" }).click();
99+
100+
// Display the new recovery key and click on the copy button
101+
await expect(dialog.getByText("Save your recovery key somewhere safe")).toBeVisible();
102+
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-key-2-encryption-tab.png", {
103+
mask: [dialog.getByTestId("recoveryKey")],
104+
});
105+
await dialog.getByRole("button", { name: "Copy" }).click();
106+
await dialog.getByRole("button", { name: "Continue" }).click();
107+
108+
// Confirm the recovery key
109+
await util.confirmRecoveryKey(
110+
"Enter your recovery key to confirm",
111+
"Finish set up",
112+
"set-up-key-3-encryption-tab.png",
113+
);
114+
115+
// The recovery key is now set up and the user can change it
116+
await expect(dialog.getByRole("button", { name: "Change recovery key" })).toBeVisible();
117+
118+
await app.closeDialog();
119+
// Check that the current device is connected to key backup and the backup version is the expected one
120+
await checkDeviceIsConnectedKeyBackup(page, "1", true);
121+
});
122+
123+
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
124+
//
125+
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
126+
// We simulate this case by deleting the cached secrets in the indexedDB.
127+
test(
128+
"should enter the recovery key when the secrets are not cached",
129+
{ tag: "@screenshot" },
130+
async ({ page, app, util }) => {
131+
await verifySession(app, "new passphrase");
132+
// We need to delete the cached secrets
133+
await deleteCachedSecrets(page);
134+
135+
await util.openEncryptionTab();
136+
// We ask the user to enter the recovery key
137+
const dialog = util.getEncryptionTabContent();
138+
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
139+
await expect(enterKeyButton).toBeVisible();
140+
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
141+
await enterKeyButton.click();
142+
143+
// Fill the recovery key
144+
await util.enterRecoveryKey(recoveryKey);
145+
await expect(dialog).toMatchScreenshot("default-recovery.png");
146+
147+
// Check that our device is now cross-signed
148+
await checkDeviceIsCrossSigned(app);
149+
150+
// Check that the current device is connected to key backup
151+
// The backup decryption key should be in cache also, as we got it directly from the 4S
152+
await app.closeDialog();
153+
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
154+
},
155+
);
156+
});
157+
158+
/**
159+
* Remove the cached secrets from the indexedDB
160+
* This is a workaround to simulate the case where the secrets are not cached.
161+
*/
162+
async function deleteCachedSecrets(page: Page) {
163+
await page.evaluate(async () => {
164+
const removeCachedSecrets = new Promise((resolve) => {
165+
const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto");
166+
request.onsuccess = async (event: Event & { target: { result: IDBDatabase } }) => {
167+
const db = event.target.result;
168+
const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity");
169+
request.onsuccess = () => {
170+
db.close();
171+
resolve(undefined);
172+
};
173+
};
174+
});
175+
await removeCachedSecrets;
176+
});
177+
await page.reload();
178+
}
Loading
Loading
Loading
Loading

0 commit comments

Comments
 (0)