Add cryptography information in devtools (#29073)

* feat(devtools): add crypto information in devtools

* ci: add crypto devtools file to crypto code owners

* test(dev tools): update test to add new crypto button

* test(dev tools): add tests for crypto component
This commit is contained in:
Florian Duros 2025-01-24 11:51:27 +01:00 committed by GitHub
parent 197afd6a9e
commit a73eb378d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 708 additions and 2 deletions

5
.github/CODEOWNERS vendored
View File

@ -10,10 +10,11 @@
/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers /test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers /src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers /test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers /src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
/src/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers /src/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers /test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers /playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
/src/components/views/dialogs/devtools/Crypto.tsx @element-hq/element-crypto-web-reviewers
# Ignore translations as those will be updated by GHA for Localazy download # Ignore translations as those will be updated by GHA for Localazy download
/src/i18n/strings /src/i18n/strings

View File

@ -134,6 +134,7 @@
@import "./views/dialogs/_ConfirmUserActionDialog.pcss"; @import "./views/dialogs/_ConfirmUserActionDialog.pcss";
@import "./views/dialogs/_CreateRoomDialog.pcss"; @import "./views/dialogs/_CreateRoomDialog.pcss";
@import "./views/dialogs/_CreateSubspaceDialog.pcss"; @import "./views/dialogs/_CreateSubspaceDialog.pcss";
@import "./views/dialogs/_Crypto.pcss";
@import "./views/dialogs/_DeactivateAccountDialog.pcss"; @import "./views/dialogs/_DeactivateAccountDialog.pcss";
@import "./views/dialogs/_DevtoolsDialog.pcss"; @import "./views/dialogs/_DevtoolsDialog.pcss";
@import "./views/dialogs/_ExportDialog.pcss"; @import "./views/dialogs/_ExportDialog.pcss";

View File

@ -0,0 +1,18 @@
/*
* Copyright 2025 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_Crypto {
table {
margin: var(--cpd-space-4x) 0;
text-align: left;
border-spacing: var(--cpd-space-2x) 0;
thead {
font: var(--cpd-font-heading-sm-semibold);
}
}
}

View File

@ -24,6 +24,7 @@ import { SettingLevel } from "../../../settings/SettingLevel";
import ServerInfo from "./devtools/ServerInfo"; import ServerInfo from "./devtools/ServerInfo";
import CopyableText from "../elements/CopyableText"; import CopyableText from "../elements/CopyableText";
import RoomNotifications from "./devtools/RoomNotifications"; import RoomNotifications from "./devtools/RoomNotifications";
import { Crypto } from "./devtools/Crypto";
enum Category { enum Category {
Room, Room,
@ -49,6 +50,7 @@ const Tools: Record<Category, [label: TranslationKey, tool: Tool][]> = {
[_td("devtools|explore_account_data"), AccountDataExplorer], [_td("devtools|explore_account_data"), AccountDataExplorer],
[_td("devtools|settings_explorer"), SettingExplorer], [_td("devtools|settings_explorer"), SettingExplorer],
[_td("devtools|server_info"), ServerInfo], [_td("devtools|server_info"), ServerInfo],
[_td("devtools|crypto|title"), Crypto],
], ],
}; };

View File

@ -0,0 +1,256 @@
/*
* Copyright 2025 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, { JSX } from "react";
import { InlineSpinner } from "@vector-im/compound-web";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import BaseTool from "./BaseTool";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import { _t } from "../../../../languageHandler";
interface KeyBackupProps {
/**
* Callback to invoke when the back button is clicked.
*/
onBack(): void;
}
/**
* A component that displays information about the key storage and cross-signing.
*/
export function Crypto({ onBack }: KeyBackupProps): JSX.Element {
const matrixClient = useMatrixClientContext();
return (
<BaseTool onBack={onBack} className="mx_Crypto">
{matrixClient.getCrypto() ? (
<>
<KeyStorage />
<CrossSigning />
</>
) : (
<span>{_t("devtools|crypto|crypto_not_available")}</span>
)}
</BaseTool>
);
}
/**
* A component that displays information about the key storage.
*/
function KeyStorage(): JSX.Element {
const matrixClient = useMatrixClientContext();
const keyStorageData = useAsyncMemo(async () => {
const crypto = matrixClient.getCrypto()!;
// Get all the key storage data that we will display
const backupInfo = await crypto.getKeyBackupInfo();
const backupKeyStored = Boolean(await matrixClient.isKeyBackupKeyStored());
const backupKeyFromCache = await crypto.getSessionBackupPrivateKey();
const backupKeyCached = Boolean(backupKeyFromCache);
const backupKeyWellFormed = backupKeyFromCache instanceof Uint8Array;
const activeBackupVersion = await crypto.getActiveSessionBackupVersion();
const secretStorageKeyInAccount = await matrixClient.secretStorage.hasKey();
const secretStorageReady = await crypto.isSecretStorageReady();
return {
backupInfo,
backupKeyStored,
backupKeyCached,
backupKeyWellFormed,
activeBackupVersion,
secretStorageKeyInAccount,
secretStorageReady,
};
}, [matrixClient]);
// Show a spinner while loading
if (keyStorageData === undefined) return <InlineSpinner aria-label={_t("common|loading")} />;
const {
backupInfo,
backupKeyStored,
backupKeyCached,
backupKeyWellFormed,
activeBackupVersion,
secretStorageKeyInAccount,
secretStorageReady,
} = keyStorageData;
return (
<table aria-label={_t("devtools|crypto|key_storage")}>
<thead>{_t("devtools|crypto|key_storage")}</thead>
<tbody>
<tr>
<th scope="row">{_t("devtools|crypto|key_backup_latest_version")}</th>
<td>
{backupInfo
? `${backupInfo.version} (${_t("settings|security|key_backup_algorithm")} ${backupInfo.algorithm})`
: _t("devtools|crypto|key_backup_inactive_warning")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|backup_key_stored_status")}</th>
<td>
{backupKeyStored
? _t("devtools|crypto|backup_key_stored")
: _t("devtools|crypto|backup_key_not_stored")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|key_backup_active_version")}</th>
<td>
{activeBackupVersion === null
? _t("devtools|crypto|key_backup_active_version_none")
: activeBackupVersion}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|backup_key_cached_status")}</th>
<td>
{`${
backupKeyCached
? _t("devtools|crypto|backup_key_cached")
: _t("devtools|crypto|not_found_locally")
}, ${
backupKeyWellFormed
? _t("devtools|crypto|backup_key_well_formed")
: _t("devtools|crypto|backup_key_unexpected_type")
}`}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|4s_public_key_status")}</th>
<td>
{secretStorageKeyInAccount
? _t("devtools|crypto|4s_public_key_in_account_data")
: _t("devtools|crypto|4s_public_key_not_in_account_data")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|secret_storage_status")}</th>
<td>
{secretStorageReady
? _t("devtools|crypto|secret_storage_ready")
: _t("devtools|crypto|secret_storage_not_ready")}
</td>
</tr>
</tbody>
</table>
);
}
/**
* A component that displays information about cross-signing.
*/
function CrossSigning(): JSX.Element {
const matrixClient = useMatrixClientContext();
const crossSigningData = useAsyncMemo(async () => {
const crypto = matrixClient.getCrypto()!;
// Get all the cross-signing data that we will display
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 crossSigningReady = await crypto.isCrossSigningReady();
return {
crossSigningPublicKeysOnDevice,
crossSigningPrivateKeysInStorage,
masterPrivateKeyCached,
selfSigningPrivateKeyCached,
userSigningPrivateKeyCached,
crossSigningReady,
};
}, [matrixClient]);
// Show a spinner while loading
if (crossSigningData === undefined) return <InlineSpinner aria-label={_t("common|loading")} />;
const {
crossSigningPublicKeysOnDevice,
crossSigningPrivateKeysInStorage,
masterPrivateKeyCached,
selfSigningPrivateKeyCached,
userSigningPrivateKeyCached,
crossSigningReady,
} = crossSigningData;
return (
<table aria-label={_t("devtools|crypto|cross_signing")}>
<thead>{_t("devtools|crypto|cross_signing")}</thead>
<tbody>
<tr>
<th scope="row">{_t("devtools|crypto|cross_signing_status")}</th>
<td>{getCrossSigningStatus(crossSigningReady, crossSigningPrivateKeysInStorage)}</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|cross_signing_public_keys_on_device_status")}</th>
<td>
{crossSigningPublicKeysOnDevice
? _t("devtools|crypto|cross_signing_public_keys_on_device")
: _t("devtools|crypto|not_found")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|cross_signing_private_keys_in_storage_status")}</th>
<td>
{crossSigningPrivateKeysInStorage
? _t("devtools|crypto|cross_signing_private_keys_in_storage")
: _t("devtools|crypto|cross_signing_private_keys_not_in_storage")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|master_private_key_cached_status")}</th>
<td>
{masterPrivateKeyCached
? _t("devtools|crypto|cross_signing_cached")
: _t("devtools|crypto|not_found_locally")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|self_signing_private_key_cached_status")}</th>
<td>
{selfSigningPrivateKeyCached
? _t("devtools|crypto|cross_signing_cached")
: _t("devtools|crypto|not_found_locally")}
</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|user_signing_private_key_cached_status")}</th>
<td>
{userSigningPrivateKeyCached
? _t("devtools|crypto|cross_signing_cached")
: _t("devtools|crypto|not_found_locally")}
</td>
</tr>
</tbody>
</table>
);
}
/**
* Get the cross-signing status.
* @param crossSigningReady Whether cross-signing is ready.
* @param crossSigningPrivateKeysInStorage Whether cross-signing private keys are in secret storage.
*/
function getCrossSigningStatus(crossSigningReady: boolean, crossSigningPrivateKeysInStorage: boolean): string {
if (crossSigningReady) {
return crossSigningPrivateKeysInStorage
? _t("devtools|crypto|cross_signing_ready")
: _t("devtools|crypto|cross_signing_untrusted");
}
if (crossSigningPrivateKeysInStorage) {
return _t("devtools|crypto|cross_signing_not_ready");
}
return _t("devtools|crypto|cross_signing_not_ready");
}

View File

@ -734,6 +734,44 @@
"category_room": "Room", "category_room": "Room",
"caution_colon": "Caution:", "caution_colon": "Caution:",
"client_versions": "Client Versions", "client_versions": "Client Versions",
"crypto": {
"4s_public_key_in_account_data": "in account data",
"4s_public_key_not_in_account_data": "not found",
"4s_public_key_status": "Secret storage public key:",
"backup_key_cached": "cached locally",
"backup_key_cached_status": "Backup key cached:",
"backup_key_not_stored": "not stored",
"backup_key_stored": "in secret storage",
"backup_key_stored_status": "Backup key stored:",
"backup_key_unexpected_type": "unexpected type",
"backup_key_well_formed": "well formed",
"cross_signing": "Cross-signing",
"cross_signing_cached": "cached locally",
"cross_signing_not_ready": "Cross-signing is not set up.",
"cross_signing_private_keys_in_storage": "in secret storage",
"cross_signing_private_keys_in_storage_status": "Cross-signing private keys:",
"cross_signing_private_keys_not_in_storage": "not found in storage",
"cross_signing_public_keys_on_device": "in memory",
"cross_signing_public_keys_on_device_status": "Cross-signing public keys:",
"cross_signing_ready": "Cross-signing is ready for use.",
"cross_signing_status": "Cross-signing status:",
"cross_signing_untrusted": "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.",
"crypto_not_available": "Cryptographic module is not available",
"key_backup_active_version": "Active backup version:",
"key_backup_active_version_none": "None",
"key_backup_inactive_warning": "Your keys are not being backed up from this session.",
"key_backup_latest_version": "Latest backup version on server:",
"key_storage": "Key Storage",
"master_private_key_cached_status": "Master private key:",
"not_found": "not found",
"not_found_locally": "not found locally",
"secret_storage_not_ready": "not ready",
"secret_storage_ready": "ready",
"secret_storage_status": "Secret storage:",
"self_signing_private_key_cached_status": "Self signing private key:",
"title": "End-to-end encryption",
"user_signing_private_key_cached_status": "User signing private key:"
},
"developer_mode": "Developer mode", "developer_mode": "Developer mode",
"developer_tools": "Developer Tools", "developer_tools": "Developer Tools",
"edit_setting": "Edit setting", "edit_setting": "Edit setting",

View File

@ -152,6 +152,8 @@ export function createTestClient(): MatrixClient {
}), }),
isCrossSigningReady: jest.fn().mockResolvedValue(false), isCrossSigningReady: jest.fn().mockResolvedValue(false),
resetEncryption: jest.fn(), resetEncryption: jest.fn(),
getSessionBackupPrivateKey: jest.fn().mockResolvedValue(null),
isSecretStorageReady: jest.fn().mockResolvedValue(false),
}), }),
getPushActionsForEvent: jest.fn(), getPushActionsForEvent: jest.fn(),

