Skip to content
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

Add key storage toggle to Encryption settings #29113

Draft
wants to merge 16 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*

Check failure on line 1 in playwright/e2e/settings/encryption-user-tab/advanced.spec.ts

View workflow job for this annotation

GitHub Actions / Run Tests [Chrome] 4/6

[Chrome] › settings/encryption-user-tab/advanced.spec.ts:31:9 › Advanced section in Encryption tab › should show the import room keys dialog

2) [Chrome] › settings/encryption-user-tab/advanced.spec.ts:31:9 › Advanced section in Encryption tab › should show the import room keys dialog Test timeout of 30000ms exceeded.

Check failure on line 1 in playwright/e2e/settings/encryption-user-tab/advanced.spec.ts

View workflow job for this annotation

GitHub Actions / Run Tests [Chrome] 4/6

[Chrome] › settings/encryption-user-tab/advanced.spec.ts:31:9 › Advanced section in Encryption tab › should show the import room keys dialog

2) [Chrome] › settings/encryption-user-tab/advanced.spec.ts:31:9 › Advanced section in Encryption tab › should show the import room keys dialog Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Test timeout of 30000ms exceeded.

Check failure on line 1 in playwright/e2e/settings/encryption-user-tab/advanced.spec.ts

View workflow job for this annotation

GitHub Actions / Run Tests [Chrome] 4/6

[Chrome] › settings/encryption-user-tab/advanced.spec.ts:31:9 › Advanced section in Encryption tab › should show the import room keys dialog

2) [Chrome] › settings/encryption-user-tab/advanced.spec.ts:31:9 › Advanced section in Encryption tab › should show the import room keys dialog Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Test timeout of 30000ms exceeded.

Check failure on line 1 in playwright/e2e/settings/encryption-user-tab/advanced.spec.ts

View workflow job for this annotation

GitHub Actions / Run Tests [Chrome] 4/6

[Chrome] › settings/encryption-user-tab/advanced.spec.ts:39:9 › Advanced section in Encryption tab › should show the export room keys dialog

3) [Chrome] › settings/encryption-user-tab/advanced.spec.ts:39:9 › Advanced section in Encryption tab › should show the export room keys dialog Test timeout of 30000ms exceeded.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Expand All @@ -21,7 +21,7 @@
const section = util.getEncryptionDetailsSection();

const deviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId());
await expect(section.getByText(deviceId)).toBeVisible();

Check failure on line 24 in playwright/e2e/settings/encryption-user-tab/advanced.spec.ts

View workflow job for this annotation

GitHub Actions / Run Tests [Chrome] 4/6

[Chrome] › settings/encryption-user-tab/advanced.spec.ts:19:9 › Advanced section in Encryption tab › should show the encryption details @screenshot

