Remove Secure Backup, Cross-signing and Cryptography sections in Security & Privacy user settings (#29088)

* feat(security tab)!: remove secure backup panel

BREAKING CHANGE: the key storage user interaction are moved into the Encryption tab. The debugging information are moved into the devtools.

* feat(security tab)!: remove cross signing section

BREAKING CHANGE: the cryptographic identity can be reseted in the Encryption tab. The debugging information are moved into the devtools

* feat(security tab)!: remove cryptography section

BREAKING CHANGE: this section can be found in the Advanced section of the encryption tab.

* test(security tab): update snapshot

* chore(security tab): remove unused component and function

* chore(security tab): update i18n

* test(e2e): remove `backups.spec.ts`
This commit is contained in:
Florian Duros
2025-04-08 14:40:06 +02:00
committed by GitHub
parent 803cb36d60
commit ab51ff6b7e
18 changed files with 8 additions and 2160 deletions

View File

@@ -1,108 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { completeCreateSecretStorageDialog } from "./utils.ts";
async function expectBackupVersionToBe(page: Page, version: string) {
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
version + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
);
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version);
}
test.describe("Backups", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.use({
displayName: "Hanako",
});
test(
"Create, delete and recreate a keys backup",
{ tag: "@no-webkit" },
async ({ page, user, app }, workerInfo) => {
// Create a backup
const securityTab = await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
const securityKey = await completeCreateSecretStorageDialog(page);
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "1");
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Delete it
await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup"
// Create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
await currentDialogLocator.getByLabel("Recovery Key").fill(securityKey);
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
// Should be successful
await expect(currentDialogLocator.getByRole("heading", { name: "Success!" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "OK", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "2");
// ==
// Ensure that if you don't have the secret storage passphrase the backup won't be created
// ==
// First delete version 2
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Click "Delete Backup"
await currentDialogLocator.getByTestId("dialog-primary-button").click();
// Try to create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
// But cancel the recovery key dialog, to simulate not having the secret storage passphrase
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible();
// check that it failed
await expect(currentDialogLocator.getByText("Unable to create key backup")).toBeVisible();
// cancel
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
// go back to the settings to check that no backup was created (the setup button should still be there)
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("button", { name: "Set up", exact: true })).toBeVisible();
},
);
});

View File

@@ -340,8 +340,6 @@
@import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss";
@import "./views/rooms/wysiwyg_composer/components/_LinkModal.pcss";
@import "./views/settings/_AvatarSetting.pcss";
@import "./views/settings/_CrossSigningPanel.pcss";
@import "./views/settings/_CryptographyPanel.pcss";
@import "./views/settings/_FontScalingPanel.pcss";
@import "./views/settings/_ImageSizePanel.pcss";
@import "./views/settings/_IntegrationManager.pcss";
@@ -354,7 +352,6 @@
@import "./views/settings/_PhoneNumbers.pcss";
@import "./views/settings/_PowerLevelSelector.pcss";
@import "./views/settings/_RoomProfileSettings.pcss";
@import "./views/settings/_SecureBackupPanel.pcss";
@import "./views/settings/_SetIntegrationManager.pcss";
@import "./views/settings/_SettingsFieldset.pcss";
@import "./views/settings/_SettingsHeader.pcss";

View File

@@ -1,36 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_CrossSigningPanel_statusList {
border-spacing: 0;
th {
text-align: start;
}
td,
th {
padding: 0;
&:first-of-type {
padding-inline-end: 1em;
}
}
}
.mx_CrossSigningPanel_buttonRow {
margin: 1em 0;
:nth-child(n + 1) {
margin-inline-end: 10px;
}
}
.mx_CrossSigningPanel_advanced {
width: fit-content;
}

View File

@@ -1,32 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_CryptographyPanel_sessionInfo {
padding: 0em;
border-spacing: 0px;
}
.mx_CryptographyPanel_sessionInfo > tr {
vertical-align: baseline;
padding: 0em;
th {
text-align: start;
}
td,
th {
padding: 0 1em 0 0;
}
}
.mx_CryptographyPanel_importExportButtons {
display: inline-flex;
flex-flow: wrap;
row-gap: $spacing-8;
column-gap: $spacing-8;
}

View File

@@ -1,44 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_SecureBackupPanel_deviceName {
font-style: italic;
}
.mx_SecureBackupPanel_buttonRow {
margin: 1em 0;
display: inline-flex;
flex-flow: wrap;
row-gap: 10px;
:nth-child(n + 1) {
margin-inline-end: 10px;
}
}
.mx_SecureBackupPanel_statusList {
border-spacing: 0;
th {
text-align: start;
}
td,
th {
padding: 0;
&:first-of-type {
padding-inline-end: 1em;
}
}
}
.mx_SecureBackupPanel_advanced {
width: fit-content;
}

View File

@@ -1,49 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { _t } from "../../../../languageHandler";
import BaseDialog from "../BaseDialog";
import DialogButtons from "../../elements/DialogButtons";
interface IProps {
onFinished: (success?: boolean) => void;
}
export default class ConfirmDestroyCrossSigningDialog extends React.Component<IProps> {
private onConfirm = (): void => {
this.props.onFinished(true);
};
private onDecline = (): void => {
this.props.onFinished(false);
};
public render(): React.ReactNode {
return (
<BaseDialog
className="mx_ConfirmDestroyCrossSigningDialog"
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("encryption|destroy_cross_signing_dialog|title")}
>
<div className="mx_ConfirmDestroyCrossSigningDialog_content">
<p>{_t("encryption|destroy_cross_signing_dialog|warning")}</p>
</div>
<DialogButtons
primaryButton={_t("encryption|destroy_cross_signing_dialog|primary_button_text")}
onPrimaryButtonClick={this.onConfirm}
primaryButtonClass="danger"
cancelButton={_t("action|cancel")}
onCancel={this.onDecline}
/>
</BaseDialog>
);
}
}

View File

