Handle cross-signing keys missing locally and/or from secret storage (#31367)
Some checks failed
Build / Build on ${{ matrix.image }} (macos-14, ${{ github.event_name == 'push' && github.ref_name == 'develop' }}, ${{ github.event_name == 'pull_request' }}) (push) Has been cancelled
Build / Build on ${{ matrix.image }} (ubuntu-24.04, ${{ github.event_name == 'push' && github.ref_name == 'develop' }}, ${{ github.event_name == 'pull_request' }}) (push) Has been cancelled
Build / Build on ${{ matrix.image }} (windows-2022, ${{ github.event_name == 'push' && github.ref_name == 'develop' }}, ${{ github.event_name == 'pull_request' }}) (push) Has been cancelled
Build and Deploy develop / Build & Deploy develop.element.io (push) Has been cancelled
Deploy documentation / GitHub Pages (push) Has been cancelled
Localazy Upload / upload (push) Has been cancelled
Shared Component Visual Tests / Run Visual Tests (push) Has been cancelled
Static Analysis / Typescript Syntax Check (push) Has been cancelled
Static Analysis / i18n Check (push) Has been cancelled
Static Analysis / Rethemendex Check (push) Has been cancelled
Static Analysis / ESLint (push) Has been cancelled
Static Analysis / Style Lint (push) Has been cancelled
Static Analysis / Workflow Lint (push) Has been cancelled
Static Analysis / Analyse Dead Code (push) Has been cancelled
Deploy documentation / deploy (push) Has been cancelled
Update Jitsi / update (push) Has been cancelled
Localazy Download / download (push) Has been cancelled

* show correct toast when cross-signing keys missing

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

* refactor: make DeviceListener in charge of device state

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

* reset key backup when needed in RecoveryPanelOutOfSync

brings RecoveryPanelOutOfSync in line with SetupEncryptionToast behaviour

* update strings to agree with designs from Figma

* use DeviceListener to determine EncryptionUserSettingsTab display

rather than using its own logic

* prompt to reset identity in Encryption Settings when needed

* fix type

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

* update snapshot

* make logs more accurate

* add tests

* make the bot use a different access token/device

* only log in a new session when requested

* Mark properties as read-only

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

* remove some duplicate strings

* make accessToken optional instead of using empty string

* switch from enum to string union as per review

* apply other changes from review

* handle errors in accessSecretStorage

* remove incorrect testid

---------

Co-authored-by: Skye Elliot <actuallyori@gmail.com>
This commit is contained in:
Hubert Chathi 2025-12-19 12:00:50 -05:00 committed by GitHub
parent ce9c66ba4c
commit ebd5df633e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 668 additions and 343 deletions

View File

@ -25,7 +25,9 @@ test.describe("Encryption tab", () => {
test.beforeEach(async ({ page, homeserver, credentials }) => { test.beforeEach(async ({ page, homeserver, credentials }) => {
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key // 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; recoveryKey = res.recoveryKey;
expectedBackupVersion = res.expectedBackupVersion; expectedBackupVersion = res.expectedBackupVersion;
}); });

View File

@ -17,7 +17,9 @@ test.describe("Recovery section in Encryption tab", () => {
let recoveryKey: GeneratedSecretStorageKey; let recoveryKey: GeneratedSecretStorageKey;
test.beforeEach(async ({ page, homeserver, credentials }) => { test.beforeEach(async ({ page, homeserver, credentials }) => {
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key // 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; recoveryKey = res.recoveryKey;
}); });

View File

@ -16,6 +16,10 @@ import type { Credentials, HomeserverInstance } from "../plugins/homeserver";
import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { bootstrapCrossSigningForClient, Client } from "./client"; import { bootstrapCrossSigningForClient, Client } from "./client";
export interface CredentialsOptionalAccessToken extends Omit<Credentials, "accessToken"> {
accessToken?: string;
}
export interface CreateBotOpts { export interface CreateBotOpts {
/** /**
* A prefix to use for the userid. If unspecified, "bot_" will be used. * 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 }; type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey };
export class Bot extends Client { export class Bot extends Client {
public credentials?: Credentials; public credentials?: CredentialsOptionalAccessToken;
private handlePromise: Promise<JSHandle<ExtendedMatrixClient>>; private handlePromise: Promise<JSHandle<ExtendedMatrixClient>>;
constructor( constructor(
@ -70,7 +74,16 @@ export class Bot extends Client {
this.opts = Object.assign({}, defaultCreateBotOptions, opts); 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"); if (this.credentials) throw new Error("Bot has already started");
this.credentials = credentials; this.credentials = credentials;
} }
@ -80,7 +93,7 @@ export class Bot extends Client {
return client.evaluate((cli) => cli.__playwright_recovery_key); return client.evaluate((cli) => cli.__playwright_recovery_key);
} }
private async getCredentials(): Promise<Credentials> { private async getCredentials(): Promise<CredentialsOptionalAccessToken> {
if (this.credentials) return this.credentials; if (this.credentials) return this.credentials;
// We want to pad the uniqueId but not the prefix // We want to pad the uniqueId but not the prefix
const username = const username =
@ -161,6 +174,30 @@ export class Bot extends Client {
getSecretStorageKey, 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({ const cli = new window.matrixcs.MatrixClient({
baseUrl, baseUrl,
userId: credentials.userId, userId: credentials.userId,

View File

@ -28,7 +28,7 @@ import type {
EmptyObject, EmptyObject,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import type { RoomMessageEventContent } from "matrix-js-sdk/src/types"; import type { RoomMessageEventContent } from "matrix-js-sdk/src/types";
import { type Credentials } from "../plugins/homeserver"; import { type CredentialsOptionalAccessToken } from "./bot";
export class Client { export class Client {
public network: Network; public network: Network;
@ -424,7 +424,7 @@ export class Client {
/** /**
* Bootstraps cross-signing. * Bootstraps cross-signing.
*/ */
public async bootstrapCrossSigning(credentials: Credentials): Promise<void> { public async bootstrapCrossSigning(credentials: CredentialsOptionalAccessToken): Promise<void> {
const client = await this.prepareClient(); const client = await this.prepareClient();
return bootstrapCrossSigningForClient(client, credentials); return bootstrapCrossSigningForClient(client, credentials);
} }
@ -522,7 +522,7 @@ export class Client {
*/ */
export function bootstrapCrossSigningForClient( export function bootstrapCrossSigningForClient(
client: JSHandle<MatrixClient>, client: JSHandle<MatrixClient>,
credentials: Credentials, credentials: CredentialsOptionalAccessToken,
resetKeys: boolean = false, resetKeys: boolean = false,
) { ) {
return client.evaluate( return client.evaluate(

View File

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

View File

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

View File

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

View File

@ -959,6 +959,7 @@
"bootstrap_title": "Setting up keys", "bootstrap_title": "Setting up keys",
"confirm_encryption_setup_body": "Click the button below to confirm setting up encryption.", "confirm_encryption_setup_body": "Click the button below to confirm setting up encryption.",
"confirm_encryption_setup_title": "Confirm encryption setup", "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_normal": "This room is end-to-end encrypted",
"cross_signing_room_verified": "Everyone in this room is verified", "cross_signing_room_verified": "Everyone in this room is verified",
"cross_signing_room_warning": "Someone is using an unknown session", "cross_signing_room_warning": "Someone is using an unknown session",
@ -974,6 +975,7 @@
"event_shield_reason_unverified_identity": "Encrypted by an unverified user.", "event_shield_reason_unverified_identity": "Encrypted by an unverified user.",
"export_unsupported": "Your browser does not support the required cryptography extensions", "export_unsupported": "Your browser does not support the required cryptography extensions",
"forgot_recovery_key": "Forgot recovery key?", "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_keyfile": "Not a valid %(brand)s keyfile",
"import_invalid_passphrase": "Authentication check failed: incorrect password?", "import_invalid_passphrase": "Authentication check failed: incorrect password?",
"key_storage_out_of_sync": "Your key storage is out of sync.", "key_storage_out_of_sync": "Your key storage is out of sync.",

View File

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

View File

@ -341,9 +341,7 @@ describe("DeviceListener", () => {
await createAndStart(); await createAndStart();
expect(mockCrypto!.getUserDeviceInfo).toHaveBeenCalled(); expect(mockCrypto!.getUserDeviceInfo).toHaveBeenCalled();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("verify_this_session");
SetupEncryptionToast.Kind.VERIFY_THIS_SESSION,
);
}); });
describe("when current device is verified", () => { describe("when current device is verified", () => {
@ -380,9 +378,23 @@ describe("DeviceListener", () => {
await createAndStart(); await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync");
SetupEncryptionToast.Kind.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 () => { it("shows an out-of-sync toast when the backup key is missing locally", async () => {
@ -392,9 +404,7 @@ describe("DeviceListener", () => {
await createAndStart(); await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync");
SetupEncryptionToast.Kind.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 () => { 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(); await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync");
SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC,
);
// Then, when we receive the secret, it should be hidden. // Then, when we receive the secret, it should be hidden.
mockCrypto!.getCrossSigningStatus.mockResolvedValue({ mockCrypto!.getCrossSigningStatus.mockResolvedValue({
@ -454,9 +462,7 @@ describe("DeviceListener", () => {
await createAndStart(); await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("set_up_recovery");
SetupEncryptionToast.Kind.SET_UP_RECOVERY,
);
}); });
it("shows an out-of-sync toast when one of the secrets is missing from 4S", async () => { 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(); await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync");
SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC,
);
}); });
}); });
}); });
@ -573,9 +577,7 @@ describe("DeviceListener", () => {
await createAndStart(); await createAndStart();
// Then the toast is displayed // Then the toast is displayed
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("turn_on_key_storage");
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
}); });
it("shows the 'Turn on key storage' toast if we turned on key storage", async () => { it("shows the 'Turn on key storage' toast if we turned on key storage", async () => {
@ -591,9 +593,7 @@ describe("DeviceListener", () => {
await createAndStart(); await createAndStart();
// Then the toast is displayed // Then the toast is displayed
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("turn_on_key_storage");
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
}); });
it("does not show the 'Turn on key storage' toast if we turned off key storage", async () => { 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(); await createAndStart();
// Then the toast is not displayed // Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage");
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
}); });
}); });
@ -626,9 +624,7 @@ describe("DeviceListener", () => {
await createAndStart(); await createAndStart();
// Then the toast is not displayed // Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage");
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
}); });
it("does not show the 'Turn on key storage' toast if we turned on key storage", async () => { 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(); await createAndStart();
// Then the toast is not displayed // Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage");
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
}); });
it("does not show the 'Turn on key storage' toast if we turned off key storage", async () => { 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(); await createAndStart();
// Then the toast is not displayed // Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage");
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
}); });
}); });
}); });
@ -1206,25 +1198,21 @@ describe("DeviceListener", () => {
await createAndStart(); 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 () => { it("does not show the 'set up recovery' toast if secret storage is set up", async () => {
mockCrypto!.getSecretStorageStatus.mockResolvedValue(readySecretStorageStatus); mockCrypto!.getSecretStorageStatus.mockResolvedValue(readySecretStorageStatus);
await createAndStart(); await createAndStart();
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("set_up_recovery");
SetupEncryptionToast.Kind.SET_UP_RECOVERY,
);
}); });
it("does not show the 'set up recovery' toast if user has no encrypted rooms", async () => { it("does not show the 'set up recovery' toast if user has no encrypted rooms", async () => {
jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false);
await createAndStart(); await createAndStart();
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("set_up_recovery");
SetupEncryptionToast.Kind.SET_UP_RECOVERY,
);
}); });
it("does not show the 'set up recovery' toast if the user has chosen to disable key storage", async () => { 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(); await createAndStart();
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("set_up_recovery");
SetupEncryptionToast.Kind.SET_UP_RECOVERY,
);
}); });
}); });
}); });

