diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 40ce534ce0..d7c845943b 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -46,6 +46,7 @@ import { recordClientInformation, removeClientInformation } from "./utils/device import SettingsStore, { CallbackFn } from "./settings/SettingsStore"; import { UIFeature } from "./settings/UIFeature"; import { isBulkUnverifiedDeviceReminderSnoozed } from "./utils/device/snoozeBulkUnverifiedDeviceReminder"; +import { getUserDeviceIds } from "./utils/crypto/deviceInfo"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -161,12 +162,8 @@ export default class DeviceListener { */ private async getDeviceIds(): Promise> { const cli = this.client; - const crypto = cli?.getCrypto(); - if (crypto === undefined) return new Set(); - - const userId = cli!.getSafeUserId(); - const devices = await crypto.getUserDeviceInfo([userId]); - return new Set(devices.get(userId)?.keys() ?? []); + if (!cli) return new Set(); + return await getUserDeviceIds(cli, cli.getSafeUserId()); } private onWillUpdateDevices = async (users: string[], initialFetch?: boolean): Promise => { diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 524a22a21a..71e39d1326 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -70,6 +70,7 @@ import { leaveRoomBehaviour } from "./utils/leave-behaviour"; import { isLocalRoom } from "./utils/localRoom/isLocalRoom"; import { SdkContextClass } from "./contexts/SDKContext"; import { MatrixClientPeg } from "./MatrixClientPeg"; +import { getDeviceCryptoInfo } from "./utils/crypto/deviceInfo"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -1031,7 +1032,7 @@ export const Commands = [ return success( (async (): Promise => { - const device = cli.getStoredDevice(userId, deviceId); + const device = await getDeviceCryptoInfo(cli, userId, deviceId); if (!device) { throw new UserFriendlyError( "Unknown (user, session) pair: (%(userId)s, %(deviceId)s)", diff --git a/src/components/views/right_panel/VerificationPanel.tsx b/src/components/views/right_panel/VerificationPanel.tsx index 29af0672fb..d3a2b98ba5 100644 --- a/src/components/views/right_panel/VerificationPanel.tsx +++ b/src/components/views/right_panel/VerificationPanel.tsx @@ -36,6 +36,7 @@ import E2EIcon, { E2EState } from "../rooms/E2EIcon"; import Spinner from "../elements/Spinner"; import AccessibleButton from "../elements/AccessibleButton"; import VerificationShowSas from "../verification/VerificationShowSas"; +import { getDeviceCryptoInfo } from "../../../utils/crypto/deviceInfo"; interface IProps { layout: string; @@ -224,12 +225,7 @@ export default class VerificationPanel extends React.PureComponent { return; } - const devices = cli.getStoredDevicesForUser(userId); - const anyDeviceUnverified = await asyncSome(devices, async (device) => { - const { deviceId } = device; + const deviceIDs = await getUserDeviceIds(cli, userId); + const anyDeviceUnverified = await asyncSome(deviceIDs, async (deviceId) => { // For your own devices, we use the stricter check of cross-signing // verification to encourage everyone to trust their own devices via // cross-signing so that other users can then safely trust you. diff --git a/src/components/views/toasts/VerificationRequestToast.tsx b/src/components/views/toasts/VerificationRequestToast.tsx index 0e9a7781f6..37c1683d81 100644 --- a/src/components/views/toasts/VerificationRequestToast.tsx +++ b/src/components/views/toasts/VerificationRequestToast.tsx @@ -20,8 +20,8 @@ import { VerificationRequest, VerificationRequestEvent, } from "matrix-js-sdk/src/crypto-api"; -import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; import { logger } from "matrix-js-sdk/src/logger"; +import { Device } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -35,6 +35,7 @@ import { Action } from "../../../dispatcher/actions"; import VerificationRequestDialog from "../dialogs/VerificationRequestDialog"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { getDeviceCryptoInfo } from "../../../utils/crypto/deviceInfo"; interface IProps { toastKey: string; @@ -44,7 +45,7 @@ interface IProps { interface IState { /** number of seconds left in the timeout counter. Zero if there is no timeout. */ counter: number; - device?: DeviceInfo; + device?: Device; ip?: string; } @@ -74,15 +75,13 @@ export default class VerificationRequestToast extends React.PureComponent { + const crypto = client.getCrypto(); + if (!crypto) { + // no crypto support, no device. + return undefined; + } + + const deviceMap = await crypto.getUserDeviceInfo([userId], downloadUncached); + return deviceMap.get(userId)?.get(deviceId); +} + +/** + * Get the IDs of the given user's devices. + * + * Only devices with Crypto support are returned. If the MatrixClient doesn't support cryptography, an empty Set is + * returned. + * + * @param client - Matrix Client. + * @param userId - ID of the user to query. + */ + +export async function getUserDeviceIds(client: MatrixClient, userId: string): Promise> { + const crypto = client.getCrypto(); + if (!crypto) { + return new Set(); + } + + const deviceMap = await crypto.getUserDeviceInfo([userId]); + return new Set(deviceMap.get(userId)?.keys() ?? []); +} diff --git a/test/components/views/right_panel/UserInfo-test.tsx b/test/components/views/right_panel/UserInfo-test.tsx index 66e75e4ab5..28b072a5d8 100644 --- a/test/components/views/right_panel/UserInfo-test.tsx +++ b/test/components/views/right_panel/UserInfo-test.tsx @@ -160,7 +160,6 @@ beforeEach(() => { credentials: {}, setPowerLevel: jest.fn(), downloadKeys: jest.fn(), - getStoredDevicesForUser: jest.fn(), getCrypto: jest.fn().mockReturnValue(mockCrypto), getStoredCrossSigningForUser: jest.fn(), } as unknown as MatrixClient); diff --git a/test/components/views/toasts/VerificationRequestToast-test.tsx b/test/components/views/toasts/VerificationRequestToast-test.tsx index 6929e99222..f5ba06f571 100644 --- a/test/components/views/toasts/VerificationRequestToast-test.tsx +++ b/test/components/views/toasts/VerificationRequestToast-test.tsx @@ -15,18 +15,23 @@ limitations under the License. */ import React, { ComponentProps } from "react"; -import { Mocked } from "jest-mock"; +import { mocked, Mocked } from "jest-mock"; import { act, render, RenderResult } from "@testing-library/react"; import { VerificationRequest, VerificationRequestEvent, } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; import { IMyDevice, MatrixClient } from "matrix-js-sdk/src/client"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; +import { Device } from "matrix-js-sdk/src/matrix"; import VerificationRequestToast from "../../../../src/components/views/toasts/VerificationRequestToast"; -import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils"; +import { + flushPromises, + getMockClientWithEventEmitter, + mockClientMethodsCrypto, + mockClientMethodsUser, +} from "../../../test-utils"; import ToastStore from "../../../../src/stores/ToastStore"; function renderComponent( @@ -46,7 +51,7 @@ describe("VerificationRequestToast", () => { beforeEach(() => { client = getMockClientWithEventEmitter({ ...mockClientMethodsUser(), - getStoredDevice: jest.fn(), + ...mockClientMethodsCrypto(), getDevice: jest.fn(), }); }); @@ -56,9 +61,15 @@ describe("VerificationRequestToast", () => { const otherIDevice: IMyDevice = { device_id: otherDeviceId, last_seen_ip: "1.1.1.1" }; client.getDevice.mockResolvedValue(otherIDevice); - const otherDeviceInfo = new DeviceInfo(otherDeviceId); - otherDeviceInfo.unsigned = { device_display_name: "my other device" }; - client.getStoredDevice.mockReturnValue(otherDeviceInfo); + const otherDeviceInfo = new Device({ + algorithms: [], + keys: new Map(), + userId: "", + deviceId: otherDeviceId, + displayName: "my other device", + }); + const deviceMap = new Map([[client.getSafeUserId(), new Map([[otherDeviceId, otherDeviceInfo]])]]); + mocked(client.getCrypto()!.getUserDeviceInfo).mockResolvedValue(deviceMap); const request = makeMockVerificationRequest({ isSelfVerification: true, diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index 582099d683..056f18eee3 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -64,6 +64,8 @@ export class MockClientWithEventEmitter extends EventEmitter { getUserId: jest.fn().mockReturnValue(aliceId), }); * ``` + * + * See also `stubClient()` which does something similar but uses a more complete mock client. */ export const getMockClientWithEventEmitter = ( mockProperties: Partial>, @@ -158,6 +160,7 @@ export const mockClientMethodsCrypto = (): Partial< getSessionBackupPrivateKey: jest.fn(), }, getCrypto: jest.fn().mockReturnValue({ + getUserDeviceInfo: jest.fn(), getCrossSigningStatus: jest.fn().mockResolvedValue({ publicKeysOnDevice: true, privateKeysInSecretStorage: false, diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 3451f17a59..1e2fe78ca1 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -60,6 +60,8 @@ import MatrixClientBackedSettingsHandler from "../../src/settings/handlers/Matri * TODO: once the components are updated to get their MatrixClients from * the react context, we can get rid of this and just inject a test client * via the context instead. + * + * See also `getMockClientWithEventEmitter` which does something similar but different. */ export function stubClient(): MatrixClient { const client = createTestClient(); diff --git a/test/utils/crypto/deviceInfo-test.ts b/test/utils/crypto/deviceInfo-test.ts new file mode 100644 index 0000000000..6c2b1a9e38 --- /dev/null +++ b/test/utils/crypto/deviceInfo-test.ts @@ -0,0 +1,80 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Mocked, mocked } from "jest-mock"; +import { Device, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { getDeviceCryptoInfo, getUserDeviceIds } from "../../../src/utils/crypto/deviceInfo"; +import { getMockClientWithEventEmitter, mockClientMethodsCrypto } from "../../test-utils"; + +describe("getDeviceCryptoInfo()", () => { + let mockClient: Mocked; + + beforeEach(() => { + mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsCrypto() }); + }); + + it("should return undefined on clients with no crypto", async () => { + jest.spyOn(mockClient, "getCrypto").mockReturnValue(undefined); + await expect(getDeviceCryptoInfo(mockClient, "@user:id", "device_id")).resolves.toBeUndefined(); + }); + + it("should return undefined for unknown users", async () => { + mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(new Map()); + await expect(getDeviceCryptoInfo(mockClient, "@user:id", "device_id")).resolves.toBeUndefined(); + }); + + it("should return undefined for unknown devices", async () => { + mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(new Map([["@user:id", new Map()]])); + await expect(getDeviceCryptoInfo(mockClient, "@user:id", "device_id")).resolves.toBeUndefined(); + }); + + it("should return the right result for known devices", async () => { + const mockDevice = { deviceId: "device_id" } as Device; + mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue( + new Map([["@user:id", new Map([["device_id", mockDevice]])]]), + ); + await expect(getDeviceCryptoInfo(mockClient, "@user:id", "device_id")).resolves.toBe(mockDevice); + expect(mockClient.getCrypto()!.getUserDeviceInfo).toHaveBeenCalledWith(["@user:id"], undefined); + }); +}); + +describe("getUserDeviceIds", () => { + let mockClient: Mocked; + + beforeEach(() => { + mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsCrypto() }); + }); + + it("should return empty set on clients with no crypto", async () => { + jest.spyOn(mockClient, "getCrypto").mockReturnValue(undefined); + await expect(getUserDeviceIds(mockClient, "@user:id")).resolves.toEqual(new Set()); + }); + + it("should return empty set for unknown users", async () => { + mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(new Map()); + await expect(getUserDeviceIds(mockClient, "@user:id")).resolves.toEqual(new Set()); + }); + + it("should return the right result for known users", async () => { + const mockDevice = { deviceId: "device_id" } as Device; + mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue( + new Map([["@user:id", new Map([["device_id", mockDevice]])]]), + ); + await expect(getUserDeviceIds(mockClient, "@user:id")).resolves.toEqual(new Set(["device_id"])); + expect(mockClient.getCrypto()!.getUserDeviceInfo).toHaveBeenCalledWith(["@user:id"]); + }); +});