@@ -1,313 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { ClientEvent, type EmptyObject, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import Spinner from "../elements/Spinner";
import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog";
import ConfirmDestroyCrossSigningDialog from "../dialogs/security/ConfirmDestroyCrossSigningDialog";
import SetupEncryptionDialog from "../dialogs/security/SetupEncryptionDialog";
import { accessSecretStorage, withSecretStorageKeyCache } from "../../../SecurityManager";
import AccessibleButton from "../elements/AccessibleButton";
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
interface IState {
error: boolean;
crossSigningPublicKeysOnDevice?: boolean;
crossSigningPrivateKeysInStorage?: boolean;
masterPrivateKeyCached?: boolean;
selfSigningPrivateKeyCached?: boolean;
userSigningPrivateKeyCached?: boolean;
homeserverSupportsCrossSigning?: boolean;
crossSigningReady?: boolean;
}
export default class CrossSigningPanel extends React.PureComponent<EmptyObject, IState> {
private unmounted = false;
public constructor(props: EmptyObject) {
super(props);
this.state = {
error: false,
};
}
public componentDidMount(): void {
this.unmounted = false;
const cli = MatrixClientPeg.safeGet();
cli.on(ClientEvent.AccountData, this.onAccountData);
cli.on(CryptoEvent.UserTrustStatusChanged, this.onStatusChanged);
cli.on(CryptoEvent.KeysChanged, this.onStatusChanged);
this.getUpdatedStatus();
}
public componentWillUnmount(): void {
this.unmounted = true;
const cli = MatrixClientPeg.get();
if (!cli) return;
cli.removeListener(ClientEvent.AccountData, this.onAccountData);
cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onStatusChanged);
cli.removeListener(CryptoEvent.KeysChanged, this.onStatusChanged);
}
private onAccountData = (event: MatrixEvent): void => {
const type = event.getType();
if (type.startsWith("m.cross_signing") || type.startsWith("m.secret_storage")) {
this.getUpdatedStatus();
}
};
private onBootstrapClick = (): void => {
if (this.state.crossSigningPrivateKeysInStorage) {
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
} else {
// Trigger the flow to set up secure backup, which is what this will do when in
// the appropriate state.
accessSecretStorage();
}
};
private onStatusChanged = (): void => {
this.getUpdatedStatus();
};
private async getUpdatedStatus(): Promise<void> {
const cli = MatrixClientPeg.safeGet();
const crypto = cli.getCrypto();
if (!crypto) return;
const crossSigningStatus = await crypto.getCrossSigningStatus();
const crossSigningPublicKeysOnDevice = crossSigningStatus.publicKeysOnDevice;
const crossSigningPrivateKeysInStorage = crossSigningStatus.privateKeysInSecretStorage;
const masterPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.masterKey;
const selfSigningPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.selfSigningKey;
const userSigningPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.userSigningKey;
const homeserverSupportsCrossSigning =
await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
const crossSigningReady = await crypto.isCrossSigningReady();
this.setState({
crossSigningPublicKeysOnDevice,
crossSigningPrivateKeysInStorage,
masterPrivateKeyCached,
selfSigningPrivateKeyCached,
userSigningPrivateKeyCached,
homeserverSupportsCrossSigning,
crossSigningReady,
});
}
/**
* Reset the user's cross-signing keys.
*/
private async resetCrossSigning(): Promise<void> {
this.setState({ error: false });
try {
const cli = MatrixClientPeg.safeGet();
await withSecretStorageKeyCache(async () => {
await cli.getCrypto()!.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient: cli,
makeRequest,
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
},
setupNewCrossSigning: true,
});
});
} catch (e) {
this.setState({ error: true });
logger.error("Error bootstrapping cross-signing", e);
}
if (this.unmounted) return;
this.getUpdatedStatus();
}
/**
* Callback for when the user clicks the "reset cross signing" button.
*
* Shows a confirmation dialog, and then does the reset if confirmed.
*/
private onResetCrossSigningClick = (): void => {
Modal.createDialog(ConfirmDestroyCrossSigningDialog, {
onFinished: async (act) => {
if (!act) return;
this.resetCrossSigning();
},
});
};
public render(): React.ReactNode {
const {
error,
crossSigningPublicKeysOnDevice,
crossSigningPrivateKeysInStorage,
masterPrivateKeyCached,
selfSigningPrivateKeyCached,
userSigningPrivateKeyCached,
homeserverSupportsCrossSigning,
crossSigningReady,
} = this.state;
let errorSection;
if (error) {
errorSection = <div className="error">{error.toString()}</div>;
}
let summarisedStatus;
if (homeserverSupportsCrossSigning === undefined) {
summarisedStatus = <Spinner />;
} else if (!homeserverSupportsCrossSigning) {
summarisedStatus = (
<SettingsSubsectionText data-testid="summarised-status">
{_t("encryption|cross_signing_unsupported")}
</SettingsSubsectionText>
);
} else if (crossSigningReady && crossSigningPrivateKeysInStorage) {
summarisedStatus = (
<SettingsSubsectionText data-testid="summarised-status">
{_t("encryption|cross_signing_ready")}
</SettingsSubsectionText>
);
} else if (crossSigningReady && !crossSigningPrivateKeysInStorage) {
summarisedStatus = (
<SettingsSubsectionText data-testid="summarised-status">
{_t("encryption|cross_signing_ready_no_backup")}
</SettingsSubsectionText>
);
} else if (crossSigningPrivateKeysInStorage) {
summarisedStatus = (
<SettingsSubsectionText data-testid="summarised-status">
{_t("encryption|cross_signing_untrusted")}
</SettingsSubsectionText>
);
} else {
summarisedStatus = (
<SettingsSubsectionText data-testid="summarised-status">
{_t("encryption|cross_signing_not_ready")}
</SettingsSubsectionText>
);
}
const keysExistAnywhere =
crossSigningPublicKeysOnDevice ||
crossSigningPrivateKeysInStorage ||
masterPrivateKeyCached ||
selfSigningPrivateKeyCached ||
userSigningPrivateKeyCached;
const keysExistEverywhere =
crossSigningPublicKeysOnDevice &&
crossSigningPrivateKeysInStorage &&
masterPrivateKeyCached &&
selfSigningPrivateKeyCached &&
userSigningPrivateKeyCached;
const actions: JSX.Element[] = [];
// TODO: determine how better to expose this to users in addition to prompts at login/toast
if (!keysExistEverywhere && homeserverSupportsCrossSigning) {
let buttonCaption = _t("encryption|set_up_toast_title");
if (crossSigningPrivateKeysInStorage) {
buttonCaption = _t("encryption|verify_toast_title");
}
actions.push(
<AccessibleButton key="setup" kind="primary_outline" onClick={this.onBootstrapClick}>
{buttonCaption}
</AccessibleButton>,
);
}
if (keysExistAnywhere) {
actions.push(
<AccessibleButton key="reset" kind="danger_outline" onClick={this.onResetCrossSigningClick}>
{_t("action|reset")}
</AccessibleButton>,
);
}
let actionRow;
if (actions.length) {
actionRow = <div className="mx_CrossSigningPanel_buttonRow">{actions}</div>;
}
return (
<>
{summarisedStatus}
<details>
<summary className="mx_CrossSigningPanel_advanced">{_t("common|advanced")}</summary>
<table className="mx_CrossSigningPanel_statusList">
<tbody>
<tr>
<th scope="row">{_t("settings|security|cross_signing_public_keys")}</th>
<td>
{crossSigningPublicKeysOnDevice
? _t("settings|security|cross_signing_in_memory")
: _t("settings|security|cross_signing_not_found")}
</td>
</tr>
<tr>
<th scope="row">{_t("settings|security|cross_signing_private_keys")}</th>
<td>
{crossSigningPrivateKeysInStorage
? _t("settings|security|cross_signing_in_4s")
: _t("settings|security|cross_signing_not_in_4s")}
</td>
</tr>
<tr>
<th scope="row">{_t("settings|security|cross_signing_master_private_Key")}</th>
<td>
{masterPrivateKeyCached
? _t("settings|security|cross_signing_cached")
: _t("settings|security|cross_signing_not_cached")}
</td>
</tr>
<tr>
<th scope="row">{_t("settings|security|cross_signing_self_signing_private_key")}</th>
<td>
{selfSigningPrivateKeyCached
? _t("settings|security|cross_signing_cached")
: _t("settings|security|cross_signing_not_cached")}
</td>
</tr>
<tr>
<th scope="row">{_t("settings|security|cross_signing_user_signing_private_key")}</th>
<td>
{userSigningPrivateKeyCached
? _t("settings|security|cross_signing_cached")
: _t("settings|security|cross_signing_not_cached")}
</td>
</tr>
<tr>
<th scope="row">{_t("settings|security|cross_signing_homeserver_support")}</th>
<td>
{homeserverSupportsCrossSigning
? _t("settings|security|cross_signing_homeserver_support_exists")
: _t("settings|security|cross_signing_not_found")}
</td>
</tr>
</tbody>
</table>
</details>
{errorSection}
{actionRow}
</>
);
}
}

