From c3fc90d153d3cb384c125571636681970085c31a Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 26 Dec 2024 17:02:15 +0100 Subject: [PATCH 1/5] feat(dehydrated): Use the dehydrated key cache API --- src/rust-crypto/DehydratedDeviceManager.ts | 33 ++++++++++++++-------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/rust-crypto/DehydratedDeviceManager.ts b/src/rust-crypto/DehydratedDeviceManager.ts index ca6be006f1b..a647632a755 100644 --- a/src/rust-crypto/DehydratedDeviceManager.ts +++ b/src/rust-crypto/DehydratedDeviceManager.ts @@ -68,8 +68,6 @@ const DEHYDRATION_INTERVAL = 7 * 24 * 60 * 60 * 1000; * @internal */ export class DehydratedDeviceManager { - /** the secret key used for dehydrating and rehydrating */ - private key?: Uint8Array; /** the ID of the interval for periodically replacing the dehydrated device */ private intervalId?: ReturnType; @@ -81,6 +79,14 @@ export class DehydratedDeviceManager { private readonly secretStorage: ServerSideSecretStorage, ) {} + private async getCachedKey(): Promise { + return await this.olmMachine.dehydratedDevices().getDehydratedDeviceKey(); + } + + private async cacheKey(key: RustSdkCryptoJs.DehydratedDeviceKey): Promise { + await this.olmMachine.dehydratedDevices().saveDehydratedDeviceKey(key); + this.emit(DehydratedDevicesEvents.PickleKeyCached); + } /** * Return whether the server supports dehydrated devices. */ @@ -153,10 +159,10 @@ export class DehydratedDeviceManager { * Creates a new key and stores it in secret storage. */ public async resetKey(): Promise { - const key = new Uint8Array(32); - globalThis.crypto.getRandomValues(key); - await this.secretStorage.store(SECRET_STORAGE_NAME, encodeUnpaddedBase64(key)); - this.key = key; + const key = RustSdkCryptoJs.DehydratedDeviceKey.createRandomKey(); + await this.secretStorage.store(SECRET_STORAGE_NAME, key.toBase64()); + // also cache it + await this.cacheKey(key); } /** @@ -166,8 +172,9 @@ export class DehydratedDeviceManager { * * @returns the key, if available, or `null` if no key is available */ - private async getKey(create: boolean): Promise { - if (this.key === undefined) { + private async getKey(create: boolean): Promise { + const cachedKey = await this.getCachedKey(); + if (!cachedKey) { const keyB64 = await this.secretStorage.get(SECRET_STORAGE_NAME); if (keyB64 === undefined) { if (!create) { @@ -175,10 +182,12 @@ export class DehydratedDeviceManager { } await this.resetKey(); } else { - this.key = decodeBase64(keyB64); + const bytes = decodeBase64(keyB64); + const key = RustSdkCryptoJs.DehydratedDeviceKey.createKeyFromArray(bytes); + await this.cacheKey(key); } } - return this.key!; + return (await this.getCachedKey())!; } /** @@ -190,7 +199,7 @@ export class DehydratedDeviceManager { * Returns whether or not a dehydrated device was found. */ public async rehydrateDeviceIfAvailable(): Promise { - const key = await this.getKey(false); + const key = (await this.getCachedKey()) || (await this.getKey(false)); if (!key) { return false; } @@ -267,7 +276,7 @@ export class DehydratedDeviceManager { * Creates and stores a new key in secret storage if none is available. */ public async createAndUploadDehydratedDevice(): Promise { - const key = (await this.getKey(true))!; + const key = ((await this.getCachedKey()) || (await this.getKey(true)))!; const dehydratedDevice = await this.olmMachine.dehydratedDevices().create(); const request = await dehydratedDevice.keysForUpload("Dehydrated device", key); From eb8c1ba55301f21b184adf76f0c7bec0110bf7cc Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 26 Dec 2024 17:03:25 +0100 Subject: [PATCH 2/5] feat(dehydrated): Add signalling to device dehydration manager --- spec/integ/crypto/device-dehydration.spec.ts | 37 +++++++++++++++- src/crypto-api/index.ts | 46 ++++++++++++++++++++ src/crypto/index.ts | 5 +++ src/rust-crypto/DehydratedDeviceManager.ts | 16 +++++-- src/rust-crypto/rust-crypto.ts | 8 ++++ 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/spec/integ/crypto/device-dehydration.spec.ts b/spec/integ/crypto/device-dehydration.spec.ts index cf319a9878c..77e5201ec6e 100644 --- a/spec/integ/crypto/device-dehydration.spec.ts +++ b/spec/integ/crypto/device-dehydration.spec.ts @@ -17,11 +17,12 @@ limitations under the License. import "fake-indexeddb/auto"; import fetchMock from "fetch-mock-jest"; -import { createClient, ClientEvent, MatrixClient, MatrixEvent } from "../../../src"; +import { ClientEvent, createClient, MatrixClient, MatrixEvent } from "../../../src"; import { RustCrypto } from "../../../src/rust-crypto/rust-crypto"; import { AddSecretStorageKeyOpts } from "../../../src/secret-storage"; import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; +import { DehydratedDevicesEvents } from "../../../src/crypto-api"; describe("Device dehydration", () => { it("should rehydrate and dehydrate a device", async () => { @@ -40,6 +41,29 @@ describe("Device dehydration", () => { await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server"); + const dehydratedDevices = matrixClient.getCrypto()!.dehydratedDevices(); + let creationEventCount = 0; + let pickleKeyCachedEventCount = 0; + let rehydrationStartedCount = 0; + let rehydrationEndedCount = 0; + let rehydrationProgressEvent = 0; + + dehydratedDevices.on(DehydratedDevicesEvents.DeviceCreated, () => { + creationEventCount++; + }); + dehydratedDevices.on(DehydratedDevicesEvents.PickleKeyCached, () => { + pickleKeyCachedEventCount++; + }); + dehydratedDevices.on(DehydratedDevicesEvents.RehydrationStarted, () => { + rehydrationStartedCount++; + }); + dehydratedDevices.on(DehydratedDevicesEvents.RehydrationEnded, () => { + rehydrationEndedCount++; + }); + dehydratedDevices.on(DehydratedDevicesEvents.RehydrationProgress, (roomKeyCount, toDeviceCount) => { + rehydrationProgressEvent++; + }); + // count the number of times the dehydration key gets set let setDehydrationCount = 0; matrixClient.on(ClientEvent.AccountData, (event: MatrixEvent) => { @@ -74,6 +98,8 @@ describe("Device dehydration", () => { await crypto.startDehydration(); expect(dehydrationCount).toEqual(1); + expect(creationEventCount).toEqual(1); + expect(pickleKeyCachedEventCount).toEqual(1); // a week later, we should have created another dehydrated device const dehydrationPromise = new Promise((resolve, reject) => { @@ -81,7 +107,10 @@ describe("Device dehydration", () => { }); jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000); await dehydrationPromise; + + expect(pickleKeyCachedEventCount).toEqual(1); expect(dehydrationCount).toEqual(2); + expect(creationEventCount).toEqual(2); // restart dehydration -- rehydrate the device that we created above, // and create a new dehydrated device. We also set `createNewKey`, so @@ -113,6 +142,12 @@ describe("Device dehydration", () => { expect(setDehydrationCount).toEqual(2); expect(eventsResponse.mock.calls).toHaveLength(2); + expect(rehydrationStartedCount).toEqual(1); + expect(rehydrationEndedCount).toEqual(1); + expect(creationEventCount).toEqual(3); + expect(rehydrationProgressEvent).toEqual(1); + expect(pickleKeyCachedEventCount).toEqual(2); + matrixClient.stopClient(); }); }); diff --git a/src/crypto-api/index.ts b/src/crypto-api/index.ts index 4a780696770..7a26f61da15 100644 --- a/src/crypto-api/index.ts +++ b/src/crypto-api/index.ts @@ -31,6 +31,7 @@ import { } from "./keybackup.ts"; import { ISignatures } from "../@types/signed.ts"; import { MatrixEvent } from "../models/event.ts"; +import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; /** * `matrix-js-sdk/lib/crypto-api`: End-to-end encryption support. @@ -615,12 +616,18 @@ export interface CryptoApi { // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + /** + * Get the API object for interacting with dehydrated devices. + */ + dehydratedDevices(): DehydratedDevicesAPI; + /** * Returns whether MSC3814 dehydrated devices are supported by the crypto * backend and by the server. * * This should be called before calling `startDehydration`, and if this * returns `false`, `startDehydration` should not be called. + * */ isDehydrationSupported(): Promise; @@ -1243,6 +1250,45 @@ export interface OwnDeviceKeys { curve25519: string; } +export enum DehydratedDevicesEvents { + /** Emitted when a new dehydrated device is created locally */ + DeviceCreated = "DeviceCreated", + /** Emitted when a new dehydrated device is successfully uploaded to the server */ + DeviceUploaded = "DeviceUploaded", + /** Emitted when rehydration has started */ + RehydrationStarted = "RehydrationStarted", + /** Emitted when rehydration has finished */ + RehydrationEnded = "RehydrationEnded", + /** Emitted during rehydration, signalling the current `roomKeyCount` and `toDeviceCount` */ + RehydrationProgress = "RehydrationProgress", + /** Emitted when a dehydrated device key has been cached */ + PickleKeyCached = "PickleKeyCached", + /** Emitted when an error occurred during rotation of the dehydrated device */ + SchedulingError = "SchedulingError", +} + +export type DehydratedDevicesEventsMap = { + [DehydratedDevicesEvents.DeviceCreated]: () => void; + [DehydratedDevicesEvents.DeviceUploaded]: () => void; + [DehydratedDevicesEvents.RehydrationStarted]: () => void; + [DehydratedDevicesEvents.RehydrationEnded]: () => void; + [DehydratedDevicesEvents.RehydrationProgress]: (roomKeyCount: number, toDeviceCount: number) => void; + [DehydratedDevicesEvents.PickleKeyCached]: () => void; + [DehydratedDevicesEvents.SchedulingError]: (msg: string) => void; +}; + +export abstract class DehydratedDevicesAPI extends TypedEventEmitter< + DehydratedDevicesEvents, + DehydratedDevicesEventsMap +> { + protected constructor() { + super(); + } + + public abstract isSupported(): Promise; + public abstract start(createNewKey?: boolean): Promise; +} + export * from "./verification.ts"; export type * from "./keybackup.ts"; export * from "./recovery-key.ts"; diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 3af8d864364..4d56715f06c 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -105,6 +105,7 @@ import { CryptoEventHandlerMap as CryptoApiCryptoEventHandlerMap, KeyBackupRestoreResult, KeyBackupRestoreOpts, + DehydratedDevicesAPI, } from "../crypto-api/index.ts"; import { Device, DeviceMap } from "../models/device.ts"; import { deviceInfoToDevice } from "./device-converter.ts"; @@ -4324,6 +4325,10 @@ export class Crypto extends TypedEventEmitter; @@ -77,7 +78,9 @@ export class DehydratedDeviceManager { private readonly http: MatrixHttpApi, private readonly outgoingRequestProcessor: OutgoingRequestProcessor, private readonly secretStorage: ServerSideSecretStorage, - ) {} + ) { + super(); + } private async getCachedKey(): Promise { return await this.olmMachine.dehydratedDevices().getDehydratedDeviceKey(); @@ -228,6 +231,7 @@ export class DehydratedDeviceManager { } this.logger.info("dehydration: dehydrated device found"); + this.emit(DehydratedDevicesEvents.RehydrationStarted); const rehydratedDevice = await this.olmMachine .dehydratedDevices() @@ -264,8 +268,11 @@ export class DehydratedDeviceManager { nextBatch = eventResp.next_batch; const roomKeyInfos = await rehydratedDevice.receiveEvents(JSON.stringify(eventResp.events)); roomKeyCount += roomKeyInfos.length; + + this.emit(DehydratedDevicesEvents.RehydrationProgress, roomKeyCount, toDeviceCount); } this.logger.info(`dehydration: received ${roomKeyCount} room keys from ${toDeviceCount} to-device events`); + this.emit(DehydratedDevicesEvents.RehydrationEnded); return true; } @@ -279,9 +286,11 @@ export class DehydratedDeviceManager { const key = ((await this.getCachedKey()) || (await this.getKey(true)))!; const dehydratedDevice = await this.olmMachine.dehydratedDevices().create(); + this.emit(DehydratedDevicesEvents.DeviceCreated); const request = await dehydratedDevice.keysForUpload("Dehydrated device", key); await this.outgoingRequestProcessor.makeOutgoingRequest(request); + this.emit(DehydratedDevicesEvents.DeviceUploaded); this.logger.info("dehydration: uploaded device"); } @@ -296,6 +305,7 @@ export class DehydratedDeviceManager { await this.createAndUploadDehydratedDevice(); this.intervalId = setInterval(() => { this.createAndUploadDehydratedDevice().catch((error) => { + this.emit(DehydratedDevicesEvents.SchedulingError, error.message); this.logger.error("Error creating dehydrated device:", error); }); }, DEHYDRATION_INTERVAL); diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 02fe6270a17..d67997574bd 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -67,6 +67,7 @@ import { CryptoEventHandlerMap, KeyBackupRestoreOpts, KeyBackupRestoreResult, + DehydratedDevicesAPI, } from "../crypto-api/index.ts"; import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter.ts"; import { IDownloadKeyResult, IQueryKeysRequest } from "../client.ts"; @@ -1406,6 +1407,13 @@ export class RustCrypto extends TypedEventEmitter Date: Wed, 8 Jan 2025 11:39:36 +0100 Subject: [PATCH 3/5] feat(dehydrated): fix unneeded call getCachedKey --- src/rust-crypto/DehydratedDeviceManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rust-crypto/DehydratedDeviceManager.ts b/src/rust-crypto/DehydratedDeviceManager.ts index f8f25ba2050..1f0a5265660 100644 --- a/src/rust-crypto/DehydratedDeviceManager.ts +++ b/src/rust-crypto/DehydratedDeviceManager.ts @@ -202,7 +202,7 @@ export class DehydratedDeviceManager extends DehydratedDevicesAPI { * Returns whether or not a dehydrated device was found. */ public async rehydrateDeviceIfAvailable(): Promise { - const key = (await this.getCachedKey()) || (await this.getKey(false)); + const key = await this.getKey(false); if (!key) { return false; } From c916a0c303a66160f90142340a51db88a95e5dba Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 9 Jan 2025 18:20:17 +0100 Subject: [PATCH 4/5] Upgrade to `matrix-sdk-crypto-wasm` v13.0.0 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a056339b7f1..1fc3d3be6ab 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ ], "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-wasm": "^12.1.0", + "@matrix-org/matrix-sdk-crypto-wasm": "^13.0.0", "@matrix-org/olm": "3.2.15", "another-json": "^0.2.0", "bs58": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index 084f6f0f426..3425570209d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1477,10 +1477,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@matrix-org/matrix-sdk-crypto-wasm@^12.1.0": - version "12.1.0" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-12.1.0.tgz#2aef64eab2d30c0a1ace9c0fe876f53aa2949f14" - integrity sha512-NhJFu/8FOGjnW7mDssRUzaMSwXrYOcCqgAjZyAw9KQ9unNADKEi7KoIKe7GtrG2PWtm36y2bUf+hB8vhSY6Wdw== +"@matrix-org/matrix-sdk-crypto-wasm@^13.0.0": + version "13.0.0" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-13.0.0.tgz#658bed951e4c8a06a6dd545575a79cf32022d4ba" + integrity sha512-2gtpjnxL42sdJAgkwitpMMI4cw7Gcjf5sW0MXoe+OAlXPlxIzyM+06F5JJ8ENvBeHkuV2RqtFIRrh8i90HLsMw== "@matrix-org/olm@3.2.15": version "3.2.15" From 9d05a1eaa500cf3365f6f5af43b0ea9bf9803f55 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 14 Jan 2025 09:56:41 +0100 Subject: [PATCH 5/5] review: quick fix and doc --- src/crypto-api/index.ts | 27 ++++++++++++++++++++++ src/rust-crypto/DehydratedDeviceManager.ts | 18 ++++----------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/crypto-api/index.ts b/src/crypto-api/index.ts index 7a26f61da15..eb2a7519869 100644 --- a/src/crypto-api/index.ts +++ b/src/crypto-api/index.ts @@ -1277,6 +1277,15 @@ export type DehydratedDevicesEventsMap = { [DehydratedDevicesEvents.SchedulingError]: (msg: string) => void; }; +/** + * Manages dehydrated devices + * + * * determining server support for dehydrated devices + * * creating new dehydrated devices when requested, including periodically + * replacing the dehydrated device with a new one + * * rehydrating a device when requested, and when present + * + */ export abstract class DehydratedDevicesAPI extends TypedEventEmitter< DehydratedDevicesEvents, DehydratedDevicesEventsMap @@ -1285,7 +1294,25 @@ export abstract class DehydratedDevicesAPI extends TypedEventEmitter< super(); } + /** + * Return whether the server supports dehydrated devices. + */ public abstract isSupported(): Promise; + + /** + * Start using device dehydration. + * + * - Rehydrates a dehydrated device, if one is available. + * - 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 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. + */ public abstract start(createNewKey?: boolean): Promise; } diff --git a/src/rust-crypto/DehydratedDeviceManager.ts b/src/rust-crypto/DehydratedDeviceManager.ts index 1f0a5265660..c8ad4ef8384 100644 --- a/src/rust-crypto/DehydratedDeviceManager.ts +++ b/src/rust-crypto/DehydratedDeviceManager.ts @@ -90,8 +90,9 @@ export class DehydratedDeviceManager extends DehydratedDevicesAPI { await this.olmMachine.dehydratedDevices().saveDehydratedDeviceKey(key); this.emit(DehydratedDevicesEvents.PickleKeyCached); } + /** - * Return whether the server supports dehydrated devices. + * Implementation of {@link DehydratedDevicesAPI#isSupported}. */ public async isSupported(): Promise { // call the endpoint to get a dehydrated device. If it returns an @@ -121,18 +122,7 @@ export class DehydratedDeviceManager extends DehydratedDevicesAPI { } /** - * Start using device dehydration. - * - * - Rehydrates a dehydrated device, if one is available. - * - 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 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. + * Implementation of {@link DehydratedDevicesAPI#start}. */ public async start(createNewKey?: boolean): Promise { this.stop(); @@ -283,7 +273,7 @@ export class DehydratedDeviceManager extends DehydratedDevicesAPI { * Creates and stores a new key in secret storage if none is available. */ public async createAndUploadDehydratedDevice(): Promise { - const key = ((await this.getCachedKey()) || (await this.getKey(true)))!; + const key = (await this.getKey(true))!; const dehydratedDevice = await this.olmMachine.dehydratedDevices().create(); this.emit(DehydratedDevicesEvents.DeviceCreated);