diff --git a/spec/test-utils/E2EKeyReceiver.ts b/spec/test-utils/E2EKeyReceiver.ts index 24af06d6e3..187923d526 100644 --- a/spec/test-utils/E2EKeyReceiver.ts +++ b/spec/test-utils/E2EKeyReceiver.ts @@ -19,6 +19,7 @@ import { Debugger } from "debug"; import fetchMock from "fetch-mock-jest"; import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto"; +import { deepCompare, sleep } from "../../src/utils"; /** Interface implemented by classes that intercept `/keys/upload` requests from test clients to catch the uploaded keys * @@ -82,9 +83,9 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { private async onKeyUploadRequest(onOnTimeKeysUploaded: () => void, options: RequestInit): Promise { const content = JSON.parse(options.body as string); - // device keys may only be uploaded once + // device keys should not change after they've been uploaded if (content.device_keys && Object.keys(content.device_keys).length > 0) { - if (this.deviceKeys) { + if (this.deviceKeys && !deepCompare(this.deviceKeys.keys, content.device_keys.keys)) { throw new Error("Application attempted to upload E2E device keys multiple times"); } this.debug(`received device keys`); @@ -98,7 +99,7 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { // otherwise the client ends up tight-looping one-time-key-uploads and filling the logs with junk. if (Object.keys(this.oneTimeKeys).length > 0) { this.debug(`received second batch of one-time keys: blocking response`); - await new Promise(() => {}); + await sleep(50); } this.debug(`received ${Object.keys(content.one_time_keys).length} one-time keys`); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 1b56dfccbc..759b75544a 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -64,6 +64,8 @@ import { VerificationRequest, } from "../../../src/crypto-api"; import * as testData from "../../test-utils/test-data"; +import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; +import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; import { defer } from "../../../src/utils"; import { logger } from "../../../src/logger"; import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager"; @@ -1722,6 +1724,168 @@ describe("RustCrypto", () => { }); expect(await rustCrypto.isDehydrationSupported()).toBe(true); }); + + it("should handle dehydration start options", async () => { + const secretStorageCallbacks = { + getSecretStorageKey: async (keys: any, name: string) => { + return [[...Object.keys(keys.keys)][0], new Uint8Array(32)]; + }, + } as SecretStorageCallbacks; + const secretStorage = new ServerSideSecretStorageImpl(new DummyAccountDataClient(), secretStorageCallbacks); + + const e2eKeyReceiver = new E2EKeyReceiver("http://server"); + const e2eKeyResponder = new E2EKeyResponder("http://server"); + e2eKeyResponder.addKeyReceiver(TEST_USER, e2eKeyReceiver); + fetchMock.get("path:/_matrix/client/v3/room_keys/version", { + status: 404, + body: { + errcode: "M_NOT_FOUND", + error: "Not found", + }, + }); + fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", { + status: 200, + body: {}, + }); + fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", { + status: 200, + body: {}, + }); + const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage); + // we need to process a sync so that the OlmMachine will upload keys + await rustCrypto.preprocessToDeviceMessages([]); + await rustCrypto.onSyncCompleted({}); + + // dehydration requires secret storage and cross signing + async function createSecretStorageKey() { + return { + keyInfo: {} as AddSecretStorageKeyOpts, + privateKey: new Uint8Array(32), + }; + } + await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); + await rustCrypto.bootstrapSecretStorage({ + createSecretStorageKey, + setupNewSecretStorage: true, + setupNewKeyBackup: false, + }); + + // set up mocks needed for device dehydration + let dehydratedDeviceInfo: Record | undefined; + const getDehydratedDeviceMock = jest.fn(() => { + if (dehydratedDeviceInfo) { + return { + status: 200, + body: dehydratedDeviceInfo, + }; + } else { + return { + status: 404, + body: { + errcode: "M_NOT_FOUND", + error: "Not found", + }, + }; + } + }); + fetchMock.get( + "path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", + getDehydratedDeviceMock, + ); + const putDehydratedDeviceMock = jest.fn((path, opts) => { + const content = JSON.parse(opts.body as string); + dehydratedDeviceInfo = { + device_id: content.device_id, + device_data: content.device_data, + }; + return { + status: 200, + body: { + device_id: content.device_id, + }, + }; + }); + fetchMock.put( + "path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", + putDehydratedDeviceMock, + ); + fetchMock.post(/_matrix\/client\/unstable\/org.matrix.msc3814.v1\/dehydrated_device\/.*\/events/, { + status: 200, + body: { + events: [], + next_batch: "foo", + }, + }); + + fetchMock.mockClear(); + + // Start with testing `onlyIfKeyCached: true`. Since this is our + // first run, there is no key cached, so should do nothing. i.e. it + // should not make any HTTP requests and should not create a new key. + await rustCrypto.startDehydration({ onlyIfKeyCached: true }); + expect(getDehydratedDeviceMock).not.toHaveBeenCalled(); + expect(putDehydratedDeviceMock).not.toHaveBeenCalled(); + expect(await secretStorage.get("org.matrix.msc3814")).toBeFalsy(); + + getDehydratedDeviceMock.mockClear(); + putDehydratedDeviceMock.mockClear(); + + // With default options and no key is available, should create new + // key and create new dehydrated device (so it will `PUT + // /dehydrated_device`). It won't try to rehydrate the device, + // since it has no dehydration key yet. + await rustCrypto.startDehydration(); + expect(putDehydratedDeviceMock).toHaveBeenCalled(); + const origDehydrationKey = await secretStorage.get("org.matrix.msc3814"); + expect(origDehydrationKey).toBeTruthy(); + + getDehydratedDeviceMock.mockClear(); + putDehydratedDeviceMock.mockClear(); + + // Start again with default options, but this time it will try to + // rehydrate a device (so it will `GET /dehydrated_device`). It + // should keep the same dehydration key. + await rustCrypto.startDehydration(); + expect(getDehydratedDeviceMock).toHaveBeenCalled(); + expect(putDehydratedDeviceMock).toHaveBeenCalled(); + expect(await secretStorage.get("org.matrix.msc3814")).toEqual(origDehydrationKey); + + getDehydratedDeviceMock.mockClear(); + putDehydratedDeviceMock.mockClear(); + + // Again try "onlyIfKeyCached: true", but this time we already have + // a cached key, so it will rehydrate the device and create a new + // dehydrated device. It should keep the same dehydration key. + await rustCrypto.startDehydration({ onlyIfKeyCached: true }); + expect(getDehydratedDeviceMock).toHaveBeenCalled(); + expect(putDehydratedDeviceMock).toHaveBeenCalled(); + expect(await secretStorage.get("org.matrix.msc3814")).toEqual(origDehydrationKey); + + getDehydratedDeviceMock.mockClear(); + putDehydratedDeviceMock.mockClear(); + + // With "rehydrate: false", it should not try to rehydrate, but + // still create a new dehydrated device. It should keep the same + // dehydration key. + await rustCrypto.startDehydration({ rehydrate: false }); + expect(getDehydratedDeviceMock).not.toHaveBeenCalled(); + expect(putDehydratedDeviceMock).toHaveBeenCalled(); + expect(await secretStorage.get("org.matrix.msc3814")).toEqual(origDehydrationKey); + + getDehydratedDeviceMock.mockClear(); + putDehydratedDeviceMock.mockClear(); + + // With "createNewKey: true", it should create a new dehydration key + await rustCrypto.startDehydration({ createNewKey: true }); + expect(getDehydratedDeviceMock).toHaveBeenCalled(); + expect(putDehydratedDeviceMock).toHaveBeenCalled(); + expect(await secretStorage.get("org.matrix.msc3814")).not.toEqual(origDehydrationKey); + + getDehydratedDeviceMock.mockClear(); + putDehydratedDeviceMock.mockClear(); + + rustCrypto.stop(); + }); }); describe("import & export secrets bundle", () => { @@ -1950,6 +2114,8 @@ class DummyAccountDataClient } } +/** emulate + /** Pad a string to 43 characters long */ function pad43(x: string): string { return x + ".".repeat(43 - x.length); diff --git a/src/crypto-api/index.ts b/src/crypto-api/index.ts index 7a26f61da1..fddb102b00 100644 --- a/src/crypto-api/index.ts +++ b/src/crypto-api/index.ts @@ -42,6 +42,18 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; * @packageDocumentation */ +/** + * The options to start device dehydration. + */ +export interface StartDehydrationOpts { + /** Force creation of a new dehydration key. Defaults to `false`. */ + createNewKey?: boolean; + /** Only start dehydration if we have a cached key. Defaults to `false`. */ + onlyIfKeyCached?: boolean; + /** Try to rehydrate a device before creating a new dehydrated device. Defaults to `true`. */ + rehydrate?: boolean; +} + /** * Public interface to the cryptography parts of the js-sdk * @@ -634,10 +646,11 @@ export interface CryptoApi { /** * Start using device dehydration. * - * - Rehydrates a dehydrated device, if one is available. + * - Rehydrates a dehydrated device, if one is available and `opts.rehydrate` + * is `true`. * - Creates a new dehydration key, if necessary, and stores it in Secret * Storage. - * - If `createNewKey` is set to true, always creates a new key. + * - If `opts.createNewKey` is set to true, always creates a new key. * - If a dehydration key is not available, creates a new one. * - Creates a new dehydrated device, and schedules periodically creating * new dehydrated devices. @@ -646,11 +659,11 @@ export interface CryptoApi { * `true`, and must not be called until after cross-signing and secret * storage have been set up. * - * @param createNewKey - whether to force creation of a new dehydration key. - * This can be used, for example, if Secret Storage is being reset. Defaults - * to false. + * @param opts - options for device dehydration. For backwards compatibility + * with old code, a boolean can be given here, which will be treated as + * the `createNewKey` option. However, this is deprecated. */ - startDehydration(createNewKey?: boolean): Promise; + startDehydration(opts?: StartDehydrationOpts | boolean): Promise; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // @@ -1286,7 +1299,7 @@ export abstract class DehydratedDevicesAPI extends TypedEventEmitter< } public abstract isSupported(): Promise; - public abstract start(createNewKey?: boolean): Promise; + public abstract start(opts: StartDehydrationOpts | boolean): Promise; } export * from "./verification.ts"; diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 4d56715f06..58a8b5d0c1 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -106,6 +106,7 @@ import { KeyBackupRestoreResult, KeyBackupRestoreOpts, DehydratedDevicesAPI, + StartDehydrationOpts, } from "../crypto-api/index.ts"; import { Device, DeviceMap } from "../models/device.ts"; import { deviceInfoToDevice } from "./device-converter.ts"; @@ -4321,7 +4322,7 @@ export class Crypto extends TypedEventEmitter { + public async startDehydration(createNewKey?: StartDehydrationOpts | boolean): Promise { throw new Error("Not implemented"); } diff --git a/src/rust-crypto/DehydratedDeviceManager.ts b/src/rust-crypto/DehydratedDeviceManager.ts index 1f0a526566..f7158f1c24 100644 --- a/src/rust-crypto/DehydratedDeviceManager.ts +++ b/src/rust-crypto/DehydratedDeviceManager.ts @@ -23,7 +23,7 @@ import { IToDeviceEvent } from "../sync-accumulator.ts"; import { ServerSideSecretStorage } from "../secret-storage.ts"; import { decodeBase64 } from "../base64.ts"; import { Logger } from "../logger.ts"; -import { DehydratedDevicesEvents, DehydratedDevicesAPI } from "../crypto-api/index.ts"; +import { DehydratedDevicesEvents, DehydratedDevicesAPI, StartDehydrationOpts } from "../crypto-api/index.ts"; /** * The response body of `GET /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device`. @@ -123,27 +123,38 @@ export class DehydratedDeviceManager extends DehydratedDevicesAPI { /** * Start using device dehydration. * - * - Rehydrates a dehydrated device, if one is available. + * - Rehydrates a dehydrated device, if one is available and `opts.rehydrate` + * is `true`. * - Creates a new dehydration key, if necessary, and stores it in Secret * Storage. - * - If `createNewKey` is set to true, always creates a new key. + * - If `opts.createNewKey` is set to true, always creates a new key. * - If a dehydration key is not available, creates a new one. * - Creates a new dehydrated device, and schedules periodically creating * new dehydrated devices. * - * @param createNewKey - whether to force creation of a new dehydration key. - * This can be used, for example, if Secret Storage is being reset. + * @param opts - options for device dehydration. For backwards compatibility + * with old code, a boolean can be given here, which will be treated as + * the `createNewKey` option. However, this is deprecated. */ - public async start(createNewKey?: boolean): Promise { + public async start(opts: StartDehydrationOpts | boolean = {}): Promise { + if (typeof opts === "boolean") { + opts = { createNewKey: opts }; + } + + if (opts.onlyIfKeyCached && !(await this.getCachedKey())) { + return; + } this.stop(); - try { - await this.rehydrateDeviceIfAvailable(); - } catch (e) { - // If rehydration fails, there isn't much we can do about it. Log - // the error, and create a new device. - this.logger.info("dehydration: Error rehydrating device:", e); + if (opts.rehydrate !== false) { + try { + await this.rehydrateDeviceIfAvailable(); + } catch (e) { + // If rehydration fails, there isn't much we can do about it. Log + // the error, and create a new device. + this.logger.info("dehydration: Error rehydrating device:", e); + } } - if (createNewKey) { + if (opts.createNewKey) { await this.resetKey(); } await this.scheduleDeviceDehydration(); diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index d67997574b..acfb10b758 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -68,6 +68,7 @@ import { KeyBackupRestoreOpts, KeyBackupRestoreResult, DehydratedDevicesAPI, + StartDehydrationOpts, } from "../crypto-api/index.ts"; import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter.ts"; import { IDownloadKeyResult, IQueryKeysRequest } from "../client.ts"; @@ -1400,11 +1401,11 @@ export class RustCrypto extends TypedEventEmitter { + public async startDehydration(opts: StartDehydrationOpts | boolean = {}): Promise { if (!(await this.isCrossSigningReady()) || !(await this.isSecretStorageReady())) { throw new Error("Device dehydration requires cross-signing and secret storage to be set up"); } - return await this.dehydratedDeviceManager.start(createNewKey); + return await this.dehydratedDeviceManager.start(opts || {}); } /**