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 107f8085cc9..97fd3d8392e 100644 --- a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts @@ -111,4 +111,21 @@ 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"); + + await page.getByRole("button", { name: "Delete key storage" }).click(); + + await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked(); + }); }); 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 ab50f21a568..f467675b35d 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/encryption-tab.spec.ts/default-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/encryption-tab.spec.ts/default-tab-linux.png index d14cff80714..38d995392d3 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 00000000000..3a5bc0f9f8a 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 8a0f80fcea8..4cfcd081232 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 a3f014d0667..a966f8ddd27 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 46b8fc932d9..bf6c223a2d0 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"; @@ -357,9 +358,9 @@ @import "./views/settings/_UserProfileSettings.pcss"; @import "./views/settings/encryption/_AdvancedPanel.pcss"; @import "./views/settings/encryption/_ChangeRecoveryKey.pcss"; +@import "./views/settings/encryption/_DestructiveComponent.pcss"; @import "./views/settings/encryption/_EncryptionCard.pcss"; @import "./views/settings/encryption/_RecoveryPanelOutOfSync.pcss"; -@import "./views/settings/encryption/_ResetIdentityPanel.pcss"; @import "./views/settings/tabs/_SettingsBanner.pcss"; @import "./views/settings/tabs/_SettingsIndent.pcss"; @import "./views/settings/tabs/_SettingsSection.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 00000000000..d7de635b5a2 --- /dev/null +++ b/res/css/components/views/settings/encryption/_KeyStoragePanel.pcss @@ -0,0 +1,3 @@ +.mx_KeyBackupPanel_toggleRow { + flex-direction: row; +} diff --git a/res/css/views/settings/encryption/_ResetIdentityPanel.pcss b/res/css/views/settings/encryption/_DestructiveComponent.pcss similarity index 72% rename from res/css/views/settings/encryption/_ResetIdentityPanel.pcss rename to res/css/views/settings/encryption/_DestructiveComponent.pcss index e4e05638ce6..0f3ddfb7054 100644 --- a/res/css/views/settings/encryption/_ResetIdentityPanel.pcss +++ b/res/css/views/settings/encryption/_DestructiveComponent.pcss @@ -5,8 +5,11 @@ * Please see LICENSE files in the repository root for full details. */ -.mx_ResetIdentityPanel { - .mx_ResetIdentityPanel_content { +/** + * Shared by multiple components that confirm a destructive action in the user settings dialog. + */ +.mx_DestructiveComponent { + .mx_DestructiveComponent_content { display: flex; flex-direction: column; gap: var(--cpd-space-3x); @@ -17,7 +20,7 @@ } } - .mx_ResetIdentityPanel_footer { + .mx_DestructiveComponent_footer { display: flex; flex-direction: column; gap: var(--cpd-space-4x); diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index d4d40a9124e..d4d6afbb5b6 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -318,8 +318,11 @@ export default class DeviceListener { // prompt the user to enter their recovery key. showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC); } else if (defaultKeyId === null) { - // the user just hasn't set up 4S yet: prompt them to do so - showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); + // the user just hasn't set up 4S yet: prompt them to do so (unless they've explicitly said no to backups) + const disabledEvent = cli.getAccountData("m.org.matrix.custom.backup_disabled"); + if (!disabledEvent || !disabledEvent.getContent()?.disabled) { + showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); + } } else { // some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did // in 'other' situations. Possibly we should consider prompting for a full reset in this case? diff --git a/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts new file mode 100644 index 00000000000..06b9ea8efee --- /dev/null +++ b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts @@ -0,0 +1,130 @@ +/* +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"; + +interface KeyStoragePanelState { + /** + * Whether key storage is enabled, 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; +} + +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; + } + const info = await crypto.getKeyBackupInfo(); + setIsEnabled(Boolean(info?.version)); + }, [matrixClient]); + + useEffect(() => { + (async () => { + await checkStatus(); + setLoading(false); + })(); + }, [checkStatus]); + + const setEnabled = useCallback( + async (enable: boolean) => { + setPendingValue(enable); + try { + const crypto = matrixClient.getCrypto(); + if (!crypto) { + logger.error("Can't change key backup status: no crypto module available"); + return; + } + if (enable) { + 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("m.org.matrix.custom.backup_disabled", { disabled: false }); + } else { + // Get the key backup version we're using + const info = await crypto.getKeyBackupInfo(); + if (!info?.version) { + logger.error("Can't delete key backup version: no version available"); + return; + } + + // Bye bye backup + await crypto.deleteKeyBackupVersion(info.version); + + // also turn off 4S, since this is also storing keys on the server. + // Delete the cross signing keys from secret storage + await matrixClient.deleteAccountData("m.cross_signing.master"); + await matrixClient.deleteAccountData("m.cross_signing.self_signing"); + await matrixClient.deleteAccountData("m.cross_signing.user_signing"); + // and the key backup key (we just turned it off anyway) + await matrixClient.deleteAccountData("m.megolm_backup.v1"); + + // Delete the key information + const defaultKey = await matrixClient.secretStorage.getDefaultKeyId(); + if (defaultKey) { + await matrixClient.deleteAccountData(`m.secret_storage.key.${defaultKey}`); + + // ...and the default key pointer + await matrixClient.deleteAccountData("m.secret_storage.default_key"); + } + + // finally, 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("m.org.matrix.custom.backup_disabled", { disabled: true }); + } + + await checkStatus(); + } finally { + setPendingValue(undefined); + } + }, + [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 00000000000..989139cd6e5 --- /dev/null +++ b/src/components/views/settings/encryption/DeleteKeyStoragePanel.tsx @@ -0,0 +1,78 @@ +/* + * 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"; +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"; + +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 + */ +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 00000000000..9910f58914f --- /dev/null +++ b/src/components/views/settings/encryption/KeyStoragePanel.tsx @@ -0,0 +1,73 @@ +/* + * 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. + */ +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/encryption/ResetIdentityPanel.tsx b/src/components/views/settings/encryption/ResetIdentityPanel.tsx index 40475b2ad17..3208da837ea 100644 --- a/src/components/views/settings/encryption/ResetIdentityPanel.tsx +++ b/src/components/views/settings/encryption/ResetIdentityPanel.tsx @@ -58,9 +58,9 @@ export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetId ? _t("settings|encryption|advanced|breadcrumb_title_forgot") : _t("settings|encryption|advanced|breadcrumb_title") } - className="mx_ResetIdentityPanel" + className="mx_DestructiveComponent" > -
+
{_t("settings|encryption|advanced|breadcrumb_first_description")} @@ -74,7 +74,7 @@ export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetId {variant === "compromised" && {_t("settings|encryption|advanced|breadcrumb_warning")}}
-
+
+
    +
  1. + + Encryption + +
  2. +
  3. + + Delete key storage + +
  4. +
+ +
+
+
+ + + +
+

+ 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/encryption/__snapshots__/ResetIdentityPanel-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/ResetIdentityPanel-test.tsx.snap index 04402cd172d..1b0e20dbb69 100644 --- a/test/unit-tests/components/views/settings/encryption/__snapshots__/ResetIdentityPanel-test.tsx.snap +++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/ResetIdentityPanel-test.tsx.snap @@ -55,7 +55,7 @@ exports[` should display the 'forgot recovery key' variant
should display the 'forgot recovery key' variant
    should display the 'forgot recovery key' variant