View File

@ -9,19 +9,45 @@ import React from "react";
import { render, screen } from "jest-matrix-react"; import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { RecoveryPanelOutOfSync } from "../../../../../../src/components/views/settings/encryption/RecoveryPanelOutOfSync"; 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", () => ({ jest.mock("../../../../../../src/SecurityManager", () => {
accessSecretStorage: jest.fn(), const originalModule = jest.requireActual("../../../../../../src/SecurityManager");
}));
return {
...originalModule,
accessSecretStorage: jest.fn(),
};
});
describe("<RecoveyPanelOutOfSync />", () => { describe("<RecoveyPanelOutOfSync />", () => {
function renderComponent(onFinish = jest.fn(), onForgotRecoveryKey = jest.fn()) { let matrixClient: MatrixClient;
return render(<RecoveryPanelOutOfSync onFinish={onFinish} onForgotRecoveryKey={onForgotRecoveryKey} />);
function renderComponent(
onFinish = jest.fn(),
onForgotRecoveryKey = jest.fn(),
onAccessSecretStorageFailed = jest.fn(),
) {
matrixClient = createTestClient();
return render(
<RecoveryPanelOutOfSync
onFinish={onFinish}
onForgotRecoveryKey={onForgotRecoveryKey}
onAccessSecretStorageFailed={onAccessSecretStorageFailed}
/>,
withClientContextRenderOptions(matrixClient),
);
} }
afterEach(() => {
jest.clearAllMocks();
});
it("should render", () => { it("should render", () => {
const { asFragment } = renderComponent(); const { asFragment } = renderComponent();
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
@ -38,8 +64,12 @@ describe("<RecoveyPanelOutOfSync />", () => {
}); });
it("should access to 4S and call onFinish when 'Enter recovery key' is clicked", async () => { 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(); const user = userEvent.setup();
mocked(accessSecretStorage).mockClear().mockResolvedValue(); mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise<void> => {}) => {
return await func();
});
const onFinish = jest.fn(); const onFinish = jest.fn();
renderComponent(onFinish); renderComponent(onFinish);
@ -47,5 +77,59 @@ describe("<RecoveyPanelOutOfSync />", () => {
await user.click(screen.getByRole("button", { name: "Enter recovery key" })); await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
expect(accessSecretStorage).toHaveBeenCalled(); expect(accessSecretStorage).toHaveBeenCalled();
expect(onFinish).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).mockImplementation(async (func = async (): Promise<void> => {}) => {
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).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<void> => {}) => {
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<void> => {}) => {
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();
}); });
}); });