View File

@@ -1,146 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, lazy } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import AccessibleButton from "../elements/AccessibleButton";
import * as FormattingUtils from "../../../utils/FormattingUtils";
import SettingsStore from "../../../settings/SettingsStore";
import SettingsFlag from "../elements/SettingsFlag";
import { SettingLevel } from "../../../settings/SettingLevel";
import { SettingsSubsection, SettingsSubsectionText } from "./shared/SettingsSubsection";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
interface IState {
/** The device's base64-encoded Ed25519 identity key, or:
*
* * `undefined`: not yet loaded
* * `null`: encryption is not supported (or the crypto stack was not correctly initialized)
*/
deviceIdentityKey: string | undefined | null;
}
export default class CryptographyPanel extends React.Component<EmptyObject, IState> {
public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: EmptyObject, context: React.ContextType<typeof MatrixClientContext>) {
super(props);
if (!context.getCrypto()) {
this.state = { deviceIdentityKey: null };
} else {
this.state = { deviceIdentityKey: undefined };
}
}
public componentDidMount(): void {
if (this.state.deviceIdentityKey === undefined) {
this.context
.getCrypto()
?.getOwnDeviceKeys()
.then((keys) => {
this.setState({ deviceIdentityKey: keys.ed25519 });
})
.catch((e) => {
logger.error(`CryptographyPanel: Error fetching own device keys: ${e}`);
this.setState({ deviceIdentityKey: null });
});
}
}
public render(): React.ReactNode {
const client = this.context;
const deviceId = client.deviceId;
let identityKey = this.state.deviceIdentityKey;
if (identityKey === undefined) {
// Should show a spinner here really, but since this will be very transitional, I can't be doing with the
// necessary styling.
identityKey = "...";
} else if (identityKey === null) {
identityKey = _t("encryption|not_supported");
} else {
identityKey = FormattingUtils.formatCryptoKey(identityKey);
}
let importExportButtons: JSX.Element | undefined;
if (client.getCrypto()) {
importExportButtons = (
<div className="mx_CryptographyPanel_importExportButtons">
<AccessibleButton kind="primary_outline" onClick={this.onExportE2eKeysClicked}>
{_t("settings|security|export_megolm_keys")}
</AccessibleButton>
<AccessibleButton kind="primary_outline" onClick={this.onImportE2eKeysClicked}>
{_t("settings|security|import_megolm_keys")}
</AccessibleButton>
</div>
);
}
let noSendUnverifiedSetting: JSX.Element | undefined;
if (SettingsStore.canSetValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE)) {
noSendUnverifiedSetting = (
<SettingsFlag
name="blacklistUnverifiedDevices"
level={SettingLevel.DEVICE}
onChange={this.updateBlacklistDevicesFlag}
/>
);
}
return (
<SettingsSubsection heading={_t("settings|security|cryptography_section")}>
<SettingsSubsectionText>
<table className="mx_CryptographyPanel_sessionInfo">
<tbody>
<tr>
<th scope="row">{_t("settings|security|session_id")}</th>
<td>
<code>{deviceId}</code>
</td>
</tr>
<tr>
<th scope="row">{_t("settings|security|session_key")}</th>
<td>
<code>
<strong>{identityKey}</strong>
</code>
</td>
</tr>
</tbody>
</table>
</SettingsSubsectionText>
{importExportButtons}
{noSendUnverifiedSetting}
</SettingsSubsection>
);
}
private onExportE2eKeysClicked = (): void => {
Modal.createDialog(
lazy(() => import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog")),
{ matrixClient: this.context },
);
};
private onImportE2eKeysClicked = (): void => {
Modal.createDialog(
lazy(() => import("../../../async-components/views/dialogs/security/ImportE2eKeysDialog")),
{ matrixClient: this.context },
);
};
private updateBlacklistDevicesFlag = (checked: boolean): void => {
const crypto = this.context.getCrypto();
if (crypto) crypto.globalBlacklistUnverifiedDevices = checked;
};
}

View File

