diff --git a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts index 291be4442a2..c944e923074 100644 --- a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts @@ -25,7 +25,9 @@ test.describe("Encryption tab", () => { test.beforeEach(async ({ page, homeserver, credentials }) => { // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key - const res = await createBot(page, homeserver, credentials); + const botCredentials = { ...credentials }; + delete botCredentials.accessToken; // use a new login for the bot + const res = await createBot(page, homeserver, botCredentials); recoveryKey = res.recoveryKey; expectedBackupVersion = res.expectedBackupVersion; }); diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts index 8895e4a7ee2..db558a43dac 100644 --- a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -17,7 +17,9 @@ test.describe("Recovery section in Encryption tab", () => { let recoveryKey: GeneratedSecretStorageKey; test.beforeEach(async ({ page, homeserver, credentials }) => { // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key - const res = await createBot(page, homeserver, credentials); + const botCredentials = { ...credentials }; + delete botCredentials.accessToken; // use a new login for the bot + const res = await createBot(page, homeserver, botCredentials); recoveryKey = res.recoveryKey; }); diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts index 05a8948a65e..c3168a89ac6 100644 --- a/playwright/pages/bot.ts +++ b/playwright/pages/bot.ts @@ -16,6 +16,10 @@ import type { Credentials, HomeserverInstance } from "../plugins/homeserver"; import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; import { bootstrapCrossSigningForClient, Client } from "./client"; +export interface CredentialsOptionalAccessToken extends Omit { + accessToken?: string; +} + export interface CreateBotOpts { /** * A prefix to use for the userid. If unspecified, "bot_" will be used. @@ -58,7 +62,7 @@ const defaultCreateBotOptions = { type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey }; export class Bot extends Client { - public credentials?: Credentials; + public credentials?: CredentialsOptionalAccessToken; private handlePromise: Promise>; constructor( @@ -70,7 +74,16 @@ export class Bot extends Client { this.opts = Object.assign({}, defaultCreateBotOptions, opts); } - public setCredentials(credentials: Credentials): void { + /** + * Set the credentials used by the bot. + * + * If `credentials.accessToken` is unset, then `buildClient` will log in a + * new session. Note that `getCredentials` will return the credentials + * passed to this function, rather than the updated credentials from the new + * login. In particular, the `accessToken` and `deviceId` will not be + * updated. + */ + public setCredentials(credentials: CredentialsOptionalAccessToken): void { if (this.credentials) throw new Error("Bot has already started"); this.credentials = credentials; } @@ -80,7 +93,7 @@ export class Bot extends Client { return client.evaluate((cli) => cli.__playwright_recovery_key); } - private async getCredentials(): Promise { + private async getCredentials(): Promise { if (this.credentials) return this.credentials; // We want to pad the uniqueId but not the prefix const username = @@ -161,6 +174,30 @@ export class Bot extends Client { getSecretStorageKey, }; + if (!("accessToken" in credentials)) { + const loginCli = new window.matrixcs.MatrixClient({ + baseUrl, + store: new window.matrixcs.MemoryStore(), + scheduler: new window.matrixcs.MatrixScheduler(), + cryptoStore: new window.matrixcs.MemoryCryptoStore(), + cryptoCallbacks, + logger, + }); + + const loginResponse = await loginCli.loginRequest({ + type: "m.login.password", + identifier: { + type: "m.id.user", + user: credentials.userId, + }, + password: credentials.password, + }); + + credentials.accessToken = loginResponse.access_token; + credentials.userId = loginResponse.user_id; + credentials.deviceId = loginResponse.device_id; + } + const cli = new window.matrixcs.MatrixClient({ baseUrl, userId: credentials.userId, diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index 86cb5813975..76f2733820f 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -28,7 +28,7 @@ import type { EmptyObject, } from "matrix-js-sdk/src/matrix"; import type { RoomMessageEventContent } from "matrix-js-sdk/src/types"; -import { type Credentials } from "../plugins/homeserver"; +import { type CredentialsOptionalAccessToken } from "./bot"; export class Client { public network: Network; @@ -424,7 +424,7 @@ export class Client { /** * Bootstraps cross-signing. */ - public async bootstrapCrossSigning(credentials: Credentials): Promise { + public async bootstrapCrossSigning(credentials: CredentialsOptionalAccessToken): Promise { const client = await this.prepareClient(); return bootstrapCrossSigningForClient(client, credentials); } @@ -522,7 +522,7 @@ export class Client { */ export function bootstrapCrossSigningForClient( client: JSHandle, - credentials: Credentials, + credentials: CredentialsOptionalAccessToken, resetKeys: boolean = false, ) { return client.evaluate( diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 3591db8d829..f9904eaef1d 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -15,6 +15,7 @@ import { RoomStateEvent, type SyncState, ClientStoppedError, + TypedEventEmitter, } from "matrix-js-sdk/src/matrix"; import { logger as baseLogger, type BaseLogger, LogSpan } from "matrix-js-sdk/src/logger"; import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; @@ -29,7 +30,6 @@ import { } from "./toasts/BulkUnverifiedSessionsToast"; import { hideToast as hideSetupEncryptionToast, - Kind as SetupKind, showToast as showSetupEncryptionToast, } from "./toasts/SetupEncryptionToast"; import { @@ -65,7 +65,47 @@ export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery"; const logger = baseLogger.getChild("DeviceListener:"); -export default class DeviceListener { +/** + * The state of the device and the user's account. + */ +export type DeviceState = + /** + * The device is in a good state. + */ + | "ok" + /** + * The user needs to set up recovery. + */ + | "set_up_recovery" + /** + * The device is not verified. + */ + | "verify_this_session" + /** + * Key storage is out of sync (keys are missing locally, from recovery, or both). + */ + | "key_storage_out_of_sync" + /** + * Key storage is not enabled, and has not been marked as purposely disabled. + */ + | "turn_on_key_storage" + /** + * The user's identity needs resetting, due to missing keys. + */ + | "identity_needs_reset"; + +/** + * The events emitted by {@link DeviceListener} + */ +export enum DeviceListenerEvents { + DeviceState = "device_state", +} + +type EventHandlerMap = { + [DeviceListenerEvents.DeviceState]: (state: DeviceState) => void; +}; + +export default class DeviceListener extends TypedEventEmitter { private dispatcherRef?: string; // device IDs for which the user has dismissed the verify toast ('Later') private dismissed = new Set(); @@ -87,6 +127,7 @@ export default class DeviceListener { private shouldRecordClientInformation = false; private enableBulkUnverifiedSessionsReminder = true; private deviceClientInformationSettingWatcherRef: string | undefined; + private deviceState: DeviceState = "ok"; // Remember the current analytics state to avoid sending the same event multiple times. private analyticsVerificationState?: string; @@ -198,8 +239,8 @@ export default class DeviceListener { } /** - * If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck} - * requires a reset of cross-signing keys. + * If the device is in a `key_storage_out_of_sync` state, check if + * it requires a reset of cross-signing keys. * * We will reset cross-signing keys if both our local cache and 4S don't * have all cross-signing keys. @@ -227,16 +268,15 @@ export default class DeviceListener { } /** - * If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck} - * requires a reset of key backup. + * If the device is in a `"key_storage_out_of_sync"` state, check if + * it requires a reset of key backup. * * If the user has their recovery key, we need to reset backup if: * - the user hasn't disabled backup, * - we don't have the backup key cached locally, *and* * - we don't have the backup key stored in 4S. - * (The user should already have a key backup created at this point, - * otherwise `doRecheck` would have triggered a `Kind.TURN_ON_KEY_STORAGE` - * condition.) + * (The user should already have a key backup created at this point, the + * device state would be `turn_on_key_storage`.) * * If the user has forgotten their recovery key, we need to reset backup if: * - the user hasn't disabled backup, and @@ -425,88 +465,93 @@ export default class DeviceListener { const recoveryIsOk = secretStorageStatus.ready || recoveryDisabled; - const isCurrentDeviceTrusted = - crossSigningReady && - Boolean( - (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, - ); + const isCurrentDeviceTrusted = Boolean( + (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, + ); const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan); const backupDisabled = await this.recheckBackupDisabled(cli); // We warn if key backup upload is turned off and we have not explicitly // said we are OK with that. - const keyBackupIsOk = keyBackupUploadActive || backupDisabled; + const keyBackupUploadIsOk = keyBackupUploadActive || backupDisabled; - // If key backup is active and not disabled: do we have the backup key - // cached locally? - const backupKeyCached = + // We warn if key backup is set up, but we don't have the decryption + // key, so can't fetch keys from backup. + const keyBackupDownloadIsOk = !keyBackupUploadActive || backupDisabled || (await crypto.getSessionBackupPrivateKey()) !== null; const allSystemsReady = - isCurrentDeviceTrusted && allCrossSigningSecretsCached && keyBackupIsOk && recoveryIsOk && backupKeyCached; + isCurrentDeviceTrusted && + allCrossSigningSecretsCached && + keyBackupUploadIsOk && + recoveryIsOk && + keyBackupDownloadIsOk; await this.reportCryptoSessionStateToAnalytics(cli); - if (this.dismissedThisDeviceToast || allSystemsReady) { + if (allSystemsReady) { logSpan.info("No toast needed"); - hideSetupEncryptionToast(); + await this.setDeviceState("ok", logSpan); this.checkKeyBackupStatus(); - } else if (await this.shouldShowSetupEncryptionToast()) { + } else { // make sure our keys are finished downloading await crypto.getUserDeviceInfo([cli.getSafeUserId()]); if (!isCurrentDeviceTrusted) { // the current device is not trusted: prompt the user to verify - logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast"); - showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); + logSpan.info("Current device not verified: setting state to VERIFY_THIS_SESSION"); + await this.setDeviceState("verify_this_session", logSpan); } else if (!allCrossSigningSecretsCached) { // cross signing ready & device trusted, but we are missing secrets from our local cache. // prompt the user to enter their recovery key. logSpan.info( - "Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast", + "Some secrets not cached: setting state to KEY_STORAGE_OUT_OF_SYNC", crossSigningStatus.privateKeysCachedLocally, + crossSigningStatus.privateKeysInSecretStorage, + ); + await this.setDeviceState( + crossSigningStatus.privateKeysInSecretStorage ? "key_storage_out_of_sync" : "identity_needs_reset", + logSpan, ); - showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC); - } else if (!keyBackupIsOk) { - logSpan.info("Key backup upload is unexpectedly turned off: showing TURN_ON_KEY_STORAGE toast"); - showSetupEncryptionToast(SetupKind.TURN_ON_KEY_STORAGE); + } else if (!keyBackupUploadIsOk) { + logSpan.info("Key backup upload is unexpectedly turned off: setting state to TURN_ON_KEY_STORAGE"); + await this.setDeviceState("turn_on_key_storage", logSpan); } else if (secretStorageStatus.defaultKeyId === null) { // The user just hasn't set up 4S yet: if they have key // backup, prompt them to turn on recovery too. (If not, they // have explicitly opted out, so don't hassle them.) if (recoveryDisabled) { logSpan.info("Recovery disabled: no toast needed"); - hideSetupEncryptionToast(); + await this.setDeviceState("ok", logSpan); } else if (keyBackupUploadActive) { - logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast"); - showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); + logSpan.info("No default 4S key: setting state to SET_UP_RECOVERY"); + await this.setDeviceState("set_up_recovery", logSpan); } else { logSpan.info("No default 4S key but backup disabled: no toast needed"); - hideSetupEncryptionToast(); + await this.setDeviceState("ok", logSpan); } } else { // If we get here, then we are verified, have key backup, and // 4S, but allSystemsReady is false, which means that either // secretStorageStatus.ready is false (which means that 4S // doesn't have all the secrets), or we don't have the backup - // key cached locally. + // key cached locally. If any of the cross-signing keys are + // missing locally, that is handled by the + // `!allCrossSigningSecretsCached` branch above. logSpan.warn("4S is missing secrets or backup key not cached", { crossSigningReady, secretStorageStatus, allCrossSigningSecretsCached, isCurrentDeviceTrusted, - backupKeyCached, + keyBackupDownloadIsOk, }); - // We use the right toast variant based on whether the backup - // key is missing locally. If any of the cross-signing keys are - // missing locally, that is handled by the - // `!allCrossSigningSecretsCached` branch above. - showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC); + await this.setDeviceState("key_storage_out_of_sync", logSpan); + } + if (this.dismissedThisDeviceToast) { + this.checkKeyBackupStatus(); } - } else { - logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false"); } // This needs to be done after awaiting on getUserDeviceInfo() above, so @@ -598,6 +643,31 @@ export default class DeviceListener { return recoveryStatus?.enabled === false; } + /** + * Get the state of the device and the user's account. The device/account + * state indicates what action the user must take in order to get a + * self-verified device that is using key backup and recovery. + */ + public getDeviceState(): DeviceState { + return this.deviceState; + } + + /** + * Set the state of the device, and perform any actions necessary in + * response to the state changing. + */ + private async setDeviceState(newState: DeviceState, logSpan: LogSpan): Promise { + this.deviceState = newState; + this.emit(DeviceListenerEvents.DeviceState, newState); + if (newState === "ok" || this.dismissedThisDeviceToast) { + hideSetupEncryptionToast(); + } else if (await this.shouldShowSetupEncryptionToast()) { + showSetupEncryptionToast(newState); + } else { + logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false"); + } + } + /** * Reports current recovery state to analytics. * Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S). diff --git a/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx b/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx index a5d47100d6f..16ab517d320 100644 --- a/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx +++ b/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx @@ -12,13 +12,20 @@ import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key"; import { SettingsSection } from "../shared/SettingsSection"; import { _t } from "../../../../languageHandler"; import { SettingsSubheader } from "../SettingsSubheader"; -import { accessSecretStorage } from "../../../../SecurityManager"; +import { AccessCancelledError, accessSecretStorage } from "../../../../SecurityManager"; +import DeviceListener from "../../../../DeviceListener"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup"; interface RecoveryPanelOutOfSyncProps { /** * Callback for when the user has finished entering their recovery key. */ onFinish: () => void; + /** + * Callback for when accessing secret storage fails. + */ + onAccessSecretStorageFailed: () => void; /** * Callback for when the user clicks on the "Forgot recovery key?" button. */ @@ -32,7 +39,13 @@ interface RecoveryPanelOutOfSyncProps { * It prompts the user to enter their recovery key so that the secrets can be loaded from 4S into * the client. */ -export function RecoveryPanelOutOfSync({ onForgotRecoveryKey, onFinish }: RecoveryPanelOutOfSyncProps): JSX.Element { +export function RecoveryPanelOutOfSync({ + onForgotRecoveryKey, + onAccessSecretStorageFailed, + onFinish, +}: RecoveryPanelOutOfSyncProps): JSX.Element { + const matrixClient = useMatrixClientContext(); + return ( { - await accessSecretStorage(); + const crypto = matrixClient.getCrypto()!; + + const deviceListener = DeviceListener.sharedInstance(); + + // we need to call keyStorageOutOfSyncNeedsBackupReset here because + // deviceListener.whilePaused() sets its client to undefined, so + // keyStorageOutOfSyncNeedsBackupReset won't be able to check + // the backup state. + const needsBackupReset = await deviceListener.keyStorageOutOfSyncNeedsBackupReset(false); + + try { + // pause the device listener because we could be making lots + // of changes, and don't want toasts to pop up and disappear + // while we're doing it + await deviceListener.whilePaused(async () => { + await accessSecretStorage(async () => { + // Reset backup if needed. + if (needsBackupReset) { + await resetKeyBackupAndWait(crypto); + } else if (await matrixClient.isKeyBackupKeyStored()) { + await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); + } + }); + }); + } catch (error) { + if (error instanceof AccessCancelledError) { + // The user cancelled the dialog - just allow it to + // close, and return to this panel + } else { + onAccessSecretStorageFailed(); + } + return; + } onFinish(); }} > diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index dea28628fbe..8003a7820f0 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -5,15 +5,13 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, useCallback, useEffect, useState } from "react"; -import { Button, InlineSpinner, Separator } from "@vector-im/compound-web"; +import React, { type JSX, useState } from "react"; +import { Button, Separator } from "@vector-im/compound-web"; import ComputerIcon from "@vector-im/compound-design-tokens/assets/web/icons/computer"; -import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import SettingsTab from "../SettingsTab"; import { RecoveryPanel } from "../../encryption/RecoveryPanel"; import { ChangeRecoveryKey } from "../../encryption/ChangeRecoveryKey"; -import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; import { _t } from "../../../../../languageHandler"; import Modal from "../../../../../Modal"; import SetupEncryptionDialog from "../../../dialogs/security/SetupEncryptionDialog"; @@ -23,17 +21,15 @@ import { AdvancedPanel } from "../../encryption/AdvancedPanel"; import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel"; import { type ResetIdentityBodyVariant } from "../../encryption/ResetIdentityBody"; import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync"; -import { useTypedEventEmitter } from "../../../../../hooks/useEventEmitter"; +import { useTypedEventEmitterState } from "../../../../../hooks/useEventEmitter"; import { KeyStoragePanel } from "../../encryption/KeyStoragePanel"; import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel"; +import DeviceListener, { DeviceListenerEvents, type DeviceState } from "../../../../../DeviceListener"; +import { useKeyStoragePanelViewModel } from "../../../../viewmodels/settings/encryption/KeyStoragePanelViewModel"; /** * The state in the encryption settings tab. - * - "loading": We are checking if the device is verified. * - "main": The main panel with all the sections (Key storage, recovery, advanced). - * - "key_storage_disabled": The user has chosen to disable key storage and options are unavailable as a result. - * - "set_up_encryption": The panel to show when the user is setting up their encryption. - * This happens when the user doesn't have cross-signing enabled, or their current device is not verified. * - "change_recovery_key": The panel to show when the user is changing their recovery key. * This happens when the user has a recovery key and the user clicks on "Change recovery key" button of the RecoveryPanel. * - "set_recovery_key": The panel to show when the user is setting up their recovery key. @@ -41,21 +37,17 @@ import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel"; * - "reset_identity_compromised": The panel to show when the user is resetting their identity, in the case where their key is compromised. * - "reset_identity_forgot": The panel to show when the user is resetting their identity, in the case where they forgot their recovery key. * - "reset_identity_sync_failed": The panel to show when the user us resetting their identity, in the case where recovery failed. - * - "secrets_not_cached": The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets. - * If the "set_up_encryption" and "secrets_not_cached" conditions are both filled, "set_up_encryption" prevails. + * - "reset_identity_cant_recover": The panel to show when the user is resetting their identity, in the case where they can't use recovery. * - "key_storage_delete": The confirmation page asking if the user really wants to turn off key storage. */ export type State = - | "loading" | "main" - | "key_storage_disabled" - | "set_up_encryption" | "change_recovery_key" | "set_recovery_key" | "reset_identity_compromised" | "reset_identity_forgot" | "reset_identity_sync_failed" - | "secrets_not_cached" + | "reset_identity_cant_recover" | "key_storage_delete"; interface Props { @@ -68,48 +60,69 @@ interface Props { /** * The encryption settings tab. */ -export function EncryptionUserSettingsTab({ initialState = "loading" }: Props): JSX.Element { +export function EncryptionUserSettingsTab({ initialState = "main" }: Readonly): JSX.Element { const [state, setState] = useState(initialState); - const checkEncryptionState = useCheckEncryptionState(state, setState); + const deviceState = useTypedEventEmitterState( + DeviceListener.sharedInstance(), + DeviceListenerEvents.DeviceState, + (state?: DeviceState): DeviceState => { + return state ?? DeviceListener.sharedInstance().getDeviceState(); + }, + ); + + const { isEnabled: isBackupEnabled } = useKeyStoragePanelViewModel(); let content: JSX.Element; switch (state) { - case "loading": - content = ; - break; - case "set_up_encryption": - content = ; - break; - case "secrets_not_cached": - content = ( - setState("reset_identity_forgot")} - /> - ); - break; - case "key_storage_disabled": case "main": - content = ( - <> - setState("key_storage_delete")} /> - - {/* We only show the "Recovery" panel if key storage is enabled.*/} - {state === "main" && ( + switch (deviceState) { + // some device states require action from the user rather than showing the main settings screen + case "verify_this_session": + content = setState("main")} />; + break; + case "key_storage_out_of_sync": + content = ( + setState("main")} + onForgotRecoveryKey={() => setState("reset_identity_forgot")} + onAccessSecretStorageFailed={async () => { + const needsCrossSigningReset = + await DeviceListener.sharedInstance().keyStorageOutOfSyncNeedsCrossSigningReset( + true, + ); + setState(needsCrossSigningReset ? "reset_identity_sync_failed" : "change_recovery_key"); + }} + /> + ); + break; + case "identity_needs_reset": + content = ( + setState("reset_identity_cant_recover")} /> + ); + break; + default: + content = ( <> - - setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") - } - /> + setState("key_storage_delete")} /> + {/* We only show the "Recovery" panel if key storage is enabled.*/} + {isBackupEnabled && ( + <> + + setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") + } + /> + + + )} + setState("reset_identity_compromised")} /> - )} - setState("reset_identity_compromised")} /> - - ); + ); + break; + } break; case "change_recovery_key": case "set_recovery_key": @@ -124,16 +137,17 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Props): case "reset_identity_compromised": case "reset_identity_forgot": case "reset_identity_sync_failed": + case "reset_identity_cant_recover": content = ( setState("main")} + onReset={() => setState("main")} /> ); break; case "key_storage_delete": - content = ; + content = setState("main")} />; break; } @@ -154,6 +168,8 @@ function findResetVariant(state: State): ResetIdentityBodyVariant { return "compromised"; case "reset_identity_sync_failed": return "sync_failed"; + case "reset_identity_cant_recover": + return "no_verification_method"; default: case "reset_identity_forgot": @@ -161,63 +177,6 @@ function findResetVariant(state: State): ResetIdentityBodyVariant { } } -/** - * Hook to check if the user needs: - * - to go through the SetupEncryption flow. - * - to enter their recovery key, if the secrets are not cached locally. - * ...and also whether megolm key backup is enabled on this device (which we use to set the state of the 'allow key storage' toggle) - * - * If cross signing is set up, key backup is enabled and the secrets are cached, the state will be set to "main". - * If cross signing is not set up, the state will be set to "set_up_encryption". - * If key backup is not enabled, the state will be set to "key_storage_disabled". - * If secrets are missing, the state will be set to "secrets_not_cached". - * - * The state is set once when the component is first mounted. - * Also returns a callback function which can be called to re-run the logic. - * - * @param setState - callback passed from the EncryptionUserSettingsTab to set the current `State`. - * @returns a callback function, which will re-run the logic and update the state. - */ -function useCheckEncryptionState(state: State, setState: (state: State) => void): () => Promise { - const matrixClient = useMatrixClientContext(); - - const checkEncryptionState = useCallback(async () => { - const crypto = matrixClient.getCrypto()!; - const isCrossSigningReady = await crypto.isCrossSigningReady(); - - // Check if the secrets are cached - const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally; - const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey; - - // Also check the key backup status - const activeBackupVersion = await crypto.getActiveSessionBackupVersion(); - - const keyStorageEnabled = activeBackupVersion !== null; - - if (isCrossSigningReady && keyStorageEnabled && secretsOk) setState("main"); - else if (!isCrossSigningReady) setState("set_up_encryption"); - else if (!keyStorageEnabled) setState("key_storage_disabled"); - else setState("secrets_not_cached"); - }, [matrixClient, setState]); - - // Initialise the state when the component is mounted - useEffect(() => { - if (state === "loading") checkEncryptionState(); - }, [checkEncryptionState, state]); - - useTypedEventEmitter(matrixClient, CryptoEvent.KeyBackupStatus, (): void => { - // Recheck the status if the key backup status has changed so we can keep the page up to date. - // Note that this could potentially update the UI while the user is trying to do something, although - // if their key backup status is changing then they're changing encryption related things - // on another device. This code is written with the assumption that it's better for the UI to refresh - // and be up to date with whatever changes they've made. - checkEncryptionState(); - }); - - // Also return the callback so that the component can re-run the logic. - return checkEncryptionState; -} - interface SetUpEncryptionPanelProps { /** * Callback to call when the user has finished setting up encryption. @@ -257,3 +216,32 @@ function SetUpEncryptionPanel({ onFinish }: SetUpEncryptionPanelProps): JSX.Elem ); } + +interface IdentityNeedsResetNoticePanelProps { + /** + * Callback to call when the user has finished setting up encryption. + */ + onContinue: () => void; +} + +/** + * Panel to tell the user that they need to reset their identity. + */ +function IdentityNeedsResetNoticePanel({ onContinue }: Readonly): JSX.Element { + return ( + + } + data-testid="recoveryPanel" + > +
+ +
+
+ ); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ddd45e35e52..adc9a9ef879 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -955,6 +955,7 @@ "bootstrap_title": "Setting up keys", "confirm_encryption_setup_body": "Click the button below to confirm setting up encryption.", "confirm_encryption_setup_title": "Confirm encryption setup", + "continue_with_reset": "Continue with reset", "cross_signing_room_normal": "This room is end-to-end encrypted", "cross_signing_room_verified": "Everyone in this room is verified", "cross_signing_room_warning": "Someone is using an unknown session", @@ -970,6 +971,7 @@ "event_shield_reason_unverified_identity": "Encrypted by an unverified user.", "export_unsupported": "Your browser does not support the required cryptography extensions", "forgot_recovery_key": "Forgot recovery key?", + "identity_needs_reset_description": "You have to reset your cryptographic identity in order to ensure access to your message history", "import_invalid_keyfile": "Not a valid %(brand)s keyfile", "import_invalid_passphrase": "Authentication check failed: incorrect password?", "key_storage_out_of_sync": "Your key storage is out of sync.", diff --git a/src/toasts/SetupEncryptionToast.ts b/src/toasts/SetupEncryptionToast.ts index 965d8e17fc4..1dd41ebc32f 100644 --- a/src/toasts/SetupEncryptionToast.ts +++ b/src/toasts/SetupEncryptionToast.ts @@ -14,7 +14,7 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even import type React from "react"; import Modal from "../Modal"; import { _t } from "../languageHandler"; -import DeviceListener from "../DeviceListener"; +import DeviceListener, { type DeviceState } from "../DeviceListener"; import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEncryptionDialog"; import { AccessCancelledError, accessSecretStorage } from "../SecurityManager"; import ToastStore, { type IToast } from "../stores/ToastStore"; @@ -33,114 +33,107 @@ import { PosthogAnalytics } from "../PosthogAnalytics"; const TOAST_KEY = "setupencryption"; -const getTitle = (kind: Kind): string => { - switch (kind) { - case Kind.SET_UP_RECOVERY: +/** + * The device states that we show a toast for (everything except for "ok"). + */ +type DeviceStateForToast = Exclude; + +const getTitle = (state: DeviceStateForToast): string => { + switch (state) { + case "set_up_recovery": return _t("encryption|set_up_recovery"); - case Kind.VERIFY_THIS_SESSION: + case "verify_this_session": return _t("encryption|verify_toast_title"); - case Kind.KEY_STORAGE_OUT_OF_SYNC: + case "key_storage_out_of_sync": + case "identity_needs_reset": return _t("encryption|key_storage_out_of_sync"); - case Kind.TURN_ON_KEY_STORAGE: + case "turn_on_key_storage": return _t("encryption|turn_on_key_storage"); } }; -const getIcon = (kind: Kind): IToast["icon"] => { - switch (kind) { - case Kind.SET_UP_RECOVERY: +const getIcon = (state: DeviceStateForToast): IToast["icon"] => { + switch (state) { + case "set_up_recovery": return undefined; - case Kind.VERIFY_THIS_SESSION: - case Kind.KEY_STORAGE_OUT_OF_SYNC: + case "verify_this_session": + case "key_storage_out_of_sync": + case "identity_needs_reset": return "verification_warning"; - case Kind.TURN_ON_KEY_STORAGE: + case "turn_on_key_storage": return "key_storage"; } }; -const getSetupCaption = (kind: Kind): string => { - switch (kind) { - case Kind.SET_UP_RECOVERY: +const getSetupCaption = (state: DeviceStateForToast): string => { + switch (state) { + case "set_up_recovery": return _t("action|continue"); - case Kind.VERIFY_THIS_SESSION: + case "verify_this_session": return _t("action|verify"); - case Kind.KEY_STORAGE_OUT_OF_SYNC: + case "key_storage_out_of_sync": return _t("encryption|enter_recovery_key"); - case Kind.TURN_ON_KEY_STORAGE: + case "turn_on_key_storage": return _t("action|continue"); + case "identity_needs_reset": + return _t("encryption|continue_with_reset"); } }; /** * Get the icon to show on the primary button. - * @param kind + * @param state */ -const getPrimaryButtonIcon = (kind: Kind): ComponentType> | undefined => { - switch (kind) { - case Kind.KEY_STORAGE_OUT_OF_SYNC: +const getPrimaryButtonIcon = ( + state: DeviceStateForToast, +): ComponentType> | undefined => { + switch (state) { + case "key_storage_out_of_sync": return KeyIcon; default: return; } }; -const getSecondaryButtonLabel = (kind: Kind): string => { - switch (kind) { - case Kind.SET_UP_RECOVERY: +const getSecondaryButtonLabel = (state: DeviceStateForToast): string => { + switch (state) { + case "set_up_recovery": return _t("action|dismiss"); - case Kind.VERIFY_THIS_SESSION: + case "verify_this_session": return _t("encryption|verification|unverified_sessions_toast_reject"); - case Kind.KEY_STORAGE_OUT_OF_SYNC: + case "key_storage_out_of_sync": return _t("encryption|forgot_recovery_key"); - case Kind.TURN_ON_KEY_STORAGE: + case "turn_on_key_storage": return _t("action|dismiss"); + case "identity_needs_reset": + return ""; } }; -const getDescription = (kind: Kind): string => { - switch (kind) { - case Kind.SET_UP_RECOVERY: +const getDescription = (state: DeviceStateForToast): string => { + switch (state) { + case "set_up_recovery": return _t("encryption|set_up_recovery_toast_description"); - case Kind.VERIFY_THIS_SESSION: + case "verify_this_session": return _t("encryption|verify_toast_description"); - case Kind.KEY_STORAGE_OUT_OF_SYNC: + case "key_storage_out_of_sync": return _t("encryption|key_storage_out_of_sync_description"); - case Kind.TURN_ON_KEY_STORAGE: + case "turn_on_key_storage": return _t("encryption|turn_on_key_storage_description"); + case "identity_needs_reset": + return _t("encryption|identity_needs_reset_description"); } }; -/** - * The kind of toast to show. - */ -export enum Kind { - /** - * Prompt the user to set up a recovery key - */ - SET_UP_RECOVERY = "set_up_recovery", - /** - * Prompt the user to verify this session - */ - VERIFY_THIS_SESSION = "verify_this_session", - /** - * Prompt the user to enter their recovery key - */ - KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync", - /** - * Prompt the user to turn on key storage - */ - TURN_ON_KEY_STORAGE = "turn_on_key_storage", -} - /** * Show a toast prompting the user for some action related to setting up their encryption. * - * @param kind The kind of toast to show + * @param state The state of the device */ -export const showToast = (kind: Kind): void => { +export const showToast = (state: DeviceStateForToast): void => { if ( ModuleRunner.instance.extensions.cryptoSetup.setupEncryptionNeeded({ - kind: kind as any, + kind: state as any, storeProvider: { getInstance: () => SetupEncryptionStore.sharedInstance() }, }) ) { @@ -148,13 +141,13 @@ export const showToast = (kind: Kind): void => { } const onPrimaryClick = async (): Promise => { - switch (kind) { - case Kind.SET_UP_RECOVERY: - case Kind.TURN_ON_KEY_STORAGE: { + switch (state) { + case "set_up_recovery": + case "turn_on_key_storage": { PosthogAnalytics.instance.trackEvent({ eventName: "Interaction", interactionType: "Pointer", - name: kind === Kind.SET_UP_RECOVERY ? "ToastSetUpRecoveryClick" : "ToastTurnOnKeyStorageClick", + name: state === "set_up_recovery" ? "ToastSetUpRecoveryClick" : "ToastTurnOnKeyStorageClick", }); // Open the user settings dialog to the encryption tab const payload: OpenToTabPayload = { @@ -164,10 +157,10 @@ export const showToast = (kind: Kind): void => { defaultDispatcher.dispatch(payload); break; } - case Kind.VERIFY_THIS_SESSION: + case "verify_this_session": Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true); break; - case Kind.KEY_STORAGE_OUT_OF_SYNC: { + case "key_storage_out_of_sync": { const modal = Modal.createDialog( Spinner, undefined, @@ -208,12 +201,24 @@ export const showToast = (kind: Kind): void => { } break; } + case "identity_needs_reset": { + // Open the user settings dialog to reset identity + const payload: OpenToTabPayload = { + action: Action.ViewUserSettings, + initialTabId: UserTab.Encryption, + props: { + initialEncryptionState: "reset_identity_cant_recover", + }, + }; + defaultDispatcher.dispatch(payload); + break; + } } }; const onSecondaryClick = async (): Promise => { - switch (kind) { - case Kind.SET_UP_RECOVERY: { + switch (state) { + case "set_up_recovery": { PosthogAnalytics.instance.trackEvent({ eventName: "Interaction", interactionType: "Pointer", @@ -225,7 +230,7 @@ export const showToast = (kind: Kind): void => { deviceListener.dismissEncryptionSetup(); break; } - case Kind.KEY_STORAGE_OUT_OF_SYNC: { + case "key_storage_out_of_sync": { // Open the user settings dialog to the encryption tab and start the flow to reset encryption or change the recovery key const deviceListener = DeviceListener.sharedInstance(); const needsCrossSigningReset = await deviceListener.keyStorageOutOfSyncNeedsCrossSigningReset(true); @@ -241,7 +246,7 @@ export const showToast = (kind: Kind): void => { defaultDispatcher.dispatch(payload); break; } - case Kind.TURN_ON_KEY_STORAGE: { + case "turn_on_key_storage": { PosthogAnalytics.instance.trackEvent({ eventName: "Interaction", interactionType: "Pointer", @@ -296,19 +301,19 @@ export const showToast = (kind: Kind): void => { ToastStore.sharedInstance().addOrReplaceToast({ key: TOAST_KEY, - title: getTitle(kind), - icon: getIcon(kind), + title: getTitle(state), + icon: getIcon(state), props: { - description: getDescription(kind), - primaryLabel: getSetupCaption(kind), - PrimaryIcon: getPrimaryButtonIcon(kind), + description: getDescription(state), + primaryLabel: getSetupCaption(state), + PrimaryIcon: getPrimaryButtonIcon(state), onPrimaryClick, - secondaryLabel: getSecondaryButtonLabel(kind), + secondaryLabel: getSecondaryButtonLabel(state), onSecondaryClick, - overrideWidth: kind === Kind.KEY_STORAGE_OUT_OF_SYNC ? "366px" : undefined, + overrideWidth: state === "key_storage_out_of_sync" ? "366px" : undefined, }, component: GenericToast, - priority: kind === Kind.VERIFY_THIS_SESSION ? 95 : 40, + priority: state === "verify_this_session" ? 95 : 40, }); }; diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index ef3b01b68c6..153739dea60 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -341,9 +341,7 @@ describe("DeviceListener", () => { await createAndStart(); expect(mockCrypto!.getUserDeviceInfo).toHaveBeenCalled(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.VERIFY_THIS_SESSION, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("verify_this_session"); }); describe("when current device is verified", () => { @@ -380,9 +378,23 @@ describe("DeviceListener", () => { await createAndStart(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync"); + }); + + it("shows an identity reset toast when one of the cross-signing secrets is missing locally and in 4S", async () => { + mockCrypto!.getCrossSigningStatus.mockResolvedValue({ + publicKeysOnDevice: true, + privateKeysInSecretStorage: false, + privateKeysCachedLocally: { + masterKey: false, + selfSigningKey: true, + userSigningKey: true, + }, + }); + + await createAndStart(); + + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("identity_needs_reset"); }); it("shows an out-of-sync toast when the backup key is missing locally", async () => { @@ -392,9 +404,7 @@ describe("DeviceListener", () => { await createAndStart(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync"); }); it("does not show an out-of-sync toast when the backup key is missing locally but backup is purposely disabled", async () => { @@ -426,9 +436,7 @@ describe("DeviceListener", () => { await createAndStart(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync"); // Then, when we receive the secret, it should be hidden. mockCrypto!.getCrossSigningStatus.mockResolvedValue({ @@ -454,9 +462,7 @@ describe("DeviceListener", () => { await createAndStart(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.SET_UP_RECOVERY, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("set_up_recovery"); }); it("shows an out-of-sync toast when one of the secrets is missing from 4S", async () => { @@ -470,9 +476,7 @@ describe("DeviceListener", () => { await createAndStart(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync"); }); }); }); @@ -573,9 +577,7 @@ describe("DeviceListener", () => { await createAndStart(); // Then the toast is displayed - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("turn_on_key_storage"); }); it("shows the 'Turn on key storage' toast if we turned on key storage", async () => { @@ -591,9 +593,7 @@ describe("DeviceListener", () => { await createAndStart(); // Then the toast is displayed - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("turn_on_key_storage"); }); it("does not show the 'Turn on key storage' toast if we turned off key storage", async () => { @@ -606,9 +606,7 @@ describe("DeviceListener", () => { await createAndStart(); // Then the toast is not displayed - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage"); }); }); @@ -626,9 +624,7 @@ describe("DeviceListener", () => { await createAndStart(); // Then the toast is not displayed - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage"); }); it("does not show the 'Turn on key storage' toast if we turned on key storage", async () => { @@ -643,9 +639,7 @@ describe("DeviceListener", () => { await createAndStart(); // Then the toast is not displayed - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage"); }); it("does not show the 'Turn on key storage' toast if we turned off key storage", async () => { @@ -661,9 +655,7 @@ describe("DeviceListener", () => { await createAndStart(); // Then the toast is not displayed - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage"); }); }); }); @@ -1206,25 +1198,21 @@ describe("DeviceListener", () => { await createAndStart(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(SetupEncryptionToast.Kind.SET_UP_RECOVERY); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("set_up_recovery"); }); it("does not show the 'set up recovery' toast if secret storage is set up", async () => { mockCrypto!.getSecretStorageStatus.mockResolvedValue(readySecretStorageStatus); await createAndStart(); - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.SET_UP_RECOVERY, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("set_up_recovery"); }); it("does not show the 'set up recovery' toast if user has no encrypted rooms", async () => { jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); await createAndStart(); - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.SET_UP_RECOVERY, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("set_up_recovery"); }); it("does not show the 'set up recovery' toast if the user has chosen to disable key storage", async () => { @@ -1236,9 +1224,7 @@ describe("DeviceListener", () => { }); await createAndStart(); - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.SET_UP_RECOVERY, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("set_up_recovery"); }); }); }); diff --git a/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx b/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx index 36e35dbe832..7f1d37b3297 100644 --- a/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx +++ b/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx @@ -9,19 +9,45 @@ import React from "react"; import { render, screen } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { mocked } from "jest-mock"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { RecoveryPanelOutOfSync } from "../../../../../../src/components/views/settings/encryption/RecoveryPanelOutOfSync"; -import { accessSecretStorage } from "../../../../../../src/SecurityManager"; +import { AccessCancelledError, accessSecretStorage } from "../../../../../../src/SecurityManager"; +import DeviceListener from "../../../../../../src/DeviceListener"; +import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils"; -jest.mock("../../../../../../src/SecurityManager", () => ({ - accessSecretStorage: jest.fn(), -})); +jest.mock("../../../../../../src/SecurityManager", () => { + const originalModule = jest.requireActual("../../../../../../src/SecurityManager"); + + return { + ...originalModule, + accessSecretStorage: jest.fn(), + }; +}); describe("", () => { - function renderComponent(onFinish = jest.fn(), onForgotRecoveryKey = jest.fn()) { - return render(); + let matrixClient: MatrixClient; + + function renderComponent( + onFinish = jest.fn(), + onForgotRecoveryKey = jest.fn(), + onAccessSecretStorageFailed = jest.fn(), + ) { + matrixClient = createTestClient(); + return render( + , + withClientContextRenderOptions(matrixClient), + ); } + afterEach(() => { + jest.clearAllMocks(); + }); + it("should render", () => { const { asFragment } = renderComponent(); expect(asFragment()).toMatchSnapshot(); @@ -38,8 +64,30 @@ describe("", () => { }); it("should access to 4S and call onFinish when 'Enter recovery key' is clicked", async () => { + jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false); + + const user = userEvent.setup(); + mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise => {}) => { + return await func(); + }); + + const onFinish = jest.fn(); + renderComponent(onFinish); + + await user.click(screen.getByRole("button", { name: "Enter recovery key" })); + expect(accessSecretStorage).toHaveBeenCalled(); + expect(onFinish).toHaveBeenCalled(); + + expect(matrixClient.getCrypto()!.resetKeyBackup).not.toHaveBeenCalled(); + }); + + it("should reset key backup if needed", async () => { + jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true); + const user = userEvent.setup(); - mocked(accessSecretStorage).mockClear().mockResolvedValue(); + mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise => {}) => { + return await func(); + }); const onFinish = jest.fn(); renderComponent(onFinish); @@ -47,5 +95,41 @@ describe("", () => { await user.click(screen.getByRole("button", { name: "Enter recovery key" })); expect(accessSecretStorage).toHaveBeenCalled(); expect(onFinish).toHaveBeenCalled(); + + expect(matrixClient.getCrypto()!.resetKeyBackup).toHaveBeenCalled(); + }); + + it("should call onAccessSecretStorageFailed on failure", async () => { + jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true); + + const user = userEvent.setup(); + mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise => {}) => { + throw new Error("Error"); + }); + + const onAccessSecretStorageFailed = jest.fn(); + renderComponent(jest.fn(), jest.fn(), onAccessSecretStorageFailed); + + await user.click(screen.getByRole("button", { name: "Enter recovery key" })); + expect(accessSecretStorage).toHaveBeenCalled(); + expect(onAccessSecretStorageFailed).toHaveBeenCalled(); + }); + + it("should not call onAccessSecretStorageFailed when cancelled", async () => { + jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true); + + const user = userEvent.setup(); + mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise => {}) => { + throw new AccessCancelledError(); + }); + + const onFinish = jest.fn(); + const onAccessSecretStorageFailed = jest.fn(); + renderComponent(onFinish, jest.fn(), onAccessSecretStorageFailed); + + await user.click(screen.getByRole("button", { name: "Enter recovery key" })); + expect(accessSecretStorage).toHaveBeenCalled(); + expect(onFinish).not.toHaveBeenCalled(); + expect(onAccessSecretStorageFailed).not.toHaveBeenCalled(); }); }); diff --git a/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx b/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx index 018ec25ef35..d86f8fbdec4 100644 --- a/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx +++ b/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx @@ -5,6 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ +import { mocked } from "jest-mock"; import React from "react"; import { act, render, screen } from "jest-matrix-react"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; @@ -18,6 +19,7 @@ import { } from "../../../../../../../src/components/views/settings/tabs/user/EncryptionUserSettingsTab"; import { createTestClient, withClientContextRenderOptions } from "../../../../../../test-utils"; import Modal from "../../../../../../../src/Modal"; +import DeviceListener from "../../../../../../../src/DeviceListener"; describe("", () => { let matrixClient: MatrixClient; @@ -37,22 +39,21 @@ describe("", () => { userSigningKey: true, }, }); + + jest.spyOn(DeviceListener.sharedInstance(), "getDeviceState").mockReturnValue("ok"); + }); + + afterEach(() => { + jest.resetAllMocks(); }); function renderComponent(props: { initialState?: State } = {}) { return render(, withClientContextRenderOptions(matrixClient)); } - it("should display a loading state when the encryption state is computed", () => { - jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockImplementation(() => new Promise(() => {})); - - renderComponent(); - expect(screen.getByLabelText("Loading…")).toBeInTheDocument(); - }); - it("should display a verify button when the encryption is not set up", async () => { const user = userEvent.setup(); - jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockResolvedValue(false); + mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("verify_this_session"); const { asFragment } = renderComponent(); await waitFor(() => @@ -81,17 +82,7 @@ describe("", () => { }); it("should display the recovery out of sync panel when secrets are not cached", async () => { - jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); - // Secrets are not cached - jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({ - privateKeysInSecretStorage: true, - publicKeysOnDevice: true, - privateKeysCachedLocally: { - masterKey: false, - selfSigningKey: true, - userSigningKey: true, - }, - }); + mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("key_storage_out_of_sync"); const user = userEvent.setup(); const { asFragment } = renderComponent(); @@ -196,18 +187,7 @@ describe("", () => { it("should re-check the encryption state and displays the correct panel when the user clicks cancel the reset identity flow", async () => { const user = userEvent.setup(); - jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); - - // Secrets are not cached - jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({ - privateKeysInSecretStorage: true, - publicKeysOnDevice: true, - privateKeysCachedLocally: { - masterKey: false, - selfSigningKey: true, - userSigningKey: true, - }, - }); + mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("key_storage_out_of_sync"); renderComponent({ initialState: "reset_identity_forgot" }); @@ -220,4 +200,17 @@ describe("", () => { screen.getByText("Your key storage is out of sync. Click one of the buttons below to fix the problem."), ); }); + + it("should display the identity needs reset panel when the user's identity needs resetting", async () => { + mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("identity_needs_reset"); + + const user = userEvent.setup(); + const { asFragment } = renderComponent(); + + await waitFor(() => screen.getByRole("button", { name: "Continue with reset" })); + expect(asFragment()).toMatchSnapshot(); + + await user.click(screen.getByRole("button", { name: "Continue with reset" })); + expect(screen.getByRole("heading", { name: "You need to reset your identity" })).toBeVisible(); + }); }); diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap index a3dc5dfecfb..67d1aebaa67 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap @@ -81,6 +81,65 @@ exports[` should display the change recovery key pa `; +exports[` should display the identity needs reset panel when the user's identity needs resetting 1`] = ` + +
+
+
+
+

+ Your key storage is out of sync. +

+
+ + + + + You have to reset your cryptographic identity in order to ensure access to your message history + +
+
+
+ +
+
+
+
+
+`; + exports[` should display the recovery out of sync panel when secrets are not cached 1`] = `
({ @@ -36,7 +37,7 @@ describe("SetupEncryptionToast", () => { describe("Set up recovery", () => { it("should render the toast", async () => { - act(() => showToast(Kind.SET_UP_RECOVERY)); + act(() => showToast("set_up_recovery")); expect(await screen.findByRole("heading", { name: "Set up recovery" })).toBeInTheDocument(); }); @@ -45,7 +46,7 @@ describe("SetupEncryptionToast", () => { jest.spyOn(DeviceListener.sharedInstance(), "recordRecoveryDisabled"); jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup"); - act(() => showToast(Kind.SET_UP_RECOVERY)); + act(() => showToast("set_up_recovery")); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Dismiss" })); @@ -69,13 +70,13 @@ describe("SetupEncryptionToast", () => { }); it("should render the toast", async () => { - act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC)); + act(() => showToast("key_storage_out_of_sync")); await expect(screen.findByText("Your key storage is out of sync.")).resolves.toBeInTheDocument(); }); it("should reset key backup if needed", async () => { - showToast(Kind.KEY_STORAGE_OUT_OF_SYNC); + showToast("key_storage_out_of_sync"); jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation( async (func = async (): Promise => {}) => { @@ -92,7 +93,7 @@ describe("SetupEncryptionToast", () => { }); it("should not reset key backup if not needed", async () => { - showToast(Kind.KEY_STORAGE_OUT_OF_SYNC); + showToast("key_storage_out_of_sync"); jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation( async (func = async (): Promise => {}) => { @@ -114,7 +115,7 @@ describe("SetupEncryptionToast", () => { }); it("should open settings to the reset flow when 'forgot recovery key' clicked and identity reset needed", async () => { - act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC)); + act(() => showToast("key_storage_out_of_sync")); jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue( true, @@ -131,7 +132,7 @@ describe("SetupEncryptionToast", () => { }); it("should open settings to the change recovery key flow when 'forgot recovery key' clicked and identity reset not needed", async () => { - act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC)); + act(() => showToast("key_storage_out_of_sync")); jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue( false, @@ -156,7 +157,7 @@ describe("SetupEncryptionToast", () => { true, ); - act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC)); + act(() => showToast("key_storage_out_of_sync")); const user = userEvent.setup(); await user.click(await screen.findByText("Enter recovery key")); @@ -177,7 +178,7 @@ describe("SetupEncryptionToast", () => { false, ); - act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC)); + act(() => showToast("key_storage_out_of_sync")); const user = userEvent.setup(); await user.click(await screen.findByText("Enter recovery key")); @@ -192,7 +193,7 @@ describe("SetupEncryptionToast", () => { describe("Turn on key storage", () => { it("should render the toast", async () => { - act(() => showToast(Kind.TURN_ON_KEY_STORAGE)); + act(() => showToast("turn_on_key_storage")); await expect(screen.findByText("Turn on key storage")).resolves.toBeInTheDocument(); await expect(screen.findByRole("button", { name: "Dismiss" })).resolves.toBeInTheDocument(); @@ -202,7 +203,7 @@ describe("SetupEncryptionToast", () => { it("should open settings to the Encryption tab when 'Continue' clicked", async () => { jest.spyOn(DeviceListener.sharedInstance(), "recordKeyBackupDisabled"); - act(() => showToast(Kind.TURN_ON_KEY_STORAGE)); + act(() => showToast("turn_on_key_storage")); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Continue" })); @@ -224,7 +225,7 @@ describe("SetupEncryptionToast", () => { }); // When we show the toast, and click Dismiss - act(() => showToast(Kind.TURN_ON_KEY_STORAGE)); + act(() => showToast("turn_on_key_storage")); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Dismiss" })); @@ -240,4 +241,65 @@ describe("SetupEncryptionToast", () => { expect(DeviceListener.sharedInstance().recordKeyBackupDisabled).toHaveBeenCalledTimes(1); }); }); + + describe("Verify this session", () => { + it("should render the toast", async () => { + act(() => showToast("verify_this_session")); + + await expect(screen.findByText("Verify this session")).resolves.toBeInTheDocument(); + await expect(screen.findByRole("button", { name: "Later" })).resolves.toBeInTheDocument(); + await expect(screen.findByRole("button", { name: "Verify" })).resolves.toBeInTheDocument(); + }); + + it("should dismiss the toast when 'Later' button clicked, and remember it", async () => { + jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup"); + + act(() => showToast("verify_this_session")); + + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "Later" })); + + expect(DeviceListener.sharedInstance().dismissEncryptionSetup).toHaveBeenCalled(); + }); + + it("should open the verification dialog when 'Verify' clicked", async () => { + jest.spyOn(Modal, "createDialog"); + + // When we show the toast, and click Verify + act(() => showToast("verify_this_session")); + + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "Verify" })); + + // Then the dialog was opened + expect(Modal.createDialog).toHaveBeenCalledWith(SetupEncryptionDialog, {}, undefined, false, true); + }); + }); + + describe("Identity needs reset", () => { + it("should render the toast", async () => { + act(() => showToast("identity_needs_reset")); + + await expect(screen.findByText("Your key storage is out of sync.")).resolves.toBeInTheDocument(); + await expect( + screen.findByText( + "You have to reset your cryptographic identity in order to ensure access to your message history", + ), + ).resolves.toBeInTheDocument(); + await expect(screen.findByRole("button", { name: "Continue with reset" })).resolves.toBeInTheDocument(); + }); + + it("should open settings to the reset flow when 'Continue with reset' clicked", async () => { + act(() => showToast("identity_needs_reset")); + + const user = userEvent.setup(); + await user.click(await screen.findByText("Continue with reset")); + + expect(dis.dispatch).toHaveBeenCalledWith({ + action: "view_user_settings", + initialTabId: "USER_ENCRYPTION_TAB", + props: { initialEncryptionState: "reset_identity_cant_recover" }, + }); + }); + }); });