Skip to content

Commit be3778b

Browse files
dbkrrichvdh
andauthored
Add key storage toggle to Encryption settings (#29310)
* Add key storage toggle to Encryption settings * Keys in the acceptable order * Fix some tests * Fix import * Fix toast showing condition * Fix import order * Fix playwright tests * Fix bits lost in merge * Add key storage delete confirm screen * Fix hardcoded Element string * Fix type imports * Fix tests * Tests for key storage delete panel * Fix test * Type import * Test for the view model * Fix type import * Actually fix type imports * Test updating * Add playwright test & clarify slightly confusing comment * Show the advnced section whatever the state of key storage * Update screenshots * Copy css to its own file * Add missing doc & merge loading states * Add tsdoc & loading alt text to spinner * Turn comments into proper tsdoc * Switch to TypedEventEmitter and remove unnecessary loading state * Add screenshot * Use higher level interface * Merge the two hooks in EncryptionUserSettingsTab * Remove unused import * Don't check key backup enabled state separately as we don't need it for all the screens * Update snapshot * Use fixed recovery key function * Amalgamate duplicated CSS files * Have "key storage disabled" as a separate state * Update snapshot * Fix... bad merge? * Add backup enabled mock to more tests * More snapshots * Use defer util * Update to use EncryptionCardButtons * Update snapshots * Use EncryptionCardEmphasisedContent * Update snapshots * Update snapshot * Try screenshot from CI playwright * Try playwright screenshots again * More screenshots * Rename to match files * Test that 4S secrets are deleted * Make description clearer * Fix typo & move related states together * Add comment * More comments * Fix hook docs * restoreAllMocks * Update snapshot because pulling in upstream has caused IDs to shift * Switch icon as apparenty the error icon has changed * Update snapshot * Missing copyright * Re-order states and also sort out indenting * Remove phantom space * Clarify 'button' * Clarify docs more * Explain thinking behind updating * Switch to getActiveBackupVersion which checks that key backup is happining on this device, which is consistent with EX. * Add use of Key Storage Panel Co-authored-by: Richard van der Hoff <[email protected]> * Change key storage panel to be consistent ie. using getActiveBackupVersion(), and add comment * Add tsdoc Co-authored-by: Richard van der Hoff <[email protected]> * Use BACKUP_DISABLED_ACCOUNT_DATA_KEY in more places * Expand doc Co-authored-by: Richard van der Hoff <[email protected]> * Undo random yarn lock change * Use aggregate method for disabling key storage in matrix-org/matrix-js-sdk#4742 * Fix tests * Use key backup status event to update * Comment formatting Co-authored-by: Richard van der Hoff <[email protected]> * Fix comment & put check inside if statement * Add comment * Prettier * Fix comment * Update snapshot Which has gained nowrap due to 917d53a --------- Co-authored-by: Richard van der Hoff <[email protected]>
1 parent 973d639 commit be3778b

File tree

20 files changed

+772
-24
lines changed

20 files changed

+772
-24
lines changed

Diff for: playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts

+33-3
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@ import {
1717
} from "../../crypto/utils";
1818

1919
test.describe("Encryption tab", () => {
20-
test.use({
21-
displayName: "Alice",
22-
});
20+
test.use({ displayName: "Alice" });
2321

2422
let recoveryKey: GeneratedSecretStorageKey;
2523
let expectedBackupVersion: string;
@@ -111,4 +109,36 @@ test.describe("Encryption tab", () => {
111109
// The user is prompted to reset their identity
112110
await expect(dialog.getByText("Forgot your recovery key? You’ll need to reset your identity.")).toBeVisible();
113111
});
112+
113+
test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => {
114+
await verifySession(app, recoveryKey.encodedPrivateKey);
115+
await util.openEncryptionTab();
116+
117+
await page.getByRole("checkbox", { name: "Allow key storage" }).click();
118+
119+
await expect(
120+
page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }),
121+
).toBeVisible();
122+
123+
await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png");
124+
125+
const deleteRequestPromises = [
126+
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")),
127+
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")),
128+
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.user_signing")),
129+
page.waitForRequest((req) => req.url().endsWith("/account_data/m.megolm_backup.v1")),
130+
page.waitForRequest((req) => req.url().endsWith("/account_data/m.secret_storage.default_key")),
131+
page.waitForRequest((req) => req.url().includes("/account_data/m.secret_storage.key.")),
132+
];
133+
134+
await page.getByRole("button", { name: "Delete key storage" }).click();
135+
136+
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
137+
138+
for (const prom of deleteRequestPromises) {
139+
const request = await prom;
140+
expect(request.method()).toBe("PUT");
141+
expect(request.postData()).toBe(JSON.stringify({}));
142+
}
143+
});
114144
});
Loading
Loading
Loading
Loading
Loading