@@ -1,421 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { lazy, type JSX, type ReactNode } from "react";
import { CryptoEvent, type BackupTrustInfo, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import { isSecureBackupRequired } from "../../../utils/WellKnownUtils";
import Spinner from "../elements/Spinner";
import AccessibleButton from "../elements/AccessibleButton";
import QuestionDialog from "../dialogs/QuestionDialog";
import RestoreKeyBackupDialog from "../dialogs/security/RestoreKeyBackupDialog";
import { accessSecretStorage } from "../../../SecurityManager";
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
interface IState {
loading: boolean;
error: boolean;
backupKeyStored: boolean | null;
backupKeyCached: boolean | null;
backupKeyWellFormed: boolean | null;
secretStorageKeyInAccount: boolean | null;
secretStorageReady: boolean | null;
/** Information on the current key backup version, as returned by the server.
*
* `null` could mean any of:
* * we haven't yet requested the data from the server.
* * we were unable to reach the server.
* * the server returned key backup version data we didn't understand or was malformed.
* * there is actually no backup on the server.
*/
backupInfo: KeyBackupInfo | null;
/**
* Information on whether the backup in `backupInfo` is correctly signed, and whether we have the right key to
* decrypt it.
*
* `undefined` if `backupInfo` is null, or if crypto is not enabled in the client.
*/
backupTrustInfo: BackupTrustInfo | undefined;
/**
* If key backup is currently enabled, the backup version we are backing up to.
*/
activeBackupVersion: string | null;
/**
* Number of sessions remaining to be backed up. `null` if we have no information on this.
*/
sessionsRemaining: number | null;
}
export default class SecureBackupPanel extends React.PureComponent<EmptyObject, IState> {
private unmounted = false;
public constructor(props: EmptyObject) {
super(props);
this.state = {
loading: true,
error: false,
backupKeyStored: null,
backupKeyCached: null,
backupKeyWellFormed: null,
secretStorageKeyInAccount: null,
secretStorageReady: null,
backupInfo: null,
backupTrustInfo: undefined,
activeBackupVersion: null,
sessionsRemaining: null,
};
}
public componentDidMount(): void {
this.unmounted = false;
this.loadBackupStatus();
MatrixClientPeg.safeGet().on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus);
MatrixClientPeg.safeGet().on(CryptoEvent.KeyBackupSessionsRemaining, this.onKeyBackupSessionsRemaining);
}
public componentWillUnmount(): void {
this.unmounted = true;
if (MatrixClientPeg.get()) {
MatrixClientPeg.get()!.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus);
MatrixClientPeg.get()!.removeListener(
CryptoEvent.KeyBackupSessionsRemaining,
this.onKeyBackupSessionsRemaining,
);
}
}
private onKeyBackupSessionsRemaining = (sessionsRemaining: number): void => {
this.setState({
sessionsRemaining,
});
};
private onKeyBackupStatus = (): void => {
// This just loads the current backup status rather than forcing
// a re-check otherwise we risk causing infinite loops
this.loadBackupStatus();
};
private async loadBackupStatus(): Promise<void> {
this.setState({ loading: true });
this.getUpdatedDiagnostics();
try {
const cli = MatrixClientPeg.safeGet();
const backupInfo = (await cli.getCrypto()?.getKeyBackupInfo()) ?? null;
const backupTrustInfo = backupInfo ? await cli.getCrypto()?.isKeyBackupTrusted(backupInfo) : undefined;
const activeBackupVersion = (await cli.getCrypto()?.getActiveSessionBackupVersion()) ?? null;
if (this.unmounted) return;
this.setState({
loading: false,
error: false,
backupInfo,
backupTrustInfo,
activeBackupVersion,
});
} catch (e) {
logger.log("Unable to fetch key backup status", e);
if (this.unmounted) return;
this.setState({
loading: false,
error: true,
backupInfo: null,
backupTrustInfo: undefined,
activeBackupVersion: null,
});
}
}
private async getUpdatedDiagnostics(): Promise<void> {
const cli = MatrixClientPeg.safeGet();
const crypto = cli.getCrypto();
if (!crypto) return;
const secretStorage = cli.secretStorage;
const backupKeyStored = !!(await cli.isKeyBackupKeyStored());
const backupKeyFromCache = await crypto.getSessionBackupPrivateKey();
const backupKeyCached = !!backupKeyFromCache;
const backupKeyWellFormed = backupKeyFromCache instanceof Uint8Array;
const secretStorageKeyInAccount = await secretStorage.hasKey();
const secretStorageReady = await crypto.isSecretStorageReady();
if (this.unmounted) return;
this.setState({
backupKeyStored,
backupKeyCached,
backupKeyWellFormed,
secretStorageKeyInAccount,
secretStorageReady,
});
}
private startNewBackup = (): void => {
Modal.createDialog(
lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")),
{
onFinished: () => {
this.loadBackupStatus();
},
},
undefined,
/* priority = */ false,
/* static = */ true,
);
};
private deleteBackup = (): void => {
Modal.createDialog(QuestionDialog, {
title: _t("settings|security|delete_backup"),
description: _t("settings|security|delete_backup_confirm_description"),
button: _t("settings|security|delete_backup"),
danger: true,
onFinished: (proceed) => {
if (!proceed) return;
this.setState({ loading: true });
const versionToDelete = this.state.backupInfo!.version!;
// deleteKeyBackupVersion fires a key backup status event
// which will update the UI
MatrixClientPeg.safeGet().getCrypto()?.deleteKeyBackupVersion(versionToDelete);
},
});
};
private restoreBackup = async (): Promise<void> => {
Modal.createDialog(RestoreKeyBackupDialog, undefined, undefined, /* priority = */ false, /* static = */ true);
};
private resetSecretStorage = async (): Promise<void> => {
this.setState({ error: false });
try {
await accessSecretStorage(async (): Promise<void> => {}, { forceReset: true });
} catch (e) {
logger.error("Error resetting secret storage", e);
if (this.unmounted) return;
this.setState({ error: true });
}
if (this.unmounted) return;
this.loadBackupStatus();
};
public render(): React.ReactNode {
const {
loading,
error,
backupKeyStored,
backupKeyCached,
backupKeyWellFormed,
secretStorageKeyInAccount,
secretStorageReady,
backupInfo,
backupTrustInfo,
sessionsRemaining,
} = this.state;
let statusDescription: JSX.Element;
let extraDetailsTableRows: JSX.Element | undefined;
let extraDetails: JSX.Element | undefined;
const actions: JSX.Element[] = [];
if (error) {
statusDescription = (
<SettingsSubsectionText className="error">
{_t("settings|security|error_loading_key_backup_status")}
</SettingsSubsectionText>
);
} else if (loading) {
statusDescription = <Spinner />;
} else if (backupInfo) {
let restoreButtonCaption = _t("settings|security|restore_key_backup");
if (this.state.activeBackupVersion !== null) {
statusDescription = (
<SettingsSubsectionText> {_t("settings|security|key_backup_active")}</SettingsSubsectionText>
);
} else {
statusDescription = (
<>
<SettingsSubsectionText>
{_t("settings|security|key_backup_inactive", {}, { b: (sub) => <strong>{sub}</strong> })}
</SettingsSubsectionText>
<SettingsSubsectionText>
{_t("settings|security|key_backup_connect_prompt")}
</SettingsSubsectionText>
</>
);
restoreButtonCaption = _t("settings|security|key_backup_connect");
}
let uploadStatus: ReactNode;
if (sessionsRemaining === null) {
// No upload status to show when backup disabled.
uploadStatus = "";
} else if (sessionsRemaining > 0) {
uploadStatus = (
<div>
{_t("settings|security|key_backup_in_progress", { sessionsRemaining })} <br />
</div>
);
} else {
uploadStatus = (
<div>
{_t("settings|security|key_backup_complete")} <br />
</div>
);
}
let trustedLocally: string | undefined;
if (backupTrustInfo?.matchesDecryptionKey) {
trustedLocally = _t("settings|security|key_backup_can_be_restored");
}
extraDetailsTableRows = (
<>
<tr>
<th scope="row">{_t("settings|security|key_backup_latest_version")}</th>
<td>
{backupInfo.version} ({_t("settings|security|key_backup_algorithm")}{" "}
<code>{backupInfo.algorithm}</code>)
</td>
</tr>
<tr>
<th scope="row">{_t("settings|security|key_backup_active_version")}</th>
<td>
{this.state.activeBackupVersion === null
? _t("settings|security|key_backup_active_version_none")
: this.state.activeBackupVersion}
</td>
</tr>
</>
);
extraDetails = (
<>
{uploadStatus}
<div>{trustedLocally}</div>
</>
);
actions.push(
<AccessibleButton key="restore" kind="primary_outline" onClick={this.restoreBackup}>
{restoreButtonCaption}
</AccessibleButton>,
);
if (!isSecureBackupRequired(MatrixClientPeg.safeGet())) {
actions.push(
<AccessibleButton key="delete" kind="danger_outline" onClick={this.deleteBackup}>
{_t("settings|security|delete_backup")}
</AccessibleButton>,
);
}
} else {
statusDescription = (
<>
<SettingsSubsectionText>
{_t(
"settings|security|key_backup_inactive_warning",
{},
{ b: (sub) => <strong>{sub}</strong> },
)}
</SettingsSubsectionText>
<SettingsSubsectionText>{_t("encryption|setup_secure_backup|explainer")}</SettingsSubsectionText>
</>
);
actions.push(
<AccessibleButton key="setup" kind="primary_outline" onClick={this.startNewBackup}>
{_t("encryption|setup_secure_backup|title")}
</AccessibleButton>,
);
}
if (secretStorageKeyInAccount) {
actions.push(
<AccessibleButton key="reset" kind="danger_outline" onClick={this.resetSecretStorage}>
{_t("action|reset")}
</AccessibleButton>,
);
}
let backupKeyWellFormedText = "";
if (backupKeyCached) {
backupKeyWellFormedText = ", ";
if (backupKeyWellFormed) {
backupKeyWellFormedText += _t("settings|security|backup_key_well_formed");
} else {
backupKeyWellFormedText += _t("settings|security|backup_key_unexpected_type");
}
}
let actionRow: JSX.Element | undefined;
if (actions.length) {
actionRow = <div className="mx_SecureBackupPanel_buttonRow">{actions}</div>;
}
return (
<>
<SettingsSubsectionText>{_t("settings|security|backup_keys_description")}</SettingsSubsectionText>
{statusDescription}
<details>
<summary className="mx_SecureBackupPanel_advanced">{_t("common|advanced")}</summary>
<table className="mx_SecureBackupPanel_statusList">
<tr>
<th scope="row">{_t("settings|security|backup_key_stored_status")}</th>
<td>
{backupKeyStored === true
? _t("settings|security|cross_signing_in_4s")
: _t("settings|security|cross_signing_not_stored")}
</td>
</tr>
<tr>
<th scope="row">{_t("settings|security|backup_key_cached_status")}</th>
<td>
{backupKeyCached
? _t("settings|security|cross_signing_cached")
: _t("settings|security|cross_signing_not_cached")}
{backupKeyWellFormedText}
</td>
</tr>
<tr>
<th scope="row">{_t("settings|security|4s_public_key_status")}</th>
<td>
{secretStorageKeyInAccount
? _t("settings|security|4s_public_key_in_account_data")
: _t("settings|security|cross_signing_not_found")}
</td>
</tr>
<tr>
<th scope="row">{_t("settings|security|secret_storage_status")}</th>
<td>
{secretStorageReady
? _t("settings|security|secret_storage_ready")
: _t("settings|security|secret_storage_not_ready")}
</td>
</tr>
{extraDetailsTableRows}
</table>
{extraDetails}
</details>
{actionRow}
</>
);
}
}

