1
0
mirror of https://github.com/element-hq/element-web.git synced 2025-08-08 03:42:14 +03:00

Provide a devtool for manually verifying other devices (#30094)

Also allows doing the same thing via a slash command.
This commit is contained in:
Andy Balaam
2025-06-10 11:55:05 +01:00
committed by GitHub
parent a333856c50
commit e7d940160a
8 changed files with 572 additions and 0 deletions

View File

@@ -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: "<device-id> <device-fingerprint>",
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"),

View File

@@ -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<Props>): 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<HTMLInputElement>) => {
setDeviceId(e.target.value);
}, []);
const onFingerprintChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setFingerprint(e.target.value);
}, []);
const body = (
<div>
<p>{_t("encryption|verification|manual|text")}</p>
<div className="mx_DeviceVerifyDialog_cryptoSection">
<Field
className="mx_TextInputDialog_input"
type="text"
label={_t("encryption|verification|manual|device_id")}
value={deviceId}
onChange={onDeviceIdChange}
/>
<Field
className="mx_TextInputDialog_input"
type="text"
label={_t("encryption|verification|manual|fingerprint")}
value={fingerprint}
onChange={onFingerprintChange}
/>
</div>
</div>
);
return (
<QuestionDialog
title={_t("settings|sessions|verify_session")}
description={body}
button={_t("settings|sessions|verify_session")}
onFinished={onDialogFinished}
/>
);
}
/**
* 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<void> {
try {
await doManuallyVerifyDevice(client, deviceId, fingerprint);
// Tell the user we verified everything
Modal.createDialog(InfoDialog, {
title: _t("encryption|verification|manual|success_title"),
description: (
<div>
<p>{_t("encryption|verification|manual|success_description", { deviceId })}</p>
</div>
),
});
} 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: (
<div>
<p>{_t("encryption|verification|manual|failure_description", { deviceId, error })}</p>
</div>
),
});
}
}
async function doManuallyVerifyDevice(client: MatrixClient, deviceId: string, fingerprint: string): Promise<void> {
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);
}

View File

@@ -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 {
<>
<KeyStorage />
<CrossSigning />
<Session />
<button
type="button"
onClick={() => {
Modal.createDialog(ManualDeviceKeyVerificationDialog);
}}
>
{_t("devtools|manual_device_verification")}
</button>
</>
) : (
<span>{_t("devtools|crypto|crypto_not_available")}</span>
@@ -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 <InlineSpinner aria-label={_t("common|loading")} />;
}
return (
<table aria-label={_t("devtools|crypto|session")}>
<thead>{_t("devtools|crypto|session")}</thead>
<tbody>
<tr>
<th scope="row">{_t("devtools|crypto|device_id")}</th>
<td>{sessionData.deviceId}</td>
</tr>
<tr>
<th scope="row">{_t("devtools|crypto|session_fingerprint")}</th>
<td>{sessionData.fingerprint}</td>
</tr>
</tbody>
</table>
);
}

View File

@@ -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 <strong>%(notificationState)s</strong>",
"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"
},

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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(
<MatrixClientContext.Provider value={mockClient}>
<ManualDeviceKeyVerificationDialog onFinished={onFinished} />
</MatrixClientContext.Provider>,
);
}
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 };
}
});

View File

@@ -0,0 +1,107 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ManualDeviceKeyVerificationDialog should render correctly 1`] = `
<DocumentFragment>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-describedby="mx_Dialog_content"
aria-labelledby="mx_BaseDialog_title"
class="mx_QuestionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Verify session
</h1>
</div>
<div
class="mx_Dialog_content"
id="mx_Dialog_content"
>
<div>
<p>
Supply the ID and fingerprint of one of your own devices to verify it.
</p>
<div
class="mx_DeviceVerifyDialog_cryptoSection"
>
<div
class="mx_Field mx_Field_input mx_TextInputDialog_input"
>
<input
id="mx_Field_1"
label="Device ID"
placeholder="Device ID"
type="text"
value="XYZ"
/>
<label
for="mx_Field_1"
>
Device ID
</label>
</div>
<div
class="mx_Field mx_Field_input mx_TextInputDialog_input"
>
<input
id="mx_Field_2"
label="Fingerprint (session key)"
placeholder="Fingerprint (session key)"
type="text"
value="ABCDEFGH"
/>
<label
for="mx_Field_2"
>
Fingerprint (session key)
</label>
</div>
</div>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-testid="dialog-cancel-button"
type="button"
>
Cancel
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
type="button"
>
Verify session
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</DocumentFragment>
`;