1) [Chrome] › settings/encryption-user-tab/advanced.spec.ts:19:9 › Advanced section in Encryption tab › should show the encryption details @screenshot Error: Timed out 5000ms waiting for expect(locator).toBeVisible() Locator: getByTestId('encryptionDetails').getByText('WJXBAYJKZX') Expected: visible Received: <element(s) not found> Call log: - expect.toBeVisible with timeout 5000ms - waiting for getByTestId('encryptionDetails').getByText('WJXBAYJKZX') 22 | 23 | const deviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId()); > 24 | await expect(section.getByText(deviceId)).toBeVisible(); | ^ 25 | 26 | await expect(section).toMatchScreenshot("encryption-details.png", { 27 | mask: [section.getByTestId("deviceId"), section.getByTestId("sessionKey")], at /home/runner/work/element-web/element-web/playwright/e2e/settings/encryption-user-tab/advanced.spec.ts:24:51

Check failure on line 24 in playwright/e2e/settings/encryption-user-tab/advanced.spec.ts

View workflow job for this annotation

GitHub Actions / Run Tests [Chrome] 4/6

[Chrome] › settings/encryption-user-tab/advanced.spec.ts:19:9 › Advanced section in Encryption tab › should show the encryption details @screenshot

1) [Chrome] › settings/encryption-user-tab/advanced.spec.ts:19:9 › Advanced section in Encryption tab › should show the encryption details @screenshot Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: Timed out 5000ms waiting for expect(locator).toBeVisible() Locator: getByTestId('encryptionDetails').getByText('ZVIYYSACSK') Expected: visible Received: <element(s) not found> Call log: - expect.toBeVisible with timeout 5000ms - waiting for getByTestId('encryptionDetails').getByText('ZVIYYSACSK') 22 | 23 | const deviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId()); > 24 | await expect(section.getByText(deviceId)).toBeVisible(); | ^ 25 | 26 | await expect(section).toMatchScreenshot("encryption-details.png", { 27 | mask: [section.getByTestId("deviceId"), section.getByTestId("sessionKey")], at /home/runner/work/element-web/element-web/playwright/e2e/settings/encryption-user-tab/advanced.spec.ts:24:51

Check failure on line 24 in playwright/e2e/settings/encryption-user-tab/advanced.spec.ts

View workflow job for this annotation

GitHub Actions / Run Tests [Chrome] 4/6

[Chrome] › settings/encryption-user-tab/advanced.spec.ts:19:9 › Advanced section in Encryption tab › should show the encryption details @screenshot

1) [Chrome] › settings/encryption-user-tab/advanced.spec.ts:19:9 › Advanced section in Encryption tab › should show the encryption details @screenshot Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: Timed out 5000ms waiting for expect(locator).toBeVisible() Locator: getByTestId('encryptionDetails').getByText('DBLCXYQLOJ') Expected: visible Received: <element(s) not found> Call log: - expect.toBeVisible with timeout 5000ms - waiting for getByTestId('encryptionDetails').getByText('DBLCXYQLOJ') 22 | 23 | const deviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId()); > 24 | await expect(section.getByText(deviceId)).toBeVisible(); | ^ 25 | 26 | await expect(section).toMatchScreenshot("encryption-details.png", { 27 | mask: [section.getByTestId("deviceId"), section.getByTestId("sessionKey")], at /home/runner/work/element-web/element-web/playwright/e2e/settings/encryption-user-tab/advanced.spec.ts:24:51

await expect(section).toMatchScreenshot("encryption-details.png", {
mask: [section.getByTestId("deviceId"), section.getByTestId("sessionKey")],
Expand All @@ -32,7 +32,7 @@
await util.openEncryptionTab();
const section = util.getEncryptionDetailsSection();

await section.getByRole("button", { name: "Import keys" }).click();

Check failure on line 35 in playwright/e2e/settings/encryption-user-tab/advanced.spec.ts

View workflow job for this annotation

GitHub Actions / Run Tests [Chrome] 4/6

[Chrome] › settings/encryption-user-tab/advanced.spec.ts:31:9 › Advanced section in Encryption tab › should show the import room keys dialog

2) [Chrome] › settings/encryption-user-tab/advanced.spec.ts:31:9 › Advanced section in Encryption tab › should show the import room keys dialog Error: locator.click: Test timeout of 30000ms exceeded. Call log: - waiting for getByTestId('encryptionDetails').getByRole('button', { name: 'Import keys' }) 33 | const section = util.getEncryptionDetailsSection(); 34 | > 35 | await section.getByRole("button", { name: "Import keys" }).click(); | ^ 36 | await expect(page.getByRole("heading", { name: "Import room keys" })).toBeVisible(); 37 | }); 38 | at /home/runner/work/element-web/element-web/playwright/e2e/settings/encryption-user-tab/advanced.spec.ts:35:68

Check failure on line 35 in playwright/e2e/settings/encryption-user-tab/advanced.spec.ts

View workflow job for this annotation

GitHub Actions / Run Tests [Chrome] 4/6

[Chrome] › settings/encryption-user-tab/advanced.spec.ts:31:9 › Advanced section in Encryption tab › should show the import room keys dialog

2) [Chrome] › settings/encryption-user-tab/advanced.spec.ts:31:9 › Advanced section in Encryption tab › should show the import room keys dialog Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: locator.click: Test timeout of 30000ms exceeded. Call log: - waiting for getByTestId('encryptionDetails').getByRole('button', { name: 'Import keys' }) 33 | const section = util.getEncryptionDetailsSection(); 34 | > 35 | await section.getByRole("button", { name: "Import keys" }).click(); | ^ 36 | await expect(page.getByRole("heading", { name: "Import room keys" })).toBeVisible(); 37 | }); 38 | at /home/runner/work/element-web/element-web/playwright/e2e/settings/encryption-user-tab/advanced.spec.ts:35:68

Check failure on line 35 in playwright/e2e/settings/encryption-user-tab/advanced.spec.ts

View workflow job for this annotation

GitHub Actions / Run Tests [Chrome] 4/6

[Chrome] › settings/encryption-user-tab/advanced.spec.ts:31:9 › Advanced section in Encryption tab › should show the import room keys dialog

2) [Chrome] › settings/encryption-user-tab/advanced.spec.ts:31:9 › Advanced section in Encryption tab › should show the import room keys dialog Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: locator.click: Test timeout of 30000ms exceeded. Call log: - waiting for getByTestId('encryptionDetails').getByRole('button', { name: 'Import keys' }) 33 | const section = util.getEncryptionDetailsSection(); 34 | > 35 | await section.getByRole("button", { name: "Import keys" }).click(); | ^ 36 | await expect(page.getByRole("heading", { name: "Import room keys" })).toBeVisible(); 37 | }); 38 | at /home/runner/work/element-web/element-web/playwright/e2e/settings/encryption-user-tab/advanced.spec.ts:35:68
await expect(page.getByRole("heading", { name: "Import room keys" })).toBeVisible();
});

Expand Down Expand Up @@ -63,9 +63,7 @@
await expect(section.getByRole("button", { name: "Reset cryptographic identity" })).toBeVisible();

// After resetting the identity, the user should set up a new recovery key
await expect(
util.getEncryptionRecoverySection().getByRole("button", { name: "Set up recovery" }),
).toBeVisible();
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();

await checkDeviceIsCrossSigned(app);
},
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions res/css/_components.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.mx_KeyBackupPanel_toggleRow {
flex-direction: row;
}
7 changes: 5 additions & 2 deletions src/DeviceListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Original file line number Diff line number Diff line change
@@ -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<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 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,
};
}
Original file line number Diff line number Diff line change
@@ -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 { 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 ResetIdentityPanelProps {
onFinish: () => void;
}

/**
* Confirms that the user really wants to turn off and delete their key storage
*/
export function DeleteKeyStoragePanel({ onFinish }: ResetIdentityPanelProps): 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_ResetIdentityPanel"
>
<div className="mx_ResetIdentityPanel_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_ResetIdentityPanel_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>
</>
);
}
70 changes: 70 additions & 0 deletions src/components/views/settings/encryption/KeyStoragePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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 {
onKeyStorageDisableClick: () => void;
}

/**
* 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 />;
}

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">
<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>
);
};
Loading
Loading