diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index afbfeeca03..c8563509f2 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -60,6 +60,7 @@ import { deop, op } from "./slash-commands/op"; import { CommandCategories } from "./slash-commands/interface"; import { Command } from "./slash-commands/command"; import { goto, join } from "./slash-commands/join"; +import { manuallyVerifyDevice } from "./components/views/dialogs/ManualDeviceKeyVerificationDialog"; export { CommandCategories, Command }; @@ -663,6 +664,24 @@ export const Commands = [ category: CommandCategories.admin, renderingTypes: [TimelineRenderingType.Room], }), + new Command({ + command: "verify", + args: " ", + description: _td("slash_command|verify"), + runFn: function (cli, _roomId, _threadId, args) { + if (args) { + const matches = args.match(/^(\S+) +(\S+)$/); + if (matches) { + const deviceId = matches[1]; + const fingerprint = matches[2]; + return success(manuallyVerifyDevice(cli, deviceId, fingerprint)); + } + } + return reject(this.getUsage()); + }, + category: CommandCategories.advanced, + renderingTypes: [TimelineRenderingType.Room], + }), new Command({ command: "discardsession", description: _td("slash_command|discardsession"), diff --git a/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx b/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx new file mode 100644 index 0000000000..8f699c4495 --- /dev/null +++ b/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx @@ -0,0 +1,171 @@ +/* +Copyright 2024-2025 New Vector Ltd. +Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2019 New Vector Ltd +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2017 Vector Creations Ltd +Copyright 2016 OpenMarket Ltd + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type ChangeEvent, type JSX, useCallback, useState } from "react"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { _t, UserFriendlyError } from "../../../languageHandler"; +import { getDeviceCryptoInfo } from "../../../utils/crypto/deviceInfo"; +import QuestionDialog from "./QuestionDialog"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; +import Field from "../elements/Field"; +import ErrorDialog from "./ErrorDialog"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; + +interface Props { + onFinished(confirm?: boolean): void; +} + +/** + * A dialog to allow us to verify devices logged in with clients that can't do + * the verification themselves. Intended for use as a dev tool. + * + * Requires entering the fingerprint ("session key") of the device in an attempt + * to prevent users being tricked into verifying a malicious device. + */ +export function ManualDeviceKeyVerificationDialog({ onFinished }: Readonly): JSX.Element { + const [deviceId, setDeviceId] = useState(""); + const [fingerprint, setFingerprint] = useState(""); + + const client = useMatrixClientContext(); + + const onDialogFinished = useCallback( + async (confirm: boolean) => { + if (confirm) { + await manuallyVerifyDevice(client, deviceId, fingerprint); + } + onFinished(confirm); + }, + [client, deviceId, fingerprint, onFinished], + ); + + const onDeviceIdChange = useCallback((e: ChangeEvent) => { + setDeviceId(e.target.value); + }, []); + + const onFingerprintChange = useCallback((e: ChangeEvent) => { + setFingerprint(e.target.value); + }, []); + + const body = ( +
+

{_t("encryption|verification|manual|text")}

+
+ + +
+
+ ); + + return ( + + ); +} + +/** + * Check the supplied fingerprint matches the fingerprint ("session key") of the + * device with the supplied device ID, and if so, mark the device as verified. + */ +export async function manuallyVerifyDevice(client: MatrixClient, deviceId: string, fingerprint: string): Promise { + try { + await doManuallyVerifyDevice(client, deviceId, fingerprint); + + // Tell the user we verified everything + Modal.createDialog(InfoDialog, { + title: _t("encryption|verification|manual|success_title"), + description: ( +
+

{_t("encryption|verification|manual|success_description", { deviceId })}

+
+ ), + }); + } catch (e: any) { + // Display an error + const error = e instanceof UserFriendlyError ? e.translatedMessage : e.toString(); + Modal.createDialog(ErrorDialog, { + title: _t("encryption|verification|manual|failure_title"), + description: ( +
+

{_t("encryption|verification|manual|failure_description", { deviceId, error })}

+
+ ), + }); + } +} + +async function doManuallyVerifyDevice(client: MatrixClient, deviceId: string, fingerprint: string): Promise { + const userId = client.getUserId(); + if (!userId) { + throw new UserFriendlyError("encryption|verification|manual|no_userid", { + cause: undefined, + }); + } + + const crypto = client.getCrypto(); + if (!crypto) { + throw new UserFriendlyError("encryption|verification|manual|no_crypto"); + } + + const device = await getDeviceCryptoInfo(client, userId, deviceId); + if (!device) { + throw new UserFriendlyError("encryption|verification|manual|no_device", { + deviceId, + cause: undefined, + }); + } + const deviceTrust = await crypto.getDeviceVerificationStatus(userId, deviceId); + + if (deviceTrust?.isVerified()) { + if (device.getFingerprint() === fingerprint) { + throw new UserFriendlyError("encryption|verification|manual|already_verified", { + deviceId, + cause: undefined, + }); + } else { + throw new UserFriendlyError("encryption|verification|manual|already_verified_and_wrong_fingerprint", { + deviceId, + cause: undefined, + }); + } + } + + if (device.getFingerprint() !== fingerprint) { + const fprint = device.getFingerprint(); + throw new UserFriendlyError("encryption|verification|manual|wrong_fingerprint", { + fprint, + deviceId, + fingerprint, + cause: undefined, + }); + } + + // We've passed all the checks - do the device verification + await crypto.crossSignDevice(deviceId); +} diff --git a/src/components/views/dialogs/devtools/Crypto.tsx b/src/components/views/dialogs/devtools/Crypto.tsx index a05c415f00..e491c00a73 100644 --- a/src/components/views/dialogs/devtools/Crypto.tsx +++ b/src/components/views/dialogs/devtools/Crypto.tsx @@ -12,6 +12,8 @@ import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext import BaseTool from "./BaseTool"; import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; import { _t } from "../../../../languageHandler"; +import Modal from "../../../../Modal"; +import { ManualDeviceKeyVerificationDialog } from "../ManualDeviceKeyVerificationDialog"; interface KeyBackupProps { /** @@ -31,6 +33,16 @@ export function Crypto({ onBack }: KeyBackupProps): JSX.Element { <> + + + ) : ( {_t("devtools|crypto|crypto_not_available")} @@ -254,3 +266,39 @@ function getCrossSigningStatus(crossSigningReady: boolean, crossSigningPrivateKe return _t("devtools|crypto|cross_signing_not_ready"); } + +/** + * A component that displays information about the current session. + */ +function Session(): JSX.Element { + const matrixClient = useMatrixClientContext(); + const sessionData = useAsyncMemo(async () => { + const crypto = matrixClient.getCrypto()!; + const keys = await crypto.getOwnDeviceKeys(); + return { + fingerprint: keys.ed25519, + deviceId: matrixClient.deviceId, + }; + }, [matrixClient]); + + // Show a spinner while loading + if (sessionData === undefined) { + return ; + } + + return ( + + {_t("devtools|crypto|session")} + + + + + + + + + + +
{_t("devtools|crypto|device_id")}{sessionData.deviceId}
{_t("devtools|crypto|session_fingerprint")}{sessionData.fingerprint}
+ ); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 0caa0fb07f..0c16e1cd05 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -786,6 +786,7 @@ "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", + "device_id": "Device ID", "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.", @@ -798,6 +799,8 @@ "secret_storage_ready": "ready", "secret_storage_status": "Secret storage:", "self_signing_private_key_cached_status": "Self signing private key:", + "session": "Session", + "session_fingerprint": "Fingerprint (session key)", "title": "End-to-end encryption", "user_signing_private_key_cached_status": "User signing private key:" }, @@ -823,6 +826,7 @@ "low_bandwidth_mode": "Low bandwidth mode", "low_bandwidth_mode_description": "Requires compatible homeserver.", "main_timeline": "Main timeline", + "manual_device_verification": "Manual device verification", "no_receipt_found": "No receipt found", "notification_state": "Notification state is %(notificationState)s", "notifications_debug": "Notifications debug", @@ -1007,6 +1011,21 @@ "incoming_sas_dialog_waiting": "Waiting for partner to confirm…", "incoming_sas_user_dialog_text_1": "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.", "incoming_sas_user_dialog_text_2": "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.", + "manual": { + "already_verified": "This device is already verified", + "already_verified_and_wrong_fingerprint": "The supplied fingerprint does not match, but the device is already verified!", + "device_id": "Device ID", + "failure_description": "Failed to verify '%(deviceId)s': %(error)s", + "failure_title": "Verification failed", + "fingerprint": "Fingerprint (session key)", + "no_crypto": "Unable to verify device - crypto is not enabled", + "no_device": "Unable to verify device - device '%(deviceId)s' was not found", + "no_userid": "Unable to verify device - cannot find our User ID", + "success_description": "The device (%(deviceId)s) is now cross-signed", + "success_title": "Verification successful", + "text": "Supply the ID and fingerprint of one of your own devices to verify it.", + "wrong_fingerprint": "Unable to verify device '%(deviceId)s' - the supplied fingerprint '%(fingerprint)s' does not match the device fingerprint, '%(fprint)s'" + }, "no_key_or_device": "It looks like you don't have a Recovery Key or any other devices you can verify against. This device will not be able to access old encrypted messages. In order to verify your identity on this device, you'll need to reset your verification keys.", "no_support_qr_emoji": "The device you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.", "other_party_cancelled": "The other party cancelled the verification.", @@ -3133,6 +3152,7 @@ "upgraderoom": "Upgrades a room to a new version", "upgraderoom_permission_error": "You do not have the required permissions to use this command.", "usage": "Usage", + "verify": "Manually verify one of your own devices", "view": "Views room with given address", "whois": "Displays information about a user" }, diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 4fd6d094bf..1c147023c2 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -157,6 +157,7 @@ export function createTestClient(): MatrixClient { getSessionBackupPrivateKey: jest.fn().mockResolvedValue(null), isSecretStorageReady: jest.fn().mockResolvedValue(false), deleteKeyBackupVersion: jest.fn(), + crossSignDevice: jest.fn(), }), getPushActionsForEvent: jest.fn(), diff --git a/test/unit-tests/SlashCommands-test.tsx b/test/unit-tests/SlashCommands-test.tsx index b30bc69176..375a8888b7 100644 --- a/test/unit-tests/SlashCommands-test.tsx +++ b/test/unit-tests/SlashCommands-test.tsx @@ -21,6 +21,7 @@ import { WidgetType } from "../../src/widgets/WidgetType"; import { warnSelfDemote } from "../../src/components/views/right_panel/UserInfo"; import dispatcher from "../../src/dispatcher/dispatcher"; import { SettingLevel } from "../../src/settings/SettingLevel"; +import ErrorDialog from "../../src/components/views/dialogs/ErrorDialog"; jest.mock("../../src/components/views/right_panel/UserInfo"); @@ -253,6 +254,20 @@ describe("SlashCommands", () => { }); }); + describe("/verify", () => { + it("should return usage if no args", () => { + const command = findCommand("verify")!; + expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage()); + }); + + it("should show an error if device is not found", async () => { + const spy = jest.spyOn(Modal, "createDialog"); + const command = findCommand("verify")!; + await command.run(client, roomId, null, "mydeviceid myfingerprint").promise; + expect(spy).toHaveBeenCalledWith(ErrorDialog, expect.objectContaining({ title: "Verification failed" })); + }); + }); + describe("/addwidget", () => { it("should parse html iframe snippets", async () => { jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true); diff --git a/test/unit-tests/components/views/dialogs/ManualDeviceKeyVerificationDialog-test.tsx b/test/unit-tests/components/views/dialogs/ManualDeviceKeyVerificationDialog-test.tsx new file mode 100644 index 0000000000..0b710bec46 --- /dev/null +++ b/test/unit-tests/components/views/dialogs/ManualDeviceKeyVerificationDialog-test.tsx @@ -0,0 +1,191 @@ +/* + * Copyright 2024-2025 New Vector Ltd. + * Copyright 2023 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api"; + +import { stubClient } from "../../../../test-utils"; +import { ManualDeviceKeyVerificationDialog } from "../../../../../src/components/views/dialogs/ManualDeviceKeyVerificationDialog"; +import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; + +describe("ManualDeviceKeyVerificationDialog", () => { + let mockClient: MatrixClient; + + function renderDialog(onFinished: (confirm: boolean) => void) { + return render( + + + , + ); + } + + beforeEach(() => { + mockClient = stubClient(); + mockExistingDevices(); + }); + + it("should render correctly", () => { + // When we render a dialog populated with data + const { dialog } = populateDialog("XYZ", "ABCDEFGH"); + + // Then the dialog looks as expected + expect(dialog.asFragment()).toMatchSnapshot(); + }); + + it("should call onFinished and crossSignDevice if we click Verify", async () => { + // Given a dialog populated with correct data + const { dialog, onFinished } = populateDialog("DEVICEID", "FINGERPRINT"); + + // When we click Verify session + dialog.getByRole("button", { name: "Verify session" }).click(); + + // Then crossSignDevice is called + await waitFor(async () => { + expect(onFinished).toHaveBeenCalledWith(true); + expect(mockClient.getCrypto()?.crossSignDevice).toHaveBeenCalledWith("DEVICEID"); + }); + }); + + it("should not call crossSignDevice if fingerprint is wrong", async () => { + // Given a dialog populated with incorrect fingerprint + const { dialog, onFinished } = populateDialog("DEVICEID", "WRONG_FINGERPRINT"); + + // When we click Verify session + act(() => dialog.getByRole("button", { name: "Verify session" }).click()); + + // Then crossSignDevice is not called + await waitFor(async () => { + expect(onFinished).toHaveBeenCalledWith(true); + expect(mockClient.getCrypto()?.crossSignDevice).not.toHaveBeenCalled(); + }); + + // And an error is displayed + expect( + screen.getByText( + "the supplied fingerprint 'WRONG_FINGERPRINT' does not match the device fingerprint, 'FINGERPRINT'", + { exact: false }, + ), + ).toBeVisible(); + }); + + it("should not call crossSignDevice if device is already verified", async () => { + // Given a dialog populated with a correct fingerprint for a verified device + const { dialog, onFinished } = populateDialog("VERIFIED_DEVICEID", "VERIFIED_FINGERPRINT"); + + // When we click Verify session + act(() => dialog.getByRole("button", { name: "Verify session" }).click()); + + // Then crossSignDevice is not called + await waitFor(async () => { + expect(onFinished).toHaveBeenCalledWith(true); + expect(mockClient.getCrypto()?.crossSignDevice).not.toHaveBeenCalled(); + }); + + // And an error is displayed + expect(screen.getByText("Failed to verify 'VERIFIED_DEVICEID': This device is already verified")).toBeVisible(); + }); + + it("should not call crossSignDevice if device is already verified and fingerprint is wrong", async () => { + // Given a dialog populated with an incorrect fingerprint for a verified device + const { dialog, onFinished } = populateDialog("VERIFIED_DEVICEID", "WRONG_FINGERPRINT"); + + // When we click Verify session + act(() => dialog.getByRole("button", { name: "Verify session" }).click()); + + // Then crossSignDevice is not called + await waitFor(async () => { + expect(onFinished).toHaveBeenCalledWith(true); + expect(mockClient.getCrypto()?.crossSignDevice).not.toHaveBeenCalled(); + }); + + // And an error is displayed + expect( + screen.getByText("The supplied fingerprint does not match, but the device is already verified!", { + exact: false, + }), + ).toBeVisible(); + }); + + it("should not call crossSignDevice if device is not found", async () => { + // Given a dialog populated with incorrect device ID + const { dialog, onFinished } = populateDialog("WRONG_DEVICE_ID", "FINGERPRINT"); + + // When we click Verify session + act(() => dialog.getByRole("button", { name: "Verify session" }).click()); + + // Then crossSignDevice is not called + await waitFor(async () => { + expect(onFinished).toHaveBeenCalledWith(true); + expect(mockClient.getCrypto()?.crossSignDevice).not.toHaveBeenCalled(); + }); + + // And an error is displayed + expect(screen.getByText("device 'WRONG_DEVICE_ID' was not found", { exact: false })).toBeVisible(); + }); + + it("should call onFinished but not crossSignDevice if we click Cancel", () => { + // Given a dialog populated with correct data + const { dialog, onFinished } = populateDialog("DEVICEID", "FINGERPRINT"); + + // When we click cancel + dialog.getByRole("button", { name: "Cancel" }).click(); + + // Then only onFinished is called + expect(onFinished).toHaveBeenCalledWith(false); + expect(mockClient.getCrypto()?.crossSignDevice).not.toHaveBeenCalled(); + }); + + function unverifiedDevice(): DeviceVerificationStatus { + return new DeviceVerificationStatus({}); + } + + function verifiedDevice(): DeviceVerificationStatus { + return new DeviceVerificationStatus({ + signedByOwner: true, + crossSigningVerified: true, + tofu: true, + localVerified: true, + trustCrossSignedDevices: true, + }); + } + + /** + * Set up two devices: DEVICEID, which is unverified, and VERIFIED_DEVICEID, which is verified. + */ + function mockExistingDevices() { + mockClient.getCrypto()!.getDeviceVerificationStatus = jest + .fn() + .mockImplementation(async (_userId, deviceId) => + deviceId === "DEVICEID" ? unverifiedDevice() : verifiedDevice(), + ); + + mockClient.getCrypto()!.getUserDeviceInfo = jest.fn().mockImplementation(async (userIds) => { + const userDevices = new Map(); + userDevices.set("DEVICEID", { getFingerprint: jest.fn().mockReturnValue("FINGERPRINT") }); + userDevices.set("VERIFIED_DEVICEID", { getFingerprint: jest.fn().mockReturnValue("VERIFIED_FINGERPRINT") }); + + const deviceMap = new Map(); + for (const userId of userIds) { + deviceMap.set(userId, userDevices); + } + return deviceMap; + }); + } + + function populateDialog(deviceId: string, fingerprint: string) { + const onFinished = jest.fn(); + const dialog = renderDialog(onFinished); + const deviceIdBox = dialog.getByRole("textbox", { name: "Device ID" }); + const fingerprintBox = dialog.getByRole("textbox", { name: "Fingerprint (session key)" }); + fireEvent.change(deviceIdBox, { target: { value: deviceId } }); + fireEvent.change(fingerprintBox, { target: { value: fingerprint } }); + return { dialog, onFinished }; + } +}); diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap new file mode 100644 index 0000000000..bb298b2fff --- /dev/null +++ b/test/unit-tests/components/views/dialogs/__snapshots__/ManualDeviceKeyVerificationDialog-test.tsx.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ManualDeviceKeyVerificationDialog should render correctly 1`] = ` + +
+