mirror of
https://github.com/Novaloop-AG/healthchat-web.git
synced 2026-11-08 07:28:26 +00:00
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:
+28
-354
@@ -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.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { type MatrixClient, ClientStoppedError, TypedEventEmitter } from "matrix-js-sdk/src/matrix";
|
||||||
type MatrixEvent,
|
import { logger as baseLogger, LogSpan } from "matrix-js-sdk/src/logger";
|
||||||
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 CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
|
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
|
||||||
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
|
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
|
||||||
|
|
||||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||||
import dis from "./dispatcher/dispatcher";
|
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 { type ActionPayload } from "./dispatcher/payloads";
|
||||||
import { Action } from "./dispatcher/actions";
|
import { Action } from "./dispatcher/actions";
|
||||||
import SdkConfig from "./SdkConfig";
|
import SdkConfig from "./SdkConfig";
|
||||||
import PlatformPeg from "./PlatformPeg";
|
import PlatformPeg from "./PlatformPeg";
|
||||||
import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation";
|
import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation";
|
||||||
import SettingsStore, { type CallbackFn } from "./settings/SettingsStore";
|
import SettingsStore, { type CallbackFn } from "./settings/SettingsStore";
|
||||||
import { asyncSomeParallel } from "./utils/arrays.ts";
|
|
||||||
import DeviceListenerOtherDevices from "./device-listener/DeviceListenerOtherDevices.ts";
|
import DeviceListenerOtherDevices from "./device-listener/DeviceListenerOtherDevices.ts";
|
||||||
|
import DeviceListenerCurrentDevice from "./device-listener/DeviceListenerCurrentDevice.ts";
|
||||||
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
import type DeviceState from "./device-listener/DeviceState.ts";
|
||||||
|
|
||||||
/**
|
|
||||||
* 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";
|
|
||||||
|
|
||||||
const logger = baseLogger.getChild("DeviceListener:");
|
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}
|
* The events emitted by {@link DeviceListener}
|
||||||
*/
|
*/
|
||||||
@@ -98,21 +40,22 @@ type EventHandlerMap = {
|
|||||||
export default class DeviceListener extends TypedEventEmitter<DeviceListenerEvents, EventHandlerMap> {
|
export default class DeviceListener extends TypedEventEmitter<DeviceListenerEvents, EventHandlerMap> {
|
||||||
private dispatcherRef?: string;
|
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;
|
public otherDevices?: DeviceListenerOtherDevices;
|
||||||
|
|
||||||
// has the user dismissed any of the various nag toasts to setup encryption on this device?
|
/** All the information about whether this device's encrypytion is OK. Only
|
||||||
private dismissedThisDeviceToast = false;
|
* set if `running` is true, otherwise undefined.
|
||||||
/** Cache of the info about the current key backup on the server. */
|
*/
|
||||||
private keyBackupInfo: KeyBackupInfo | null = null;
|
public currentDevice?: DeviceListenerCurrentDevice;
|
||||||
/** When `keyBackupInfo` was last updated */
|
|
||||||
private keyBackupFetchedAt: number | null = null;
|
|
||||||
private running = false;
|
private running = false;
|
||||||
// The client with which the instance is running. Only set if `running` is true, otherwise undefined.
|
// The client with which the instance is running. Only set if `running` is true, otherwise undefined.
|
||||||
private client?: MatrixClient;
|
private client?: MatrixClient;
|
||||||
private shouldRecordClientInformation = false;
|
private shouldRecordClientInformation = false;
|
||||||
private deviceClientInformationSettingWatcherRef: string | undefined;
|
private deviceClientInformationSettingWatcherRef: string | undefined;
|
||||||
private deviceState: DeviceState = "ok";
|
|
||||||
|
|
||||||
// Remember the current analytics state to avoid sending the same event multiple times.
|
// Remember the current analytics state to avoid sending the same event multiple times.
|
||||||
private analyticsVerificationState?: string;
|
private analyticsVerificationState?: string;
|
||||||
@@ -127,15 +70,9 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
|||||||
this.running = true;
|
this.running = true;
|
||||||
|
|
||||||
this.otherDevices = new DeviceListenerOtherDevices(this, matrixClient);
|
this.otherDevices = new DeviceListenerOtherDevices(this, matrixClient);
|
||||||
|
this.currentDevice = new DeviceListenerCurrentDevice(this, matrixClient, logger);
|
||||||
|
|
||||||
this.client = matrixClient;
|
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");
|
this.shouldRecordClientInformation = SettingsStore.getValue("deviceClientInformationOptIn");
|
||||||
// only configurable in config, so we don't need to watch the value
|
// 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 {
|
public stop(): void {
|
||||||
this.running = false;
|
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);
|
SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef);
|
||||||
|
|
||||||
dis.unregister(this.dispatcherRef);
|
dis.unregister(this.dispatcherRef);
|
||||||
|
|
||||||
this.dispatcherRef = undefined;
|
this.dispatcherRef = undefined;
|
||||||
this.otherDevices?.stop();
|
this.otherDevices?.stop();
|
||||||
this.dismissedThisDeviceToast = false;
|
this.currentDevice?.stop();
|
||||||
this.keyBackupInfo = null;
|
|
||||||
this.keyBackupFetchedAt = null;
|
|
||||||
this.cachedKeyBackupUploadActive = undefined;
|
|
||||||
this.client = undefined;
|
this.client = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,22 +130,21 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
|||||||
}
|
}
|
||||||
|
|
||||||
public dismissEncryptionSetup(): void {
|
public dismissEncryptionSetup(): void {
|
||||||
this.dismissedThisDeviceToast = true;
|
this.currentDevice?.dismissEncryptionSetup();
|
||||||
this.recheck();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the account data "m.org.matrix.custom.backup_disabled" to { "disabled": true }.
|
* Set the account data "m.org.matrix.custom.backup_disabled" to { "disabled": true }.
|
||||||
*/
|
*/
|
||||||
public async recordKeyBackupDisabled(): Promise<void> {
|
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
|
* Set the account data to indicate that recovery is disabled
|
||||||
*/
|
*/
|
||||||
public async recordRecoveryDisabled(): Promise<void> {
|
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> {
|
public async keyStorageOutOfSyncNeedsBackupReset(forgotRecovery: boolean): Promise<boolean> {
|
||||||
const crypto = this.client?.getCrypto();
|
const crypto = this.client?.getCrypto();
|
||||||
if (!crypto) {
|
const thisDevice = this.currentDevice;
|
||||||
|
if (!(crypto && thisDevice)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const shouldHaveBackup = !(await this.recheckBackupDisabled(this.client!));
|
const shouldHaveBackup = !(await thisDevice.recheckBackupDisabled());
|
||||||
const backupKeyCached = (await crypto.getSessionBackupPrivateKey()) !== null;
|
const backupKeyCached = (await crypto.getSessionBackupPrivateKey()) !== null;
|
||||||
const backupKeyStored = await this.client!.isKeyBackupKeyStored();
|
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 => {
|
private onAction = ({ action }: ActionPayload): void => {
|
||||||
if (action !== Action.OnLoggedIn) return;
|
if (action !== Action.OnLoggedIn) return;
|
||||||
this.recheck();
|
this.recheck();
|
||||||
this.updateClientInformation();
|
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 {
|
public recheck(): void {
|
||||||
this.doRecheck().catch((e) => {
|
this.doRecheck().catch((e) => {
|
||||||
if (e instanceof ClientStoppedError) {
|
if (e instanceof ClientStoppedError) {
|
||||||
@@ -405,127 +245,10 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const crossSigningReady = await crypto.isCrossSigningReady();
|
await this.currentDevice?.recheck(logSpan);
|
||||||
const secretStorageStatus = await crypto.getSecretStorageStatus();
|
await this.otherDevices?.recheck(logSpan);
|
||||||
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.reportCryptoSessionStateToAnalytics(cli);
|
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.
|
* self-verified device that is using key backup and recovery.
|
||||||
*/
|
*/
|
||||||
public getDeviceState(): DeviceState {
|
public getDeviceState(): DeviceState {
|
||||||
return this.deviceState;
|
return this.currentDevice?.getDeviceState() ?? "ok";
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -564,7 +271,7 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
|||||||
const secretStorageStatus = await crypto.getSecretStorageStatus();
|
const secretStorageStatus = await crypto.getSecretStorageStatus();
|
||||||
const secretStorageReady = secretStorageStatus.ready;
|
const secretStorageReady = secretStorageStatus.ready;
|
||||||
const crossSigningStatus = await crypto.getCrossSigningStatus();
|
const crossSigningStatus = await crypto.getCrossSigningStatus();
|
||||||
const backupInfo = await this.getKeyBackupInfo();
|
const backupInfo = await this.currentDevice?.getKeyBackupInfo();
|
||||||
const is4SEnabled = secretStorageStatus.defaultKeyId != null;
|
const is4SEnabled = secretStorageStatus.defaultKeyId != null;
|
||||||
const deviceVerificationStatus = await crypto.getDeviceVerificationStatus(cli.getUserId()!, cli.getDeviceId()!);
|
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 = (
|
private onRecordClientInformationSettingChange: CallbackFn = (
|
||||||
_originalSettingName,
|
_originalSettingName,
|
||||||
_roomId,
|
_roomId,
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
|||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||||
import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
|
import DeviceListener from "../../../../DeviceListener";
|
||||||
import { useEventEmitterAsyncState } from "../../../../hooks/useEventEmitter";
|
import { useEventEmitterAsyncState } from "../../../../hooks/useEventEmitter";
|
||||||
import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
|
import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
|
||||||
|
import { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../device-listener/DeviceListenerCurrentDevice";
|
||||||
|
|
||||||
interface KeyStoragePanelState {
|
interface KeyStoragePanelState {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydra
|
|||||||
import { withSecretStorageKeyCache } from "../../../../SecurityManager";
|
import { withSecretStorageKeyCache } from "../../../../SecurityManager";
|
||||||
import { EncryptionCardButtons } from "./EncryptionCardButtons";
|
import { EncryptionCardButtons } from "./EncryptionCardButtons";
|
||||||
import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx";
|
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 { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
|
||||||
|
import { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../device-listener/DeviceListenerCurrentDevice.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The possible states of the component.
|
* The possible states of the component.
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync"
|
|||||||
import { useTypedEventEmitterState } from "../../../../../hooks/useEventEmitter";
|
import { useTypedEventEmitterState } from "../../../../../hooks/useEventEmitter";
|
||||||
import { KeyStoragePanel } from "../../encryption/KeyStoragePanel";
|
import { KeyStoragePanel } from "../../encryption/KeyStoragePanel";
|
||||||
import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel";
|
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 { useKeyStoragePanelViewModel } from "../../../../viewmodels/settings/encryption/KeyStoragePanelViewModel";
|
||||||
|
import type DeviceState from "../../../../../device-listener/DeviceState";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The state in the encryption settings tab.
|
* The state in the encryption settings tab.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -14,7 +14,7 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even
|
|||||||
|
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
import { _t } from "../languageHandler";
|
import { _t } from "../languageHandler";
|
||||||
import DeviceListener, { type DeviceState } from "../DeviceListener";
|
import DeviceListener from "../DeviceListener";
|
||||||
import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEncryptionDialog";
|
import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEncryptionDialog";
|
||||||
import { AccessCancelledError, accessSecretStorage } from "../SecurityManager";
|
import { AccessCancelledError, accessSecretStorage } from "../SecurityManager";
|
||||||
import ToastStore, { type IToast } from "../stores/ToastStore";
|
import ToastStore, { type IToast } from "../stores/ToastStore";
|
||||||
@@ -30,6 +30,7 @@ import ConfirmKeyStorageOffDialog from "../components/views/dialogs/ConfirmKeySt
|
|||||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||||
import { resetKeyBackupAndWait } from "../utils/crypto/resetKeyBackup";
|
import { resetKeyBackupAndWait } from "../utils/crypto/resetKeyBackup";
|
||||||
import { PosthogAnalytics } from "../PosthogAnalytics";
|
import { PosthogAnalytics } from "../PosthogAnalytics";
|
||||||
|
import type DeviceState from "../device-listener/DeviceState";
|
||||||
|
|
||||||
const TOAST_KEY = "setupencryption";
|
const TOAST_KEY = "setupencryption";
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from "matrix-js-sdk/src/crypto-api";
|
} from "matrix-js-sdk/src/crypto-api";
|
||||||
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
|
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 { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
||||||
import * as SetupEncryptionToast from "../../src/toasts/SetupEncryptionToast";
|
import * as SetupEncryptionToast from "../../src/toasts/SetupEncryptionToast";
|
||||||
import * as UnverifiedSessionToast from "../../src/toasts/UnverifiedSessionToast";
|
import * as UnverifiedSessionToast from "../../src/toasts/UnverifiedSessionToast";
|
||||||
@@ -37,6 +37,7 @@ import { SettingLevel } from "../../src/settings/SettingLevel";
|
|||||||
import { getMockClientWithEventEmitter, mockPlatformPeg } from "../test-utils";
|
import { getMockClientWithEventEmitter, mockPlatformPeg } from "../test-utils";
|
||||||
import { isBulkUnverifiedDeviceReminderSnoozed } from "../../src/utils/device/snoozeBulkUnverifiedDeviceReminder";
|
import { isBulkUnverifiedDeviceReminderSnoozed } from "../../src/utils/device/snoozeBulkUnverifiedDeviceReminder";
|
||||||
import { PosthogAnalytics } from "../../src/PosthogAnalytics";
|
import { PosthogAnalytics } from "../../src/PosthogAnalytics";
|
||||||
|
import { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../src/device-listener/DeviceListenerCurrentDevice";
|
||||||
|
|
||||||
jest.mock("../../src/dispatcher/dispatcher", () => ({
|
jest.mock("../../src/dispatcher/dispatcher", () => ({
|
||||||
dispatch: jest.fn(),
|
dispatch: jest.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user