View File

@@ -17,13 +17,10 @@ import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton";
import dis from "../../../../../dispatcher/dispatcher";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import SecureBackupPanel from "../../SecureBackupPanel";
import SettingsStore from "../../../../../settings/SettingsStore";
import { UIFeature } from "../../../../../settings/UIFeature";
import { type ActionPayload } from "../../../../../dispatcher/payloads";
import CryptographyPanel from "../../CryptographyPanel";
import SettingsFlag from "../../../elements/SettingsFlag";
import CrossSigningPanel from "../../CrossSigningPanel";
import EventIndexPanel from "../../EventIndexPanel";
import InlineSpinner from "../../../elements/InlineSpinner";
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
@@ -42,21 +39,20 @@ interface IIgnoredUserProps {
inProgress: boolean;
}
const DehydratedDeviceStatus: React.FC = () => {
const SecureBackup: React.FC = () => {
const { dehydratedDeviceId } = useOwnDevices();
if (!dehydratedDeviceId) return null;
if (dehydratedDeviceId) {
return (
return (
<SettingsSubsection heading={_t("common|secure_backup")}>
<div className="mx_SettingsSubsection_content">
<div className="mx_SettingsFlag_label">{_t("settings|security|dehydrated_device_enabled")}</div>
<div className="mx_SettingsSubsection_text">
{_t("settings|security|dehydrated_device_description")}
</div>
</div>
);
} else {
return null;
}
</SettingsSubsection>
);
};
export class IgnoredUser extends React.Component<IIgnoredUserProps> {
@@ -297,12 +293,7 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
}
public render(): React.ReactNode {
const secureBackup = (
<SettingsSubsection heading={_t("common|secure_backup")}>
<SecureBackupPanel />
<DehydratedDeviceStatus />
</SettingsSubsection>
);
const secureBackup = <SecureBackup />;
const eventIndex = (
<SettingsSubsection heading={_t("settings|security|message_search_section")}>
@@ -310,16 +301,6 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
</SettingsSubsection>
);
// XXX: There's no such panel in the current cross-signing designs, but
// it's useful to have for testing the feature. If there's no interest
// in having advanced details here once all flows are implemented, we
// can remove this.
const crossSigning = (
<SettingsSubsection heading={_t("common|cross_signing")}>
<CrossSigningPanel />
</SettingsSubsection>
);
let warning;
if (!privateShouldBeEncrypted(MatrixClientPeg.safeGet())) {
warning = (
@@ -379,8 +360,6 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
<SettingsSection heading={_t("settings|security|encryption_section")}>
{secureBackup}
{eventIndex}
{crossSigning}
<CryptographyPanel />
</SettingsSection>
<SettingsSection heading={_t("common|privacy")}>
<DiscoverySettings />

View File

@@ -487,7 +487,6 @@
"capabilities": "Capabilities",
"copied": "Copied!",
"credits": "Credits",
"cross_signing": "Cross-signing",
"dark": "Dark",
"description": "Description",
"deselect_all": "Deselect all",
@@ -931,22 +930,12 @@
"cancel_entering_passphrase_title": "Cancel entering passphrase?",
"confirm_encryption_setup_body": "Click the button below to confirm setting up encryption.",
"confirm_encryption_setup_title": "Confirm encryption setup",
"cross_signing_not_ready": "Cross-signing is not set up.",
"cross_signing_ready": "Cross-signing is ready for use.",
"cross_signing_ready_no_backup": "Cross-signing is ready but keys are not backed up.",
"cross_signing_room_normal": "This room is end-to-end encrypted",
"cross_signing_room_verified": "Everyone in this room is verified",
"cross_signing_room_warning": "Someone is using an unknown session",
"cross_signing_unsupported": "Your homeserver does not support cross-signing.",
"cross_signing_untrusted": "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.",
"cross_signing_user_normal": "You have not verified this user.",
"cross_signing_user_verified": "You have verified this user. This user has verified all of their sessions.",
"cross_signing_user_warning": "This user has not verified all of their sessions.",
"destroy_cross_signing_dialog": {
"primary_button_text": "Clear cross-signing keys",
"title": "Destroy cross-signing keys?",
"warning": "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from."
},
"enter_recovery_key": "Enter recovery key",
"event_shield_reason_authenticity_not_guaranteed": "The authenticity of this encrypted message can't be guaranteed on this device.",
"event_shield_reason_mismatched_sender_key": "Encrypted by an unverified session",
@@ -973,7 +962,6 @@
"title": "New Recovery Method",
"warning": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings."
},
"not_supported": "<not supported>",
"pinned_identity_changed": "%(displayName)s's (<b>%(userId)s</b>) identity was reset. <a>Learn more</a>",
"pinned_identity_changed_no_displayname": "<b>%(userId)s</b>'s identity was reset. <a>Learn more</a>",
"recovery_method_removed": {
@@ -989,8 +977,7 @@
"set_up_toast_description": "Safeguard against losing access to encrypted messages & data",
"set_up_toast_title": "Set up Secure Backup",
"setup_secure_backup": {
"explainer": "Back up your keys before signing out to avoid losing them.",
"title": "Set up"
"explainer": "Back up your keys before signing out to avoid losing them."
},
"udd": {
"interactive_verification_button": "Interactively verify by emoji",
@@ -2832,57 +2819,20 @@
"prompt_invite": "Prompt before sending invites to potentially invalid matrix IDs",
"replace_plain_emoji": "Automatically replace plain text Emoji",
"security": {
"4s_public_key_in_account_data": "in account data",
"4s_public_key_status": "Secret storage public key:",
"analytics_description": "Share anonymous data to help us identify issues. Nothing personal. No third parties.",
"backup_key_cached_status": "Backup key cached:",
"backup_key_stored_status": "Backup key stored:",
"backup_key_unexpected_type": "unexpected type",
"backup_key_well_formed": "well formed",
"backup_keys_description": "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Recovery Key.",
"bulk_options_accept_all_invites": "Accept all %(invitedRooms)s invites",
"bulk_options_reject_all_invites": "Reject all %(invitedRooms)s invites",
"bulk_options_section": "Bulk options",
"cross_signing_cached": "cached locally",
"cross_signing_homeserver_support": "Homeserver feature support:",
"cross_signing_homeserver_support_exists": "exists",
"cross_signing_in_4s": "in secret storage",
"cross_signing_in_memory": "in memory",
"cross_signing_master_private_Key": "Master private key:",
"cross_signing_not_cached": "not found locally",
"cross_signing_not_found": "not found",
"cross_signing_not_in_4s": "not found in storage",
"cross_signing_not_stored": "not stored",
"cross_signing_private_keys": "Cross-signing private keys:",
"cross_signing_public_keys": "Cross-signing public keys:",
"cross_signing_self_signing_private_key": "Self signing private key:",
"cross_signing_user_signing_private_key": "User signing private key:",
"cryptography_section": "Cryptography",
"dehydrated_device_description": "The offline device feature allows you to receive encrypted messages even when you are not logged in to any devices",
"dehydrated_device_enabled": "Offline device enabled",
"delete_backup": "Delete Backup",
"delete_backup_confirm_description": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.",
"dialog_title": "<strong>Settings:</strong> Security & Privacy",
"e2ee_default_disabled_warning": "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.",
"enable_message_search": "Enable message search in encrypted rooms",
"encryption_section": "Encryption",
"error_loading_key_backup_status": "Unable to load key backup status",
"export_megolm_keys": "Export E2E room keys",
"ignore_users_empty": "You have no ignored users.",
"ignore_users_section": "Ignored users",
"import_megolm_keys": "Import E2E room keys",
"key_backup_active": "This session is backing up your keys.",
"key_backup_active_version": "Active backup version:",
"key_backup_active_version_none": "None",
"key_backup_algorithm": "Algorithm:",
"key_backup_can_be_restored": "This backup can be restored on this session",
"key_backup_complete": "All keys backed up",
"key_backup_connect": "Connect this session to Key Backup",
"key_backup_connect_prompt": "Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.",
"key_backup_in_progress": "Backing up %(sessionsRemaining)s keys…",
"key_backup_inactive": "This session is <b>not backing up your keys</b>, but you do have an existing backup you can restore from and add to going forward.",
"key_backup_inactive_warning": "Your keys are <b>not being backed up from this session</b>.",
"key_backup_latest_version": "Latest backup version on server:",
"message_search_disable_warning": "If disabled, messages from encrypted rooms won't appear in search results.",
"message_search_disabled": "Securely cache encrypted messages locally for them to appear in search results.",
"message_search_enabled": {
@@ -2902,13 +2852,7 @@
"message_search_unsupported": "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with <nativeLink>search components added</nativeLink>.",
"message_search_unsupported_web": "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> for encrypted messages to appear in search results.",
"record_session_details": "Record the client name, version, and url to recognise sessions more easily in session manager",
"restore_key_backup": "Restore from Backup",
"secret_storage_not_ready": "not ready",
"secret_storage_ready": "ready",
"secret_storage_status": "Secret storage:",
"send_analytics": "Send analytics data",
"session_id": "Session ID:",
"session_key": "Session key:",
"strict_encryption": "Only send messages to verified users"
},
"send_read_receipts": "Send read receipts",

View File

@@ -56,17 +56,6 @@ export function formatBytes(bytes: number, decimals = 2): string {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
/**
* format a key into groups of 4 characters, for easier visual inspection
*
* @param {string} key key to format
*
* @return {string}
*/
export function formatCryptoKey(key: string): string {
return key.match(/.{1,4}/g)!.join(" ");
}
export function getUserNameColorClass(userId: string): string {
// eslint-disable-next-line react-hooks/rules-of-hooks
const number = useIdColorHash(userId);

View File

@@ -1,133 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import CrossSigningPanel from "../../../../../src/components/views/settings/CrossSigningPanel";
import {
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsUser,
} from "../../../../test-utils";
import Modal from "../../../../../src/Modal";
import ConfirmDestroyCrossSigningDialog from "../../../../../src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog";
describe("<CrossSigningPanel />", () => {
const userId = "@alice:server.org";
let mockClient: Mocked<MatrixClient>;
const getComponent = () => render(<CrossSigningPanel />);
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsCrypto(),
doesServerSupportUnstableFeature: jest.fn(),
});
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(true);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should render a spinner while loading", () => {
getComponent();
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("should render when homeserver does not support cross-signing", async () => {
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false);
getComponent();
await flushPromises();
expect(screen.getByText("Your homeserver does not support cross-signing.")).toBeInTheDocument();
});
describe("when cross signing is ready", () => {
it("should render when keys are not backed up", async () => {
getComponent();
await flushPromises();
expect(screen.getByTestId("summarised-status").innerHTML).toEqual(
"⚠️ Cross-signing is ready but keys are not backed up.",
);
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
});
it("should render when keys are backed up", async () => {
mocked(mockClient.getCrypto()!.getCrossSigningStatus).mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
});
getComponent();
await flushPromises();
expect(screen.getByTestId("summarised-status").innerHTML).toEqual("✅ Cross-signing is ready for use.");
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
});
it("should allow reset of cross-signing", async () => {
mockClient.getCrypto()!.bootstrapCrossSigning = jest.fn().mockResolvedValue(undefined);
getComponent();
await flushPromises();
const modalSpy = jest.spyOn(Modal, "createDialog");
screen.getByRole("button", { name: "Reset" }).click();
expect(modalSpy).toHaveBeenCalledWith(ConfirmDestroyCrossSigningDialog, expect.any(Object));
modalSpy.mock.lastCall![1]!.onFinished(true);
expect(mockClient.getCrypto()!.bootstrapCrossSigning).toHaveBeenCalledWith(
expect.objectContaining({ setupNewCrossSigning: true }),
);
});
});
describe("when cross signing is not ready", () => {
beforeEach(() => {
mocked(mockClient.getCrypto()!.isCrossSigningReady).mockResolvedValue(false);
});
it("should render when keys are not backed up", async () => {
getComponent();
await flushPromises();
expect(screen.getByTestId("summarised-status").innerHTML).toEqual("Cross-signing is not set up.");
});
it("should render when keys are backed up", async () => {
mocked(mockClient.getCrypto()!.getCrossSigningStatus).mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
});
getComponent();
await flushPromises();
expect(screen.getByTestId("summarised-status").innerHTML).toEqual(
"Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.",
);
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
});
});
});

View File

@@ -1,97 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, waitFor, screen, fireEvent } from "jest-matrix-react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import * as TestUtils from "../../../../test-utils";
import CryptographyPanel from "../../../../../src/components/views/settings/CryptographyPanel";
import { withClientContextRenderOptions } from "../../../../test-utils";
describe("CryptographyPanel", () => {
it("shows the session ID and key", async () => {
const sessionId = "ABCDEFGHIJ";
const sessionKey = "AbCDeFghIJK7L/m4nOPqRSTUVW4xyzaBCDef6gHIJkl";
const sessionKeyFormatted = "<strong>AbCD eFgh IJK7 L/m4 nOPq RSTU VW4x yzaB CDef 6gHI Jkl</strong>";
TestUtils.stubClient();
const client: MatrixClient = MatrixClientPeg.safeGet();
client.deviceId = sessionId;
mocked(client.getCrypto()!.getOwnDeviceKeys).mockResolvedValue({ ed25519: sessionKey, curve25519: "1234" });
// When we render the CryptographyPanel
const rendered = render(<CryptographyPanel />, withClientContextRenderOptions(client));
// Then it displays info about the user's session
const codes = rendered.container.querySelectorAll("code");
expect(codes.length).toEqual(2);
expect(codes[0].innerHTML).toEqual(sessionId);
// Initially a placeholder
expect(codes[1].innerHTML).toEqual("<strong>...</strong>");
// Then the actual key
await waitFor(() => expect(codes[1].innerHTML).toEqual(sessionKeyFormatted));
});
it("handles errors fetching session key", async () => {
const sessionId = "ABCDEFGHIJ";
TestUtils.stubClient();
const client: MatrixClient = MatrixClientPeg.safeGet();
client.deviceId = sessionId;
mocked(client.getCrypto()!.getOwnDeviceKeys).mockRejectedValue(new Error("bleh"));
// When we render the CryptographyPanel
const rendered = render(<CryptographyPanel />, withClientContextRenderOptions(client));
// Then it displays info about the user's session
const codes = rendered.container.querySelectorAll("code");
// Initially a placeholder
expect(codes[1].innerHTML).toEqual("<strong>...</strong>");
// Then "not supported key
await waitFor(() => expect(codes[1].innerHTML).toEqual("<strong>&lt;not supported&gt;</strong>"));
});
it("should open the export e2e keys dialog on click", async () => {
const sessionId = "ABCDEFGHIJ";
const sessionKey = "AbCDeFghIJK7L/m4nOPqRSTUVW4xyzaBCDef6gHIJkl";
TestUtils.stubClient();
const client: MatrixClient = MatrixClientPeg.safeGet();
client.deviceId = sessionId;
mocked(client.getCrypto()!.getOwnDeviceKeys).mockResolvedValue({ ed25519: sessionKey, curve25519: "1234" });
render(<CryptographyPanel />, withClientContextRenderOptions(client));
fireEvent.click(await screen.findByRole("button", { name: "Export E2E room keys" }));
await expect(screen.findByRole("heading", { name: "Export room keys" })).resolves.toBeInTheDocument();
});
it("should open the import e2e keys dialog on click", async () => {
const sessionId = "ABCDEFGHIJ";
const sessionKey = "AbCDeFghIJK7L/m4nOPqRSTUVW4xyzaBCDef6gHIJkl";
TestUtils.stubClient();
const client: MatrixClient = MatrixClientPeg.safeGet();
client.deviceId = sessionId;
mocked(client.getCrypto()!.getOwnDeviceKeys).mockResolvedValue({ ed25519: sessionKey, curve25519: "1234" });
render(<CryptographyPanel />, withClientContextRenderOptions(client));
fireEvent.click(await screen.findByRole("button", { name: "Import E2E room keys" }));
await expect(screen.findByRole("heading", { name: "Import room keys" })).resolves.toBeInTheDocument();
});
});

