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 <actuallyori@gmail.com>

* Rename to DeviceListenerCurrentDevice

---------

Co-authored-by: Skye Elliot <actuallyori@gmail.com>
This commit is contained in:
Andy Balaam
2026-02-10 11:52:36 +00:00
committed by GitHub
parent 753e94f165
commit 09884c6bd1
8 changed files with 497 additions and 359 deletions

View File

@@ -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<DeviceListenerEvents, EventHandlerMap> {
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<DeviceListenerEven
this.running = true;
this.otherDevices = new DeviceListenerOtherDevices(this, matrixClient);
this.currentDevice = new DeviceListenerCurrentDevice(this, matrixClient, logger);
this.client = matrixClient;
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);
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
this.shouldRecordClientInformation = SettingsStore.getValue("deviceClientInformationOptIn");
// only configurable in config, so we don't need to watch the value
@@ -151,22 +88,14 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
public stop(): void {
this.running = false;
if (this.client) {
this.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
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);
}
SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef);
dis.unregister(this.dispatcherRef);
this.dispatcherRef = undefined;
this.otherDevices?.stop();
this.dismissedThisDeviceToast = false;
this.keyBackupInfo = null;
this.keyBackupFetchedAt = null;
this.cachedKeyBackupUploadActive = undefined;
this.currentDevice?.stop();
this.client = undefined;
}
@@ -201,22 +130,21 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
}
public dismissEncryptionSetup(): void {
this.dismissedThisDeviceToast = true;
this.recheck();
this.currentDevice?.dismissEncryptionSetup();
}
/**
* 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 });
await this.currentDevice?.recordKeyBackupDisabled();
}
/**
* Set the account data to indicate that recovery is disabled
*/
public async recordRecoveryDisabled(): Promise<void> {
await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false });
await this.currentDevice?.recordRecoveryDisabled();
}
/**
@@ -265,10 +193,11 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
*/
public async keyStorageOutOfSyncNeedsBackupReset(forgotRecovery: boolean): Promise<boolean> {
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<DeviceListenerEven
}
}
private onUserTrustStatusChanged = (userId: string): void => {
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<KeyBackupInfo | null> {
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<boolean> {
// 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<DeviceListenerEven
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(cli);
const recoveryIsOk = secretStorageStatus.ready || recoveryDisabled;
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 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;
await this.currentDevice?.recheck(logSpan);
await this.otherDevices?.recheck(logSpan);
await this.reportCryptoSessionStateToAnalytics(cli);
if (allSystemsReady) {
logSpan.info("No toast needed");
await this.setDeviceState("ok", logSpan);
} 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: 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);
}
}
await this.otherDevices?.recheck(logSpan);
}
/**
* 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;
}
/**
* 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<boolean> {
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<DeviceListenerEven
* 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");
}
return this.currentDevice?.getDeviceState() ?? "ok";
}
/**
@@ -564,7 +271,7 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
const secretStorageStatus = await crypto.getSecretStorageStatus();
const secretStorageReady = secretStorageStatus.ready;
const crossSigningStatus = await crypto.getCrossSigningStatus();
const backupInfo = await this.getKeyBackupInfo();
const backupInfo = await this.currentDevice?.getKeyBackupInfo();
const is4SEnabled = secretStorageStatus.defaultKeyId != null;
const deviceVerificationStatus = await crypto.getDeviceVerificationStatus(cli.getUserId()!, cli.getDeviceId()!);
@@ -618,39 +325,6 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
});
}
/**
* Is key backup enabled? Use a cached answer if we have one.
*/
private isKeyBackupUploadActive = async (logger: BaseLogger): Promise<boolean> => {
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,

View File

@@ -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 {
/**

View File

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

View File

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

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<KeyBackupInfo | 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;
}
/**
* Is the user in at least one encrypted room?
*/
private async shouldShowSetupEncryptionToast(): Promise<boolean> {
// 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<boolean> => {
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;
};
}

View File

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

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, { 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";

View File

@@ -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(),