Handle cross-signing keys missing locally and/or from secret storage (#31367)

* show correct toast when cross-signing keys missing

If cross-signing keys are missing both locally and in 4S, show a new toast
saying that identity needs resetting, rather than saying that the device
needs to be verified.

* refactor: make DeviceListener in charge of device state

- move enum from SetupEncryptionToast to DeviceListener
- DeviceListener has public method to get device state
- DeviceListener emits events to update device state

* reset key backup when needed in RecoveryPanelOutOfSync

brings RecoveryPanelOutOfSync in line with SetupEncryptionToast behaviour

* update strings to agree with designs from Figma

* use DeviceListener to determine EncryptionUserSettingsTab display

rather than using its own logic

* prompt to reset identity in Encryption Settings when needed

* fix type

* calculate device state even if we aren't going to show a toast

* update snapshot

* make logs more accurate

* add tests

* make the bot use a different access token/device

* only log in a new session when requested

* Mark properties as read-only

Co-authored-by: Skye Elliot <actuallyori@gmail.com>

* remove some duplicate strings

* make accessToken optional instead of using empty string

* switch from enum to string union as per review

* apply other changes from review

* handle errors in accessSecretStorage

* remove incorrect testid

---------

Co-authored-by: Skye Elliot <actuallyori@gmail.com>
This commit is contained in:
Hubert Chathi
2025-12-19 12:00:50 -05:00
committed by GitHub
parent ce9c66ba4c
commit ebd5df633e
14 changed files with 668 additions and 343 deletions

View File

@@ -15,6 +15,7 @@ import {
RoomStateEvent,
type SyncState,
ClientStoppedError,
TypedEventEmitter,
} from "matrix-js-sdk/src/matrix";
import { logger as baseLogger, type BaseLogger, LogSpan } from "matrix-js-sdk/src/logger";
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
@@ -29,7 +30,6 @@ import {
} from "./toasts/BulkUnverifiedSessionsToast";
import {
hideToast as hideSetupEncryptionToast,
Kind as SetupKind,
showToast as showSetupEncryptionToast,
} from "./toasts/SetupEncryptionToast";
import {
@@ -65,7 +65,47 @@ export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery";
const logger = baseLogger.getChild("DeviceListener:");
export default class DeviceListener {
/**
* The state of the device and the user's account.
*/
export type DeviceState =
/**
* The device is in a good state.
*/
| "ok"
/**
* The user needs to set up recovery.
*/
| "set_up_recovery"
/**
* The device is not verified.
*/
| "verify_this_session"
/**
* Key storage is out of sync (keys are missing locally, from recovery, or both).
*/
| "key_storage_out_of_sync"
/**
* Key storage is not enabled, and has not been marked as purposely disabled.
*/
| "turn_on_key_storage"
/**
* The user's identity needs resetting, due to missing keys.
*/
| "identity_needs_reset";
/**
* The events emitted by {@link DeviceListener}
*/
export enum DeviceListenerEvents {
DeviceState = "device_state",
}
type EventHandlerMap = {
[DeviceListenerEvents.DeviceState]: (state: DeviceState) => void;
};
export default class DeviceListener extends TypedEventEmitter<DeviceListenerEvents, EventHandlerMap> {
private dispatcherRef?: string;
// device IDs for which the user has dismissed the verify toast ('Later')
private dismissed = new Set<string>();
@@ -87,6 +127,7 @@ export default class DeviceListener {
private shouldRecordClientInformation = false;
private enableBulkUnverifiedSessionsReminder = true;
private deviceClientInformationSettingWatcherRef: string | undefined;
private deviceState: DeviceState = "ok";
// Remember the current analytics state to avoid sending the same event multiple times.
private analyticsVerificationState?: string;
@@ -198,8 +239,8 @@ export default class DeviceListener {
}
/**
* If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck}
* requires a reset of cross-signing keys.
* If the device is in a `key_storage_out_of_sync` state, check if
* it requires a reset of cross-signing keys.
*
* We will reset cross-signing keys if both our local cache and 4S don't
* have all cross-signing keys.
@@ -227,16 +268,15 @@ export default class DeviceListener {
}
/**
* If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck}
* requires a reset of key backup.
* If the device is in a `"key_storage_out_of_sync"` state, check if
* it requires a reset of key backup.
*
* If the user has their recovery key, we need to reset backup if:
* - the user hasn't disabled backup,
* - we don't have the backup key cached locally, *and*
* - we don't have the backup key stored in 4S.
* (The user should already have a key backup created at this point,
* otherwise `doRecheck` would have triggered a `Kind.TURN_ON_KEY_STORAGE`
* condition.)
* (The user should already have a key backup created at this point, the
* device state would be `turn_on_key_storage`.)
*
* If the user has forgotten their recovery key, we need to reset backup if:
* - the user hasn't disabled backup, and
@@ -425,88 +465,93 @@ export default class DeviceListener {
const recoveryIsOk = secretStorageStatus.ready || recoveryDisabled;
const isCurrentDeviceTrusted =
crossSigningReady &&
Boolean(
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);
const isCurrentDeviceTrusted = Boolean(
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);
const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan);
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 keyBackupUploadIsOk = keyBackupUploadActive || backupDisabled;
// If key backup is active and not disabled: do we have the backup key
// cached locally?
const backupKeyCached =
// We warn if key backup is set up, but we don't have the decryption
// key, so can't fetch keys from backup.
const keyBackupDownloadIsOk =
!keyBackupUploadActive || backupDisabled || (await crypto.getSessionBackupPrivateKey()) !== null;
const allSystemsReady =
isCurrentDeviceTrusted && allCrossSigningSecretsCached && keyBackupIsOk && recoveryIsOk && backupKeyCached;
isCurrentDeviceTrusted &&
allCrossSigningSecretsCached &&
keyBackupUploadIsOk &&
recoveryIsOk &&
keyBackupDownloadIsOk;
await this.reportCryptoSessionStateToAnalytics(cli);
if (this.dismissedThisDeviceToast || allSystemsReady) {
if (allSystemsReady) {
logSpan.info("No toast needed");
hideSetupEncryptionToast();
await this.setDeviceState("ok", logSpan);
this.checkKeyBackupStatus();
} else if (await this.shouldShowSetupEncryptionToast()) {
} else {
// make sure our keys are finished downloading
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
if (!isCurrentDeviceTrusted) {
// the current device is not trusted: prompt the user to verify
logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast");
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
logSpan.info("Current device not verified: setting state to VERIFY_THIS_SESSION");
await this.setDeviceState("verify_this_session", logSpan);
} else if (!allCrossSigningSecretsCached) {
// cross signing ready & device trusted, but we are missing secrets from our local cache.
// prompt the user to enter their recovery key.
logSpan.info(
"Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast",
"Some secrets not cached: setting state to KEY_STORAGE_OUT_OF_SYNC",
crossSigningStatus.privateKeysCachedLocally,
crossSigningStatus.privateKeysInSecretStorage,
);
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);
await this.setDeviceState(
crossSigningStatus.privateKeysInSecretStorage ? "key_storage_out_of_sync" : "identity_needs_reset",
logSpan,
);
} else if (!keyBackupUploadIsOk) {
logSpan.info("Key backup upload is unexpectedly turned off: setting state to TURN_ON_KEY_STORAGE");
await this.setDeviceState("turn_on_key_storage", logSpan);
} else if (secretStorageStatus.defaultKeyId === null) {
// 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 (recoveryDisabled) {
logSpan.info("Recovery disabled: no toast needed");
hideSetupEncryptionToast();
await this.setDeviceState("ok", logSpan);
} else if (keyBackupUploadActive) {
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
logSpan.info("No default 4S key: setting state to SET_UP_RECOVERY");
await this.setDeviceState("set_up_recovery", logSpan);
} else {
logSpan.info("No default 4S key but backup disabled: no toast needed");
hideSetupEncryptionToast();
await this.setDeviceState("ok", logSpan);
}
} else {
// If we get here, then we are verified, have key backup, and
// 4S, but allSystemsReady is false, which means that either
// secretStorageStatus.ready is false (which means that 4S
// doesn't have all the secrets), or we don't have the backup
// key cached locally.
// key cached locally. If any of the cross-signing keys are
// missing locally, that is handled by the
// `!allCrossSigningSecretsCached` branch above.
logSpan.warn("4S is missing secrets or backup key not cached", {
crossSigningReady,
secretStorageStatus,
allCrossSigningSecretsCached,
isCurrentDeviceTrusted,
backupKeyCached,
keyBackupDownloadIsOk,
});
// We use the right toast variant based on whether the backup
// key is missing locally. If any of the cross-signing keys are
// missing locally, that is handled by the
// `!allCrossSigningSecretsCached` branch above.
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
await this.setDeviceState("key_storage_out_of_sync", logSpan);
}
if (this.dismissedThisDeviceToast) {
this.checkKeyBackupStatus();
}
} else {
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
}
// This needs to be done after awaiting on getUserDeviceInfo() above, so
@@ -598,6 +643,31 @@ export default class DeviceListener {
return recoveryStatus?.enabled === false;
}
/**
* Get the state of the device and the user's account. The device/account
* state indicates what action the user must take in order to get a
* self-verified device that is using key backup and recovery.
*/
public getDeviceState(): DeviceState {
return this.deviceState;
}
/**
* Set the state of the device, and perform any actions necessary in
* response to the state changing.
*/
private async setDeviceState(newState: DeviceState, logSpan: LogSpan): Promise<void> {
this.deviceState = newState;
this.emit(DeviceListenerEvents.DeviceState, newState);
if (newState === "ok" || this.dismissedThisDeviceToast) {
hideSetupEncryptionToast();
} else if (await this.shouldShowSetupEncryptionToast()) {
showSetupEncryptionToast(newState);
} else {
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
}
}
/**
* 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).

View File

@@ -12,13 +12,20 @@ import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
import { SettingsSection } from "../shared/SettingsSection";
import { _t } from "../../../../languageHandler";
import { SettingsSubheader } from "../SettingsSubheader";
import { accessSecretStorage } from "../../../../SecurityManager";
import { AccessCancelledError, accessSecretStorage } from "../../../../SecurityManager";
import DeviceListener from "../../../../DeviceListener";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
interface RecoveryPanelOutOfSyncProps {
/**
* Callback for when the user has finished entering their recovery key.
*/
onFinish: () => void;
/**
* Callback for when accessing secret storage fails.
*/
onAccessSecretStorageFailed: () => void;
/**
* Callback for when the user clicks on the "Forgot recovery key?" button.
*/
@@ -32,7 +39,13 @@ interface RecoveryPanelOutOfSyncProps {
* It prompts the user to enter their recovery key so that the secrets can be loaded from 4S into
* the client.
*/
export function RecoveryPanelOutOfSync({ onForgotRecoveryKey, onFinish }: RecoveryPanelOutOfSyncProps): JSX.Element {
export function RecoveryPanelOutOfSync({
onForgotRecoveryKey,
onAccessSecretStorageFailed,
onFinish,
}: RecoveryPanelOutOfSyncProps): JSX.Element {
const matrixClient = useMatrixClientContext();
return (
<SettingsSection
legacy={false}
@@ -55,7 +68,39 @@ export function RecoveryPanelOutOfSync({ onForgotRecoveryKey, onFinish }: Recove
kind="primary"
Icon={KeyIcon}
onClick={async () => {
await accessSecretStorage();
const crypto = matrixClient.getCrypto()!;
const deviceListener = DeviceListener.sharedInstance();
// we need to call keyStorageOutOfSyncNeedsBackupReset here because
// deviceListener.whilePaused() sets its client to undefined, so
// keyStorageOutOfSyncNeedsBackupReset won't be able to check
// the backup state.
const needsBackupReset = await deviceListener.keyStorageOutOfSyncNeedsBackupReset(false);
try {
// pause the device listener because we could be making lots
// of changes, and don't want toasts to pop up and disappear
// while we're doing it
await deviceListener.whilePaused(async () => {
await accessSecretStorage(async () => {
// Reset backup if needed.
if (needsBackupReset) {
await resetKeyBackupAndWait(crypto);
} else if (await matrixClient.isKeyBackupKeyStored()) {
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
}
});
});
} catch (error) {
if (error instanceof AccessCancelledError) {
// The user cancelled the dialog - just allow it to
// close, and return to this panel
} else {
onAccessSecretStorageFailed();
}
return;
}
onFinish();
}}
>

View File

@@ -5,15 +5,13 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useCallback, useEffect, useState } from "react";
import { Button, InlineSpinner, Separator } from "@vector-im/compound-web";
import React, { type JSX, useState } from "react";
import { Button, Separator } from "@vector-im/compound-web";
import ComputerIcon from "@vector-im/compound-design-tokens/assets/web/icons/computer";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import SettingsTab from "../SettingsTab";
import { RecoveryPanel } from "../../encryption/RecoveryPanel";
import { ChangeRecoveryKey } from "../../encryption/ChangeRecoveryKey";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
import SetupEncryptionDialog from "../../../dialogs/security/SetupEncryptionDialog";
@@ -23,17 +21,15 @@ import { AdvancedPanel } from "../../encryption/AdvancedPanel";
import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel";
import { type ResetIdentityBodyVariant } from "../../encryption/ResetIdentityBody";
import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync";
import { useTypedEventEmitter } from "../../../../../hooks/useEventEmitter";
import { useTypedEventEmitterState } from "../../../../../hooks/useEventEmitter";
import { KeyStoragePanel } from "../../encryption/KeyStoragePanel";
import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel";
import DeviceListener, { DeviceListenerEvents, type DeviceState } from "../../../../../DeviceListener";
import { useKeyStoragePanelViewModel } from "../../../../viewmodels/settings/encryption/KeyStoragePanelViewModel";
/**
* The state in the encryption settings tab.
* - "loading": We are checking if the device is verified.
* - "main": The main panel with all the sections (Key storage, recovery, advanced).
* - "key_storage_disabled": The user has chosen to disable key storage and options are unavailable as a result.
* - "set_up_encryption": The panel to show when the user is setting up their encryption.
* This happens when the user doesn't have cross-signing enabled, or their current device is not verified.
* - "change_recovery_key": The panel to show when the user is changing their recovery key.
* This happens when the user has a recovery key and the user clicks on "Change recovery key" button of the RecoveryPanel.
* - "set_recovery_key": The panel to show when the user is setting up their recovery key.
@@ -41,21 +37,17 @@ import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel";
* - "reset_identity_compromised": The panel to show when the user is resetting their identity, in the case where their key is compromised.
* - "reset_identity_forgot": The panel to show when the user is resetting their identity, in the case where they forgot their recovery key.
* - "reset_identity_sync_failed": The panel to show when the user us resetting their identity, in the case where recovery failed.
* - "secrets_not_cached": The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
* If the "set_up_encryption" and "secrets_not_cached" conditions are both filled, "set_up_encryption" prevails.
* - "reset_identity_cant_recover": The panel to show when the user is resetting their identity, in the case where they can't use recovery.
* - "key_storage_delete": The confirmation page asking if the user really wants to turn off key storage.
*/
export type State =
| "loading"
| "main"
| "key_storage_disabled"
| "set_up_encryption"
| "change_recovery_key"
| "set_recovery_key"
| "reset_identity_compromised"
| "reset_identity_forgot"
| "reset_identity_sync_failed"
| "secrets_not_cached"
| "reset_identity_cant_recover"
| "key_storage_delete";
interface Props {
@@ -68,48 +60,69 @@ interface Props {
/**
* The encryption settings tab.
*/
export function EncryptionUserSettingsTab({ initialState = "loading" }: Props): JSX.Element {
export function EncryptionUserSettingsTab({ initialState = "main" }: Readonly<Props>): JSX.Element {
const [state, setState] = useState<State>(initialState);
const checkEncryptionState = useCheckEncryptionState(state, setState);
const deviceState = useTypedEventEmitterState(
DeviceListener.sharedInstance(),
DeviceListenerEvents.DeviceState,
(state?: DeviceState): DeviceState => {
return state ?? DeviceListener.sharedInstance().getDeviceState();
},
);
const { isEnabled: isBackupEnabled } = useKeyStoragePanelViewModel();
let content: JSX.Element;
switch (state) {
case "loading":
content = <InlineSpinner aria-label={_t("common|loading")} />;
break;
case "set_up_encryption":
content = <SetUpEncryptionPanel onFinish={checkEncryptionState} />;
break;
case "secrets_not_cached":
content = (
<RecoveryPanelOutOfSync
onFinish={checkEncryptionState}
onForgotRecoveryKey={() => setState("reset_identity_forgot")}
/>
);
break;
case "key_storage_disabled":
case "main":
content = (
<>
<KeyStoragePanel onKeyStorageDisableClick={() => setState("key_storage_delete")} />
<Separator kind="section" />
{/* We only show the "Recovery" panel if key storage is enabled.*/}
{state === "main" && (
switch (deviceState) {
// some device states require action from the user rather than showing the main settings screen
case "verify_this_session":
content = <SetUpEncryptionPanel onFinish={() => setState("main")} />;
break;
case "key_storage_out_of_sync":
content = (
<RecoveryPanelOutOfSync
onFinish={() => setState("main")}
onForgotRecoveryKey={() => setState("reset_identity_forgot")}
onAccessSecretStorageFailed={async () => {
const needsCrossSigningReset =
await DeviceListener.sharedInstance().keyStorageOutOfSyncNeedsCrossSigningReset(
true,
);
setState(needsCrossSigningReset ? "reset_identity_sync_failed" : "change_recovery_key");
}}
/>
);
break;
case "identity_needs_reset":
content = (
<IdentityNeedsResetNoticePanel onContinue={() => setState("reset_identity_cant_recover")} />
);
break;
default:
content = (
<>
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<KeyStoragePanel onKeyStorageDisableClick={() => setState("key_storage_delete")} />
<Separator kind="section" />
{/* We only show the "Recovery" panel if key storage is enabled.*/}
{isBackupEnabled && (
<>
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<Separator kind="section" />
</>
)}
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />
</>
)}
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />
</>
);
);
break;
}
break;
case "change_recovery_key":
case "set_recovery_key":
@@ -124,16 +137,17 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Props):
case "reset_identity_compromised":
case "reset_identity_forgot":
case "reset_identity_sync_failed":
case "reset_identity_cant_recover":
content = (
<ResetIdentityPanel
variant={findResetVariant(state)}
onCancelClick={checkEncryptionState}
onReset={checkEncryptionState}
onCancelClick={() => setState("main")}
onReset={() => setState("main")}
/>
);
break;
case "key_storage_delete":
content = <DeleteKeyStoragePanel onFinish={checkEncryptionState} />;
content = <DeleteKeyStoragePanel onFinish={() => setState("main")} />;
break;
}
@@ -154,6 +168,8 @@ function findResetVariant(state: State): ResetIdentityBodyVariant {
return "compromised";
case "reset_identity_sync_failed":
return "sync_failed";
case "reset_identity_cant_recover":
return "no_verification_method";
default:
case "reset_identity_forgot":
@@ -161,63 +177,6 @@ function findResetVariant(state: State): ResetIdentityBodyVariant {
}
}
/**
* Hook to check if the user needs:
* - to go through the SetupEncryption flow.
* - to enter their recovery key, if the secrets are not cached locally.
* ...and also whether megolm key backup is enabled on this device (which we use to set the state of the 'allow key storage' toggle)
*
* If cross signing is set up, key backup is enabled and the secrets are cached, the state will be set to "main".
* If cross signing is not set up, the state will be set to "set_up_encryption".
* If key backup is not enabled, the state will be set to "key_storage_disabled".
* If secrets are missing, the state will be set to "secrets_not_cached".
*
* The state is set once when the component is first mounted.
* Also returns a callback function which can be called to re-run the logic.
*
* @param setState - callback passed from the EncryptionUserSettingsTab to set the current `State`.
* @returns a callback function, which will re-run the logic and update the state.
*/
function useCheckEncryptionState(state: State, setState: (state: State) => void): () => Promise<void> {
const matrixClient = useMatrixClientContext();
const checkEncryptionState = useCallback(async () => {
const crypto = matrixClient.getCrypto()!;
const isCrossSigningReady = await crypto.isCrossSigningReady();
// Check if the secrets are cached
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
// Also check the key backup status
const activeBackupVersion = await crypto.getActiveSessionBackupVersion();
const keyStorageEnabled = activeBackupVersion !== null;
if (isCrossSigningReady && keyStorageEnabled && secretsOk) setState("main");
else if (!isCrossSigningReady) setState("set_up_encryption");
else if (!keyStorageEnabled) setState("key_storage_disabled");
else setState("secrets_not_cached");
}, [matrixClient, setState]);
// Initialise the state when the component is mounted
useEffect(() => {
if (state === "loading") checkEncryptionState();
}, [checkEncryptionState, state]);
useTypedEventEmitter(matrixClient, CryptoEvent.KeyBackupStatus, (): void => {
// Recheck the status if the key backup status has changed so we can keep the page up to date.
// Note that this could potentially update the UI while the user is trying to do something, although
// if their key backup status is changing then they're changing encryption related things
// on another device. This code is written with the assumption that it's better for the UI to refresh
// and be up to date with whatever changes they've made.
checkEncryptionState();
});
// Also return the callback so that the component can re-run the logic.
return checkEncryptionState;
}
interface SetUpEncryptionPanelProps {
/**
* Callback to call when the user has finished setting up encryption.
@@ -257,3 +216,31 @@ function SetUpEncryptionPanel({ onFinish }: SetUpEncryptionPanelProps): JSX.Elem
</SettingsSection>
);
}
interface IdentityNeedsResetNoticePanelProps {
/**
* Callback to call when the user has finished setting up encryption.
*/
onContinue: () => void;
}
/**
* Panel to tell the user that they need to reset their identity.
*/
function IdentityNeedsResetNoticePanel({ onContinue }: Readonly<IdentityNeedsResetNoticePanelProps>): JSX.Element {
return (
<SettingsSection
legacy={false}
heading={_t("encryption|key_storage_out_of_sync")}
subHeading={
<SettingsSubheader state="error" stateMessage={_t("encryption|identity_needs_reset_description")} />
}
>
<div>
<Button size="sm" kind="primary" onClick={onContinue}>
{_t("encryption|continue_with_reset")}
</Button>
</div>
</SettingsSection>
);
}

View File

@@ -959,6 +959,7 @@
"bootstrap_title": "Setting up keys",
"confirm_encryption_setup_body": "Click the button below to confirm setting up encryption.",
"confirm_encryption_setup_title": "Confirm encryption setup",
"continue_with_reset": "Continue with reset",
"cross_signing_room_normal": "This room is end-to-end encrypted",
"cross_signing_room_verified": "Everyone in this room is verified",
"cross_signing_room_warning": "Someone is using an unknown session",
@@ -974,6 +975,7 @@
"event_shield_reason_unverified_identity": "Encrypted by an unverified user.",
"export_unsupported": "Your browser does not support the required cryptography extensions",
"forgot_recovery_key": "Forgot recovery key?",
"identity_needs_reset_description": "You have to reset your cryptographic identity in order to ensure access to your message history",
"import_invalid_keyfile": "Not a valid %(brand)s keyfile",
"import_invalid_passphrase": "Authentication check failed: incorrect password?",
"key_storage_out_of_sync": "Your key storage is out of sync.",

View File

@@ -14,7 +14,7 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even
import Modal from "../Modal";
import { _t } from "../languageHandler";
import DeviceListener from "../DeviceListener";
import DeviceListener, { type DeviceState } from "../DeviceListener";
import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEncryptionDialog";
import { AccessCancelledError, accessSecretStorage } from "../SecurityManager";
import ToastStore, { type IToast } from "../stores/ToastStore";
@@ -33,114 +33,107 @@ import { PosthogAnalytics } from "../PosthogAnalytics";
const TOAST_KEY = "setupencryption";
const getTitle = (kind: Kind): string => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
/**
* The device states that we show a toast for (everything except for "ok").
*/
type DeviceStateForToast = Exclude<DeviceState, "ok">;
const getTitle = (state: DeviceStateForToast): string => {
switch (state) {
case "set_up_recovery":
return _t("encryption|set_up_recovery");
case Kind.VERIFY_THIS_SESSION:
case "verify_this_session":
return _t("encryption|verify_toast_title");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case "key_storage_out_of_sync":
case "identity_needs_reset":
return _t("encryption|key_storage_out_of_sync");
case Kind.TURN_ON_KEY_STORAGE:
case "turn_on_key_storage":
return _t("encryption|turn_on_key_storage");
}
};
const getIcon = (kind: Kind): IToast<any>["icon"] => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
const getIcon = (state: DeviceStateForToast): IToast<any>["icon"] => {
switch (state) {
case "set_up_recovery":
return undefined;
case Kind.VERIFY_THIS_SESSION:
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case "verify_this_session":
case "key_storage_out_of_sync":
case "identity_needs_reset":
return <ErrorSolidIcon color="var(--cpd-color-icon-critical-primary)" />;
case Kind.TURN_ON_KEY_STORAGE:
case "turn_on_key_storage":
return <SettingsSolidIcon color="var(--cpd-color-text-primary)" />;
}
};
const getSetupCaption = (kind: Kind): string => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
const getSetupCaption = (state: DeviceStateForToast): string => {
switch (state) {
case "set_up_recovery":
return _t("action|continue");
case Kind.VERIFY_THIS_SESSION:
case "verify_this_session":
return _t("action|verify");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case "key_storage_out_of_sync":
return _t("encryption|enter_recovery_key");
case Kind.TURN_ON_KEY_STORAGE:
case "turn_on_key_storage":
return _t("action|continue");
case "identity_needs_reset":
return _t("encryption|continue_with_reset");
}
};
/**
* Get the icon to show on the primary button.
* @param kind
* @param state
*/
const getPrimaryButtonIcon = (kind: Kind): ComponentType<React.SVGAttributes<SVGElement>> | undefined => {
switch (kind) {
case Kind.KEY_STORAGE_OUT_OF_SYNC:
const getPrimaryButtonIcon = (
state: DeviceStateForToast,
): ComponentType<React.SVGAttributes<SVGElement>> | undefined => {
switch (state) {
case "key_storage_out_of_sync":
return KeyIcon;
default:
return;
}
};
const getSecondaryButtonLabel = (kind: Kind): string => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
const getSecondaryButtonLabel = (state: DeviceStateForToast): string => {
switch (state) {
case "set_up_recovery":
return _t("action|dismiss");
case Kind.VERIFY_THIS_SESSION:
case "verify_this_session":
return _t("encryption|verification|unverified_sessions_toast_reject");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case "key_storage_out_of_sync":
return _t("encryption|forgot_recovery_key");
case Kind.TURN_ON_KEY_STORAGE:
case "turn_on_key_storage":
return _t("action|dismiss");
case "identity_needs_reset":
return "";
}
};
const getDescription = (kind: Kind): string => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
const getDescription = (state: DeviceStateForToast): string => {
switch (state) {
case "set_up_recovery":
return _t("encryption|set_up_recovery_toast_description");
case Kind.VERIFY_THIS_SESSION:
case "verify_this_session":
return _t("encryption|verify_toast_description");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
case "key_storage_out_of_sync":
return _t("encryption|key_storage_out_of_sync_description");
case Kind.TURN_ON_KEY_STORAGE:
case "turn_on_key_storage":
return _t("encryption|turn_on_key_storage_description");
case "identity_needs_reset":
return _t("encryption|identity_needs_reset_description");
}
};
/**
* The kind of toast to show.
*/
export enum Kind {
/**
* Prompt the user to set up a recovery key
*/
SET_UP_RECOVERY = "set_up_recovery",
/**
* Prompt the user to verify this session
*/
VERIFY_THIS_SESSION = "verify_this_session",
/**
* 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",
}
/**
* Show a toast prompting the user for some action related to setting up their encryption.
*
* @param kind The kind of toast to show
* @param state The state of the device
*/
export const showToast = (kind: Kind): void => {
export const showToast = (state: DeviceStateForToast): void => {
if (
ModuleRunner.instance.extensions.cryptoSetup.setupEncryptionNeeded({
kind: kind as any,
kind: state as any,
storeProvider: { getInstance: () => SetupEncryptionStore.sharedInstance() },
})
) {
@@ -148,13 +141,13 @@ export const showToast = (kind: Kind): void => {
}
const onPrimaryClick = async (): Promise<void> => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
case Kind.TURN_ON_KEY_STORAGE: {
switch (state) {
case "set_up_recovery":
case "turn_on_key_storage": {
PosthogAnalytics.instance.trackEvent<InteractionEvent>({
eventName: "Interaction",
interactionType: "Pointer",
name: kind === Kind.SET_UP_RECOVERY ? "ToastSetUpRecoveryClick" : "ToastTurnOnKeyStorageClick",
name: state === "set_up_recovery" ? "ToastSetUpRecoveryClick" : "ToastTurnOnKeyStorageClick",
});
// Open the user settings dialog to the encryption tab
const payload: OpenToTabPayload = {
@@ -164,10 +157,10 @@ export const showToast = (kind: Kind): void => {
defaultDispatcher.dispatch(payload);
break;
}
case Kind.VERIFY_THIS_SESSION:
case "verify_this_session":
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
break;
case Kind.KEY_STORAGE_OUT_OF_SYNC: {
case "key_storage_out_of_sync": {
const modal = Modal.createDialog(
Spinner,
undefined,
@@ -208,12 +201,24 @@ export const showToast = (kind: Kind): void => {
}
break;
}
case "identity_needs_reset": {
// Open the user settings dialog to reset identity
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
props: {
initialEncryptionState: "reset_identity_cant_recover",
},
};
defaultDispatcher.dispatch(payload);
break;
}
}
};
const onSecondaryClick = async (): Promise<void> => {
switch (kind) {
case Kind.SET_UP_RECOVERY: {
switch (state) {
case "set_up_recovery": {
PosthogAnalytics.instance.trackEvent<InteractionEvent>({
eventName: "Interaction",
interactionType: "Pointer",
@@ -225,7 +230,7 @@ export const showToast = (kind: Kind): void => {
deviceListener.dismissEncryptionSetup();
break;
}
case Kind.KEY_STORAGE_OUT_OF_SYNC: {
case "key_storage_out_of_sync": {
// Open the user settings dialog to the encryption tab and start the flow to reset encryption or change the recovery key
const deviceListener = DeviceListener.sharedInstance();
const needsCrossSigningReset = await deviceListener.keyStorageOutOfSyncNeedsCrossSigningReset(true);
@@ -241,7 +246,7 @@ export const showToast = (kind: Kind): void => {
defaultDispatcher.dispatch(payload);
break;
}
case Kind.TURN_ON_KEY_STORAGE: {
case "turn_on_key_storage": {
PosthogAnalytics.instance.trackEvent<InteractionEvent>({
eventName: "Interaction",
interactionType: "Pointer",
@@ -296,19 +301,19 @@ export const showToast = (kind: Kind): void => {
ToastStore.sharedInstance().addOrReplaceToast({
key: TOAST_KEY,
title: getTitle(kind),
icon: getIcon(kind),
title: getTitle(state),
icon: getIcon(state),
props: {
description: getDescription(kind),
primaryLabel: getSetupCaption(kind),
PrimaryIcon: getPrimaryButtonIcon(kind),
description: getDescription(state),
primaryLabel: getSetupCaption(state),
PrimaryIcon: getPrimaryButtonIcon(state),
onPrimaryClick,
secondaryLabel: getSecondaryButtonLabel(kind),
secondaryLabel: getSecondaryButtonLabel(state),
onSecondaryClick,
overrideWidth: kind === Kind.KEY_STORAGE_OUT_OF_SYNC ? "366px" : undefined,
overrideWidth: state === "key_storage_out_of_sync" ? "366px" : undefined,
},
component: GenericToast,
priority: kind === Kind.VERIFY_THIS_SESSION ? 95 : 40,
priority: state === "verify_this_session" ? 95 : 40,
});
};