diff --git a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts new file mode 100644 index 0000000000..79ee3fc7a5 --- /dev/null +++ b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2025 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 { test, expect } from "."; +import { + checkDeviceIsConnectedKeyBackup, + checkDeviceIsCrossSigned, + createBot, + deleteCachedSecrets, + verifySession, +} from "../../crypto/utils"; + +test.describe("Encryption tab", () => { + test.use({ + displayName: "Alice", + }); + + let recoveryKey: GeneratedSecretStorageKey; + let expectedBackupVersion: string; + + test.beforeEach(async ({ page, homeserver, credentials }) => { + // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key + const res = await createBot(page, homeserver, credentials); + recoveryKey = res.recoveryKey; + expectedBackupVersion = res.expectedBackupVersion; + }); + + test( + "should show a 'Verify this device' button if the device is unverified", + { tag: "@screenshot" }, + async ({ page, app, util }) => { + const dialog = await util.openEncryptionTab(); + const content = util.getEncryptionTabContent(); + + // 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(content).toMatchScreenshot("verify-device-encryption-tab.png"); + await verifyButton.click(); + + await util.verifyDevice(recoveryKey); + + await expect(content).toMatchScreenshot("default-tab.png", { + mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")], + }); + + // 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 checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); + }, + ); + + // Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB. + // + // This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets. + // We simulate this case by deleting the cached secrets in the indexedDB. + test( + "should prompt to enter the recovery key when the secrets are not cached locally", + { 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-tab.png", { + mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")], + }); + + // 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 checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); + }, + ); +}); diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts index 316f305c97..8bb16f018b 100644 --- a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -5,53 +5,17 @@ * Please see LICENSE files in the repository root for full details. */ -import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; - import { test, expect } from "."; -import { - checkDeviceIsConnectedKeyBackup, - checkDeviceIsCrossSigned, - createBot, - deleteCachedSecrets, - verifySession, -} from "../../crypto/utils"; +import { checkDeviceIsConnectedKeyBackup, 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(); - const content = util.getEncryptionTabContent(); - - // 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(content).toMatchScreenshot("verify-device-encryption-tab.png"); - await verifyButton.click(); - - await util.verifyDevice(recoveryKey); - - await expect(content).toMatchScreenshot("default-tab.png", { - mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")], - }); - - // 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 checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); + // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key + await createBot(page, homeserver, credentials); }); test( @@ -121,37 +85,4 @@ test.describe("Recovery section in Encryption tab", () => { // Check that the current device is connected to key backup and the backup version is the expected one await checkDeviceIsConnectedKeyBackup(app, "1", true); }); - - // Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB. - // - // This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets. - // We simulate this case by deleting the cached secrets in the indexedDB. - 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(util.getEncryptionRecoverySection()).toMatchScreenshot("out-of-sync-recovery.png"); - await enterKeyButton.click(); - - // Fill the recovery key - await util.enterRecoveryKey(recoveryKey); - await expect(util.getEncryptionRecoverySection()).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 checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); - }, - ); }); diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png similarity index 100% rename from playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-tab-linux.png rename to playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png diff --git a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/out-of-sync-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/out-of-sync-recovery-linux.png new file mode 100644 index 0000000000..e6664a5f79 Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/out-of-sync-recovery-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/verify-device-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/verify-device-encryption-tab-linux.png similarity index 100% rename from playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/verify-device-encryption-tab-linux.png rename to playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/verify-device-encryption-tab-linux.png diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png deleted file mode 100644 index d799e944c3..0000000000 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png and /dev/null differ diff --git a/src/components/views/settings/encryption/RecoveryPanel.tsx b/src/components/views/settings/encryption/RecoveryPanel.tsx index cd89ba7617..129f698912 100644 --- a/src/components/views/settings/encryption/RecoveryPanel.tsx +++ b/src/components/views/settings/encryption/RecoveryPanel.tsx @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { JSX, useCallback, useEffect, useState } from "react"; +import React, { JSX } from "react"; import { Button, InlineSpinner } from "@vector-im/compound-web"; import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key"; @@ -13,18 +13,15 @@ import { SettingsSection } from "../shared/SettingsSection"; import { _t } from "../../../../languageHandler"; import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; import { SettingsHeader } from "../SettingsHeader"; -import { accessSecretStorage } from "../../../../SecurityManager"; -import { SettingsSubheader } from "../SettingsSubheader"; +import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; /** * The possible states of the recovery panel. * - `loading`: We are checking the recovery key and the secrets. * - `missing_recovery_key`: The user has no recovery key. - * - `secrets_not_cached`: The user has a recovery key but the secrets are not cached. - * This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets. * - `good`: The user has a recovery key and the secrets are cached. */ -type State = "loading" | "missing_recovery_key" | "secrets_not_cached" | "good"; +type State = "loading" | "missing_recovery_key" | "good"; interface RecoveryPanelProps { /** @@ -40,29 +37,18 @@ interface RecoveryPanelProps { * This component allows the user to set up or change their recovery key. */ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): JSX.Element { - const [state, setState] = useState("loading"); - const isMissingRecoveryKey = state === "missing_recovery_key"; - const matrixClient = useMatrixClientContext(); - - const checkEncryption = useCallback(async () => { - const crypto = matrixClient.getCrypto()!; - - // Check if the user has a recovery key - const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId()); - if (!hasRecoveryKey) return setState("missing_recovery_key"); - - // Check if the secrets are cached - const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally; - const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey; - if (!secretsOk) return setState("secrets_not_cached"); - - setState("good"); - }, [matrixClient]); - - useEffect(() => { - checkEncryption(); - }, [checkEncryption]); + const state = useAsyncMemo( + async () => { + // Check if the user has a recovery key + const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId()); + if (hasRecoveryKey) return "good"; + else return "missing_recovery_key"; + }, + [matrixClient], + "loading", + ); + const isMissingRecoveryKey = state === "missing_recovery_key"; let content: JSX.Element; switch (state) { @@ -76,18 +62,6 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): ); break; - case "secrets_not_cached": - content = ( - - ); - break; case "good": content = ( + + ); +} diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index 4c5030cb58..0f4a164a07 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -20,6 +20,7 @@ import { SettingsSection } from "../../shared/SettingsSection"; import { SettingsSubheader } from "../../SettingsSubheader"; import { AdvancedPanel } from "../../encryption/AdvancedPanel"; import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel"; +import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync"; /** * The state in the encryption settings tab. @@ -32,12 +33,22 @@ import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel"; * - "set_recovery_key": The panel to show when the user is setting up their recovery key. * This happens when the user doesn't have a key a recovery key and the user clicks on "Set up recovery key" button of the RecoveryPanel. * - "reset_identity": The panel to show when the user is resetting their identity. + * - `secrets_not_cached`: The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets. + * If the "set_up_encryption" and "secrets_not_cached" conditions are both filled, "set_up_encryption" prevails. + * */ -type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key" | "reset_identity"; +type State = + | "loading" + | "main" + | "set_up_encryption" + | "change_recovery_key" + | "set_recovery_key" + | "reset_identity" + | "secrets_not_cached"; export function EncryptionUserSettingsTab(): JSX.Element { const [state, setState] = useState("loading"); - const setUpEncryptionRequired = useSetUpEncryptionRequired(setState); + const checkEncryptionState = useCheckEncryptionState(setState); let content: JSX.Element; switch (state) { @@ -45,7 +56,10 @@ export function EncryptionUserSettingsTab(): JSX.Element { content = ; break; case "set_up_encryption": - content = ; + content = ; + break; + case "secrets_not_cached": + content = ; break; case "main": content = ( @@ -83,8 +97,12 @@ export function EncryptionUserSettingsTab(): JSX.Element { } /** - * Hook to check if the user needs to go through the SetupEncryption flow. + * Hook to check if the user needs: + * - to go through the SetupEncryption flow. + * - to enter their recovery key, if the secrets are not cached locally. + * * If the user needs to set up the encryption, the state will be set to "set_up_encryption". + * If the user secrets are not cached, the state will be set to "secrets_not_cached". * Otherwise, the state will be set to "main". * * The state is set once when the component is first mounted. @@ -93,23 +111,29 @@ export function EncryptionUserSettingsTab(): JSX.Element { * @param setState - callback passed from the EncryptionUserSettingsTab to set the current `State`. * @returns a callback function, which will re-run the logic and update the state. */ -function useSetUpEncryptionRequired(setState: (state: State) => void): () => Promise { +function useCheckEncryptionState(setState: (state: State) => void): () => Promise { const matrixClient = useMatrixClientContext(); - const setUpEncryptionRequired = useCallback(async () => { + const checkEncryptionState = useCallback(async () => { const crypto = matrixClient.getCrypto()!; const isCrossSigningReady = await crypto.isCrossSigningReady(); - if (isCrossSigningReady) setState("main"); - else setState("set_up_encryption"); + + // Check if the secrets are cached + const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally; + const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey; + + if (isCrossSigningReady && secretsOk) setState("main"); + else if (!isCrossSigningReady) setState("set_up_encryption"); + else setState("secrets_not_cached"); }, [matrixClient, setState]); // Initialise the state when the component is mounted useEffect(() => { - setUpEncryptionRequired(); - }, [setUpEncryptionRequired]); + checkEncryptionState(); + }, [checkEncryptionState]); // Also return the callback so that the component can re-run the logic. - return setUpEncryptionRequired; + return checkEncryptionState; } interface SetUpEncryptionPanelProps { diff --git a/test/unit-tests/components/views/settings/encryption/RecoveryPanel-test.tsx b/test/unit-tests/components/views/settings/encryption/RecoveryPanel-test.tsx index 6ef79876c7..d13e857954 100644 --- a/test/unit-tests/components/views/settings/encryption/RecoveryPanel-test.tsx +++ b/test/unit-tests/components/views/settings/encryption/RecoveryPanel-test.tsx @@ -10,22 +10,15 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { render, screen } from "jest-matrix-react"; import { waitFor } from "@testing-library/dom"; import userEvent from "@testing-library/user-event"; -import { mocked } from "jest-mock"; import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils"; import { RecoveryPanel } from "../../../../../../src/components/views/settings/encryption/RecoveryPanel"; -import { accessSecretStorage } from "../../../../../../src/SecurityManager"; - -jest.mock("../../../../../../src/SecurityManager", () => ({ - accessSecretStorage: jest.fn(), -})); describe("", () => { let matrixClient: MatrixClient; beforeEach(() => { matrixClient = createTestClient(); - mocked(accessSecretStorage).mockClear().mockResolvedValue(); }); function renderRecoverPanel(onChangeRecoveryKeyClick = jest.fn()) { @@ -56,18 +49,6 @@ describe("", () => { expect(onChangeRecoveryKeyClick).toHaveBeenCalledWith(true); }); - it("should ask to enter the recovery key when secrets are not cached", async () => { - jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue("default key"); - const user = userEvent.setup(); - const { asFragment } = renderRecoverPanel(); - - await waitFor(() => screen.getByRole("button", { name: "Enter recovery key" })); - expect(asFragment()).toMatchSnapshot(); - - await user.click(screen.getByRole("button", { name: "Enter recovery key" })); - expect(accessSecretStorage).toHaveBeenCalled(); - }); - it("should allow to change the recovery key when everything is good", async () => { jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue("default key"); jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({ diff --git a/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap index ff43b40677..d4d860d2cb 100644 --- a/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap +++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanel-test.tsx.snap @@ -41,67 +41,6 @@ exports[` should allow to change the recovery key when everythi `; -exports[` should ask to enter the recovery key when secrets are not cached 1`] = ` - -
-
-

- Recovery -

-
- Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices. - - - - - Your key storage is out of sync. Click the button below to fix the problem. - -
-
- -
-
-`; - exports[` should ask to set up a recovery key when there is no recovery key 1`] = `
({ + accessSecretStorage: jest.fn(), +})); describe("", () => { let matrixClient: MatrixClient; @@ -33,6 +39,8 @@ describe("", () => { userSigningKey: true, }, }); + + mocked(accessSecretStorage).mockClear().mockResolvedValue(); }); function renderComponent() { @@ -68,6 +76,28 @@ describe("", () => { await waitFor(() => expect(screen.getByText("Recovery")).toBeInTheDocument()); }); + it("should ask to enter the recovery key when secrets are not cached", async () => { + // Secrets are not cached + jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({ + privateKeysInSecretStorage: true, + publicKeysOnDevice: true, + privateKeysCachedLocally: { + masterKey: false, + selfSigningKey: true, + userSigningKey: true, + }, + }); + + const user = userEvent.setup(); + const { asFragment } = renderComponent(); + + await waitFor(() => screen.getByRole("button", { name: "Enter recovery key" })); + expect(asFragment()).toMatchSnapshot(); + + await user.click(screen.getByRole("button", { name: "Enter recovery key" })); + expect(accessSecretStorage).toHaveBeenCalled(); + }); + it("should display the change recovery key panel when the user clicks on the change recovery button", async () => { const user = userEvent.setup(); diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap index b460b91e51..5856e6fda3 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap @@ -1,5 +1,75 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[` should ask to enter the recovery key when secrets are not cached 1`] = ` + +
+
+
+
+

+ Recovery +

+
+ Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices. + + + + + Your key storage is out of sync. Click the button below to fix the problem. + +
+
+ +
+
+
+
+`; + exports[` should display a verify button when the encryption is not set up 1`] = `