From 7d8cbd6ef0edc11a9edb4b3724182f7360e7ff99 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 27 Jan 2025 23:05:23 +0100 Subject: [PATCH] Device Dehydration | js-sdk: store/load dehydration key (#4599) * feat(dehydrated): Use the dehydrated key cache API * feat(dehydrated): Add signalling to device dehydration manager * feat(dehydrated): fix unneeded call getCachedKey * Upgrade to `matrix-sdk-crypto-wasm` v13.0.0 * review: quick fix and doc * apply changes from review * apply changes from review * fix comment * add some tests and emit an event on rehydration failure * factor out event counter into a test util, since it may be useful elsewhere * adjust test to cover a few more lines * fix documentation * Apply suggestions from code review Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * fix missing bracket * add test for getting the dehydration key from SSSS --------- Co-authored-by: Hubert Chathi Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --- package.json | 2 +- spec/integ/crypto/device-dehydration.spec.ts | 48 ++++++++- spec/test-utils/test-utils.ts | 13 +++ spec/unit/rust-crypto/rust-crypto.spec.ts | 106 ++++++++++++++++++- src/client.ts | 8 ++ src/crypto-api/CryptoEvent.ts | 59 +++++++++++ src/crypto-api/CryptoEventHandlerMap.ts | 8 ++ src/rust-crypto/DehydratedDeviceManager.ts | 88 +++++++++++---- src/rust-crypto/rust-crypto.ts | 11 ++ yarn.lock | 8 +- 10 files changed, 323 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 619f109d2..5957af93e 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/spec/integ/crypto/device-dehydration.spec.ts b/spec/integ/crypto/device-dehydration.spec.ts index cf319a987..4bb82bcf5 100644 --- a/spec/integ/crypto/device-dehydration.spec.ts +++ b/spec/integ/crypto/device-dehydration.spec.ts @@ -17,11 +17,13 @@ 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 { CryptoEvent } from "../../../src/crypto-api/index"; 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 { emitPromise, EventCounter } from "../../test-utils/test-utils"; describe("Device dehydration", () => { it("should rehydrate and dehydrate a device", async () => { @@ -40,6 +42,12 @@ describe("Device dehydration", () => { await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server"); + const creationEventCounter = new EventCounter(matrixClient, CryptoEvent.DehydratedDeviceCreated); + const dehydrationKeyCachedEventCounter = new EventCounter(matrixClient, CryptoEvent.DehydrationKeyCached); + const rehydrationStartedCounter = new EventCounter(matrixClient, CryptoEvent.RehydrationStarted); + const rehydrationCompletedCounter = new EventCounter(matrixClient, CryptoEvent.RehydrationCompleted); + const rehydrationProgressCounter = new EventCounter(matrixClient, CryptoEvent.RehydrationProgress); + // count the number of times the dehydration key gets set let setDehydrationCount = 0; matrixClient.on(ClientEvent.AccountData, (event: MatrixEvent) => { @@ -74,6 +82,8 @@ describe("Device dehydration", () => { await crypto.startDehydration(); expect(dehydrationCount).toEqual(1); + expect(creationEventCounter.counter).toEqual(1); + expect(dehydrationKeyCachedEventCounter.counter).toEqual(1); // a week later, we should have created another dehydrated device const dehydrationPromise = new Promise((resolve, reject) => { @@ -81,7 +91,10 @@ describe("Device dehydration", () => { }); jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000); await dehydrationPromise; + + expect(dehydrationKeyCachedEventCounter.counter).toEqual(1); expect(dehydrationCount).toEqual(2); + expect(creationEventCounter.counter).toEqual(2); // restart dehydration -- rehydrate the device that we created above, // and create a new dehydrated device. We also set `createNewKey`, so @@ -113,6 +126,39 @@ describe("Device dehydration", () => { expect(setDehydrationCount).toEqual(2); expect(eventsResponse.mock.calls).toHaveLength(2); + expect(rehydrationStartedCounter.counter).toEqual(1); + expect(rehydrationCompletedCounter.counter).toEqual(1); + expect(creationEventCounter.counter).toEqual(3); + expect(rehydrationProgressCounter.counter).toEqual(1); + expect(dehydrationKeyCachedEventCounter.counter).toEqual(2); + + // test that if we get an error when we try to rotate, it emits an event + fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { + status: 500, + body: { + errcode: "M_UNKNOWN", + error: "Unknown error", + }, + }); + const rotationErrorEventPromise = emitPromise(matrixClient, CryptoEvent.DehydratedDeviceRotationError); + jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000); + await rotationErrorEventPromise; + + // Restart dehydration, but return an error for GET /dehydrated_device so that rehydration fails. + fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { + status: 500, + body: { + errcode: "M_UNKNOWN", + error: "Unknown error", + }, + }); + fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", (_, opts) => { + return {}; + }); + const rehydrationErrorEventPromise = emitPromise(matrixClient, CryptoEvent.RehydrationError); + await crypto.startDehydration(true); + await rehydrationErrorEventPromise; + matrixClient.stopClient(); }); }); diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index d0c9abb2a..4edc11618 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -566,6 +566,19 @@ if (globalThis.Olm) { export const emitPromise = (e: EventEmitter, k: string): Promise => new Promise((r) => e.once(k, r)); +/** + * Counts the number of times that an event was emitted. + */ +export class EventCounter { + public counter; + constructor(emitter: EventEmitter, event: string) { + this.counter = 0; + emitter.on(event, () => { + this.counter++; + }); + } +} + /** * Advance the fake timers in a loop until the given promise resolves or rejects. * diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 50ec50c5e..cc52b1792 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -44,7 +44,7 @@ import { MemoryCryptoStore, TypedEventEmitter, } from "../../../src"; -import { mkEvent } from "../../test-utils/test-utils"; +import { emitPromise, mkEvent } from "../../test-utils/test-utils"; import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend"; import { IEventDecryptionResult, IMegolmSessionData } from "../../../src/@types/crypto"; import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor"; @@ -1739,6 +1739,110 @@ describe("RustCrypto", () => { }); expect(await rustCrypto.isDehydrationSupported()).toBe(true); }); + + it("should load the dehydration key from SSSS if available", async () => { + fetchMock.config.overwriteRoutes = true; + + 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); + + // Create a RustCrypto to set up device dehydration. + const e2eKeyReceiver1 = new E2EKeyReceiver("http://server"); + const e2eKeyResponder1 = new E2EKeyResponder("http://server"); + e2eKeyResponder1.addKeyReceiver(TEST_USER, e2eKeyReceiver1); + 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 rustCrypto1 = 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 rustCrypto1.bootstrapCrossSigning({ setupNewCrossSigning: true }); + await rustCrypto1.bootstrapSecretStorage({ + createSecretStorageKey, + setupNewSecretStorage: true, + setupNewKeyBackup: false, + }); + + // we need to process a sync so that the OlmMachine will upload keys + await rustCrypto1.preprocessToDeviceMessages([]); + await rustCrypto1.onSyncCompleted({}); + + fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { + status: 404, + body: { + errcode: "M_NOT_FOUND", + error: "Not found", + }, + }); + let dehydratedDeviceBody: any; + fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", (_, opts) => { + dehydratedDeviceBody = JSON.parse(opts.body as string); + return {}; + }); + await rustCrypto1.startDehydration(); + await rustCrypto1.stop(); + + // Create another RustCrypto, using the same SecretStorage, to + // rehydrate the device. + const e2eKeyReceiver2 = new E2EKeyReceiver("http://server"); + const e2eKeyResponder2 = new E2EKeyResponder("http://server"); + e2eKeyResponder2.addKeyReceiver(TEST_USER, e2eKeyReceiver2); + + const rustCrypto2 = await makeTestRustCrypto( + makeMatrixHttpApi(), + TEST_USER, + "ANOTHERDEVICE", + secretStorage, + ); + + // dehydration requires secret storage and cross signing + await rustCrypto2.bootstrapCrossSigning({ setupNewCrossSigning: true }); + + // we need to process a sync so that the OlmMachine will upload keys + await rustCrypto2.preprocessToDeviceMessages([]); + await rustCrypto2.onSyncCompleted({}); + + fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { + device_id: dehydratedDeviceBody.device_id, + device_data: dehydratedDeviceBody.device_data, + }); + fetchMock.post( + `path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/${encodeURIComponent(dehydratedDeviceBody.device_id)}/events`, + { + events: [], + next_batch: "token", + }, + ); + + // We check that a RehydrationCompleted event gets emitted, which + // means that the device was successfully rehydrated. + const rehydrationCompletedPromise = emitPromise(rustCrypto2, CryptoEvent.RehydrationCompleted); + await rustCrypto2.startDehydration(); + await rehydrationCompletedPromise; + await rustCrypto2.stop(); + }); }); describe("import & export secrets bundle", () => { diff --git a/src/client.ts b/src/client.ts index 69c9b9d0b..5e1c8deee 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2311,6 +2311,14 @@ export class MatrixClient extends TypedEventEmitter void; [CryptoEvent.DevicesUpdated]: (users: string[], initialFetch: boolean) => void; [CryptoEvent.LegacyCryptoStoreMigrationProgress]: (progress: number, total: number) => void; + [CryptoEvent.DehydratedDeviceCreated]: () => void; + [CryptoEvent.DehydratedDeviceUploaded]: () => void; + [CryptoEvent.RehydrationStarted]: () => void; + [CryptoEvent.RehydrationProgress]: (roomKeyCount: number, toDeviceCount: number) => void; + [CryptoEvent.RehydrationCompleted]: () => void; + [CryptoEvent.RehydrationError]: (msg: string) => void; + [CryptoEvent.DehydrationKeyCached]: () => void; + [CryptoEvent.DehydratedDeviceRotationError]: (msg: string) => void; } & RustBackupCryptoEventMap; diff --git a/src/rust-crypto/DehydratedDeviceManager.ts b/src/rust-crypto/DehydratedDeviceManager.ts index ca6be006f..b487ab0d4 100644 --- a/src/rust-crypto/DehydratedDeviceManager.ts +++ b/src/rust-crypto/DehydratedDeviceManager.ts @@ -21,8 +21,10 @@ import { encodeUri } from "../utils.ts"; import { IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api/index.ts"; import { IToDeviceEvent } from "../sync-accumulator.ts"; import { ServerSideSecretStorage } from "../secret-storage.ts"; -import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts"; +import { decodeBase64 } from "../base64.ts"; import { Logger } from "../logger.ts"; +import { CryptoEvent, CryptoEventHandlerMap } from "../crypto-api/index.ts"; +import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; /** * The response body of `GET /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device`. @@ -67,9 +69,7 @@ const DEHYDRATION_INTERVAL = 7 * 24 * 60 * 60 * 1000; * * @internal */ -export class DehydratedDeviceManager { - /** the secret key used for dehydrating and rehydrating */ - private key?: Uint8Array; +export class DehydratedDeviceManager extends TypedEventEmitter { /** the ID of the interval for periodically replacing the dehydrated device */ private intervalId?: ReturnType; @@ -79,7 +79,14 @@ export class DehydratedDeviceManager { private readonly http: MatrixHttpApi, private readonly outgoingRequestProcessor: OutgoingRequestProcessor, private readonly secretStorage: ServerSideSecretStorage, - ) {} + ) { + super(); + } + + private async cacheKey(key: RustSdkCryptoJs.DehydratedDeviceKey): Promise { + await this.olmMachine.dehydratedDevices().saveDehydratedDeviceKey(key); + this.emit(CryptoEvent.DehydrationKeyCached); + } /** * Return whether the server supports dehydrated devices. @@ -133,6 +140,7 @@ export class DehydratedDeviceManager { // 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) { await this.resetKey(); @@ -151,12 +159,15 @@ export class DehydratedDeviceManager { * Reset the dehydration key. * * Creates a new key and stores it in secret storage. + * + * @returns The newly-generated key. */ - 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; + public async resetKey(): Promise { + const key = RustSdkCryptoJs.DehydratedDeviceKey.createRandomKey(); + await this.secretStorage.store(SECRET_STORAGE_NAME, key.toBase64()); + // Also cache it in the rust SDK's crypto store. + await this.cacheKey(key); + return key; } /** @@ -166,19 +177,27 @@ 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) { - const keyB64 = await this.secretStorage.get(SECRET_STORAGE_NAME); - if (keyB64 === undefined) { - if (!create) { - return null; - } - await this.resetKey(); - } else { - this.key = decodeBase64(keyB64); + private async getKey(create: boolean): Promise { + const cachedKey = await this.olmMachine.dehydratedDevices().getDehydratedDeviceKey(); + if (cachedKey) return cachedKey; + const keyB64 = await this.secretStorage.get(SECRET_STORAGE_NAME); + if (keyB64 === undefined) { + if (!create) { + return null; } + return await this.resetKey(); + } + + // We successfully found the key in secret storage: decode it, and cache it in + // the rust SDK's crypto store. + const bytes = decodeBase64(keyB64); + try { + const key = RustSdkCryptoJs.DehydratedDeviceKey.createKeyFromArray(bytes); + await this.cacheKey(key); + return key; + } finally { + bytes.fill(0); } - return this.key!; } /** @@ -219,6 +238,7 @@ export class DehydratedDeviceManager { } this.logger.info("dehydration: dehydrated device found"); + this.emit(CryptoEvent.RehydrationStarted); const rehydratedDevice = await this.olmMachine .dehydratedDevices() @@ -255,8 +275,11 @@ export class DehydratedDeviceManager { nextBatch = eventResp.next_batch; const roomKeyInfos = await rehydratedDevice.receiveEvents(JSON.stringify(eventResp.events)); roomKeyCount += roomKeyInfos.length; + + this.emit(CryptoEvent.RehydrationProgress, roomKeyCount, toDeviceCount); } this.logger.info(`dehydration: received ${roomKeyCount} room keys from ${toDeviceCount} to-device events`); + this.emit(CryptoEvent.RehydrationCompleted); return true; } @@ -270,9 +293,11 @@ export class DehydratedDeviceManager { const key = (await this.getKey(true))!; const dehydratedDevice = await this.olmMachine.dehydratedDevices().create(); + this.emit(CryptoEvent.DehydratedDeviceCreated); const request = await dehydratedDevice.keysForUpload("Dehydrated device", key); await this.outgoingRequestProcessor.makeOutgoingRequest(request); + this.emit(CryptoEvent.DehydratedDeviceUploaded); this.logger.info("dehydration: uploaded device"); } @@ -287,6 +312,7 @@ export class DehydratedDeviceManager { await this.createAndUploadDehydratedDevice(); this.intervalId = setInterval(() => { this.createAndUploadDehydratedDevice().catch((error) => { + this.emit(CryptoEvent.DehydratedDeviceRotationError, error.message); this.logger.error("Error creating dehydrated device:", error); }); }, DEHYDRATION_INTERVAL); @@ -304,3 +330,23 @@ export class DehydratedDeviceManager { } } } + +/** + * The events fired by the DehydratedDeviceManager + * @internal + */ +type DehydratedDevicesEvents = + | CryptoEvent.DehydratedDeviceCreated + | CryptoEvent.DehydratedDeviceUploaded + | CryptoEvent.RehydrationStarted + | CryptoEvent.RehydrationProgress + | CryptoEvent.RehydrationCompleted + | CryptoEvent.RehydrationError + | CryptoEvent.DehydrationKeyCached + | CryptoEvent.DehydratedDeviceRotationError; + +/** + * A map of the {@link DehydratedDeviceEvents} fired by the {@link DehydratedDeviceManager} and their payloads. + * @internal + */ +type DehydratedDevicesEventMap = Pick; diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index d995b165d..cbbf5d0fa 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -183,12 +183,23 @@ export class RustCrypto extends TypedEventEmitter