diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b31ec5e3bf9..4e48c7bba9d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,10 +10,11 @@ /test/components/views/dialogs/security/ @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 -/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 /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 /src/i18n/strings diff --git a/res/css/_components.pcss b/res/css/_components.pcss index f5b022c16b9..a114c998b8b 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -134,6 +134,7 @@ @import "./views/dialogs/_ConfirmUserActionDialog.pcss"; @import "./views/dialogs/_CreateRoomDialog.pcss"; @import "./views/dialogs/_CreateSubspaceDialog.pcss"; +@import "./views/dialogs/_Crypto.pcss"; @import "./views/dialogs/_DeactivateAccountDialog.pcss"; @import "./views/dialogs/_DevtoolsDialog.pcss"; @import "./views/dialogs/_ExportDialog.pcss"; diff --git a/res/css/views/dialogs/_Crypto.pcss b/res/css/views/dialogs/_Crypto.pcss new file mode 100644 index 00000000000..12d46cf75b2 --- /dev/null +++ b/res/css/views/dialogs/_Crypto.pcss @@ -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); + } + } +} diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index e9e2c9a3344..440e37393ff 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -24,6 +24,7 @@ import { SettingLevel } from "../../../settings/SettingLevel"; import ServerInfo from "./devtools/ServerInfo"; import CopyableText from "../elements/CopyableText"; import RoomNotifications from "./devtools/RoomNotifications"; +import { Crypto } from "./devtools/Crypto"; enum Category { Room, @@ -49,6 +50,7 @@ const Tools: Record = { [_td("devtools|explore_account_data"), AccountDataExplorer], [_td("devtools|settings_explorer"), SettingExplorer], [_td("devtools|server_info"), ServerInfo], + [_td("devtools|crypto|title"), Crypto], ], }; diff --git a/src/components/views/dialogs/devtools/Crypto.tsx b/src/components/views/dialogs/devtools/Crypto.tsx new file mode 100644 index 00000000000..78e95bfdffd --- /dev/null +++ b/src/components/views/dialogs/devtools/Crypto.tsx @@ -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 ( + + {matrixClient.getCrypto() ? ( + <> + + + + ) : ( + {_t("devtools|crypto|crypto_not_available")} + )} + + ); +} + +/** + * 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 ; + + const { + backupInfo, + backupKeyStored, + backupKeyCached, + backupKeyWellFormed, + activeBackupVersion, + secretStorageKeyInAccount, + secretStorageReady, + } = keyStorageData; + + return ( + + {_t("devtools|crypto|key_storage")} + + + + + + + + + + + + + + + + + + + + + + + + + + +
{_t("devtools|crypto|key_backup_latest_version")} + {backupInfo + ? `${backupInfo.version} (${_t("settings|security|key_backup_algorithm")} ${backupInfo.algorithm})` + : _t("devtools|crypto|key_backup_inactive_warning")} +
{_t("devtools|crypto|backup_key_stored_status")} + {backupKeyStored + ? _t("devtools|crypto|backup_key_stored") + : _t("devtools|crypto|backup_key_not_stored")} +
{_t("devtools|crypto|key_backup_active_version")} + {activeBackupVersion === null + ? _t("devtools|crypto|key_backup_active_version_none") + : activeBackupVersion} +
{_t("devtools|crypto|backup_key_cached_status")} + {`${ + 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") + }`} +
{_t("devtools|crypto|4s_public_key_status")} + {secretStorageKeyInAccount + ? _t("devtools|crypto|4s_public_key_in_account_data") + : _t("devtools|crypto|4s_public_key_not_in_account_data")} +
{_t("devtools|crypto|secret_storage_status")} + {secretStorageReady + ? _t("devtools|crypto|secret_storage_ready") + : _t("devtools|crypto|secret_storage_not_ready")} +
+ ); +} + +/** + * 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 ; + + const { + crossSigningPublicKeysOnDevice, + crossSigningPrivateKeysInStorage, + masterPrivateKeyCached, + selfSigningPrivateKeyCached, + userSigningPrivateKeyCached, + crossSigningReady, + } = crossSigningData; + + return ( + + {_t("devtools|crypto|cross_signing")} + + + + + + + + + + + + + + + + + + + + + + + + + + +
{_t("devtools|crypto|cross_signing_status")}{getCrossSigningStatus(crossSigningReady, crossSigningPrivateKeysInStorage)}
{_t("devtools|crypto|cross_signing_public_keys_on_device_status")} + {crossSigningPublicKeysOnDevice + ? _t("devtools|crypto|cross_signing_public_keys_on_device") + : _t("devtools|crypto|not_found")} +
{_t("devtools|crypto|cross_signing_private_keys_in_storage_status")} + {crossSigningPrivateKeysInStorage + ? _t("devtools|crypto|cross_signing_private_keys_in_storage") + : _t("devtools|crypto|cross_signing_private_keys_not_in_storage")} +
{_t("devtools|crypto|master_private_key_cached_status")} + {masterPrivateKeyCached + ? _t("devtools|crypto|cross_signing_cached") + : _t("devtools|crypto|not_found_locally")} +
{_t("devtools|crypto|self_signing_private_key_cached_status")} + {selfSigningPrivateKeyCached + ? _t("devtools|crypto|cross_signing_cached") + : _t("devtools|crypto|not_found_locally")} +
{_t("devtools|crypto|user_signing_private_key_cached_status")} + {userSigningPrivateKeyCached + ? _t("devtools|crypto|cross_signing_cached") + : _t("devtools|crypto|not_found_locally")} +
+ ); +} + +/** + * 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"); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 455324d7756..a9825a16e4d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -734,6 +734,44 @@ "category_room": "Room", "caution_colon": "Caution:", "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_tools": "Developer Tools", "edit_setting": "Edit setting", diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index b474a24aa81..18da07db92b 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -152,6 +152,8 @@ export function createTestClient(): MatrixClient { }), isCrossSigningReady: jest.fn().mockResolvedValue(false), resetEncryption: jest.fn(), + getSessionBackupPrivateKey: jest.fn().mockResolvedValue(null), + isSecretStorageReady: jest.fn().mockResolvedValue(false), }), getPushActionsForEvent: jest.fn(), diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap index 622ed320652..dd1deb8696e 100644 --- a/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap +++ b/test/unit-tests/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap @@ -99,6 +99,11 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = ` > Server info +

diff --git a/test/unit-tests/components/views/dialogs/devtools/Crypto-test.tsx b/test/unit-tests/components/views/dialogs/devtools/Crypto-test.tsx new file mode 100644 index 00000000000..9086be63e88 --- /dev/null +++ b/test/unit-tests/components/views/dialogs/devtools/Crypto-test.tsx @@ -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("", () => { + let matrixClient: MatrixClient; + beforeEach(() => { + matrixClient = createTestClient(); + }); + + function renderComponent() { + return render(, 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("", () => { + 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("", () => { + 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(); + }); + }); +}); diff --git a/test/unit-tests/components/views/dialogs/devtools/__snapshots__/Crypto-test.tsx.snap b/test/unit-tests/components/views/dialogs/devtools/__snapshots__/Crypto-test.tsx.snap new file mode 100644 index 00000000000..c1f3f56902c --- /dev/null +++ b/test/unit-tests/components/views/dialogs/devtools/__snapshots__/Crypto-test.tsx.snap @@ -0,0 +1,289 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should display when the cross-signing data are available 1`] = ` + + + Cross-signing + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Cross-signing status: + + Cross-signing is ready for use. +
+ Cross-signing public keys: + + in memory +
+ Cross-signing private keys: + + in secret storage +
+ Master private key: + + cached locally +
+ Self signing private key: + + cached locally +
+ User signing private key: + + cached locally +
+`; + +exports[` should display when the cross-signing data are missing 1`] = ` + + + Cross-signing + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Cross-signing status: + + Cross-signing is not set up. +
+ Cross-signing public keys: + + not found +
+ Cross-signing private keys: + + not found in storage +
+ Master private key: + + not found locally +
+ Self signing private key: + + not found locally +
+ User signing private key: + + not found locally +
+`; + +exports[` should display when the key storage data are available 1`] = ` + + + Key Storage + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Latest backup version on server: + + 1 (Algorithm: m.megolm_backup.v1) +
+ Backup key stored: + + in secret storage +
+ Active backup version: + + 2 +
+ Backup key cached: + + cached locally, well formed +
+ Secret storage public key: + + in account data +
+ Secret storage: + + ready +
+`; + +exports[` should display when the key storage data are missing 1`] = ` + + + Key Storage + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Latest backup version on server: + + Your keys are not being backed up from this session. +
+ Backup key stored: + + not stored +
+ Active backup version: + + None +
+ Backup key cached: + + not found locally, unexpected type +
+ Secret storage public key: + + not found +
+ Secret storage: + + not ready +
+`;