Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/SlashCommands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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"),
Expand Down
171 changes: 171 additions & 0 deletions src/components/views/dialogs/ManualDeviceKeyVerificationDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>
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);
}
48 changes: 48 additions & 0 deletions src/components/views/dialogs/devtools/Crypto.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -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>
Expand Down Expand Up @@ -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>
);
}
20 changes: 20 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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:"
},
Expand All @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -3129,6 +3148,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"
},
Expand Down
1 change: 1 addition & 0 deletions test/test-utils/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
15 changes: 15 additions & 0 deletions test/unit-tests/SlashCommands-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading