mirror of
https://github.com/element-hq/element-web.git
synced 2025-12-28 07:14:20 +00:00
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
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:
parent
ce9c66ba4c
commit
ebd5df633e
@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user