Prompt the user when key storage is unexpectedly off (#29912)

* Assert that we set backup_disabled when turning off key storage

* Prompt the user when key storage is unexpectedly off

* Playwright tests for the Turn on key storage toast
This commit is contained in:
Andy Balaam
2025-05-20 13:28:22 +01:00
committed by GitHub
parent 22c7bf346c
commit b539eda4fe
16 changed files with 655 additions and 20 deletions

View File

@@ -97,6 +97,7 @@ export default class DeviceListener {
this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
this.client.on(ClientEvent.AccountData, this.onAccountData);
this.client.on(ClientEvent.Sync, this.onSync);
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
@@ -132,7 +133,7 @@ export default class DeviceListener {
this.dismissedThisDeviceToast = false;
this.keyBackupInfo = null;
this.keyBackupFetchedAt = null;
this.cachedKeyBackupStatus = undefined;
this.cachedKeyBackupUploadActive = undefined;
this.ourDeviceIdsAtStart = null;
this.displayingToastsForDeviceIds = new Set();
this.client = undefined;
@@ -157,6 +158,13 @@ export default class DeviceListener {
this.recheck();
}
/**
* Set the account data "m.org.matrix.custom.backup_disabled" to { "disabled": true }.
*/
public async recordKeyBackupDisabled(): Promise<void> {
await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
}
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
if (this.ourDeviceIdsAtStart === null) {
this.ourDeviceIdsAtStart = await this.getDeviceIds();
@@ -192,6 +200,11 @@ export default class DeviceListener {
this.recheck();
};
private onKeyBackupStatusChanged = (): void => {
this.cachedKeyBackupUploadActive = undefined;
this.recheck();
};
private onCrossSingingKeysChanged = (): void => {
this.recheck();
};
@@ -201,11 +214,13 @@ export default class DeviceListener {
// * migrated SSSS to symmetric
// * uploaded keys to secret storage
// * completed secret storage creation
// * disabled key backup
// which result in account data changes affecting checks below.
if (
ev.getType().startsWith("m.secret_storage.") ||
ev.getType().startsWith("m.cross_signing.") ||
ev.getType() === "m.megolm_backup.v1"
ev.getType() === "m.megolm_backup.v1" ||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY
) {
this.recheck();
}
@@ -324,7 +339,16 @@ export default class DeviceListener {
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);
const allSystemsReady = crossSigningReady && secretStorageReady && allCrossSigningSecretsCached;
const keyBackupUploadActive = await this.isKeyBackupUploadActive();
const backupDisabled = await this.recheckBackupDisabled(cli);
// We warn if key backup upload is turned off and we have not explicitly
// said we are OK with that.
const keyBackupIsOk = keyBackupUploadActive || backupDisabled;
const allSystemsReady =
crossSigningReady && keyBackupIsOk && secretStorageReady && allCrossSigningSecretsCached;
await this.reportCryptoSessionStateToAnalytics(cli);
if (this.dismissedThisDeviceToast || allSystemsReady) {
@@ -353,14 +377,19 @@ export default class DeviceListener {
crossSigningStatus.privateKeysCachedLocally,
);
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
} else if (!keyBackupIsOk) {
logSpan.info("Key backup upload is unexpectedly turned off: showing TURN_ON_KEY_STORAGE toast");
showSetupEncryptionToast(SetupKind.TURN_ON_KEY_STORAGE);
} else if (defaultKeyId === null) {
// the user just hasn't set up 4S yet: prompt them to do so (unless they've explicitly said no to key storage)
const disabledEvent = cli.getAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
if (!disabledEvent?.getContent().disabled) {
// The user just hasn't set up 4S yet: if they have key
// backup, prompt them to turn on recovery too. (If not, they
// have explicitly opted out, so don't hassle them.)
if (keyBackupUploadActive) {
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
} else {
logSpan.info("No default 4S key but backup disabled: no toast needed");
hideSetupEncryptionToast();
}
} else {
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
@@ -443,6 +472,16 @@ export default class DeviceListener {
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
}
/**
* Fetch the account data for `backup_disabled`. If this is the first time,
* fetch it from the server (in case the initial sync has not finished).
* Otherwise, fetch it from the store as normal.
*/
private async recheckBackupDisabled(cli: MatrixClient): Promise<boolean> {
const backupDisabled = await cli.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
return !!backupDisabled?.disabled;
}
/**
* Reports current recovery state to analytics.
* Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S).
@@ -512,7 +551,7 @@ export default class DeviceListener {
* trigger an auto-rageshake).
*/
private checkKeyBackupStatus = async (): Promise<void> => {
if (!(await this.getKeyBackupStatus())) {
if (!(await this.isKeyBackupUploadActive())) {
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
}
};
@@ -520,28 +559,34 @@ export default class DeviceListener {
/**
* Is key backup enabled? Use a cached answer if we have one.
*/
private getKeyBackupStatus = async (): Promise<boolean> => {
private isKeyBackupUploadActive = async (): Promise<boolean> => {
if (!this.client) {
// To preserve existing behaviour, if there is no client, we
// pretend key storage is on.
// pretend key backup upload is on.
//
// Someone looking to improve this code could try throwing an error
// here since we don't expect client to be undefined.
return true;
}
const crypto = this.client.getCrypto();
if (!crypto) {
// If there is no crypto, there is no key backup
return false;
}
// If we've already cached the answer, return it.
if (this.cachedKeyBackupStatus !== undefined) {
return this.cachedKeyBackupStatus;
if (this.cachedKeyBackupUploadActive !== undefined) {
return this.cachedKeyBackupUploadActive;
}
// Fetch the answer and cache it
const activeKeyBackupVersion = await this.client.getCrypto()?.getActiveSessionBackupVersion();
this.cachedKeyBackupStatus = !!activeKeyBackupVersion;
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
return this.cachedKeyBackupStatus;
return this.cachedKeyBackupUploadActive;
};
private cachedKeyBackupStatus: boolean | undefined = undefined;
private cachedKeyBackupUploadActive: boolean | undefined = undefined;
private onRecordClientInformationSettingChange: CallbackFn = (
_originalSettingName,

View File

@@ -0,0 +1,80 @@
/*
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 from "react";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import { Button } from "@vector-im/compound-web";
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { EncryptionCard } from "../settings/encryption/EncryptionCard";
import { EncryptionCardButtons } from "../settings/encryption/EncryptionCardButtons";
import { type OpenToTabPayload } from "../../../dispatcher/payloads/OpenToTabPayload";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserTab";
interface Props {
onFinished: (dismissed: boolean) => void;
}
/**
* Ask the user whether they really want to dismiss the toast about key storage.
*
* Launched from the {@link SetupEncryptionToast} in mode `TURN_ON_KEY_STORAGE`,
* when the user clicks "Dismiss". The caller handles any action via the
* `onFinished` prop which takes a boolean that is true if the user clicked
* "Yes, dismiss".
*/
export default class ConfirmKeyStorageOffDialog extends React.Component<Props> {
public constructor(props: Props) {
super(props);
}
private onGoToSettingsClick = (): void => {
// Open Settings at the Encryption tab
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
};
defaultDispatcher.dispatch(payload);
this.props.onFinished(false);
};
private onDismissClick = (): void => {
this.props.onFinished(true);
};
public render(): React.ReactNode {
return (
<EncryptionCard
Icon={ErrorIcon}
destructive={true}
title={_t("settings|encryption|confirm_key_storage_off")}
>
{_t("settings|encryption|confirm_key_storage_off_description", undefined, {
a: (sub) => (
<>
<br />
<a href="https://element.io/help#encryption5" target="_blank" rel="noreferrer noopener">
{sub} <PopOutIcon />
</a>
</>
),
})}
<EncryptionCardButtons>
<Button onClick={this.onGoToSettingsClick} autoFocus kind="primary" className="">
{_t("common|go_to_settings")}
</Button>
<Button onClick={this.onDismissClick} kind="secondary">
{_t("action|yes_dismiss")}
</Button>
</EncryptionCardButtons>
</EncryptionCard>
);
}
}

View File

@@ -157,6 +157,7 @@
"view_message": "View message",
"view_source": "View Source",
"yes": "Yes",
"yes_dismiss": "Yes, dismiss",
"zoom_in": "Zoom in",
"zoom_out": "Zoom out"
},
@@ -981,6 +982,8 @@
"setup_secure_backup": {
"explainer": "Back up your keys before signing out to avoid losing them."
},
"turn_on_key_storage": "Turn on key storage",
"turn_on_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.",
"udd": {
"interactive_verification_button": "Interactively verify by emoji",
"other_ask_verify_text": "Ask this user to verify their session, or manually verify it below.",
@@ -2559,6 +2562,8 @@
"session_key": "Session key:",
"title": "Advanced"
},
"confirm_key_storage_off": "Are you sure you want to keep key storage turned off?",
"confirm_key_storage_off_description": "If you sign out of all your devices you will lose your message history and will need to verify all your existing contacts again. <a>Learn more</a>",
"delete_key_storage": {
"breadcrumb_page": "Delete key storage",
"confirm": "Delete key storage",

View File

@@ -24,6 +24,7 @@ import { type OpenToTabPayload } from "../dispatcher/payloads/OpenToTabPayload";
import { Action } from "../dispatcher/actions";
import { UserTab } from "../components/views/dialogs/UserTab";
import defaultDispatcher from "../dispatcher/dispatcher";
import ConfirmKeyStorageOffDialog from "../components/views/dialogs/ConfirmKeyStorageOffDialog";
const TOAST_KEY = "setupencryption";
@@ -37,6 +38,8 @@ const getTitle = (kind: Kind): string => {
return _t("encryption|verify_toast_title");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
return _t("encryption|key_storage_out_of_sync");
case Kind.TURN_ON_KEY_STORAGE:
return _t("encryption|turn_on_key_storage");
}
};
@@ -49,6 +52,8 @@ const getIcon = (kind: Kind): string | undefined => {
case Kind.VERIFY_THIS_SESSION:
case Kind.KEY_STORAGE_OUT_OF_SYNC:
return "verification_warning";
case Kind.TURN_ON_KEY_STORAGE:
return "key_storage";
}
};
@@ -62,6 +67,8 @@ const getSetupCaption = (kind: Kind): string => {
return _t("action|verify");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
return _t("encryption|enter_recovery_key");
case Kind.TURN_ON_KEY_STORAGE:
return _t("action|continue");
}
};
@@ -87,6 +94,8 @@ const getSecondaryButtonLabel = (kind: Kind): string => {
return _t("encryption|verification|unverified_sessions_toast_reject");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
return _t("encryption|forgot_recovery_key");
case Kind.TURN_ON_KEY_STORAGE:
return _t("action|dismiss");
}
};
@@ -100,6 +109,8 @@ const getDescription = (kind: Kind): string => {
return _t("encryption|verify_toast_description");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
return _t("encryption|key_storage_out_of_sync_description");
case Kind.TURN_ON_KEY_STORAGE:
return _t("encryption|turn_on_key_storage_description");
}
};
@@ -123,6 +134,10 @@ export enum Kind {
* Prompt the user to enter their recovery key
*/
KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync",
/**
* Prompt the user to turn on key storage
*/
TURN_ON_KEY_STORAGE = "turn_on_key_storage",
}
/**
@@ -143,6 +158,13 @@ export const showToast = (kind: Kind): void => {
const onPrimaryClick = async (): Promise<void> => {
if (kind === Kind.VERIFY_THIS_SESSION) {
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
} else if (kind == Kind.TURN_ON_KEY_STORAGE) {
// Open the user settings dialog to the encryption tab
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
};
defaultDispatcher.dispatch(payload);
} else {
const modal = Modal.createDialog(
Spinner,
@@ -161,7 +183,7 @@ export const showToast = (kind: Kind): void => {
}
};
const onSecondaryClick = (): void => {
const onSecondaryClick = async (): Promise<void> => {
if (kind === Kind.KEY_STORAGE_OUT_OF_SYNC) {
// Open the user settings dialog to the encryption tab and start the flow to reset encryption
const payload: OpenToTabPayload = {
@@ -170,6 +192,15 @@ export const showToast = (kind: Kind): void => {
props: { initialEncryptionState: "reset_identity_forgot" },
};
defaultDispatcher.dispatch(payload);
} else if (kind === Kind.TURN_ON_KEY_STORAGE) {
// The user clicked "Dismiss": offer them "Are you sure?"
const modal = Modal.createDialog(ConfirmKeyStorageOffDialog, undefined, "mx_ConfirmKeyStorageOffDialog");
const [dismissed] = await modal.finished;
if (dismissed) {
const deviceListener = DeviceListener.sharedInstance();
await deviceListener.recordKeyBackupDisabled();
deviceListener.dismissEncryptionSetup();
}
} else {
DeviceListener.sharedInstance().dismissEncryptionSetup();
}