diff --git a/spec/integ/crypto.spec.ts b/spec/integ/crypto.spec.ts index ec37360e0f8..4e70bdd6e9f 100644 --- a/spec/integ/crypto.spec.ts +++ b/spec/integ/crypto.spec.ts @@ -48,6 +48,8 @@ import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { E2EKeyReceiver, IE2EKeyReceiver } from "../test-utils/E2EKeyReceiver"; import { ISyncResponder, SyncResponder } from "../test-utils/SyncResponder"; import { escapeRegExp } from "../../src/utils"; +import { downloadDeviceToJsDevice } from "../../src/rust-crypto/device-converter"; +import { flushPromises } from "../test-utils/flushPromises"; const ROOM_ID = "!room:id"; @@ -1997,4 +1999,178 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(res.fallbackKeysCount).toBeGreaterThan(0); }); }); + + describe("getUserDeviceInfo", () => { + afterEach(() => { + jest.useRealTimers(); + }); + + // From https://spec.matrix.org/v1.6/client-server-api/#post_matrixclientv3keysquery + // Using extracted response from matrix.org, it needs to have real keys etc to pass old crypto verification + const queryResponseBody = { + device_keys: { + "@testing_florian1:matrix.org": { + EBMMPAFOPU: { + algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], + device_id: "EBMMPAFOPU", + keys: { + "curve25519:EBMMPAFOPU": "HyhQD4mXwNViqns0noABW9NxHbCAOkriQ4QKGGndk3w", + "ed25519:EBMMPAFOPU": "xSQaxrFOTXH+7Zjo+iwb445hlNPFjnx1O3KaV3Am55k", + }, + signatures: { + "@testing_florian1:matrix.org": { + "ed25519:EBMMPAFOPU": + "XFJVq9HmO5lfJN7l6muaUt887aUHg0/poR3p9XHGXBrLUqzfG7Qllq7jjtUjtcTc5CMD7/mpsXfuC2eV+X1uAw", + }, + }, + user_id: "@testing_florian1:matrix.org", + unsigned: { + device_display_name: "display name", + }, + }, + }, + }, + failures: {}, + master_keys: { + "@testing_florian1:matrix.org": { + user_id: "@testing_florian1:matrix.org", + usage: ["master"], + keys: { + "ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0": + "O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0", + }, + signatures: { + "@testing_florian1:matrix.org": { + "ed25519:UKAQMJSJZC": + "q4GuzzuhZfTpwrlqnJ9+AEUtEfEQ0um1PO3puwp/+vidzFicw0xEPjedpJoASYQIJ8XJAAWX8Q235EKeCzEXCA", + }, + }, + }, + }, + self_signing_keys: { + "@testing_florian1:matrix.org": { + user_id: "@testing_florian1:matrix.org", + usage: ["self_signing"], + keys: { + "ed25519:YYWIHBCuKGEy9CXiVrfBVR0N1I60JtiJTNCWjiLAFzo": + "YYWIHBCuKGEy9CXiVrfBVR0N1I60JtiJTNCWjiLAFzo", + }, + signatures: { + "@testing_florian1:matrix.org": { + "ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0": + "yckmxgQ3JA5bb205/RunJipnpZ37ycGNf4OFzDwAad++chd71aGHqAMQ1f6D2GVfl8XdHmiRaohZf4mGnDL0AA", + }, + }, + }, + }, + user_signing_keys: { + "@testing_florian1:matrix.org": { + user_id: "@testing_florian1:matrix.org", + usage: ["user_signing"], + keys: { + "ed25519:Maa77okgZxnABGqaiChEUnV4rVsAI61WXWeL5TSEUhs": + "Maa77okgZxnABGqaiChEUnV4rVsAI61WXWeL5TSEUhs", + }, + signatures: { + "@testing_florian1:matrix.org": { + "ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0": + "WxNNXb13yCrBwXUQzdDWDvWSQ/qWCfwpvssOudlAgbtMzRESMbCTDkeA8sS1awaAtUmu7FrPtDb5LYfK/EE2CQ", + }, + }, + }, + }, + }; + + function awaitKeyQueryRequest(): Promise> { + return new Promise((resolve) => { + const listener = (url: string, options: RequestInit) => { + const content = JSON.parse(options.body as string); + // Resolve with request payload + resolve(content.device_keys); + + // Return response of `/keys/query` + return queryResponseBody; + }; + + for (const path of ["/_matrix/client/r0/keys/query", "/_matrix/client/v3/keys/query"]) { + fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener); + } + }); + } + + it("Download uncached keys for known user", async () => { + const queryPromise = awaitKeyQueryRequest(); + + const user = "@testing_florian1:matrix.org"; + const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user], true); + + // Wait for `/keys/query` to be called + const deviceKeysPayload = await queryPromise; + + expect(deviceKeysPayload).toStrictEqual({ [user]: [] }); + expect(devicesInfo.get(user)?.size).toBe(1); + + // Convert the expected device to IDevice and check + expect(devicesInfo.get(user)?.get("EBMMPAFOPU")).toStrictEqual( + downloadDeviceToJsDevice(queryResponseBody.device_keys[user]?.EBMMPAFOPU), + ); + }); + + it("Download uncached keys for unknown user", async () => { + const queryPromise = awaitKeyQueryRequest(); + + const user = "@bob:xyz"; + const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user], true); + + // Wait for `/keys/query` to be called + const deviceKeysPayload = await queryPromise; + + expect(deviceKeysPayload).toStrictEqual({ [user]: [] }); + // The old crypto has an empty map for `@bob:xyz` + // The new crypto does not have the `@bob:xyz` entry in `devicesInfo` + expect(devicesInfo.get(user)?.size).toBeFalsy(); + }); + + it("Get devices from tacked users", async () => { + jest.useFakeTimers(); + + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); + const queryPromise = awaitKeyQueryRequest(); + + const user = "@testing_florian1:matrix.org"; + // `user` will be added to the room + syncResponder.sendOrQueueSyncResponse(getSyncResponse([user, "@bob:xyz"])); + + // Advance local date to 2 minutes + // The old crypto only runs the upload every 60 seconds + jest.setSystemTime(Date.now() + 2 * 60 * 1000); + + await syncPromise(aliceClient); + + // Old crypto: for alice: run over the `sleep(5)` in `doQueuedQueries` of `DeviceList` + jest.runAllTimers(); + // Old crypto: for alice: run the `processQueryResponseForUser` in `doQueuedQueries` of `DeviceList` + await flushPromises(); + + // Wait for alice to query `user` keys + await queryPromise; + + // Old crypto: for `user`: run over the `sleep(5)` in `doQueuedQueries` of `DeviceList` + jest.runAllTimers(); + // Old crypto: for `user`: run the `processQueryResponseForUser` in `doQueuedQueries` of `DeviceList` + // It will add `@testing_florian1:matrix.org` devices to the DeviceList + await flushPromises(); + + const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user]); + + // We should only have the `user` in it + expect(devicesInfo.size).toBe(1); + // We are expecting only the EBMMPAFOPU device + expect(devicesInfo.get(user)!.size).toBe(1); + expect(devicesInfo.get(user)!.get("EBMMPAFOPU")).toEqual( + downloadDeviceToJsDevice(queryResponseBody.device_keys[user]["EBMMPAFOPU"]), + ); + }); + }); }); diff --git a/spec/unit/crypto/device-converter.spec.ts b/spec/unit/crypto/device-converter.spec.ts new file mode 100644 index 00000000000..d54f8f4e7ca --- /dev/null +++ b/spec/unit/crypto/device-converter.spec.ts @@ -0,0 +1,58 @@ +/* +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 { DeviceInfo } from "../../../src/crypto/deviceinfo"; +import { DeviceVerification } from "../../../src"; +import { deviceInfoToDevice } from "../../../src/crypto/device-converter"; + +describe("device-converter", () => { + const userId = "@alice:example.com"; + const deviceId = "xcvf"; + + // All parameters for DeviceInfo initialization + const keys = { + [`ed25519:${deviceId}`]: "key1", + [`curve25519:${deviceId}`]: "key2", + }; + const algorithms = ["algo1", "algo2"]; + const verified = DeviceVerification.Verified; + const signatures = { [userId]: { [deviceId]: "sign1" } }; + const displayName = "display name"; + const unsigned = { + device_display_name: displayName, + }; + + describe("deviceInfoToDevice", () => { + it("should convert a DeviceInfo to a Device", () => { + const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified, signatures, unsigned }, deviceId); + const device = deviceInfoToDevice(deviceInfo, userId); + + expect(device.deviceId).toBe(deviceId); + expect(device.userId).toBe(userId); + expect(device.verified).toBe(verified); + expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]); + expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]); + expect(device.displayName).toBe(displayName); + }); + + it("should add empty signatures", () => { + const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified }, deviceId); + const device = deviceInfoToDevice(deviceInfo, userId); + + expect(device.signatures.size).toBe(0); + }); + }); +}); diff --git a/spec/unit/rust-crypto/device-converter.spec.ts b/spec/unit/rust-crypto/device-converter.spec.ts new file mode 100644 index 00000000000..5fffb53229d --- /dev/null +++ b/spec/unit/rust-crypto/device-converter.spec.ts @@ -0,0 +1,68 @@ +/* +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 { DeviceKeys, DeviceVerification } from "../../../src"; +import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter"; + +describe("device-converter", () => { + const userId = "@alice:example.com"; + const deviceId = "xcvf"; + + // All parameters for QueryDevice initialization + const keys = { + [`ed25519:${deviceId}`]: "key1", + [`curve25519:${deviceId}`]: "key2", + }; + const algorithms = ["algo1", "algo2"]; + const signatures = { [userId]: { [deviceId]: "sign1" } }; + const displayName = "display name"; + const unsigned = { + device_display_name: displayName, + }; + + describe("downloadDeviceToJsDevice", () => { + it("should convert a QueryDevice to a Device", () => { + const queryDevice: DeviceKeys[keyof DeviceKeys] = { + keys, + algorithms, + device_id: deviceId, + user_id: userId, + signatures, + unsigned, + }; + const device = downloadDeviceToJsDevice(queryDevice); + + expect(device.deviceId).toBe(deviceId); + expect(device.userId).toBe(userId); + expect(device.verified).toBe(DeviceVerification.Unverified); + expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]); + expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]); + expect(device.displayName).toBe(displayName); + }); + + it("should add empty signatures", () => { + const queryDevice: DeviceKeys[keyof DeviceKeys] = { + keys, + algorithms, + device_id: deviceId, + user_id: userId, + }; + const device = downloadDeviceToJsDevice(queryDevice); + + expect(device.signatures.size).toBe(0); + }); + }); +}); diff --git a/src/crypto-api.ts b/src/crypto-api.ts index ca849a88392..e88111d5025 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -16,6 +16,7 @@ limitations under the License. import type { IMegolmSessionData } from "./@types/crypto"; import { Room } from "./models/room"; +import { DeviceMap } from "./models/device"; /** * Public interface to the cryptography parts of the js-sdk @@ -73,6 +74,23 @@ export interface CryptoApi { */ exportRoomKeys(): Promise; + /** + * Get the device information for the given list of users. + * + * For any users whose device lists are cached (due to sharing an encrypted room with the user), the + * cached device data is returned. + * + * If there are uncached users, and the `downloadUncached` parameter is set to `true`, + * a `/keys/query` request is made to the server to retrieve these devices. + * + * @param userIds - The users to fetch. + * @param downloadUncached - If true, download the device list for users whose device list we are not + * currently tracking. Defaults to false, in which case such users will not appear at all in the result map. + * + * @returns A map `{@link DeviceMap}`. + */ + getUserDeviceInfo(userIds: string[], downloadUncached?: boolean): Promise; + /** * Set whether to trust other user's signatures of their devices. * diff --git a/src/crypto/device-converter.ts b/src/crypto/device-converter.ts new file mode 100644 index 00000000000..cf32ef6141e --- /dev/null +++ b/src/crypto/device-converter.ts @@ -0,0 +1,45 @@ +/* +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 { Device } from "../models/device"; +import { DeviceInfo } from "./deviceinfo"; + +/** + * Convert a {@link DeviceInfo} to a {@link Device}. + * @param deviceInfo - deviceInfo to convert + * @param userId - id of the user that owns the device. + */ +export function deviceInfoToDevice(deviceInfo: DeviceInfo, userId: string): Device { + const keys = new Map(Object.entries(deviceInfo.keys)); + const displayName = deviceInfo.getDisplayName() || undefined; + + const signatures = new Map>(); + if (deviceInfo.signatures) { + for (const userId in deviceInfo.signatures) { + signatures.set(userId, new Map(Object.entries(deviceInfo.signatures[userId]))); + } + } + + return new Device({ + deviceId: deviceInfo.deviceId, + userId: userId, + keys, + algorithms: deviceInfo.algorithms, + verified: deviceInfo.verified, + signatures, + displayName, + }); +} diff --git a/src/crypto/deviceinfo.ts b/src/crypto/deviceinfo.ts index b4bb4fd2e80..76113e3ad76 100644 --- a/src/crypto/deviceinfo.ts +++ b/src/crypto/deviceinfo.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { ISignatures } from "../@types/signed"; +import { DeviceVerification } from "../models/device"; export interface IDevice { keys: Record; @@ -25,12 +26,6 @@ export interface IDevice { signatures?: ISignatures; } -enum DeviceVerification { - Blocked = -1, - Unverified = 0, - Verified = 1, -} - /** * Information about a user's device */ diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 02df829f6ed..9554e617359 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -89,6 +89,8 @@ import { } from "../secret-storage"; import { ISecretRequest } from "./SecretSharing"; import { DeviceVerificationStatus } from "../crypto-api"; +import { Device, DeviceMap } from "../models/device"; +import { deviceInfoToDevice } from "./device-converter"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -2063,6 +2065,54 @@ export class Crypto extends TypedEventEmitter { + const deviceMapByUserId = new Map>(); + // Keep the users without device to download theirs keys + const usersWithoutDeviceInfo: string[] = []; + + for (const userId of userIds) { + const deviceInfos = await this.getStoredDevicesForUser(userId); + // If there are device infos for a userId, we transform it into a map + // Else, the keys will be downloaded after + if (deviceInfos) { + const deviceMap = new Map( + // Convert DeviceInfo to Device + deviceInfos.map((deviceInfo) => [deviceInfo.deviceId, deviceInfoToDevice(deviceInfo, userId)]), + ); + deviceMapByUserId.set(userId, deviceMap); + } else { + usersWithoutDeviceInfo.push(userId); + } + } + + // Download device info for users without device infos + if (downloadUncached && usersWithoutDeviceInfo.length > 0) { + const newDeviceInfoMap = await this.downloadKeys(usersWithoutDeviceInfo); + + newDeviceInfoMap.forEach((deviceInfoMap, userId) => { + const deviceMap = new Map(); + // Convert DeviceInfo to Device + deviceInfoMap.forEach((deviceInfo, deviceId) => + deviceMap.set(deviceId, deviceInfoToDevice(deviceInfo, userId)), + ); + + // Put the new device infos into the returned map + deviceMapByUserId.set(userId, deviceMap); + }); + } + + return deviceMapByUserId; + } + /** * Get the stored keys for a single device * diff --git a/src/matrix.ts b/src/matrix.ts index 2fb71ab6402..a60376df6a4 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -38,6 +38,7 @@ export * from "./models/poll"; export * from "./models/room-member"; export * from "./models/room-state"; export * from "./models/user"; +export * from "./models/device"; export * from "./scheduler"; export * from "./filter"; export * from "./timeline-window"; diff --git a/src/models/device.ts b/src/models/device.ts new file mode 100644 index 00000000000..6f63e2e0634 --- /dev/null +++ b/src/models/device.ts @@ -0,0 +1,81 @@ +/* +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. +*/ + +/** State of the verification of the device. */ +export enum DeviceVerification { + Blocked = -1, + Unverified = 0, + Verified = 1, +} + +/** A map from user ID to device ID to Device */ +export type DeviceMap = Map>; + +type DeviceParameters = Pick & Partial; + +/** + * Information on a user's device, as returned by {@link CryptoApi.getUserDeviceInfo}. + */ +export class Device { + /** id of the device */ + public readonly deviceId: string; + + /** id of the user that owns the device */ + public readonly userId: string; + + /** list of algorithms supported by this device */ + public readonly algorithms: string[]; + + /** a map from `: -> ` */ + public readonly keys: Map; + + /** whether the device has been verified/blocked by the user */ + public readonly verified: DeviceVerification; + + /** a map `>` */ + public readonly signatures: Map>; + + /** display name of the device */ + public readonly displayName?: string; + + public constructor(opts: DeviceParameters) { + this.deviceId = opts.deviceId; + this.userId = opts.userId; + this.algorithms = opts.algorithms; + this.keys = opts.keys; + this.verified = opts.verified || DeviceVerification.Unverified; + this.signatures = opts.signatures || new Map(); + this.displayName = opts.displayName; + } + + /** + * Get the fingerprint for this device (ie, the Ed25519 key) + * + * @returns base64-encoded fingerprint of this device + */ + public getFingerprint(): string | undefined { + return this.keys.get(`ed25519:${this.deviceId}`); + } + + /** + * Get the identity key for this device (ie, the Curve25519 key) + * + * @returns base64-encoded identity key of this device + */ + public getIdentityKey(): string | undefined { + return this.keys.get(`curve25519:${this.deviceId}`); + } +} diff --git a/src/rust-crypto/device-converter.ts b/src/rust-crypto/device-converter.ts new file mode 100644 index 00000000000..54dc838fe1f --- /dev/null +++ b/src/rust-crypto/device-converter.ts @@ -0,0 +1,121 @@ +/* +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 * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js"; + +import { Device, DeviceVerification } from "../models/device"; +import { DeviceKeys } from "../client"; + +/** + * Convert a {@link RustSdkCryptoJs.Device} to a {@link Device} + * @param device - Rust Sdk device + * @param userId - owner of the device + */ +export function rustDeviceToJsDevice(device: RustSdkCryptoJs.Device, userId: RustSdkCryptoJs.UserId): Device { + // Copy rust device keys to Device.keys + const keys = new Map(); + for (const [keyId, key] of device.keys.entries()) { + keys.set(keyId.toString(), key.toBase64()); + } + + // Compute verified from device state + let verified: DeviceVerification = DeviceVerification.Unverified; + if (device.isBlacklisted()) { + verified = DeviceVerification.Blocked; + } else if (device.isVerified()) { + verified = DeviceVerification.Verified; + } + + // Convert rust signatures to Device.signatures + const signatures = new Map>(); + const mayBeSignatureMap: Map | undefined = device.signatures.get(userId); + if (mayBeSignatureMap) { + const convertedSignatures = new Map(); + // Convert maybeSignatures map to a Map + for (const [key, value] of mayBeSignatureMap.entries()) { + if (value.isValid() && value.signature) { + convertedSignatures.set(key, value.signature.toBase64()); + } + } + + signatures.set(userId.toString(), convertedSignatures); + } + + // Convert rust algorithms to algorithms + const rustAlgorithms: RustSdkCryptoJs.EncryptionAlgorithm[] = device.algorithms; + // Use set to ensure that algorithms are not duplicated + const algorithms = new Set(); + rustAlgorithms.forEach((algorithm) => { + switch (algorithm) { + case RustSdkCryptoJs.EncryptionAlgorithm.MegolmV1AesSha2: + algorithms.add("m.megolm.v1.aes-sha2"); + break; + case RustSdkCryptoJs.EncryptionAlgorithm.OlmV1Curve25519AesSha2: + default: + algorithms.add("m.olm.v1.curve25519-aes-sha2"); + break; + } + }); + + return new Device({ + deviceId: device.deviceId.toString(), + userId: userId.toString(), + keys, + algorithms: Array.from(algorithms), + verified, + signatures, + displayName: device.displayName, + }); +} + +/** + * Convert {@link DeviceKeys} from `/keys/query` request to a `Map` + * @param deviceKeys - Device keys object to convert + */ +export function deviceKeysToDeviceMap(deviceKeys: DeviceKeys): Map { + return new Map( + Object.entries(deviceKeys).map(([deviceId, device]) => [deviceId, downloadDeviceToJsDevice(device)]), + ); +} + +// Device from `/keys/query` request +type QueryDevice = DeviceKeys[keyof DeviceKeys]; + +/** + * Convert `/keys/query` {@link QueryDevice} device to {@link Device} + * @param device - Device from `/keys/query` request + */ +export function downloadDeviceToJsDevice(device: QueryDevice): Device { + const keys = new Map(Object.entries(device.keys)); + const displayName = device.unsigned?.device_display_name; + + const signatures = new Map>(); + if (device.signatures) { + for (const userId in device.signatures) { + signatures.set(userId, new Map(Object.entries(device.signatures[userId]))); + } + } + + return new Device({ + deviceId: device.device_id, + userId: device.user_id, + keys, + algorithms: device.algorithms, + verified: DeviceVerification.Unverified, + signatures, + displayName, + }); +} diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 2ae3b7b5fce..f8032616ea7 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -24,13 +24,16 @@ import { Room } from "../models/room"; import { RoomMember } from "../models/room-member"; import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend"; import { logger } from "../logger"; -import { IHttpOpts, MatrixHttpApi } from "../http-api"; +import { IHttpOpts, MatrixHttpApi, Method } from "../http-api"; import { UserTrustLevel } from "../crypto/CrossSigning"; import { RoomEncryptor } from "./RoomEncryptor"; import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor"; import { KeyClaimManager } from "./KeyClaimManager"; import { MapWithDefault } from "../utils"; import { DeviceVerificationStatus } from "../crypto-api"; +import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter"; +import { IDownloadKeyResult, IQueryKeysRequest } from "../client"; +import { Device, DeviceMap } from "../models/device"; /** * An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto. @@ -54,7 +57,7 @@ export class RustCrypto implements CryptoBackend { public constructor( private readonly olmMachine: RustSdkCryptoJs.OlmMachine, - http: MatrixHttpApi, + private readonly http: MatrixHttpApi, _userId: string, _deviceId: string, ) { @@ -162,6 +165,79 @@ export class RustCrypto implements CryptoBackend { return []; } + /** + * Get the device information for the given list of users. + * + * @param userIds - The users to fetch. + * @param downloadUncached - If true, download the device list for users whose device list we are not + * currently tracking. Defaults to false, in which case such users will not appear at all in the result map. + * + * @returns A map `{@link DeviceMap}`. + */ + public async getUserDeviceInfo(userIds: string[], downloadUncached = false): Promise { + const deviceMapByUserId = new Map>(); + const rustTrackedUsers: Set = await this.olmMachine.trackedUsers(); + + // Convert RustSdkCryptoJs.UserId to a `Set` + const trackedUsers = new Set(); + rustTrackedUsers.forEach((rustUserId) => trackedUsers.add(rustUserId.toString())); + + // Keep untracked user to download their keys after + const untrackedUsers: Set = new Set(); + + for (const userId of userIds) { + // if this is a tracked user, we can just fetch the device list from the rust-sdk + // (NB: this is probably ok even if we race with a leave event such that we stop tracking the user's + // devices: the rust-sdk will return the last-known device list, which will be good enough.) + if (trackedUsers.has(userId)) { + deviceMapByUserId.set(userId, await this.getUserDevices(userId)); + } else { + untrackedUsers.add(userId); + } + } + + // for any users whose device lists we are not tracking, fall back to downloading the device list + // over HTTP. + if (downloadUncached && untrackedUsers.size >= 1) { + const queryResult = await this.downloadDeviceList(untrackedUsers); + Object.entries(queryResult.device_keys).forEach(([userId, deviceKeys]) => + deviceMapByUserId.set(userId, deviceKeysToDeviceMap(deviceKeys)), + ); + } + + return deviceMapByUserId; + } + + /** + * Get the device list for the given user from the olm machine + * @param userId - Rust SDK UserId + */ + private async getUserDevices(userId: string): Promise> { + const rustUserId = new RustSdkCryptoJs.UserId(userId); + const devices: RustSdkCryptoJs.UserDevices = await this.olmMachine.getUserDevices(rustUserId); + return new Map( + devices + .devices() + .map((device: RustSdkCryptoJs.Device) => [ + device.deviceId.toString(), + rustDeviceToJsDevice(device, rustUserId), + ]), + ); + } + + /** + * Download the given user keys by calling `/keys/query` request + * @param untrackedUsers - download keys of these users + */ + private async downloadDeviceList(untrackedUsers: Set): Promise { + const queryBody: IQueryKeysRequest = { device_keys: {} }; + untrackedUsers.forEach((user) => (queryBody.device_keys[user] = [])); + + return await this.http.authedRequest(Method.Post, "/_matrix/client/v3/keys/query", undefined, queryBody, { + prefix: "", + }); + } + /** * Implementation of {@link CryptoApi#getTrustCrossSignedDevices}. */