-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Add key storage toggle to Encryption settings #29113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5a74298
98950de
4eb07d4
97057de
4ec09f0
4db196e
98114c8
6a20703
b7b2ea3
e408715
aa6de76
5ac2004
1304587
a26efc5
1b99071
6b238d1
87d44a7
4ea6a33
df4c23b
4a3a373
e70afdb
40f9bd9
16c76cb
f818d6e
fc9bc09
25f8fe2
1178d77
7c2d9f4
cfd55a6
f586c43
e8483e0
8ca4a8b
2ef05c5
7149b3d
64f84cb
0c5f2b0
9c4625d
f0d9e05
de18311
c4525b9
d1aef9f
96a70f2
98edffa
a5cec9e
0162c82
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
| }); | ||
|
Comment on lines
+115
to
+130
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it would be good if we confirmed that the switch actually worked, rather than just exposing some nice UI? |
||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| .mx_KeyBackupPanel_toggleRow { | ||
| flex-direction: row; | ||
| } |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't have a strong opinion on this, but having a Wouldn't the normal way be to have a |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| const disabledEvent = cli.getAccountData("m.org.matrix.custom.backup_disabled"); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could we add a constant somewhere for this name, so that we don't need to hardcode it in 15 different places? |
||||||
| 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? | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
dbkr marked this conversation as resolved.
Comment on lines
+14
to
+17
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. again, I'd like us to be clearer about what "enabled" means here. Naively, I assumed it meant |
||
|
|
||
| /** | ||
| * 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<boolean | undefined>(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<boolean | undefined>(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"); | ||
| } | ||
|
Comment on lines
+93
to
+108
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we need to disable dehydration and delete the dehydrated device key here too. This whole thing could usefully become |
||
|
|
||
| // 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, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
dbkr marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * 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 ( | ||
| <> | ||
| <Breadcrumb | ||
| backLabel={_t("action|back")} | ||
| onBackClick={onFinish} | ||
| pages={[_t("settings|encryption|title"), _t("settings|encryption|delete_key_storage|breadcrumb_page")]} | ||
| onPageClick={onFinish} | ||
| /> | ||
| <EncryptionCard | ||
| Icon={ErrorIcon} | ||
| destructive={true} | ||
| title={_t("settings|encryption|delete_key_storage|title")} | ||
| className="mx_DestructiveComponent" | ||
| > | ||
| <div className="mx_DestructiveComponent_content"> | ||
| {_t("settings|encryption|delete_key_storage|description")} | ||
| <VisualList> | ||
| <VisualListItem Icon={CrossIcon} destructive={true}> | ||
| {_t("settings|encryption|delete_key_storage|list_first")} | ||
| </VisualListItem> | ||
| <VisualListItem Icon={CrossIcon} destructive={true}> | ||
| {_t("settings|encryption|delete_key_storage|list_second", { brand: SdkConfig.get().brand })} | ||
| </VisualListItem> | ||
| </VisualList> | ||
| </div> | ||
| <div className="mx_DestructiveComponent_footer"> | ||
| <Button destructive={true} onClick={onDeleteClick} disabled={busy}> | ||
| {_t("settings|encryption|delete_key_storage|confirm")} | ||
| </Button> | ||
| <Button kind="tertiary" onClick={onFinish}> | ||
| {_t("action|cancel")} | ||
| </Button> | ||
| </div> | ||
| </EncryptionCard> | ||
| </> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
dbkr marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * This component allows the user to set up or change their recovery key. | ||
| */ | ||
| export const KeyStoragePanel: React.FC<Props> = ({ onKeyStorageDisableClick }) => { | ||
| const { isEnabled, setEnabled, loading, busy } = useKeyStoragePanelViewModel(); | ||
|
|
||
| const onKeyBackupChange = useCallback( | ||
| (e: FormEvent<HTMLInputElement>) => { | ||
| if (e.currentTarget.checked) { | ||
| setEnabled(true); | ||
| } else { | ||
| onKeyStorageDisableClick(); | ||
| } | ||
| }, | ||
| [setEnabled, onKeyStorageDisableClick], | ||
| ); | ||
|
|
||
| if (loading) { | ||
| return <InlineSpinner aria-label={_t("common|loading")} />; | ||
| } | ||
|
|
||
| return ( | ||
| <SettingsSection | ||
| legacy={false} | ||
| heading={ | ||
| <SettingsHeader | ||
| hasRecommendedTag={isEnabled === false} | ||
| label={_t("settings|encryption|key_storage|title")} | ||
| /> | ||
| } | ||
| subHeading={_t("settings|encryption|key_storage|description", undefined, { | ||
| a: (sub) => ( | ||
| <a href="https://element.io/help#encryption5" target="_blank" rel="noreferrer noopener"> | ||
| {sub} | ||
| </a> | ||
| ), | ||
| })} | ||
| > | ||
| <Root className="mx_KeyBackupPanel_toggleRow"> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. shouldn't the class here be about |
||
| <InlineField | ||
| name="keyStorage" | ||
| control={<ToggleControl name="keyStorage" checked={isEnabled} onChange={onKeyBackupChange} />} | ||
| > | ||
| <Label>{_t("settings|encryption|key_storage|allow_key_storage")}</Label> | ||
| </InlineField> | ||
| {busy && <InlineSpinner />} | ||
| </Root> | ||
| </SettingsSection> | ||
| ); | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.