From 09884c6bd17287389ab2c11e92a603aa57370e41 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Tue, 10 Feb 2026 11:52:36 +0000 Subject: [PATCH] Extract the "this device" logic from DeviceListener (#32420) * Extract logic for this device from DeviceListener * Remove a listener that I assume we forgot in DeviceListener * Use a code block for JSON in a comment Co-authored-by: Skye Elliot * Rename to DeviceListenerCurrentDevice --------- Co-authored-by: Skye Elliot --- src/DeviceListener.ts | 382 ++-------------- .../encryption/KeyStoragePanelViewModel.ts | 3 +- .../settings/encryption/ChangeRecoveryKey.tsx | 3 +- .../tabs/user/EncryptionUserSettingsTab.tsx | 3 +- .../DeviceListenerCurrentDevice.ts | 420 ++++++++++++++++++ src/device-listener/DeviceState.ts | 39 ++ src/toasts/SetupEncryptionToast.tsx | 3 +- test/unit-tests/DeviceListener-test.ts | 3 +- 8 files changed, 497 insertions(+), 359 deletions(-) create mode 100644 src/device-listener/DeviceListenerCurrentDevice.ts create mode 100644 src/device-listener/DeviceState.ts diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 1a04db3269..111808a976 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -7,83 +7,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { - type MatrixEvent, - ClientEvent, - EventType, - type MatrixClient, - 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"; +import { type MatrixClient, ClientStoppedError, TypedEventEmitter } from "matrix-js-sdk/src/matrix"; +import { logger as baseLogger, LogSpan } from "matrix-js-sdk/src/logger"; import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange"; import { secureRandomString } from "matrix-js-sdk/src/randomstring"; import { PosthogAnalytics } from "./PosthogAnalytics"; import dis from "./dispatcher/dispatcher"; -import { - hideToast as hideSetupEncryptionToast, - showToast as showSetupEncryptionToast, -} from "./toasts/SetupEncryptionToast"; -import { isSecretStorageBeingAccessed } from "./SecurityManager"; import { type ActionPayload } from "./dispatcher/payloads"; import { Action } from "./dispatcher/actions"; import SdkConfig from "./SdkConfig"; import PlatformPeg from "./PlatformPeg"; import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation"; import SettingsStore, { type CallbackFn } from "./settings/SettingsStore"; -import { asyncSomeParallel } from "./utils/arrays.ts"; import DeviceListenerOtherDevices from "./device-listener/DeviceListenerOtherDevices.ts"; - -const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; - -/** - * Unfortunately-named account data key used by Element X to indicate that the user - * has chosen to disable server side key backups. - * - * We need to set and honour this to prevent Element X from automatically turning key backup back on. - */ -export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled"; - -/** - * Account data key to indicate whether the user has chosen to enable or disable recovery. - */ -export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery"; +import DeviceListenerCurrentDevice from "./device-listener/DeviceListenerCurrentDevice.ts"; +import type DeviceState from "./device-listener/DeviceState.ts"; const logger = baseLogger.getChild("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} */ @@ -98,21 +40,22 @@ type EventHandlerMap = { export default class DeviceListener extends TypedEventEmitter { private dispatcherRef?: string; - /** All the information about whether other devices are verified. */ + /** + * All the information about whether other devices are verified. Only set + * if `running` is true, otherwise undefined. + */ public otherDevices?: DeviceListenerOtherDevices; - // has the user dismissed any of the various nag toasts to setup encryption on this device? - private dismissedThisDeviceToast = false; - /** Cache of the info about the current key backup on the server. */ - private keyBackupInfo: KeyBackupInfo | null = null; - /** When `keyBackupInfo` was last updated */ - private keyBackupFetchedAt: number | null = null; + /** All the information about whether this device's encrypytion is OK. Only + * set if `running` is true, otherwise undefined. + */ + public currentDevice?: DeviceListenerCurrentDevice; + private running = false; // The client with which the instance is running. Only set if `running` is true, otherwise undefined. private client?: MatrixClient; private shouldRecordClientInformation = false; private deviceClientInformationSettingWatcherRef: string | undefined; - private deviceState: DeviceState = "ok"; // Remember the current analytics state to avoid sending the same event multiple times. private analyticsVerificationState?: string; @@ -127,15 +70,9 @@ export default class DeviceListener extends TypedEventEmitter { - await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true }); + await this.currentDevice?.recordKeyBackupDisabled(); } /** * Set the account data to indicate that recovery is disabled */ public async recordRecoveryDisabled(): Promise { - await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false }); + await this.currentDevice?.recordRecoveryDisabled(); } /** @@ -265,10 +193,11 @@ export default class DeviceListener extends TypedEventEmitter { const crypto = this.client?.getCrypto(); - if (!crypto) { + const thisDevice = this.currentDevice; + if (!(crypto && thisDevice)) { return false; } - const shouldHaveBackup = !(await this.recheckBackupDisabled(this.client!)); + const shouldHaveBackup = !(await thisDevice.recheckBackupDisabled()); const backupKeyCached = (await crypto.getSessionBackupPrivateKey()) !== null; const backupKeyStored = await this.client!.isKeyBackupKeyStored(); @@ -279,101 +208,12 @@ export default class DeviceListener extends TypedEventEmitter { - if (!this.client) return; - if (userId !== this.client.getUserId()) return; - this.recheck(); - }; - - private onKeyBackupStatusChanged = (): void => { - logger.info("Backup status changed"); - this.cachedKeyBackupUploadActive = undefined; - this.recheck(); - }; - - private onCrossSingingKeysChanged = (): void => { - this.recheck(); - }; - - private onAccountData = (ev: MatrixEvent): void => { - // User may have: - // * 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() === BACKUP_DISABLED_ACCOUNT_DATA_KEY || - ev.getType() === RECOVERY_ACCOUNT_DATA_KEY - ) { - this.recheck(); - } - }; - - private onSync = (state: SyncState, prevState: SyncState | null): void => { - if (state === "PREPARED" && prevState === null) { - this.recheck(); - } - }; - - private onRoomStateEvents = (ev: MatrixEvent): void => { - if (ev.getType() !== EventType.RoomEncryption) return; - - // If a room changes to encrypted, re-check as it may be our first - // encrypted room. This also catches encrypted room creation as well. - this.recheck(); - }; - private onAction = ({ action }: ActionPayload): void => { if (action !== Action.OnLoggedIn) return; this.recheck(); this.updateClientInformation(); }; - private onToDeviceEvent = (event: MatrixEvent): void => { - // Receiving a 4S secret can mean we are in sync where we were not before. - if (event.getType() === EventType.SecretSend) this.recheck(); - }; - - /** - * Fetch the key backup information from the server. - * - * The result is cached for `KEY_BACKUP_POLL_INTERVAL` ms to avoid repeated API calls. - * - * @returns The key backup info from the server, or `null` if there is no key backup. - */ - private async getKeyBackupInfo(): Promise { - if (!this.client) return null; - const now = new Date().getTime(); - const crypto = this.client.getCrypto(); - if (!crypto) return null; - - if ( - !this.keyBackupInfo || - !this.keyBackupFetchedAt || - this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL - ) { - this.keyBackupInfo = await crypto.getKeyBackupInfo(); - this.keyBackupFetchedAt = now; - } - return this.keyBackupInfo; - } - - private async shouldShowSetupEncryptionToast(): Promise { - // If we're in the middle of a secret storage operation, we're likely - // modifying the state involved here, so don't add new toasts to setup. - if (isSecretStorageBeingAccessed()) return false; - // Show setup toasts once the user is in at least one encrypted room. - const cli = this.client; - const cryptoApi = cli?.getCrypto(); - if (!cli || !cryptoApi) return false; - - return await asyncSomeParallel(cli.getRooms(), ({ roomId }) => cryptoApi.isEncryptionEnabledInRoom(roomId)); - } - public recheck(): void { this.doRecheck().catch((e) => { if (e instanceof ClientStoppedError) { @@ -405,127 +245,10 @@ export default class DeviceListener extends TypedEventEmitter { - const backupDisabled = await cli.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY); - return !!backupDisabled?.disabled; - } - - /** - * Check whether the user has disabled recovery. 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 recheckRecoveryDisabled(cli: MatrixClient): Promise { - const recoveryStatus = await cli.getAccountDataFromServer(RECOVERY_ACCOUNT_DATA_KEY); - // Recovery is disabled only if the `enabled` flag is set to `false`. - // If it is missing, or set to any other value, we consider it as - // not-disabled, and will prompt the user to create recovery (if - // missing). - return recoveryStatus?.enabled === false; } /** @@ -534,23 +257,7 @@ export default class DeviceListener extends TypedEventEmitter { - 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"); - } + return this.currentDevice?.getDeviceState() ?? "ok"; } /** @@ -564,7 +271,7 @@ export default class DeviceListener extends TypedEventEmitter => { - if (!this.client) { - // To preserve existing behaviour, if there is no client, we - // 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.cachedKeyBackupUploadActive !== undefined) { - return this.cachedKeyBackupUploadActive; - } - - // Fetch the answer and cache it - const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion(); - this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion; - logger.debug(`Key backup upload is ${this.cachedKeyBackupUploadActive ? "active" : "inactive"}`); - - return this.cachedKeyBackupUploadActive; - }; - private cachedKeyBackupUploadActive: boolean | undefined = undefined; - private onRecordClientInformationSettingChange: CallbackFn = ( _originalSettingName, _roomId, diff --git a/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts index e0979d8dbb..7dd2419c62 100644 --- a/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts +++ b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts @@ -10,9 +10,10 @@ import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; -import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../DeviceListener"; +import DeviceListener from "../../../../DeviceListener"; import { useEventEmitterAsyncState } from "../../../../hooks/useEventEmitter"; import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup"; +import { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../device-listener/DeviceListenerCurrentDevice"; interface KeyStoragePanelState { /** diff --git a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx index 21ff7437d8..2694539f5a 100644 --- a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx +++ b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx @@ -30,8 +30,9 @@ import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydra import { withSecretStorageKeyCache } from "../../../../SecurityManager"; import { EncryptionCardButtons } from "./EncryptionCardButtons"; import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx"; -import DeviceListener, { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../DeviceListener"; +import DeviceListener from "../../../../DeviceListener"; import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup"; +import { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../device-listener/DeviceListenerCurrentDevice.ts"; /** * The possible states of the component. diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index ddc594a0b0..c0cc936f03 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -24,8 +24,9 @@ import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync" import { useTypedEventEmitterState } from "../../../../../hooks/useEventEmitter"; import { KeyStoragePanel } from "../../encryption/KeyStoragePanel"; import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel"; -import DeviceListener, { DeviceListenerEvents, type DeviceState } from "../../../../../DeviceListener"; +import DeviceListener, { DeviceListenerEvents } from "../../../../../DeviceListener"; import { useKeyStoragePanelViewModel } from "../../../../viewmodels/settings/encryption/KeyStoragePanelViewModel"; +import type DeviceState from "../../../../../device-listener/DeviceState"; /** * The state in the encryption settings tab. diff --git a/src/device-listener/DeviceListenerCurrentDevice.ts b/src/device-listener/DeviceListenerCurrentDevice.ts new file mode 100644 index 0000000000..4debb378f7 --- /dev/null +++ b/src/device-listener/DeviceListenerCurrentDevice.ts @@ -0,0 +1,420 @@ +/* +Copyright 2025-2026 Element Creations Ltd. +Copyright 2024 New Vector Ltd. +Copyright 2020 The Matrix.org Foundation C.I.C. + +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 { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; +import { type LogSpan, type BaseLogger, type Logger } from "matrix-js-sdk/src/logger"; +import { + type MatrixEvent, + type MatrixClient, + EventType, + type SyncState, + RoomStateEvent, + ClientEvent, +} from "matrix-js-sdk/src/matrix"; + +import type DeviceListener from "../DeviceListener"; +import type DeviceState from "./DeviceState"; +import { DeviceListenerEvents } from "../DeviceListener"; +import { + hideToast as hideSetupEncryptionToast, + showToast as showSetupEncryptionToast, +} from "../toasts/SetupEncryptionToast"; +import { isSecretStorageBeingAccessed } from "../SecurityManager"; +import { asyncSomeParallel } from "../utils/arrays"; + +const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; + +/** + * Unfortunately-named account data key used by Element X to indicate that the user + * has chosen to disable server side key backups. + * + * We need to set and honour this to prevent Element X from automatically turning key backup back on. + */ +export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled"; + +/** + * Account data key to indicate whether the user has chosen to enable or disable recovery. + */ +export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery"; + +/** + * Handles all of DeviceListener's work that relates to the current device. + */ +export default class DeviceListenerCurrentDevice { + /** + * The DeviceListener launching this instance. + */ + private deviceListener: DeviceListener; + + /** + * The Matrix client in use by the current user. + */ + private client: MatrixClient; + + /** + * A Logger we use to write our debug information. + */ + private logger: Logger; + + /** + * Has the user dismissed any of the various nag toasts to setup encryption + * on this device? + */ + private dismissedThisDeviceToast = false; + + /** + * Cache of the info about the current key backup on the server. + */ + private keyBackupInfo: KeyBackupInfo | null = null; + + /** + * When `keyBackupInfo` was last updated (in ms since the epoch). + */ + private keyBackupFetchedAt: number | null = null; + + /** + * What is the current state of the device: is its crypto OK? + */ + private deviceState: DeviceState = "ok"; + + /** + * Was key backup upload active last time we checked? + */ + private cachedKeyBackupUploadActive: boolean | undefined = undefined; + + public constructor(deviceListener: DeviceListener, client: MatrixClient, logger: Logger) { + this.deviceListener = deviceListener; + this.client = client; + this.logger = logger; + + this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged); + this.client.on(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); + 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); + this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + } + + /** + * Stop listening for events and clear the stored information. + */ + public stop(): void { + this.dismissedThisDeviceToast = false; + this.keyBackupInfo = null; + this.keyBackupFetchedAt = null; + this.cachedKeyBackupUploadActive = undefined; + + this.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged); + this.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); + this.client.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged); + this.client.removeListener(ClientEvent.AccountData, this.onAccountData); + this.client.removeListener(ClientEvent.Sync, this.onSync); + this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); + this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + } + + /** + * The user dismissed the Key Storage out of Sync toast, so we won't nag + * them again until they refresh or restart the app. + */ + public dismissEncryptionSetup(): void { + this.dismissedThisDeviceToast = true; + this.deviceListener.recheck(); + } + + /** + * Set the account data "m.org.matrix.custom.backup_disabled" to `{ "disabled": true }`. + */ + public async recordKeyBackupDisabled(): Promise { + await this.client.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true }); + } + + /** + * Set the account data to indicate that recovery is disabled + */ + public async recordRecoveryDisabled(): Promise { + await this.client.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false }); + } + + /** + * Display a toast if our crypto is in an unexpected state, or if we want to + * nag the user about setting up more stuff. + */ + public async recheck(logSpan: LogSpan): Promise { + const crypto = this.client.getCrypto(); + if (!crypto) { + return; + } + + const crossSigningReady = await crypto.isCrossSigningReady(); + const secretStorageStatus = await crypto.getSecretStorageStatus(); + const crossSigningStatus = await crypto.getCrossSigningStatus(); + const allCrossSigningSecretsCached = + crossSigningStatus.privateKeysCachedLocally.masterKey && + crossSigningStatus.privateKeysCachedLocally.selfSigningKey && + crossSigningStatus.privateKeysCachedLocally.userSigningKey; + + const recoveryDisabled = await this.recheckRecoveryDisabled(this.client); + + const recoveryIsOk = secretStorageStatus.ready || recoveryDisabled; + + const isCurrentDeviceTrusted = Boolean( + (await crypto.getDeviceVerificationStatus(this.client.getSafeUserId(), this.client.deviceId!)) + ?.crossSigningVerified, + ); + + const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan); + const backupDisabled = await this.recheckBackupDisabled(); + + // We warn if key backup upload is turned off and we have not explicitly + // said we are OK with that. + const keyBackupUploadIsOk = keyBackupUploadActive || backupDisabled; + + // 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 && + keyBackupUploadIsOk && + recoveryIsOk && + keyBackupDownloadIsOk; + + if (allSystemsReady) { + logSpan.info("No toast needed"); + await this.setDeviceState("ok", logSpan); + } else { + // make sure our keys are finished downloading + await crypto.getUserDeviceInfo([this.client.getSafeUserId()]); + + if (!isCurrentDeviceTrusted) { + // the current device is not trusted: prompt the user to verify + 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: setting state to KEY_STORAGE_OUT_OF_SYNC", + crossSigningStatus.privateKeysCachedLocally, + crossSigningStatus.privateKeysInSecretStorage, + ); + 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"); + await this.setDeviceState("ok", logSpan); + } else if (keyBackupUploadActive) { + 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"); + 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. 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, + keyBackupDownloadIsOk, + }); + await this.setDeviceState("key_storage_out_of_sync", logSpan); + } + } + } + + /** + * 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 { + this.deviceState = newState; + + this.deviceListener.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"); + } + } + + /** + * 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. + */ + public async recheckBackupDisabled(): Promise { + const backupDisabled = await this.client.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY); + return !!backupDisabled?.disabled; + } + + /** + * Check whether the user has disabled recovery. 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 recheckRecoveryDisabled(cli: MatrixClient): Promise { + const recoveryStatus = await cli.getAccountDataFromServer(RECOVERY_ACCOUNT_DATA_KEY); + // Recovery is disabled only if the `enabled` flag is set to `false`. + // If it is missing, or set to any other value, we consider it as + // not-disabled, and will prompt the user to create recovery (if + // missing). + return recoveryStatus?.enabled === false; + } + + private onUserTrustStatusChanged = (userId: string): void => { + if (userId !== this.client.getUserId()) return; + this.deviceListener.recheck(); + }; + + private onKeyBackupStatusChanged = (): void => { + this.logger.info("Backup status changed"); + this.cachedKeyBackupUploadActive = undefined; + this.deviceListener.recheck(); + }; + + private onCrossSigningKeysChanged = (): void => { + this.deviceListener.recheck(); + }; + + private onAccountData = (ev: MatrixEvent): void => { + // User may have: + // * 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() === BACKUP_DISABLED_ACCOUNT_DATA_KEY || + ev.getType() === RECOVERY_ACCOUNT_DATA_KEY + ) { + this.deviceListener.recheck(); + } + }; + + private onSync = (state: SyncState, prevState: SyncState | null): void => { + if (state === "PREPARED" && prevState === null) { + this.deviceListener.recheck(); + } + }; + + private onRoomStateEvents = (ev: MatrixEvent): void => { + if (ev.getType() !== EventType.RoomEncryption) return; + + // If a room changes to encrypted, re-check as it may be our first + // encrypted room. This also catches encrypted room creation as well. + this.deviceListener.recheck(); + }; + + private onToDeviceEvent = (event: MatrixEvent): void => { + // Receiving a 4S secret can mean we are in sync where we were not before. + if (event.getType() === EventType.SecretSend) { + this.deviceListener.recheck(); + } + }; + + /** + * Fetch the key backup information from the server. + * + * The result is cached for `KEY_BACKUP_POLL_INTERVAL` ms to avoid repeated API calls. + * + * @returns The key backup info from the server, or `null` if there is no key backup. + */ + public async getKeyBackupInfo(): Promise { + const now = new Date().getTime(); + const crypto = this.client.getCrypto(); + if (!crypto) return null; + + if ( + !this.keyBackupInfo || + !this.keyBackupFetchedAt || + this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL + ) { + this.keyBackupInfo = await crypto.getKeyBackupInfo(); + this.keyBackupFetchedAt = now; + } + + return this.keyBackupInfo; + } + + /** + * Is the user in at least one encrypted room? + */ + private async shouldShowSetupEncryptionToast(): Promise { + // If we're in the middle of a secret storage operation, we're likely + // modifying the state involved here, so don't add new toasts to setup. + if (isSecretStorageBeingAccessed()) return false; + + // Show setup toasts once the user is in at least one encrypted room. + const cryptoApi = this.client.getCrypto(); + if (!cryptoApi) return false; + + return await asyncSomeParallel(this.client.getRooms(), ({ roomId }) => + cryptoApi.isEncryptionEnabledInRoom(roomId), + ); + } + + /** + * Is key backup enabled? Use a cached answer if we have one. + */ + private isKeyBackupUploadActive = async (logger: BaseLogger): Promise => { + 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.cachedKeyBackupUploadActive !== undefined) { + return this.cachedKeyBackupUploadActive; + } + + // Fetch the answer and cache it + const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion(); + this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion; + logger.debug(`Key backup upload is ${this.cachedKeyBackupUploadActive ? "active" : "inactive"}`); + + return this.cachedKeyBackupUploadActive; + }; +} diff --git a/src/device-listener/DeviceState.ts b/src/device-listener/DeviceState.ts new file mode 100644 index 0000000000..569258d1d2 --- /dev/null +++ b/src/device-listener/DeviceState.ts @@ -0,0 +1,39 @@ +/* +Copyright 2025-2026 Element Creations Ltd. +Copyright 2024 New Vector Ltd. +Copyright 2020 The Matrix.org Foundation C.I.C. + +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. +*/ + +/** + * The state of the device and the user's account. + */ +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"; + +export default DeviceState; diff --git a/src/toasts/SetupEncryptionToast.tsx b/src/toasts/SetupEncryptionToast.tsx index 93b97db365..c4b2b9d112 100644 --- a/src/toasts/SetupEncryptionToast.tsx +++ b/src/toasts/SetupEncryptionToast.tsx @@ -14,7 +14,7 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even import Modal from "../Modal"; import { _t } from "../languageHandler"; -import DeviceListener, { type DeviceState } from "../DeviceListener"; +import DeviceListener from "../DeviceListener"; import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEncryptionDialog"; import { AccessCancelledError, accessSecretStorage } from "../SecurityManager"; import ToastStore, { type IToast } from "../stores/ToastStore"; @@ -30,6 +30,7 @@ import ConfirmKeyStorageOffDialog from "../components/views/dialogs/ConfirmKeySt import { MatrixClientPeg } from "../MatrixClientPeg"; import { resetKeyBackupAndWait } from "../utils/crypto/resetKeyBackup"; import { PosthogAnalytics } from "../PosthogAnalytics"; +import type DeviceState from "../device-listener/DeviceState"; const TOAST_KEY = "setupencryption"; diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index f2c3a3dc98..f5fa239520 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -25,7 +25,7 @@ import { } from "matrix-js-sdk/src/crypto-api"; import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange"; -import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../src/DeviceListener"; +import DeviceListener from "../../src/DeviceListener"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import * as SetupEncryptionToast from "../../src/toasts/SetupEncryptionToast"; import * as UnverifiedSessionToast from "../../src/toasts/UnverifiedSessionToast"; @@ -37,6 +37,7 @@ import { SettingLevel } from "../../src/settings/SettingLevel"; import { getMockClientWithEventEmitter, mockPlatformPeg } from "../test-utils"; import { isBulkUnverifiedDeviceReminderSnoozed } from "../../src/utils/device/snoozeBulkUnverifiedDeviceReminder"; import { PosthogAnalytics } from "../../src/PosthogAnalytics"; +import { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../src/device-listener/DeviceListenerCurrentDevice"; jest.mock("../../src/dispatcher/dispatcher", () => ({ dispatch: jest.fn(),