View File

@@ -1,171 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render, screen, within } from "jest-matrix-react";
import { mocked } from "jest-mock";
import {
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsUser,
} from "../../../../test-utils";
import SecureBackupPanel from "../../../../../src/components/views/settings/SecureBackupPanel";
import { accessSecretStorage } from "../../../../../src/SecurityManager";
jest.mock("../../../../../src/SecurityManager", () => ({
accessSecretStorage: jest.fn(),
}));
describe("<SecureBackupPanel />", () => {
const userId = "@alice:server.org";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsCrypto(),
getClientWellKnown: jest.fn(),
});
const getComponent = () => render(<SecureBackupPanel />);
beforeEach(() => {
jest.spyOn(client.getCrypto()!, "getKeyBackupInfo").mockResolvedValue({
version: "1",
algorithm: "test",
auth_data: {
public_key: "1234",
},
});
Object.assign(client.getCrypto()!, {
isKeyBackupTrusted: jest.fn().mockResolvedValue({
trusted: false,
matchesDecryptionKey: false,
}),
getActiveSessionBackupVersion: jest.fn().mockResolvedValue(null),
deleteKeyBackupVersion: jest.fn().mockResolvedValue(undefined),
});
mocked(client.secretStorage.hasKey).mockClear().mockResolvedValue(false);
mocked(accessSecretStorage).mockClear().mockResolvedValue();
});
it("displays a loader while checking keybackup", async () => {
getComponent();
expect(screen.getByRole("progressbar")).toBeInTheDocument();
await flushPromises();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});
it("handles error fetching backup", async () => {
// getKeyBackupInfo can fail for various reasons
jest.spyOn(client.getCrypto()!, "getKeyBackupInfo").mockImplementation(async () => {
throw new Error("beep beep");
});
const renderResult = getComponent();
await renderResult.findByText("Unable to load key backup status");
expect(renderResult.container).toMatchSnapshot();
});
it("handles absence of backup", async () => {
jest.spyOn(client.getCrypto()!, "getKeyBackupInfo").mockResolvedValue(null);
getComponent();
// flush getKeyBackupInfo promise
await flushPromises();
expect(screen.getByText("Back up your keys before signing out to avoid losing them.")).toBeInTheDocument();
});
it("suggests connecting session to key backup when backup exists", async () => {
const { container } = getComponent();
// flush checkKeyBackup promise
await flushPromises();
expect(container).toMatchSnapshot();
});
it("displays when session is connected to key backup", async () => {
mocked(client.getCrypto()!).getActiveSessionBackupVersion.mockResolvedValue("1");
getComponent();
// flush checkKeyBackup promise
await flushPromises();
expect(screen.getByText("✅ This session is backing up your keys.")).toBeInTheDocument();
});
it("asks for confirmation before deleting a backup", async () => {
getComponent();
// flush checkKeyBackup promise
await flushPromises();
fireEvent.click(screen.getByText("Delete Backup"));
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText(
"Are you sure? You will lose your encrypted messages if your keys are not backed up properly.",
),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByText("Cancel"));
expect(client.getCrypto()!.deleteKeyBackupVersion).not.toHaveBeenCalled();
});
it("deletes backup after confirmation", async () => {
jest.spyOn(client.getCrypto()!, "getKeyBackupInfo")
.mockResolvedValueOnce({
version: "1",
algorithm: "test",
auth_data: {
public_key: "1234",
},
})
.mockResolvedValue(null);
getComponent();
fireEvent.click(await screen.findByText("Delete Backup"));
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText(
"Are you sure? You will lose your encrypted messages if your keys are not backed up properly.",
),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByTestId("dialog-primary-button"));
expect(client.getCrypto()!.deleteKeyBackupVersion).toHaveBeenCalledWith("1");
// delete request
await flushPromises();
// refresh backup info
await flushPromises();
});
it("resets secret storage", async () => {
mocked(client.secretStorage.hasKey).mockClear().mockResolvedValue(true);
getComponent();
// flush checkKeyBackup promise
await flushPromises();
jest.spyOn(client.getCrypto()!, "getKeyBackupInfo").mockClear();
mocked(client.getCrypto()!).isKeyBackupTrusted.mockClear();
fireEvent.click(screen.getByText("Reset"));
// enter loading state
expect(accessSecretStorage).toHaveBeenCalled();
await flushPromises();
// backup status refreshed
expect(client.getCrypto()!.getKeyBackupInfo).toHaveBeenCalled();
expect(client.getCrypto()!.isKeyBackupTrusted).toHaveBeenCalled();
});
});

