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:
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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><not supported></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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user