View File

@ -5,6 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import { mocked } from "jest-mock";
import React from "react"; import React from "react";
import { act, render, screen } from "jest-matrix-react"; import { act, render, screen } from "jest-matrix-react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { type MatrixClient } from "matrix-js-sdk/src/matrix";
@ -18,6 +19,7 @@ import {
} from "../../../../../../../src/components/views/settings/tabs/user/EncryptionUserSettingsTab"; } from "../../../../../../../src/components/views/settings/tabs/user/EncryptionUserSettingsTab";
import { createTestClient, withClientContextRenderOptions } from "../../../../../../test-utils"; import { createTestClient, withClientContextRenderOptions } from "../../../../../../test-utils";
import Modal from "../../../../../../../src/Modal"; import Modal from "../../../../../../../src/Modal";
import DeviceListener from "../../../../../../../src/DeviceListener";
describe("<EncryptionUserSettingsTab />", () => { describe("<EncryptionUserSettingsTab />", () => {
let matrixClient: MatrixClient; let matrixClient: MatrixClient;
@ -37,22 +39,21 @@ describe("<EncryptionUserSettingsTab />", () => {
userSigningKey: true, userSigningKey: true,
}, },
}); });
jest.spyOn(DeviceListener.sharedInstance(), "getDeviceState").mockReturnValue("ok");
});
afterEach(() => {
jest.resetAllMocks();
}); });
function renderComponent(props: { initialState?: State } = {}) { function renderComponent(props: { initialState?: State } = {}) {
return render(<EncryptionUserSettingsTab {...props} />, withClientContextRenderOptions(matrixClient)); return render(<EncryptionUserSettingsTab {...props} />, 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 () => { it("should display a verify button when the encryption is not set up", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockResolvedValue(false); mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("verify_this_session");
const { asFragment } = renderComponent(); const { asFragment } = renderComponent();
await waitFor(() => await waitFor(() =>
@ -81,17 +82,7 @@ describe("<EncryptionUserSettingsTab />", () => {
}); });
it("should display the recovery out of sync panel when secrets are not cached", async () => { it("should display the recovery out of sync panel when secrets are not cached", async () => {
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("key_storage_out_of_sync");
// Secrets are not cached
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
privateKeysInSecretStorage: true,
publicKeysOnDevice: true,
privateKeysCachedLocally: {
masterKey: false,
selfSigningKey: true,
userSigningKey: true,
},
});
const user = userEvent.setup(); const user = userEvent.setup();
const { asFragment } = renderComponent(); const { asFragment } = renderComponent();
@ -196,18 +187,7 @@ describe("<EncryptionUserSettingsTab />", () => {
it("should re-check the encryption state and displays the correct panel when the user clicks cancel the reset identity flow", async () => { 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(); const user = userEvent.setup();
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("key_storage_out_of_sync");
// Secrets are not cached
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
privateKeysInSecretStorage: true,
publicKeysOnDevice: true,
privateKeysCachedLocally: {
masterKey: false,
selfSigningKey: true,
userSigningKey: true,
},
});
renderComponent({ initialState: "reset_identity_forgot" }); renderComponent({ initialState: "reset_identity_forgot" });
@ -220,4 +200,17 @@ describe("<EncryptionUserSettingsTab />", () => {
screen.getByText("Your key storage is out of sync. Click one of the buttons below to fix the problem."), 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();
});
}); });

View File

@ -81,6 +81,64 @@ exports[`<EncryptionUserSettingsTab /> should display the change recovery key pa
</DocumentFragment> </DocumentFragment>
`; `;
exports[`<EncryptionUserSettingsTab /> should display the identity needs reset panel when the user's identity needs resetting 1`] = `
<DocumentFragment>
<div
class="mx_SettingsTab mx_EncryptionUserSettingsTab"
data-testid="encryptionTab"
>
<div
class="mx_SettingsTab_sections"
>
<div
class="mx_SettingsSection mx_SettingsSection_newUi"
>
<div
class="mx_SettingsSection_header"
>
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
>
Your key storage is out of sync.
</h2>
<div
class="mx_SettingsSubheader"
>
<span
class="mx_SettingsSubheader_error"
>
<svg
fill="currentColor"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
You have to reset your cryptographic identity in order to ensure access to your message history
</span>
</div>
</div>
<div>
<button
class="_button_187yx_8"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
Continue with reset
</button>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<EncryptionUserSettingsTab /> should display the recovery out of sync panel when secrets are not cached 1`] = ` exports[`<EncryptionUserSettingsTab /> should display the recovery out of sync panel when secrets are not cached 1`] = `
<DocumentFragment> <DocumentFragment>
<div <div

View File

@ -15,11 +15,12 @@ import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import * as SecurityManager from "../../../src/SecurityManager"; import * as SecurityManager from "../../../src/SecurityManager";
import ToastContainer from "../../../src/components/structures/ToastContainer"; import ToastContainer from "../../../src/components/structures/ToastContainer";
import { Kind, showToast } from "../../../src/toasts/SetupEncryptionToast"; import { showToast } from "../../../src/toasts/SetupEncryptionToast";
import dis from "../../../src/dispatcher/dispatcher"; import dis from "../../../src/dispatcher/dispatcher";
import DeviceListener from "../../../src/DeviceListener"; import DeviceListener from "../../../src/DeviceListener";
import Modal from "../../../src/Modal"; import Modal from "../../../src/Modal";
import ConfirmKeyStorageOffDialog from "../../../src/components/views/dialogs/ConfirmKeyStorageOffDialog"; import ConfirmKeyStorageOffDialog from "../../../src/components/views/dialogs/ConfirmKeyStorageOffDialog";
import SetupEncryptionDialog from "../../../src/components/views/dialogs/security/SetupEncryptionDialog";
import { stubClient } from "../../test-utils"; import { stubClient } from "../../test-utils";
jest.mock("../../../src/dispatcher/dispatcher", () => ({ jest.mock("../../../src/dispatcher/dispatcher", () => ({
@ -36,7 +37,7 @@ describe("SetupEncryptionToast", () => {
describe("Set up recovery", () => { describe("Set up recovery", () => {
it("should render the toast", async () => { 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(); 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(), "recordRecoveryDisabled");
jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup"); jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup");
act(() => showToast(Kind.SET_UP_RECOVERY)); act(() => showToast("set_up_recovery"));
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Dismiss" })); await user.click(await screen.findByRole("button", { name: "Dismiss" }));
@ -55,14 +56,6 @@ describe("SetupEncryptionToast", () => {
}); });
}); });
describe("Verify this session", () => {
it("should render the toast", async () => {
act(() => showToast(Kind.VERIFY_THIS_SESSION));
expect(await screen.findByRole("heading", { name: "Verify this session" })).toBeInTheDocument();
});
});
describe("Key storage out of sync", () => { describe("Key storage out of sync", () => {
let client: Mocked<MatrixClient>; let client: Mocked<MatrixClient>;
@ -77,13 +70,13 @@ describe("SetupEncryptionToast", () => {
}); });
it("should render the toast", async () => { 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(); await expect(screen.findByText("Your key storage is out of sync.")).resolves.toBeInTheDocument();
}); });
it("should reset key backup if needed", async () => { 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( jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation(
async (func = async (): Promise<void> => {}) => { async (func = async (): Promise<void> => {}) => {
@ -100,7 +93,7 @@ describe("SetupEncryptionToast", () => {
}); });
it("should not reset key backup if not needed", async () => { 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( jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation(
async (func = async (): Promise<void> => {}) => { async (func = async (): Promise<void> => {}) => {
@ -122,7 +115,7 @@ describe("SetupEncryptionToast", () => {
}); });
it("should open settings to the reset flow when 'forgot recovery key' clicked and identity reset needed", async () => { 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( jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue(
true, true,
@ -139,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 () => { 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( jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue(
false, false,
@ -164,7 +157,7 @@ describe("SetupEncryptionToast", () => {
true, true,
); );
act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC)); act(() => showToast("key_storage_out_of_sync"));
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(await screen.findByText("Enter recovery key")); await user.click(await screen.findByText("Enter recovery key"));
@ -185,7 +178,7 @@ describe("SetupEncryptionToast", () => {
false, false,
); );
act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC)); act(() => showToast("key_storage_out_of_sync"));
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(await screen.findByText("Enter recovery key")); await user.click(await screen.findByText("Enter recovery key"));
@ -200,7 +193,7 @@ describe("SetupEncryptionToast", () => {
describe("Turn on key storage", () => { describe("Turn on key storage", () => {
it("should render the toast", async () => { 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.findByText("Turn on key storage")).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Dismiss" })).resolves.toBeInTheDocument(); await expect(screen.findByRole("button", { name: "Dismiss" })).resolves.toBeInTheDocument();
@ -210,7 +203,7 @@ describe("SetupEncryptionToast", () => {
it("should open settings to the Encryption tab when 'Continue' clicked", async () => { it("should open settings to the Encryption tab when 'Continue' clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "recordKeyBackupDisabled"); jest.spyOn(DeviceListener.sharedInstance(), "recordKeyBackupDisabled");
act(() => showToast(Kind.TURN_ON_KEY_STORAGE)); act(() => showToast("turn_on_key_storage"));
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Continue" })); await user.click(await screen.findByRole("button", { name: "Continue" }));
@ -232,7 +225,7 @@ describe("SetupEncryptionToast", () => {
}); });
// When we show the toast, and click Dismiss // 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(); const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Dismiss" })); await user.click(await screen.findByRole("button", { name: "Dismiss" }));
@ -248,4 +241,65 @@ describe("SetupEncryptionToast", () => {
expect(DeviceListener.sharedInstance().recordKeyBackupDisabled).toHaveBeenCalledTimes(1); 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" },
});
});
});
}); });