View File

@@ -1,40 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<CrossSigningPanel /> when cross signing is not ready should render when keys are backed up 1`] = `
<tr>
<th
scope="row"
>
Cross-signing private keys:
</th>
<td>
in secret storage
</td>
</tr>
`;
exports[`<CrossSigningPanel /> when cross signing is ready should render when keys are backed up 1`] = `
<tr>
<th
scope="row"
>
Cross-signing private keys:
</th>
<td>
in secret storage
</td>
</tr>
`;
exports[`<CrossSigningPanel /> when cross signing is ready should render when keys are not backed up 1`] = `
<tr>
<th
scope="row"
>
Cross-signing private keys:
</th>
<td>
not found in storage
</td>
</tr>
`;

View File

@@ -1,190 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SecureBackupPanel /> handles error fetching backup 1`] = `
<div>
<div
class="mx_SettingsSubsection_text"
>
Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Recovery Key.
</div>
<div
class="mx_SettingsSubsection_text"
>
Unable to load key backup status
</div>
<details>
<summary
class="mx_SecureBackupPanel_advanced"
>
Advanced
</summary>
<table
class="mx_SecureBackupPanel_statusList"
>
<tr>
<th
scope="row"
>
Backup key stored:
</th>
<td>
not stored
</td>
</tr>
<tr>
<th
scope="row"
>
Backup key cached:
</th>
<td>
not found locally
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage public key:
</th>
<td>
not found
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage:
</th>
<td>
not ready
</td>
</tr>
</table>
</details>
</div>
`;
exports[`<SecureBackupPanel /> suggests connecting session to key backup when backup exists 1`] = `
<div>
<div
class="mx_SettingsSubsection_text"
>
Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Recovery Key.
</div>
<div
class="mx_SettingsSubsection_text"
>
<span>
This session is
<strong>
not backing up your keys
</strong>
, but you do have an existing backup you can restore from and add to going forward.
</span>
</div>
<div
class="mx_SettingsSubsection_text"
>
Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.
</div>
<details>
<summary
class="mx_SecureBackupPanel_advanced"
>
Advanced
</summary>
<table
class="mx_SecureBackupPanel_statusList"
>
<tr>
<th
scope="row"
>
Backup key stored:
</th>
<td>
not stored
</td>
</tr>
<tr>
<th
scope="row"
>
Backup key cached:
</th>
<td>
not found locally
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage public key:
</th>
<td>
not found
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage:
</th>
<td>
not ready
</td>
</tr>
<tr>
<th
scope="row"
>
Latest backup version on server:
</th>
<td>
1
(
Algorithm:
<code>
test
</code>
)
</td>
</tr>
<tr>
<th
scope="row"
>
Active backup version:
</th>
<td>
None
</td>
</tr>
</table>
<div />
</details>
<div
class="mx_SecureBackupPanel_buttonRow"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="0"
>
Connect this session to Key Backup
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_outline"
role="button"
tabindex="0"
>
Delete Backup
</div>
</div>
</div>
`;

