diff --git a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts index 107f8085cc..427c801ef4 100644 --- a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts @@ -17,9 +17,7 @@ import { } from "../../crypto/utils"; test.describe("Encryption tab", () => { - test.use({ - displayName: "Alice", - }); + test.use({ displayName: "Alice" }); let recoveryKey: GeneratedSecretStorageKey; let expectedBackupVersion: string; @@ -111,4 +109,36 @@ test.describe("Encryption tab", () => { // The user is prompted to reset their identity await expect(dialog.getByText("Forgot your recovery key? You’ll need to reset your identity.")).toBeVisible(); }); + + test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => { + await verifySession(app, recoveryKey.encodedPrivateKey); + await util.openEncryptionTab(); + + await page.getByRole("checkbox", { name: "Allow key storage" }).click(); + + await expect( + page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }), + ).toBeVisible(); + + await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png"); + + const deleteRequestPromises = [ + page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")), + page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")), + page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.user_signing")), + page.waitForRequest((req) => req.url().endsWith("/account_data/m.megolm_backup.v1")), + page.waitForRequest((req) => req.url().endsWith("/account_data/m.secret_storage.default_key")), + page.waitForRequest((req) => req.url().includes("/account_data/m.secret_storage.key.")), + ]; + + await page.getByRole("button", { name: "Delete key storage" }).click(); + + await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked(); + + for (const prom of deleteRequestPromises) { + const request = await prom; + expect(request.method()).toBe("PUT"); + expect(request.postData()).toBe(JSON.stringify({})); + } + }); }); diff --git a/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/encryption-details-linux.png b/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/encryption-details-linux.png index 51c0bd8740..6a95f36da7 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/encryption-details-linux.png and b/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/encryption-details-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/reset-cryptographic-identity-linux.png b/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/reset-cryptographic-identity-linux.png index c8b3bb6f17..18213b5375 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/reset-cryptographic-identity-linux.png and b/playwright/snapshots/settings/encryption-user-tab/advanced.spec.ts/reset-cryptographic-identity-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png index ebcab00f75..3af3e2aedf 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png and b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/delete-key-storage-confirm-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/delete-key-storage-confirm-linux.png new file mode 100644 index 0000000000..10ece913d4 Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/delete-key-storage-confirm-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png index fb778886c6..9c23f7ea20 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png index d34640d213..e7dcea9436 100644 Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index a8536a2d4d..89948acd20 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -48,6 +48,7 @@ @import "./components/views/settings/devices/_FilteredDeviceListHeader.pcss"; @import "./components/views/settings/devices/_SecurityRecommendations.pcss"; @import "./components/views/settings/devices/_SelectableDeviceTile.pcss"; +@import "./components/views/settings/encryption/_KeyStoragePanel.pcss"; @import "./components/views/settings/shared/_SettingsSubsection.pcss"; @import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss"; @import "./components/views/spaces/_QuickThemeSwitcher.pcss"; diff --git a/res/css/components/views/settings/encryption/_KeyStoragePanel.pcss b/res/css/components/views/settings/encryption/_KeyStoragePanel.pcss new file mode 100644 index 0000000000..34a79db9cd --- /dev/null +++ b/res/css/components/views/settings/encryption/_KeyStoragePanel.pcss @@ -0,0 +1,10 @@ +/* + * 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. + */ + +.mx_KeyStoragePanel_toggleRow { + flex-direction: row; +} diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index cf46be41fa..751e71dd9f 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -49,10 +49,13 @@ import { asyncSomeParallel } from "./utils/arrays.ts"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; -// Unfortunately named account data key used by Element X to indicate that the user -// has chosen to disable server side key backups. We need to set and honour this -// to prevent Element X from automatically turning key backup back on. -const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled"; +/** + * Unfortunately-named account data key used by Element X to indicate that the user + * has chosen to disable server side key backups. + * + * We need to set and honour this to prevent Element X from automatically turning key backup back on. + */ +export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled"; const logger = baseLogger.getChild("DeviceListener:"); diff --git a/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts new file mode 100644 index 0000000000..ee301bd27f --- /dev/null +++ b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts @@ -0,0 +1,116 @@ +/* +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 { useCallback, useEffect, useState } from "react"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../DeviceListener"; + +interface KeyStoragePanelState { + /** + * Whether the app's "key storage" option should show as enabled to the user, + * or 'undefined' if the state is still loading. + */ + isEnabled: boolean | undefined; + + /** + * A function that can be called to enable or disable key storage. + * @param enable True to turn key storage on or false to turn it off + */ + setEnabled: (enable: boolean) => void; + + /** + * True if the state is still loading for the first time + */ + loading: boolean; + + /** + * True if the status is in the process of being changed + */ + busy: boolean; +} + +/** Returns a ViewModel for use in {@link KeyStoragePanel} and {@link DeleteKeyStoragePanel}. */ +export function useKeyStoragePanelViewModel(): KeyStoragePanelState { + const [isEnabled, setIsEnabled] = useState(undefined); + const [loading, setLoading] = useState(true); + // Whilst the change is being made, the toggle will reflect the pending value rather than the actual state + const [pendingValue, setPendingValue] = useState(undefined); + + const matrixClient = useMatrixClientContext(); + + const checkStatus = useCallback(async () => { + const crypto = matrixClient.getCrypto(); + if (!crypto) { + logger.error("Can't check key backup status: no crypto module available"); + return; + } + // The toggle is enabled only if this device will upload megolm keys to the backup. + // This is consistent with EX. + const activeBackupVersion = await crypto.getActiveSessionBackupVersion(); + setIsEnabled(activeBackupVersion !== null); + }, [matrixClient]); + + useEffect(() => { + (async () => { + await checkStatus(); + setLoading(false); + })(); + }, [checkStatus]); + + const setEnabled = useCallback( + async (enable: boolean) => { + setPendingValue(enable); + try { + // stop the device listener since enabling or (especially) disabling key storage must be + // done with a sequence of API calls that will put the account in a slightly different + // state each time, so suppress any warning toasts until the process is finished (when + // we'll turn it back on again.) + DeviceListener.sharedInstance().stop(); + + const crypto = matrixClient.getCrypto(); + if (!crypto) { + logger.error("Can't change key backup status: no crypto module available"); + return; + } + if (enable) { + // If there is no existing key backup on the server, create one. + // `resetKeyBackup` will delete any existing backup, so we only do this if there is no existing backup. + const currentKeyBackup = await crypto.checkKeyBackupAndEnable(); + if (currentKeyBackup === null) { + await crypto.resetKeyBackup(); + + // resetKeyBackup fires this off in the background without waiting, so we need to do it + // explicitly and wait for it, otherwise it won't be enabled yet when we check again. + await crypto.checkKeyBackupAndEnable(); + } + + // Set the flag so that EX no longer thinks the user wants backup disabled + await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: false }); + } else { + // This method will delete the key backup as well as server side recovery keys and other + // server-side crypto data. + await crypto.disableKeyStorage(); + + // Set a flag to say that the user doesn't want key backup. + // Element X uses this to determine whether to set up automatically, + // so this will stop EX turning it back on spontaneously. + await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true }); + } + + await checkStatus(); + } finally { + setPendingValue(undefined); + DeviceListener.sharedInstance().start(matrixClient); + } + }, + [setPendingValue, checkStatus, matrixClient], + ); + + return { isEnabled: pendingValue ?? isEnabled, setEnabled, loading, busy: pendingValue !== undefined }; +} diff --git a/src/components/views/settings/encryption/DeleteKeyStoragePanel.tsx b/src/components/views/settings/encryption/DeleteKeyStoragePanel.tsx new file mode 100644 index 0000000000..31fac19f07 --- /dev/null +++ b/src/components/views/settings/encryption/DeleteKeyStoragePanel.tsx @@ -0,0 +1,79 @@ +/* + * 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 { Breadcrumb, Button, VisualList, VisualListItem } from "@vector-im/compound-web"; +import CrossIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; +import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid"; +import React, { useCallback, useState } from "react"; + +import { _t } from "../../../../languageHandler"; +import { EncryptionCard } from "./EncryptionCard"; +import { useKeyStoragePanelViewModel } from "../../../viewmodels/settings/encryption/KeyStoragePanelViewModel"; +import SdkConfig from "../../../../SdkConfig"; +import { EncryptionCardButtons } from "./EncryptionCardButtons"; +import { EncryptionCardEmphasisedContent } from "./EncryptionCardEmphasisedContent"; + +interface Props { + /** + * Called when the user either cancels the operation or key storage has been disabled + */ + onFinish: () => void; +} + +/** + * Confirms that the user really wants to turn off and delete their key storage. Part of the "Encryption" settings tab. + */ +export function DeleteKeyStoragePanel({ onFinish }: Props): JSX.Element { + const { setEnabled } = useKeyStoragePanelViewModel(); + const [busy, setBusy] = useState(false); + + const onDeleteClick = useCallback(async () => { + setBusy(true); + try { + await setEnabled(false); + } finally { + setBusy(false); + } + onFinish(); + }, [setEnabled, onFinish]); + + return ( + <> + + + + {_t("settings|encryption|delete_key_storage|description")} + + + {_t("settings|encryption|delete_key_storage|list_first")} + + + {_t("settings|encryption|delete_key_storage|list_second", { brand: SdkConfig.get().brand })} + + + + + + + + + + ); +} diff --git a/src/components/views/settings/encryption/KeyStoragePanel.tsx b/src/components/views/settings/encryption/KeyStoragePanel.tsx new file mode 100644 index 0000000000..0de55942a7 --- /dev/null +++ b/src/components/views/settings/encryption/KeyStoragePanel.tsx @@ -0,0 +1,75 @@ +/* + * 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 React, { useCallback } from "react"; +import { InlineField, InlineSpinner, Label, Root, ToggleControl } from "@vector-im/compound-web"; + +import type { FormEvent } from "react"; +import { SettingsSection } from "../shared/SettingsSection"; +import { _t } from "../../../../languageHandler"; +import { SettingsHeader } from "../SettingsHeader"; +import { useKeyStoragePanelViewModel } from "../../../viewmodels/settings/encryption/KeyStoragePanelViewModel"; + +interface Props { + /** + * Called when the user turns off the "allow key storage" toggle + */ + onKeyStorageDisableClick: () => void; +} + +/** + * This component allows the user to set up or change their recovery key. + * + * It is used within the "Encryption" settings tab. + */ +export const KeyStoragePanel: React.FC = ({ onKeyStorageDisableClick }) => { + const { isEnabled, setEnabled, loading, busy } = useKeyStoragePanelViewModel(); + + const onKeyBackupChange = useCallback( + (e: FormEvent) => { + if (e.currentTarget.checked) { + setEnabled(true); + } else { + onKeyStorageDisableClick(); + } + }, + [setEnabled, onKeyStorageDisableClick], + ); + + if (loading) { + return ; + } + + return ( + + } + subHeading={_t("settings|encryption|key_storage|description", undefined, { + a: (sub) => ( + + {sub} + + ), + })} + > + + } + > + + + {busy && } + + + ); +}; diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index 2a041a04c8..3af555ed49 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -8,6 +8,7 @@ import React, { type JSX, useCallback, useEffect, useState } from "react"; import { Button, InlineSpinner, Separator } from "@vector-im/compound-web"; import ComputerIcon from "@vector-im/compound-design-tokens/assets/web/icons/computer"; +import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import SettingsTab from "../SettingsTab"; import { RecoveryPanel } from "../../encryption/RecoveryPanel"; @@ -21,11 +22,15 @@ import { SettingsSubheader } from "../../SettingsSubheader"; import { AdvancedPanel } from "../../encryption/AdvancedPanel"; import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel"; import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync"; +import { useTypedEventEmitter } from "../../../../../hooks/useEventEmitter"; +import { KeyStoragePanel } from "../../encryption/KeyStoragePanel"; +import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel"; /** * The state in the encryption settings tab. * - "loading": We are checking if the device is verified. * - "main": The main panel with all the sections (Key storage, recovery, advanced). + * - "key_storage_disabled": The user has chosen to disable key storage and options are unavailable as a result. * - "set_up_encryption": The panel to show when the user is setting up their encryption. * This happens when the user doesn't have cross-signing enabled, or their current device is not verified. * - "change_recovery_key": The panel to show when the user is changing their recovery key. @@ -33,19 +38,22 @@ import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync" * - "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_compromised": The panel to show when the user is resetting their identity, in te case where their key is compromised. - * - "reset_identity_forgot": The panel to show when the user is resetting their identity, in the case where they forgot their recovery key. - * - `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. + * - "reset_identity_forgot": The panel to show when the user is resetting their identity, in the case where they forgot their recovery key. + * - "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. + * - "key_storage_delete": The confirmation page asking if the user really wants to turn off key storage. */ export type State = | "loading" | "main" + | "key_storage_disabled" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key" | "reset_identity_compromised" | "reset_identity_forgot" - | "secrets_not_cached"; + | "secrets_not_cached" + | "key_storage_delete"; interface EncryptionUserSettingsTabProps { /** @@ -63,6 +71,7 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti const checkEncryptionState = useCheckEncryptionState(state, setState); let content: JSX.Element; + switch (state) { case "loading": content = ; @@ -78,15 +87,23 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti /> ); break; + case "key_storage_disabled": case "main": content = ( <> - - setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") - } - /> + setState("key_storage_delete")} /> + {/* We only show the "Recovery" panel if key storage is enabled.*/} + {state === "main" && ( + <> + + setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") + } + /> + + + )} setState("reset_identity_compromised")} /> ); @@ -111,6 +128,9 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti /> ); break; + case "key_storage_delete": + content = ; + break; } return ( @@ -124,10 +144,12 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti * 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. + * ...and also whether megolm key backup is enabled on this device (which we use to set the state of the 'allow key storage' toggle) * - * 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". + * If cross signing is set up, key backup is enabled and the secrets are cached, the state will be set to "main". + * If cross signing is not set up, the state will be set to "set_up_encryption". + * If key backup is not enabled, the state will be set to "key_storage_disabled". + * If secrets are missing, the state will be set to "secrets_not_cached". * * The state is set once when the component is first mounted. * Also returns a callback function which can be called to re-run the logic. @@ -146,8 +168,14 @@ function useCheckEncryptionState(state: State, setState: (state: State) => void) const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally; const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey; - if (isCrossSigningReady && secretsOk) setState("main"); + // Also check the key backup status + const activeBackupVersion = await crypto.getActiveSessionBackupVersion(); + + const keyStorageEnabled = activeBackupVersion !== null; + + if (isCrossSigningReady && keyStorageEnabled && secretsOk) setState("main"); else if (!isCrossSigningReady) setState("set_up_encryption"); + else if (!keyStorageEnabled) setState("key_storage_disabled"); else setState("secrets_not_cached"); }, [matrixClient, setState]); @@ -156,6 +184,15 @@ function useCheckEncryptionState(state: State, setState: (state: State) => void) if (state === "loading") checkEncryptionState(); }, [checkEncryptionState, state]); + useTypedEventEmitter(matrixClient, CryptoEvent.KeyBackupStatus, (): void => { + // Recheck the status if the key backup status has changed so we can keep the page up to date. + // Note that this could potentially update the UI while the user is trying to do something, although + // if their key backup status is changing then they're changing encryption related things + // on another device. This code is written with the assumption that it's better for the UI to refresh + // and be up to date with whatever changes they've made. + checkEncryptionState(); + }); + // Also return the callback so that the component can re-run the logic. return checkEncryptionState; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9281382715..5570279115 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2510,10 +2510,23 @@ "session_key": "Session key:", "title": "Advanced" }, + "delete_key_storage": { + "breadcrumb_page": "Delete key storage", + "confirm": "Delete key storage", + "description": "Deleting key storage will remove your cryptographic identity and message keys from the server and turn off the following security features:", + "list_first": "You will not have encrypted message history on new devices", + "list_second": "You will lose access to your encrypted messages if you are signed out of %(brand)s everywhere", + "title": "Are you sure you want to turn off key storage and delete it?" + }, "device_not_verified_button": "Verify this device", "device_not_verified_description": "You need to verify this device in order to view your encryption settings.", "device_not_verified_title": "Device not verified", "dialog_title": "Settings: Encryption", + "key_storage": { + "allow_key_storage": "Allow key storage", + "description": "Store your cryptographic identity and message keys securely on the server. This will allow you to view your message history on any new devices. Learn more", + "title": "Key storage" + }, "recovery": { "change_recovery_confirm_button": "Confirm new recovery key", "change_recovery_confirm_description": "Enter your new recovery key below to finish. Your old one will no longer work.", diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index a8b53abb0e..321ff8b27e 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -151,9 +151,11 @@ export function createTestClient(): MatrixClient { }, }), isCrossSigningReady: jest.fn().mockResolvedValue(false), + disableKeyStorage: jest.fn(), resetEncryption: jest.fn(), getSessionBackupPrivateKey: jest.fn().mockResolvedValue(null), isSecretStorageReady: jest.fn().mockResolvedValue(false), + deleteKeyBackupVersion: jest.fn(), }), getPushActionsForEvent: jest.fn(), @@ -192,6 +194,7 @@ export function createTestClient(): MatrixClient { }), mxcUrlToHttp: jest.fn().mockImplementation((mxc: string) => `http://this.is.a.url/${mxc.substring(6)}`), setAccountData: jest.fn(), + deleteAccountData: jest.fn(), setRoomAccountData: jest.fn(), setRoomTopic: jest.fn(), setRoomReadMarkers: jest.fn().mockResolvedValue({}), diff --git a/test/unit-tests/components/viewmodels/settings/encryption/KeyStoragePanelViewModel-test.ts b/test/unit-tests/components/viewmodels/settings/encryption/KeyStoragePanelViewModel-test.ts new file mode 100644 index 0000000000..68333d0fea --- /dev/null +++ b/test/unit-tests/components/viewmodels/settings/encryption/KeyStoragePanelViewModel-test.ts @@ -0,0 +1,91 @@ +/* +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 { renderHook } from "jest-matrix-react"; +import { act } from "react"; +import { mocked } from "jest-mock"; + +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; +import type { KeyBackupCheck, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; +import { useKeyStoragePanelViewModel } from "../../../../../../src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel"; +import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils"; + +describe("KeyStoragePanelViewModel", () => { + let matrixClient: MatrixClient; + + beforeEach(() => { + matrixClient = createTestClient(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should update the pending value immediately", async () => { + const { result } = renderHook( + () => useKeyStoragePanelViewModel(), + withClientContextRenderOptions(matrixClient), + ); + act(() => { + result.current.setEnabled(true); + }); + expect(result.current.isEnabled).toBe(true); + expect(result.current.busy).toBe(true); + }); + + it("should call resetKeyBackup if there is no backup currently", async () => { + mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue(null); + + const { result } = renderHook( + () => useKeyStoragePanelViewModel(), + withClientContextRenderOptions(matrixClient), + ); + + await result.current.setEnabled(true); + expect(mocked(matrixClient.getCrypto()!.resetKeyBackup)).toHaveBeenCalled(); + }); + + it("should not call resetKeyBackup if there is a backup currently", async () => { + mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue({} as KeyBackupCheck); + + const { result } = renderHook( + () => useKeyStoragePanelViewModel(), + withClientContextRenderOptions(matrixClient), + ); + + await result.current.setEnabled(true); + expect(mocked(matrixClient.getCrypto()!.resetKeyBackup)).not.toHaveBeenCalled(); + }); + + it("should set account data flag when enabling", async () => { + mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue(null); + + const { result } = renderHook( + () => useKeyStoragePanelViewModel(), + withClientContextRenderOptions(matrixClient), + ); + + await result.current.setEnabled(true); + expect(mocked(matrixClient.setAccountData)).toHaveBeenCalledWith("m.org.matrix.custom.backup_disabled", { + disabled: false, + }); + }); + + it("should delete key storage when disabling", async () => { + mocked(matrixClient.getCrypto()!.checkKeyBackupAndEnable).mockResolvedValue({} as KeyBackupCheck); + mocked(matrixClient.getCrypto()!.getKeyBackupInfo).mockResolvedValue({ version: "99" } as KeyBackupInfo); + + const { result } = renderHook( + () => useKeyStoragePanelViewModel(), + withClientContextRenderOptions(matrixClient), + ); + + await result.current.setEnabled(false); + + expect(mocked(matrixClient.getCrypto()!.disableKeyStorage)).toHaveBeenCalled(); + }); +}); diff --git a/test/unit-tests/components/views/settings/encryption/DeleteKeyStoragePanel-test.tsx b/test/unit-tests/components/views/settings/encryption/DeleteKeyStoragePanel-test.tsx new file mode 100644 index 0000000000..eb9e557025 --- /dev/null +++ b/test/unit-tests/components/views/settings/encryption/DeleteKeyStoragePanel-test.tsx @@ -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 React from "react"; +import { render, screen, waitFor } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; +import { mocked } from "jest-mock"; +import { defer } from "matrix-js-sdk/src/utils"; + +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils"; +import { DeleteKeyStoragePanel } from "../../../../../../src/components/views/settings/encryption/DeleteKeyStoragePanel"; +import { useKeyStoragePanelViewModel } from "../../../../../../src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel"; + +jest.mock("../../../../../../src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel", () => ({ + useKeyStoragePanelViewModel: jest + .fn() + .mockReturnValue({ setEnabled: jest.fn(), isEnabled: true, loading: false, busy: false }), +})); + +describe("", () => { + let matrixClient: MatrixClient; + + beforeEach(() => { + matrixClient = createTestClient(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should match snapshot", async () => { + const { asFragment } = render( + {}} />, + withClientContextRenderOptions(matrixClient), + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should call onFinished when cancel pressed", async () => { + const user = userEvent.setup(); + + const onFinish = jest.fn(); + render(, withClientContextRenderOptions(matrixClient)); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(onFinish).toHaveBeenCalled(); + }); + + it("should call disable key storage when confirm pressed", async () => { + const setEnabled = jest.fn(); + + mocked(useKeyStoragePanelViewModel).mockReturnValue({ + setEnabled, + isEnabled: true, + loading: false, + busy: false, + }); + + const user = userEvent.setup(); + + const onFinish = jest.fn(); + render(, withClientContextRenderOptions(matrixClient)); + + await user.click(screen.getByRole("button", { name: "Delete key storage" })); + + expect(setEnabled).toHaveBeenCalledWith(false); + }); + + it("should wait with button disabled while setEnabled runs", async () => { + const setEnabledDefer = defer(); + + mocked(useKeyStoragePanelViewModel).mockReturnValue({ + setEnabled: jest.fn().mockReturnValue(setEnabledDefer.promise), + isEnabled: true, + loading: false, + busy: false, + }); + + const user = userEvent.setup(); + + const onFinish = jest.fn(); + render(, withClientContextRenderOptions(matrixClient)); + + await user.click(screen.getByRole("button", { name: "Delete key storage" })); + + expect(onFinish).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Delete key storage" })).toHaveAttribute("aria-disabled", "true"); + setEnabledDefer.resolve(); + await waitFor(() => expect(onFinish).toHaveBeenCalled()); + }); +}); diff --git a/test/unit-tests/components/views/settings/encryption/__snapshots__/DeleteKeyStoragePanel-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/DeleteKeyStoragePanel-test.tsx.snap new file mode 100644 index 0000000000..52541c468b --- /dev/null +++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/DeleteKeyStoragePanel-test.tsx.snap @@ -0,0 +1,156 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should match snapshot 1`] = ` + + +
+
+
+ + + +
+

+ Are you sure you want to turn off key storage and delete it? +

+
+
+ Deleting key storage will remove your cryptographic identity and message keys from the server and turn off the following security features: +
    +
  • + + You will not have encrypted message history on new devices +
  • +
  • + + You will lose access to your encrypted messages if you are signed out of Element everywhere +
  • +
+
+
+ + +
+
+
+`; diff --git a/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx b/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx index 536dadf305..04b34573ac 100644 --- a/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx +++ b/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx @@ -6,10 +6,11 @@ */ import React from "react"; -import { render, screen } from "jest-matrix-react"; +import { act, render, screen } from "jest-matrix-react"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { waitFor } from "@testing-library/dom"; import userEvent from "@testing-library/user-event"; +import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { EncryptionUserSettingsTab, @@ -66,12 +67,21 @@ describe("", () => { expect(spy).toHaveBeenCalled(); }); - it("should display the recovery panel when the encryption is set up", async () => { + it("should display the recovery panel when key storage is enabled", async () => { + jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); renderComponent(); await waitFor(() => expect(screen.getByText("Recovery")).toBeInTheDocument()); }); + it("should not display the recovery panel when key storage is not enabled", async () => { + jest.spyOn(matrixClient.getCrypto()!, "getKeyBackupInfo").mockResolvedValue(null); + jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue(null); + renderComponent(); + await expect(screen.queryByText("Recovery")).not.toBeInTheDocument(); + }); + it("should display the recovery out of sync panel when secrets are not cached", async () => { + jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); // Secrets are not cached jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({ privateKeysInSecretStorage: true, @@ -96,6 +106,7 @@ describe("", () => { }); it("should display the change recovery key panel when the user clicks on the change recovery button", async () => { + jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); const user = userEvent.setup(); const { asFragment } = renderComponent(); @@ -109,6 +120,7 @@ describe("", () => { }); it("should display the set up recovery key when the user clicks on the set up recovery key button", async () => { + jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue(null); const user = userEvent.setup(); @@ -123,6 +135,8 @@ describe("", () => { }); it("should display the reset identity panel when the user clicks on the reset cryptographic identity panel", async () => { + jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); + const user = userEvent.setup(); const { asFragment } = renderComponent(); @@ -137,17 +151,41 @@ describe("", () => { expect(asFragment()).toMatchSnapshot(); }); - it("should enter reset flow when showResetIdentity is set", () => { + it("should enter reset flow when showResetIdentity is set", async () => { + jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); + renderComponent({ initialState: "reset_identity_forgot" }); - expect( - screen.getByRole("heading", { name: "Forgot your recovery key? You’ll need to reset your identity." }), + await expect( + await screen.findByRole("heading", { + name: "Forgot your recovery key? You’ll need to reset your identity.", + }), ).toBeVisible(); }); + it("should update when key backup status event is fired", async () => { + jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); + + renderComponent(); + + await expect(await screen.findByRole("heading", { name: "Recovery" })).toBeVisible(); + + jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue(null); + + act(() => { + matrixClient.emit(CryptoEvent.KeyBackupStatus, false); + }); + + await waitFor(() => { + expect(screen.queryByRole("heading", { name: "Recovery" })).toBeNull(); + }); + }); + it("should re-check the encryption state and displays the correct panel when the user clicks cancel the reset identity flow", async () => { const user = userEvent.setup(); + jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); + // Secrets are not cached jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({ privateKeysInSecretStorage: true,