Diff for: res/css/_components.pcss

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
@import "./components/views/settings/devices/_FilteredDeviceListHeader.pcss";
4949
@import "./components/views/settings/devices/_SecurityRecommendations.pcss";
5050
@import "./components/views/settings/devices/_SelectableDeviceTile.pcss";
51+
@import "./components/views/settings/encryption/_KeyStoragePanel.pcss";
5152
@import "./components/views/settings/shared/_SettingsSubsection.pcss";
5253
@import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss";
5354
@import "./components/views/spaces/_QuickThemeSwitcher.pcss";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
.mx_KeyStoragePanel_toggleRow {
9+
flex-direction: row;
10+
}

Diff for: src/DeviceListener.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,13 @@ import { asyncSomeParallel } from "./utils/arrays.ts";
4949

5050
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
5151

52-
// Unfortunately named account data key used by Element X to indicate that the user
53-
// has chosen to disable server side key backups. We need to set and honour this
54-
// to prevent Element X from automatically turning key backup back on.
55-
const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
52+
/**
53+
* Unfortunately-named account data key used by Element X to indicate that the user
54+
* has chosen to disable server side key backups.
55+
*
56+
* We need to set and honour this to prevent Element X from automatically turning key backup back on.
57+
*/
58+
export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
5659

5760
const logger = baseLogger.getChild("DeviceListener:");
5861

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { useCallback, useEffect, useState } from "react";
9+
import { logger } from "matrix-js-sdk/src/logger";
10+
11+
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
12+
import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
13+
14+
interface KeyStoragePanelState {
15+
/**
16+
* Whether the app's "key storage" option should show as enabled to the user,
17+
* or 'undefined' if the state is still loading.
18+
*/
19+
isEnabled: boolean | undefined;
20+
21+
/**
22+
* A function that can be called to enable or disable key storage.
23+
* @param enable True to turn key storage on or false to turn it off
24+
*/
25+
setEnabled: (enable: boolean) => void;
26+
27+
/**
28+
* True if the state is still loading for the first time
29+
*/
30+
loading: boolean;
31+
32+
/**
33+
* True if the status is in the process of being changed
34+
*/
35+
busy: boolean;
36+
}
37+
38+
/** Returns a ViewModel for use in {@link KeyStoragePanel} and {@link DeleteKeyStoragePanel}. */
39+
export function useKeyStoragePanelViewModel(): KeyStoragePanelState {
40+
const [isEnabled, setIsEnabled] = useState<boolean | undefined>(undefined);
41+
const [loading, setLoading] = useState(true);
42+
// Whilst the change is being made, the toggle will reflect the pending value rather than the actual state
43+
const [pendingValue, setPendingValue] = useState<boolean | undefined>(undefined);
44+
45+
const matrixClient = useMatrixClientContext();
46+
47+
const checkStatus = useCallback(async () => {
48+
const crypto = matrixClient.getCrypto();
49+
if (!crypto) {
50+
logger.error("Can't check key backup status: no crypto module available");
51+
return;
52+
}
53+
// The toggle is enabled only if this device will upload megolm keys to the backup.
54+
// This is consistent with EX.
55+
const activeBackupVersion = await crypto.getActiveSessionBackupVersion();
56+
setIsEnabled(activeBackupVersion !== null);
57+
}, [matrixClient]);
58+
59+
useEffect(() => {
60+
(async () => {
61+
await checkStatus();
62+
setLoading(false);
63+
})();
64+
}, [checkStatus]);
65+
66+
const setEnabled = useCallback(
67+
async (enable: boolean) => {
68+
setPendingValue(enable);
69+
try {
70+
// stop the device listener since enabling or (especially) disabling key storage must be
71+
// done with a sequence of API calls that will put the account in a slightly different
72+
// state each time, so suppress any warning toasts until the process is finished (when
73+
// we'll turn it back on again.)
74+
DeviceListener.sharedInstance().stop();
75+
76+
const crypto = matrixClient.getCrypto();
77+
if (!crypto) {
78+
logger.error("Can't change key backup status: no crypto module available");
79+
return;
80+
}
81+
if (enable) {
82+
// If there is no existing key backup on the server, create one.
83+
// `resetKeyBackup` will delete any existing backup, so we only do this if there is no existing backup.
84+
const currentKeyBackup = await crypto.checkKeyBackupAndEnable();
85+
if (currentKeyBackup === null) {
86+
await crypto.resetKeyBackup();
87+
88+
// resetKeyBackup fires this off in the background without waiting, so we need to do it
89+
// explicitly and wait for it, otherwise it won't be enabled yet when we check again.
90+
await crypto.checkKeyBackupAndEnable();
91+
}
92+
93+
// Set the flag so that EX no longer thinks the user wants backup disabled
94+
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: false });
95+
} else {
96+
// This method will delete the key backup as well as server side recovery keys and other
97+
// server-side crypto data.
98+
await crypto.disableKeyStorage();
99+
100+
// Set a flag to say that the user doesn't want key backup.
101+
// Element X uses this to determine whether to set up automatically,
102+
// so this will stop EX turning it back on spontaneously.
103+
await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
104+
}
105+
106+
await checkStatus();
107+
} finally {
108+
setPendingValue(undefined);
109+
DeviceListener.sharedInstance().start(matrixClient);
110+
}
111+
},
112+
[setPendingValue, checkStatus, matrixClient],
113+
);
114+
115+
return { isEnabled: pendingValue ?? isEnabled, setEnabled, loading, busy: pendingValue !== undefined };
116+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { Breadcrumb, Button, VisualList, VisualListItem } from "@vector-im/compound-web";
9+
import CrossIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
10+
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
11+
import React, { useCallback, useState } from "react";
12+
13+
import { _t } from "../../../../languageHandler";
14+
import { EncryptionCard } from "./EncryptionCard";
15+
import { useKeyStoragePanelViewModel } from "../../../viewmodels/settings/encryption/KeyStoragePanelViewModel";
16+
import SdkConfig from "../../../../SdkConfig";
17+
import { EncryptionCardButtons } from "./EncryptionCardButtons";
18+
import { EncryptionCardEmphasisedContent } from "./EncryptionCardEmphasisedContent";
19+
20+
interface Props {
21+
/**
22+
* Called when the user either cancels the operation or key storage has been disabled
23+
*/
24+
onFinish: () => void;
25+
}
26+
27+
/**
28+
* Confirms that the user really wants to turn off and delete their key storage. Part of the "Encryption" settings tab.
29+
*/
30+
export function DeleteKeyStoragePanel({ onFinish }: Props): JSX.Element {
31+
const { setEnabled } = useKeyStoragePanelViewModel();
32+
const [busy, setBusy] = useState(false);
33+
34+
const onDeleteClick = useCallback(async () => {
35+
setBusy(true);
36+
try {
37+
await setEnabled(false);
38+
} finally {
39+
setBusy(false);
40+
}
41+
onFinish();
42+
}, [setEnabled, onFinish]);
43+
44+
return (
45+
<>
46+
<Breadcrumb
47+
backLabel={_t("action|back")}
48+
onBackClick={onFinish}
49+
pages={[_t("settings|encryption|title"), _t("settings|encryption|delete_key_storage|breadcrumb_page")]}
50+
onPageClick={onFinish}
51+
/>
52+
<EncryptionCard
53+
Icon={ErrorIcon}
54+
destructive={true}
55+
title={_t("settings|encryption|delete_key_storage|title")}
56+
>
57+
<EncryptionCardEmphasisedContent>
58+
{_t("settings|encryption|delete_key_storage|description")}
59+
<VisualList>
60+
<VisualListItem Icon={CrossIcon} destructive={true}>
61+
{_t("settings|encryption|delete_key_storage|list_first")}
62+
</VisualListItem>
63+
<VisualListItem Icon={CrossIcon} destructive={true}>
64+
{_t("settings|encryption|delete_key_storage|list_second", { brand: SdkConfig.get().brand })}
65+
</VisualListItem>
66+
</VisualList>
67+
</EncryptionCardEmphasisedContent>
68+
<EncryptionCardButtons>
69+
<Button destructive={true} onClick={onDeleteClick} disabled={busy}>
70+
{_t("settings|encryption|delete_key_storage|confirm")}
71+
</Button>
72+
<Button kind="tertiary" onClick={onFinish}>
73+
{_t("action|cancel")}
74+
</Button>
75+
</EncryptionCardButtons>
76+
</EncryptionCard>
77+
</>
78+
);
79+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React, { useCallback } from "react";
9+
import { InlineField, InlineSpinner, Label, Root, ToggleControl } from "@vector-im/compound-web";
10+
11+
import type { FormEvent } from "react";
12+
import { SettingsSection } from "../shared/SettingsSection";
13+
import { _t } from "../../../../languageHandler";
14+
import { SettingsHeader } from "../SettingsHeader";
15+
import { useKeyStoragePanelViewModel } from "../../../viewmodels/settings/encryption/KeyStoragePanelViewModel";
16+
17+
interface Props {
18+
/**
19+
* Called when the user turns off the "allow key storage" toggle
20+
*/
21+
onKeyStorageDisableClick: () => void;
22+
}
23+
24+
/**
25+
* This component allows the user to set up or change their recovery key.
26+
*
27+
* It is used within the "Encryption" settings tab.
28+
*/
29+
export const KeyStoragePanel: React.FC<Props> = ({ onKeyStorageDisableClick }) => {
30+
const { isEnabled, setEnabled, loading, busy } = useKeyStoragePanelViewModel();
31+
32+
const onKeyBackupChange = useCallback(
33+
(e: FormEvent<HTMLInputElement>) => {
34+
if (e.currentTarget.checked) {
35+
setEnabled(true);
36+
} else {
37+
onKeyStorageDisableClick();
38+
}
39+
},
40+
[setEnabled, onKeyStorageDisableClick],
41+
);
42+
43+
if (loading) {
44+
return <InlineSpinner aria-label={_t("common|loading")} />;
45+
}
46+
47+
return (
48+
<SettingsSection
49+
legacy={false}
50+
heading={
51+
<SettingsHeader
52+
hasRecommendedTag={isEnabled === false}
53+
label={_t("settings|encryption|key_storage|title")}
54+
/>
55+
}
56+
subHeading={_t("settings|encryption|key_storage|description", undefined, {
57+
a: (sub) => (
58+
<a href="https://element.io/help#encryption5" target="_blank" rel="noreferrer noopener">
59+
{sub}
60+
</a>
61+
),
62+
})}
63+
>
64+
<Root className="mx_KeyStoragePanel_toggleRow">
65+
<InlineField
66+
name="keyStorage"
67+
control={<ToggleControl name="keyStorage" checked={isEnabled} onChange={onKeyBackupChange} />}
68+
>
69+
<Label>{_t("settings|encryption|key_storage|allow_key_storage")}</Label>
70+
</InlineField>
71+
{busy && <InlineSpinner />}
72+
</Root>
73+
</SettingsSection>
74+
);
75+
};

0 commit comments

Comments
 (0)