View File

@ -99,6 +99,11 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = `
> >
Server info Server info
</button> </button>
<button
class="mx_DevTools_button"
>
End-to-end encryption
</button>
</div> </div>
<div> <div>
<h3> <h3>

View File

@ -0,0 +1,94 @@
/*
* Copyright 2025 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 from "react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { render, screen, waitFor } from "jest-matrix-react";
import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
import { Crypto } from "../../../../../../src/components/views/dialogs/devtools/Crypto";
describe("<Crypto />", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = createTestClient();
});
function renderComponent() {
return render(<Crypto onBack={jest.fn} />, withClientContextRenderOptions(matrixClient));
}
it("should display message if crypto is not available", async () => {
jest.spyOn(matrixClient, "getCrypto").mockReturnValue(undefined);
renderComponent();
expect(screen.getByText("Cryptographic module is not available")).toBeInTheDocument();
});
describe("<KeyStorage />", () => {
it("should display loading spinner while loading", async () => {
jest.spyOn(matrixClient.getCrypto()!, "getKeyBackupInfo").mockImplementation(() => new Promise(() => {}));
renderComponent();
await waitFor(() => expect(screen.getByLabelText("Loading…")).toBeInTheDocument());
});
it("should display when the key storage data are missing", async () => {
renderComponent();
await waitFor(() => expect(screen.getByRole("table", { name: "Key Storage" })).toBeInTheDocument());
expect(screen.getByRole("table", { name: "Key Storage" })).toMatchSnapshot();
});
it("should display when the key storage data are available", async () => {
jest.spyOn(matrixClient.getCrypto()!, "getKeyBackupInfo").mockResolvedValue({
algorithm: "m.megolm_backup.v1",
version: "1",
} as unknown as KeyBackupInfo);
jest.spyOn(matrixClient, "isKeyBackupKeyStored").mockResolvedValue({});
jest.spyOn(matrixClient.getCrypto()!, "getSessionBackupPrivateKey").mockResolvedValue(new Uint8Array(32));
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("2");
jest.spyOn(matrixClient.secretStorage, "hasKey").mockResolvedValue(true);
jest.spyOn(matrixClient.getCrypto()!, "isSecretStorageReady").mockResolvedValue(true);
renderComponent();
await waitFor(() => expect(screen.getByRole("table", { name: "Key Storage" })).toBeInTheDocument());
expect(screen.getByRole("table", { name: "Key Storage" })).toMatchSnapshot();
});
});
describe("<CrossSigning />", () => {
it("should display loading spinner while loading", async () => {
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockImplementation(
() => new Promise(() => {}),
);
renderComponent();
await waitFor(() => expect(screen.getByLabelText("Loading…")).toBeInTheDocument());
});
it("should display when the cross-signing data are missing", async () => {
renderComponent();
await waitFor(() => expect(screen.getByRole("table", { name: "Cross-signing" })).toBeInTheDocument());
expect(screen.getByRole("table", { name: "Cross-signing" })).toMatchSnapshot();
});
it("should display when the cross-signing data are available", async () => {
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
});
jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockResolvedValue(true);
renderComponent();
await waitFor(() => expect(screen.getByRole("table", { name: "Cross-signing" })).toBeInTheDocument());
expect(screen.getByRole("table", { name: "Cross-signing" })).toMatchSnapshot();
});
});
});

View File

@ -0,0 +1,289 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Crypto /> <CrossSigning /> should display when the cross-signing data are available 1`] = `
<table
aria-label="Cross-signing"
>
<thead>
Cross-signing
</thead>
<tbody>
<tr>
<th
scope="row"
>
Cross-signing status:
</th>
<td>
Cross-signing is ready for use.
</td>
</tr>
<tr>
<th
scope="row"
>
Cross-signing public keys:
</th>
<td>
in memory
</td>
</tr>
<tr>
<th
scope="row"
>
Cross-signing private keys:
</th>
<td>
in secret storage
</td>
</tr>
<tr>
<th
scope="row"
>
Master private key:
</th>
<td>
cached locally
</td>
</tr>
<tr>
<th
scope="row"
>
Self signing private key:
</th>
<td>
cached locally
</td>
</tr>
<tr>
<th
scope="row"
>
User signing private key:
</th>
<td>
cached locally
</td>
</tr>
</tbody>
</table>
`;
exports[`<Crypto /> <CrossSigning /> should display when the cross-signing data are missing 1`] = `
<table
aria-label="Cross-signing"
>
<thead>
Cross-signing
</thead>
<tbody>
<tr>
<th
scope="row"
>
Cross-signing status:
</th>
<td>
Cross-signing is not set up.
</td>
</tr>
<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>
</tbody>
</table>
`;
exports[`<Crypto /> <KeyStorage /> should display when the key storage data are available 1`] = `
<table
aria-label="Key Storage"
>
<thead>
Key Storage
</thead>
<tbody>
<tr>
<th
scope="row"
>
Latest backup version on server:
</th>
<td>
1 (Algorithm: m.megolm_backup.v1)
</td>
</tr>
<tr>
<th
scope="row"
>
Backup key stored:
</th>
<td>
in secret storage
</td>
</tr>
<tr>
<th
scope="row"
>
Active backup version:
</th>
<td>
2
</td>
</tr>
<tr>
<th
scope="row"
>
Backup key cached:
</th>
<td>
cached locally, well formed
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage public key:
</th>
<td>
in account data
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage:
</th>
<td>
ready
</td>
</tr>
</tbody>
</table>
`;
exports[`<Crypto /> <KeyStorage /> should display when the key storage data are missing 1`] = `
<table
aria-label="Key Storage"
>
<thead>
Key Storage
</thead>
<tbody>
<tr>
<th
scope="row"
>
Latest backup version on server:
</th>
<td>
Your keys are not being backed up from this session.
</td>
</tr>
<tr>
<th
scope="row"
>
Backup key stored:
</th>
<td>
not stored
</td>
</tr>
<tr>
<th
scope="row"
>
Active backup version:
</th>
<td>
None
</td>
</tr>
<tr>
<th
scope="row"
>
Backup key cached:
</th>
<td>
not found locally, unexpected type
</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>
</tbody>
</table>
`;