From 5a74298d667d385083e6d31be655fb0233a5eb60 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 27 Jan 2025 16:56:25 +0000 Subject: [PATCH] Add key storage toggle to Encryption settings --- res/css/_components.pcss | 1 + .../settings/encryption/_KeyStoragePanel.pcss | 3 + src/DeviceListener.ts | 7 +- .../encryption/KeyStoragePanelViewModel.ts | 118 ++++++++++++++++++ .../settings/encryption/KeyStoragePanel.tsx | 61 +++++++++ .../tabs/user/EncryptionUserSettingsTab.tsx | 110 +++++++++++----- src/i18n/strings/en_EN.json | 5 + 7 files changed, 271 insertions(+), 34 deletions(-) create mode 100644 res/css/components/views/settings/encryption/_KeyStoragePanel.pcss create mode 100644 src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts create mode 100644 src/components/views/settings/encryption/KeyStoragePanel.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index a114c998b8..de47b1ec08 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..d7de635b5a --- /dev/null +++ b/res/css/components/views/settings/encryption/_KeyStoragePanel.pcss @@ -0,0 +1,3 @@ +.mx_KeyBackupPanel_toggleRow { + flex-direction: row; +} diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index e50f0d3f9b..6bc36dc3f0 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 0000000000..4abeebecd3 --- /dev/null +++ b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts @@ -0,0 +1,118 @@ +/* +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 { + // 'null' means no backup is active. 'undefined' means we're still loading. + isEnabled: boolean | undefined; + setEnabled: (enable: boolean) => void; + loading: boolean; // true if the state is still loading for the first time + busy: boolean; // true if the status is in the process of being changed +} + +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 defaultKeyEvent = matrixClient.getAccountData("m.secret_storage.default_key"); + if (defaultKeyEvent) { + if (defaultKeyEvent.getContent()?.key) { + await matrixClient.deleteAccountData( + `m.secret_storage.key.${defaultKeyEvent.getContent().key}`, + ); + } + // ...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 prevent EX from turning it back on. + 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/KeyStoragePanel.tsx b/src/components/views/settings/encryption/KeyStoragePanel.tsx new file mode 100644 index 0000000000..448025f672 --- /dev/null +++ b/src/components/views/settings/encryption/KeyStoragePanel.tsx @@ -0,0 +1,61 @@ +/* + * 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, { FormEvent, JSX, useCallback } from "react"; +import { InlineField, InlineSpinner, Label, Root, ToggleControl } from "@vector-im/compound-web"; + +import { SettingsSection } from "../shared/SettingsSection"; +import { _t } from "../../../../languageHandler"; +import { SettingsHeader } from "../SettingsHeader"; +import { useKeyStoragePanelViewModel } from "../../../viewmodels/settings/encryption/KeyStoragePanelViewModel"; + +/** + * This component allows the user to set up or change their recovery key. + */ +export function KeyBackupPanel(): JSX.Element { + const { isEnabled, setEnabled, loading, busy } = useKeyStoragePanelViewModel(); + + const onKeyBackupChange = useCallback( + (e: FormEvent) => { + setEnabled(e.currentTarget.checked); + }, + [setEnabled], + ); + + 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 4c5030cb58..68f414a7bc 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -20,6 +20,11 @@ import { SettingsSection } from "../../shared/SettingsSection"; import { SettingsSubheader } from "../../SettingsSubheader"; import { AdvancedPanel } from "../../encryption/AdvancedPanel"; import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel"; +import { KeyBackupPanel } from "../../encryption/KeyStoragePanel"; +import Spinner from "../../../elements/Spinner"; +import { useEventEmitter } from "../../../../../hooks/useEventEmitter"; +import { MatrixEvent } from "matrix-js-sdk"; +import { ClientEvent } from "matrix-js-sdk/src/matrix"; /** * The state in the encryption settings tab. @@ -35,44 +40,85 @@ import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel"; */ type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key" | "reset_identity"; +const useKeyBackupIsEnabled = (): boolean | undefined => { + const [isEnabled, setIsEnabled] = useState(undefined); + const [loading, setLoading] = useState(true); + + const matrixClient = useMatrixClientContext(); + + const checkStatus = useCallback(async () => { + const crypto = matrixClient.getCrypto()!; + const info = await crypto.getKeyBackupInfo(); + setIsEnabled(Boolean(info?.version)); + }, [matrixClient]); + + useEffect(() => { + (async () => { + await checkStatus(); + setLoading(false); + })(); + }, [checkStatus]); + + useEventEmitter(matrixClient, ClientEvent.AccountData, (event: MatrixEvent): void => { + const type = event.getType(); + if (type === "m.org.matrix.custom.backup_disabled") { + checkStatus(); + } + }); + + return loading ? undefined : isEnabled; +}; + export function EncryptionUserSettingsTab(): JSX.Element { const [state, setState] = useState("loading"); const setUpEncryptionRequired = useSetUpEncryptionRequired(setState); + const keyBackupIsEnabled = useKeyBackupIsEnabled(); let content: JSX.Element; - switch (state) { - case "loading": - content = ; - break; - case "set_up_encryption": - content = ; - break; - case "main": - content = ( - <> - - setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") - } + if (keyBackupIsEnabled === undefined) { + content = ; + } else { + switch (state) { + case "loading": + content = ; + break; + case "set_up_encryption": + content = ; + break; + case "main": + content = ( + <> + + {keyBackupIsEnabled && ( + <> + + setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") + } + /> + + + )} + setState("reset_identity")} /> + + ); + break; + case "change_recovery_key": + case "set_recovery_key": + content = ( + setState("main")} + onFinish={() => setState("main")} /> - - setState("reset_identity")} /> - - ); - break; - case "change_recovery_key": - case "set_recovery_key": - content = ( - setState("main")} - onFinish={() => setState("main")} - /> - ); - break; - case "reset_identity": - content = setState("main")} onFinish={() => setState("main")} />; - break; + ); + break; + case "reset_identity": + content = ( + setState("main")} onFinish={() => setState("main")} /> + ); + break; + } } return ( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a9825a16e4..59d325c158 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2482,6 +2482,11 @@ "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": { + "title": "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", + "allow_key_storage": "Allow 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.",