View File

@@ -120,90 +120,6 @@ exports[`<SecurityUserSettingsTab /> renders security section 1`] = `
<div
class="mx_SettingsSection_subSections"
>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Secure Backup
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_SettingsSubsection_text"
>
Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Recovery Key.
</div>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
<details>
<summary
class="mx_SecureBackupPanel_advanced"
>
Advanced
</summary>
<table
class="mx_SecureBackupPanel_statusList"
>
<tr>
<th
scope="row"
>
Backup key stored:
</th>
<td>
not stored
</td>
</tr>
<tr>
<th
scope="row"
>
Backup key cached:
</th>
<td>
not found locally
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage public key:
</th>
<td>
not found
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage:
</th>
<td>
not ready
</td>
</tr>
</table>
</details>
</div>
</div>
<div
class="mx_SettingsSubsection"
>
@@ -240,203 +156,6 @@ exports[`<SecurityUserSettingsTab /> renders security section 1`] = `
</div>
</div>
</div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Cross-signing
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
<details>
<summary
class="mx_CrossSigningPanel_advanced"
>
Advanced
</summary>
<table
class="mx_CrossSigningPanel_statusList"
>
<tbody>
<tr>
<th
scope="row"
>
Cross-signing public keys:
</th>
<td>
not found
</td>
</tr>
<tr>
<th
scope="row"
>
Cross-signing private keys:
</th>
<td>
not found in storage
</td>
</tr>
<tr>
<th
scope="row"
>
Master private key:
</th>
<td>
not found locally
</td>
</tr>
<tr>
<th
scope="row"
>
Self signing private key:
</th>
<td>
not found locally
</td>
</tr>
<tr>
<th
scope="row"
>
User signing private key:
</th>
<td>
not found locally
</td>
</tr>
<tr>
<th
scope="row"
>
Homeserver feature support:
</th>
<td>
not found
</td>
</tr>
</tbody>
</table>
</details>
</div>
</div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Cryptography
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_SettingsSubsection_text"
>
<table
class="mx_CryptographyPanel_sessionInfo"
>
<tbody>
<tr>
<th
scope="row"
>
Session ID:
</th>
<td>
<code />
</td>
</tr>
<tr>
<th
scope="row"
>
Session key:
</th>
<td>
<code>
<strong>
...
</strong>
</code>
</td>
</tr>
</tbody>
</table>
</div>
<div
class="mx_CryptographyPanel_importExportButtons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="0"
>
Export E2E room keys
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="0"
>
Import E2E room keys
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_vY7Q4uEh9K38"
>
<span
class="mx_SettingsFlag_labelText"
>
Only send messages to verified users
</span>
</label>
<div
aria-checked="false"
aria-disabled="false"
aria-label="Only send messages to verified users"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled"
id="mx_SettingsFlag_vY7Q4uEh9K38"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div