Add key storage toggle to Encryption settings

This commit is contained in:
David Baker
2025-01-27 16:56:25 +00:00
parent 95da3834f2
commit 5a74298d66
7 changed files with 271 additions and 34 deletions

View File

@@ -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";

View File

@@ -0,0 +1,3 @@
.mx_KeyBackupPanel_toggleRow {
flex-direction: row;
}

View File

@@ -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?

View File

@@ -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,
};
}

View File

@@ -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<HTMLInputElement>) => {
setEnabled(e.currentTarget.checked);
},
[setEnabled],
);
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>
);
}

View File

@@ -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<boolean | undefined>(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<State>("loading");
const setUpEncryptionRequired = useSetUpEncryptionRequired(setState);
const keyBackupIsEnabled = useKeyBackupIsEnabled();
let content: JSX.Element;
switch (state) {
case "loading":
content = <InlineSpinner aria-label={_t("common|loading")} />;
break;
case "set_up_encryption":
content = <SetUpEncryptionPanel onFinish={setUpEncryptionRequired} />;
break;
case "main":
content = (
<>
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
if (keyBackupIsEnabled === undefined) {
content = <Spinner />;
} else {
switch (state) {
case "loading":
content = <InlineSpinner aria-label={_t("common|loading")} />;
break;
case "set_up_encryption":
content = <SetUpEncryptionPanel onFinish={setUpEncryptionRequired} />;
break;
case "main":
content = (
<>
<KeyBackupPanel />
{keyBackupIsEnabled && (
<>
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<Separator kind="section" />
</>
)}
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity")} />
</>
);
break;
case "change_recovery_key":
case "set_recovery_key":
content = (
<ChangeRecoveryKey
userHasRecoveryKey={state === "change_recovery_key"}
onCancelClick={() => setState("main")}
onFinish={() => setState("main")}
/>
<Separator kind="section" />
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity")} />
</>
);
break;
case "change_recovery_key":
case "set_recovery_key":
content = (
<ChangeRecoveryKey
userHasRecoveryKey={state === "change_recovery_key"}
onCancelClick={() => setState("main")}
onFinish={() => setState("main")}
/>
);
break;
case "reset_identity":
content = <ResetIdentityPanel onCancelClick={() => setState("main")} onFinish={() => setState("main")} />;
break;
);
break;
case "reset_identity":
content = (
<ResetIdentityPanel onCancelClick={() => setState("main")} onFinish={() => setState("main")} />
);
break;
}
}
return (

View File

@@ -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": "<strong>Settings:</strong> 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. <a>Learn more</a>",
"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.",