diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 6ebdf661f7..dec85b1675 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1894,6 +1894,193 @@ describe("RustCrypto", () => { await rehydrationCompletedPromise; await rustCrypto2.stop(); }); + + describe("start dehydration options", () => { + let rustCrypto: RustCrypto; + let secretStorage: ServerSideSecretStorageImpl; + let dehydratedDeviceInfo: Record | undefined; + + // Function that is called when `GET /dehydrated_device` is called + // (i.e. when we try to rehydrate a device) + const getDehydratedDeviceMock = jest.fn(() => { + if (dehydratedDeviceInfo) { + return { + status: 200, + body: dehydratedDeviceInfo, + }; + } else { + return { + status: 404, + body: { + errcode: "M_NOT_FOUND", + error: "Not found", + }, + }; + } + }); + // Function that is called when `PUT /dehydrated_device` is called + // (i.e. when we create a new dehydrated device) + 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, + }, + }; + }); + + beforeEach(async () => { + // Set up a RustCrypto object with secret storage and cross-signing. + const secretStorageCallbacks = { + getSecretStorageKey: async (keys: any, name: string) => { + return [[...Object.keys(keys.keys)][0], new Uint8Array(32)]; + }, + } as SecretStorageCallbacks; + 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: {}, + }); + rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage); + + // 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, + }); + // we need to process a sync so that the OlmMachine will upload keys + await rustCrypto.preprocessToDeviceMessages([]); + await rustCrypto.onSyncCompleted({}); + + // set up mocks needed for device dehydration + dehydratedDeviceInfo = undefined; + fetchMock.get( + "path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", + getDehydratedDeviceMock, + ); + 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", + }, + }); + getDehydratedDeviceMock.mockClear(); + putDehydratedDeviceMock.mockClear(); + }); + + afterEach(() => { + rustCrypto.stop(); + }); + + // Several tests require a dehydrated device and dehydration key + // already set up. + async function setUpInitialDehydratedDevice() { + await rustCrypto.startDehydration(); + getDehydratedDeviceMock.mockClear(); + putDehydratedDeviceMock.mockClear(); + return await secretStorage.get("org.matrix.msc3814"); + } + + it("should create a new key and dehydrate a device when no options given", async () => { + // With the default options, when we don't have an existing key ... + await rustCrypto.startDehydration(); + // ... we create a new dehydration key ... + expect(await secretStorage.get("org.matrix.msc3814")).toBeTruthy(); + // ... and create a new dehydrated device. + expect(putDehydratedDeviceMock).toHaveBeenCalled(); + }); + + it("should rehydrate a device if available and keep existing key when no options given", async () => { + const origDehydrationKey = await setUpInitialDehydratedDevice(); + + // If we already have a dehydration key and dehydrated device... + await rustCrypto.startDehydration(); + // ... we should fetch the device to rehydrate it ... + expect(getDehydratedDeviceMock).toHaveBeenCalled(); + // ... create a new dehydrated device ... + expect(putDehydratedDeviceMock).toHaveBeenCalled(); + // ... and keep the same dehydration key. + expect(await secretStorage.get("org.matrix.msc3814")).toEqual(origDehydrationKey); + }); + + it("should do nothing if onlyIfKeyCached is true and we have no key cached", async () => { + // Since 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(); + }); + + it("should start dehydration when onlyIfKeyCached is true, and we have a cached key", async () => { + const origDehydrationKey = await setUpInitialDehydratedDevice(); + + // If `onlyIfKeyCached` is `true`, and we already have have a + // key, we should behave the same as if no options were given. + await rustCrypto.startDehydration({ onlyIfKeyCached: true }); + expect(getDehydratedDeviceMock).toHaveBeenCalled(); + expect(putDehydratedDeviceMock).toHaveBeenCalled(); + expect(await secretStorage.get("org.matrix.msc3814")).toEqual(origDehydrationKey); + }); + + it("should not rehydrate if rehydrate is set to false", async () => { + const origDehydrationKey = await setUpInitialDehydratedDevice(); + + // If `rehydrate` is set to `false` ... + await rustCrypto.startDehydration({ rehydrate: false }); + // ... we should not try to rehydrate ... + expect(getDehydratedDeviceMock).not.toHaveBeenCalled(); + // ... but we should still create a new dehydrated device ... + expect(putDehydratedDeviceMock).toHaveBeenCalled(); + // ... and we should keep the same dehydration key. + expect(await secretStorage.get("org.matrix.msc3814")).toEqual(origDehydrationKey); + }); + + it("should create a new key if createNewKey is set to true", async () => { + const origDehydrationKey = await setUpInitialDehydratedDevice(); + + // If `createNewKey` is set to `true` ... + await rustCrypto.startDehydration({ createNewKey: true }); + // ... we should rehydrate and dehydrate as normal ... + expect(getDehydratedDeviceMock).toHaveBeenCalled(); + expect(putDehydratedDeviceMock).toHaveBeenCalled(); + // ... and we should create a new dehydration key. + expect(await secretStorage.get("org.matrix.msc3814")).not.toEqual(origDehydrationKey); + }); + }); }); describe("import & export secrets bundle", () => { diff --git a/src/crypto-api/index.ts b/src/crypto-api/index.ts index 4761be6405..94bfe2b0ea 100644 --- a/src/crypto-api/index.ts +++ b/src/crypto-api/index.ts @@ -41,6 +41,37 @@ import { MatrixEvent } from "../models/event.ts"; * @packageDocumentation */ +/** + * The options to start device dehydration. + */ +export interface StartDehydrationOpts { + /** + * Force creation of a new dehydration key, even if there is already an + * existing dehydration key. If `false`, and `onlyIfKeyCached` is `false`, a + * new key will be created if there is no existing dehydration key, whether + * already cached in our local storage or stored in Secret Storage. + * + * Checking for the presence of the key in Secret Storage may result in the + * `getSecretStorageKey` callback being called. + * + * Defaults to `false`. + */ + createNewKey?: boolean; + /** + * Only start dehydration if we have a dehydration key cached in our local + * storage. If `true`, Secret Storage will not be checked. Defaults to + * `false`. + */ + onlyIfKeyCached?: boolean; + /** + * Try to rehydrate a device before creating a new dehydrated device. + * Setting this to `false` may be useful for situations where the client is + * known to pre-date the dehydrated device, and so rehydration is + * unnecessary. Defaults to `true`. + */ + rehydrate?: boolean; +} + /** * Public interface to the cryptography parts of the js-sdk * @@ -649,10 +680,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. @@ -661,11 +693,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; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // diff --git a/src/crypto/index.ts b/src/crypto/index.ts index d2dff60cd5..5f457f149d 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -105,6 +105,7 @@ import { CryptoEventHandlerMap as CryptoApiCryptoEventHandlerMap, KeyBackupRestoreResult, KeyBackupRestoreOpts, + StartDehydrationOpts, } from "../crypto-api/index.ts"; import { Device, DeviceMap } from "../models/device.ts"; import { deviceInfoToDevice } from "./device-converter.ts"; @@ -4327,7 +4328,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 b487ab0d4b..eb78588c97 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 { CryptoEvent, CryptoEventHandlerMap } from "../crypto-api/index.ts"; +import { CryptoEvent, CryptoEventHandlerMap, StartDehydrationOpts } from "../crypto-api/index.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; /** @@ -121,28 +121,39 @@ export class DehydratedDeviceManager extends TypedEventEmitter { + public async start(opts: StartDehydrationOpts | boolean = {}): Promise { + if (typeof opts === "boolean") { + opts = { createNewKey: opts }; + } + + if (opts.onlyIfKeyCached && !(await this.olmMachine.dehydratedDevices().getDehydratedDeviceKey())) { + 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); - this.emit(CryptoEvent.RehydrationError, (e as Error).message); + 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); + this.emit(CryptoEvent.RehydrationError, (e as Error).message); + } } - 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 e0f24a2161..c50dd95fc0 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -67,6 +67,7 @@ import { CryptoEventHandlerMap, KeyBackupRestoreOpts, KeyBackupRestoreResult, + StartDehydrationOpts, } from "../crypto-api/index.ts"; import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter.ts"; import { IDownloadKeyResult, IQueryKeysRequest } from "../client.ts"; @@ -1425,11 +1426,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 || {}); } /**