From b8552005cefbfe68e279c23ae835fb90c2f9c176 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Mon, 12 Feb 2024 17:39:08 -0500 Subject: [PATCH 01/21] initial implementation of device dehydration --- spec/unit/rust-crypto/rust-crypto.spec.ts | 7 +++-- src/crypto-api.ts | 17 +++++++++++ src/crypto/index.ts | 14 +++++++++ src/rust-crypto/OutgoingRequestProcessor.ts | 9 +++++- src/rust-crypto/rust-crypto.ts | 33 +++++++++++++++++++++ 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 75b3decc6c7..dc488cb60c0 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -736,8 +736,11 @@ describe("RustCrypto", () => { }, }, }; - } else if (request instanceof RustSdkCryptoJs.UploadSigningKeysRequest) { - // SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent. + } else if ( + request instanceof RustSdkCryptoJs.UploadSigningKeysRequest || + request instanceof RustSdkCryptoJs.PutDehydratedDeviceRequest + ) { + // These request types does not implement OutgoingRequest and does not need to be marked as sent. return; } if (request.id) { diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 1ce64152668..c9450c9504b 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -474,6 +474,23 @@ export interface CryptoApi { * @param version - The backup version to delete. */ deleteKeyBackupVersion(version: string): Promise; + + /** + * Rehydrate the dehydrated device stored on the server + * + * Checks if there is a dehydrated device on the server. If so, rehydrates + * the device and processes the to-device events. + * + * Returns whether or not a dehydrated device was found. + */ + rehydrateDeviceIfAvailable(): Promise; + + /** + * Creates and uploads a new dehydrated device + * + * Creates and stores a new key in secret storage if none is available. + */ + createAndUploadDehydratedDevice(): Promise; } /** diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 1a0e4f43dae..bfa4763fd46 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -4287,6 +4287,20 @@ export class Crypto extends TypedEventEmitter { + return false; + } + + /** + * Stub function -- dehydration is not implemented here, so throw error + */ + public async createAndUploadDehydratedDevice(): Promise { + throw new Error("Not implemented"); + } } /** diff --git a/src/rust-crypto/OutgoingRequestProcessor.ts b/src/rust-crypto/OutgoingRequestProcessor.ts index 18b9d6b03fa..18fb7dd88c6 100644 --- a/src/rust-crypto/OutgoingRequestProcessor.ts +++ b/src/rust-crypto/OutgoingRequestProcessor.ts @@ -16,6 +16,7 @@ limitations under the License. import { OlmMachine, + PutDehydratedDeviceRequest, KeysBackupRequest, KeysClaimRequest, KeysQueryRequest, @@ -32,6 +33,7 @@ import { logDuration, QueryDict } from "../utils"; import { IAuthDict, UIAuthCallback } from "../interactive-auth"; import { UIAResponse } from "../@types/uia"; import { ToDeviceMessageId } from "../@types/event"; +import { UnstablePrefix as DehydrationUnstablePrefix } from "./dehydration"; /** * Common interface for all the request types returned by `OlmMachine.outgoingRequests`. @@ -62,7 +64,7 @@ export class OutgoingRequestProcessor { ) {} public async makeOutgoingRequest( - msg: OutgoingRequest | UploadSigningKeysRequest, + msg: OutgoingRequest | UploadSigningKeysRequest | PutDehydratedDeviceRequest, uiaCallback?: UIAuthCallback, ): Promise { let resp: string; @@ -102,6 +104,11 @@ export class OutgoingRequestProcessor { ); // SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent. return; + } else if (msg instanceof PutDehydratedDeviceRequest) { + const path = DehydrationUnstablePrefix + "/dehydrated_device"; + await this.rawJsonRequest(Method.Put, path, {}, msg.body); + // PutDehydratedDeviceRequest does not implement OutgoingRequest and does not need to be marked as sent. + return; } else { logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg)); resp = ""; diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 30c27201f9d..daf1a36c025 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -73,6 +73,7 @@ import { encodeBase64 } from "../base64"; import { DecryptionError } from "../crypto/algorithms"; import { OutgoingRequestsManager } from "./OutgoingRequestsManager"; import { PerSessionKeyBackupDownloader } from "./PerSessionKeyBackupDownloader"; +import { RustDehydrationManager } from "./dehydration"; const ALL_VERIFICATION_METHODS = ["m.sas.v1", "m.qr_code.scan.v1", "m.qr_code.show.v1", "m.reciprocate.v1"]; @@ -106,6 +107,8 @@ export class RustCrypto extends TypedEventEmitter(this); + private readonly dehydrationManager: RustDehydrationManager; + public constructor( private readonly logger: Logger, @@ -143,6 +146,15 @@ export class RustCrypto extends TypedEventEmitter { + return await this.dehydrationManager.rehydrateDeviceIfAvailable(); + } + + /** + * Creates and uploads a new dehydrated device + * + * Creates and stores a new key in secret storage if none is available. + */ + public async createAndUploadDehydratedDevice(): Promise { + return await this.dehydrationManager.createAndUploadDehydratedDevice(); + } } class EventDecryptor { From 8e632f344dbe917b3389f42f3795c0acfc77bb08 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Fri, 23 Feb 2024 16:22:19 -0500 Subject: [PATCH 02/21] add dehydrated flag for devices --- src/models/device.ts | 4 ++++ src/rust-crypto/device-converter.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/src/models/device.ts b/src/models/device.ts index 0a451fd5a8d..8498b55157f 100644 --- a/src/models/device.ts +++ b/src/models/device.ts @@ -51,6 +51,9 @@ export class Device { /** display name of the device */ public readonly displayName?: string; + /** whether the device is a dehydrated device */ + public readonly dehydrated: boolean = false; + public constructor(opts: DeviceParameters) { this.deviceId = opts.deviceId; this.userId = opts.userId; @@ -59,6 +62,7 @@ export class Device { this.verified = opts.verified || DeviceVerification.Unverified; this.signatures = opts.signatures || new Map(); this.displayName = opts.displayName; + this.dehydrated = !!opts.dehydrated; } /** diff --git a/src/rust-crypto/device-converter.ts b/src/rust-crypto/device-converter.ts index b37a63dcca0..587640cb0e9 100644 --- a/src/rust-crypto/device-converter.ts +++ b/src/rust-crypto/device-converter.ts @@ -80,6 +80,7 @@ export function rustDeviceToJsDevice(device: RustSdkCryptoJs.Device, userId: Rus verified, signatures, displayName: device.displayName, + dehydrated: device.isDehydrated, }); } From 4f231a696a46b91aaa1aff92bd63857882e8732d Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Tue, 5 Mar 2024 13:37:02 -0500 Subject: [PATCH 03/21] add missing dehydration.ts file, add test, add function to schedule dehydration --- spec/unit/rust-crypto/rust-crypto.spec.ts | 77 ++++++++ src/crypto-api.ts | 15 ++ src/crypto/index.ts | 7 + src/rust-crypto/dehydration.ts | 225 ++++++++++++++++++++++ src/rust-crypto/rust-crypto.ts | 17 ++ 5 files changed, 341 insertions(+) create mode 100644 src/rust-crypto/dehydration.ts diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 3ba89ab9848..6b86bfa3140 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1395,6 +1395,83 @@ describe("RustCrypto", () => { }); }); }); + + describe("dehydration", () => { + it("should rehydrate and dehydrate a device", async () => { + jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); + + 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 rustCrypto = await makeTestRustCrypto( + new MatrixHttpApi(new TypedEventEmitter(), { + baseUrl: "http://server/", + prefix: "", + onlyData: true, + }), + testData.TEST_USER_ID, + undefined, + secretStorage, + ); + const olmMachine: OlmMachine = rustCrypto["olmMachine"]; + rustCrypto["checkKeyBackupAndEnable"] = async () => { + return null; + }; + + const outgoingRequestProcessor = { + makeOutgoingRequest: jest.fn(), + } as unknown as OutgoingRequestProcessor; + rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor; + (rustCrypto["crossSigningIdentity"] as any)["outgoingRequestProcessor"] = outgoingRequestProcessor; + (rustCrypto["dehydrationManager"] as any)["outgoingRequestProcessor"] = outgoingRequestProcessor; + const outgoingRequestsManager = new OutgoingRequestsManager(logger, olmMachine, outgoingRequestProcessor); + rustCrypto["outgoingRequestsManager"] = outgoingRequestsManager; + + async function createSecretStorageKey() { + return { + keyInfo: {} as AddSecretStorageKeyOpts, + privateKey: new Uint8Array(32), + }; + } + + // create initial secret storage + await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); + await rustCrypto.bootstrapSecretStorage({ + createSecretStorageKey, + setupNewSecretStorage: true, + setupNewKeyBackup: false, + }); + + let dehydratedDeviceBody: any; + const makeDehydrationRequest = (outgoingRequestProcessor.makeOutgoingRequest = jest.fn( + async (req, uiaCallback) => { + expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); + dehydratedDeviceBody = JSON.parse((req as RustSdkCryptoJs.PutDehydratedDeviceRequest).body); + }, + )); + await rustCrypto.createAndUploadDehydratedDevice(); + + expect(makeDehydrationRequest).toHaveBeenCalled(); + + 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", + }, + ); + + expect(await rustCrypto.rehydrateDeviceIfAvailable()).toBe(true); + }); + }); }); /** Build a MatrixHttpApi instance */ diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 7aa99982ec9..babbd839e37 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -497,6 +497,12 @@ export interface CryptoApi { */ deleteKeyBackupVersion(version: string): Promise; + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // + // Dehydrated devices + // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + /** * Rehydrate the dehydrated device stored on the server * @@ -513,6 +519,15 @@ export interface CryptoApi { * Creates and stores a new key in secret storage if none is available. */ createAndUploadDehydratedDevice(): Promise; + + /** + * Schedule periodic creation of dehydrated devices + * + * @param interval - the time to wait between creating dehydrated devices + * @param delay - how long to wait before creating the first dehydrated device. + * Defaults to creating the device immediately. + */ + scheduleDeviceDehydration(interval: number, delay?: number): Promise; } /** diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 5348c897366..a06df65ef14 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -4324,6 +4324,13 @@ export class Crypto extends TypedEventEmitter { throw new Error("Not implemented"); } + + /** + * Stub function -- dehydration is not implemented here, so throw error + */ + public async scheduleDeviceDehydration(interval: number, delay?: number): Promise { + throw new Error("Not implemented"); + } } /** diff --git a/src/rust-crypto/dehydration.ts b/src/rust-crypto/dehydration.ts new file mode 100644 index 00000000000..756928e7237 --- /dev/null +++ b/src/rust-crypto/dehydration.ts @@ -0,0 +1,225 @@ +/* +Copyright 2024 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-wasm"; + +import { RustCrypto } from "./rust-crypto"; +import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor"; +import { encodeUri } from "../utils"; +import { ClientPrefix, IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api"; +import { IToDeviceEvent } from "../sync-accumulator"; +import { ServerSideSecretStorage } from "../secret-storage"; +import { crypto } from "../crypto/crypto"; +import { decodeBase64, encodeUnpaddedBase64 } from "../base64"; +import { Logger } from "../logger"; +import { UnstableValue } from "../NamespacedValue"; + +// schema for the API response bodies +interface IDehydratedDeviceResp { + device_id: string; + device_data: { + algorithm: string; + }; +} +interface IDehydratedDeviceEventsResp { + events: IToDeviceEvent[]; + next_batch: string; +} + +export const UnstablePrefix = ClientPrefix.Unstable + "/org.matrix.msc3814.v1"; +const SECRET_STORAGE_NAME = new UnstableValue("m.dehydrated_device", "org.matrix.msc3814"); + +/** + * Manages dehydrated devices + */ +export class RustDehydrationManager { + private readonly dehydratedDevices: RustSdkCryptoJs.DehydratedDevices; + private key?: Uint8Array; + private intervalId?: ReturnType; + private timeoutId?: ReturnType; + + public constructor( + private readonly logger: Logger, + private readonly rustCrypto: RustCrypto, + olmMachine: RustSdkCryptoJs.OlmMachine, + private readonly http: MatrixHttpApi, + private readonly outgoingRequestProcessor: OutgoingRequestProcessor, + private readonly secretStorage: ServerSideSecretStorage, + ) { + this.dehydratedDevices = olmMachine.dehydratedDevices(); + } + + /** Return whether the dehydration key is stored in Secret Storage + */ + public async isKeyStored(): Promise { + return Boolean(await this.secretStorage.isStored(SECRET_STORAGE_NAME.name)); + } + + /** Get and cache the encryption key from secret storage + * + * If `create` is `true`, creates a new key if no existing key is present. + */ + private async getKey(create: boolean): Promise { + if (this.key === undefined) { + const keyB64 = await this.secretStorage.get(SECRET_STORAGE_NAME.name); + if (keyB64 === undefined) { + if (!create) { + return false; + } + this.key = new Uint8Array(32); + crypto.getRandomValues(this.key); + await this.secretStorage.store(SECRET_STORAGE_NAME.name, encodeUnpaddedBase64(this.key)); + } else { + this.key = decodeBase64(keyB64); + } + } + return true; + } + + /** + * Rehydrate the dehydrated device stored on the server + * + * Checks if there is a dehydrated device on the server. If so, rehydrates + * the device and processes the to-device events. + * + * Returns whether or not a dehydrated device was found. + */ + public async rehydrateDeviceIfAvailable(): Promise { + if (!(await this.getKey(false))) { + return false; + } + + let dehydratedDeviceResp; + try { + dehydratedDeviceResp = await this.http.authedRequest( + Method.Get, + "/dehydrated_device", + undefined, + undefined, + { + prefix: UnstablePrefix, + }, + ); + } catch (error) { + const err = error as MatrixError; + // We ignore M_NOT_FOUND (there is no dehydrated device, so nothing + // us to do) and M_UNRECOGNIZED (the server does not understand the + // endpoint). We pass through any other errors. + if (err.errcode === "M_NOT_FOUND" || err.errcode === "M_UNRECOGNIZED") { + this.logger.info("dehydration: No dehydrated device"); + return false; + } + throw err; + } + + this.logger.info("dehydration: device found"); + + const rehydratedDevice = await this.dehydratedDevices.rehydrate( + this.key!, + new RustSdkCryptoJs.DeviceId(dehydratedDeviceResp.device_id), + JSON.stringify(dehydratedDeviceResp.device_data), + ); + + this.logger.info("dehydration: device rehydrated"); + + let nextBatch: string | undefined = undefined; + let toDeviceCount = 0; + let roomKeyCount = 0; + for (;;) { + const path = encodeUri("/dehydrated_device/$device_id/events", { + $device_id: dehydratedDeviceResp.device_id, + }); + const eventResp: IDehydratedDeviceEventsResp = await this.http.authedRequest( + Method.Post, + path, + undefined, + nextBatch ? { next_batch: nextBatch } : {}, + { + prefix: UnstablePrefix, + }, + ); + + if (eventResp.events.length == 0) { + break; + } + toDeviceCount += eventResp.events.length; + nextBatch = eventResp.next_batch; + const roomKeyInfos: RustSdkCryptoJs.RoomKeyInfo[] = await rehydratedDevice.receiveEvents( + JSON.stringify(eventResp.events), + ); + roomKeyCount += eventResp.events.length; + + // FIXME: is this actually needed? It looks like the OlmMachine + // automatically re-tries decryption + await this.rustCrypto.onRoomKeysUpdated(roomKeyInfos); + } + this.logger.info(`dehydration: received ${roomKeyCount} room keys from ${toDeviceCount} to-device events`); + + return true; + } + + /** + * Creates and uploads a new dehydrated device + * + * Creates and stores a new key in secret storage if none is available. + */ + public async createAndUploadDehydratedDevice(): Promise { + await this.getKey(true); + + // FIXME: should raise error if server doesn't support dehydration + + const dehydratedDevice = await this.dehydratedDevices.create(); + // FIXME: should the device display name be localised? passed as a + // parameter? + const request = await dehydratedDevice.keysForUpload("Dehydrated device", this.key!); + + await this.outgoingRequestProcessor.makeOutgoingRequest(request); + + this.logger.info("dehydration: uploaded device"); + + // FIXME: emit event when done + } + + /** + * Schedule periodic creation of dehydrated devices + * + * @param interval - the time to wait between creating dehydrated devices + * @param delay - how long to wait before creating the first dehydrated device. + * Defaults to creating the device immediately. + */ + public async scheduleDeviceDehydration(interval: number, delay?: number): Promise { + // cancel any previously-scheduled tasks + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + + if (delay) { + this.timeoutId = setTimeout(() => { + this.scheduleDeviceDehydration(interval); + this.timeoutId = undefined; + }, delay); + } else { + await this.createAndUploadDehydratedDevice(); + // FIXME: should we randomize the time? + this.intervalId = setInterval(this.createAndUploadDehydratedDevice.bind(this), interval); + } + } +} diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index b096d3632e4..906b8b2f031 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -1658,6 +1658,23 @@ export class RustCrypto extends TypedEventEmitter { return await this.dehydrationManager.createAndUploadDehydratedDevice(); } + + /** Return whether the dehydration key is stored in Secret Storage + */ + public async isDehydrationKeyStored(): Promise { + return await this.dehydrationManager.isKeyStored(); + } + + /** + * Schedule periodic creation of dehydrated devices + * + * @param interval - the time to wait between creating dehydrated devices + * @param delay - how long to wait before creating the first dehydrated device. + * Defaults to creating the device immediately. + */ + public async scheduleDeviceDehydration(interval: number, delay?: number): Promise { + return await this.dehydrationManager.scheduleDeviceDehydration(interval, delay); + } } class EventDecryptor { From 560511d10871d690823ff737c0144546babf7935 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Fri, 8 Mar 2024 17:39:53 -0500 Subject: [PATCH 04/21] add more dehydration utility functions --- src/crypto-api.ts | 19 +++++++++++++++++ src/crypto/index.ts | 22 ++++++++++++++++++++ src/rust-crypto/dehydration.ts | 37 +++++++++++++++++++++++++++++++--- src/rust-crypto/rust-crypto.ts | 19 +++++++++++++++++ 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/crypto-api.ts b/src/crypto-api.ts index babbd839e37..dd4cd9a4cf3 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -503,6 +503,12 @@ export interface CryptoApi { // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + /** + * Returns whether MSC3814 dehydrated devices are supported by the crypto + * backend and by the server. + */ + isDehydrationSupported(): Promise; + /** * Rehydrate the dehydrated device stored on the server * @@ -528,6 +534,19 @@ export interface CryptoApi { * Defaults to creating the device immediately. */ scheduleDeviceDehydration(interval: number, delay?: number): Promise; + + /** Return whether the dehydration key is stored in Secret Storage + */ + isDehydrationKeyStored(): Promise; + + /** + * Reset the dehydration key + * + * Note: this does not create a new dehydrated device. This will need to + * be done either by calling `createAndUploadDehydratedDevice` or + * `scheduleDeviceDehydration`. + */ + resetDehydrationKey(): Promise; } /** diff --git a/src/crypto/index.ts b/src/crypto/index.ts index a06df65ef14..1eedbb5e7e3 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -4311,6 +4311,14 @@ export class Crypto extends TypedEventEmitter { + return false; + } + /** * Stub function -- dehydration is not implemented here, so always return false */ @@ -4331,6 +4339,20 @@ export class Crypto extends TypedEventEmitter { throw new Error("Not implemented"); } + + /** + * Stub function -- dehydration is not implemented here, so throw error + */ + public async isDehydrationKeyStored(): Promise { + return false; + } + + /** + * Stub function -- dehydration is not implemented here, so throw error + */ + public async resetDehydrationKey(): Promise { + throw new Error("Not implemented"); + } } /** diff --git a/src/rust-crypto/dehydration.ts b/src/rust-crypto/dehydration.ts index 756928e7237..84de59c9a08 100644 --- a/src/rust-crypto/dehydration.ts +++ b/src/rust-crypto/dehydration.ts @@ -62,12 +62,45 @@ export class RustDehydrationManager { this.dehydratedDevices = olmMachine.dehydratedDevices(); } + /** Return whether the server supports dehydrated devices + */ + public async isSupported(): Promise { + // call the endpoint to get a dehydrated device. If it returns an + // M_UNRECOGNIZED error, then dehydration is unsupported + try { + await this.http.authedRequest( + Method.Get, + "/dehydrated_device", + undefined, + undefined, + { + prefix: UnstablePrefix, + }, + ); + } catch (error) { + const err = error as MatrixError; + if (err.errcode === "M_UNRECOGNIZED") { + return false; + } + } + return true; + } + /** Return whether the dehydration key is stored in Secret Storage */ public async isKeyStored(): Promise { return Boolean(await this.secretStorage.isStored(SECRET_STORAGE_NAME.name)); } + /** Reset the dehydration key + */ + public async resetKey(): Promise { + const key = new Uint8Array(32); + crypto.getRandomValues(key); + await this.secretStorage.store(SECRET_STORAGE_NAME.name, encodeUnpaddedBase64(key)); + this.key = key; + } + /** Get and cache the encryption key from secret storage * * If `create` is `true`, creates a new key if no existing key is present. @@ -79,9 +112,7 @@ export class RustDehydrationManager { if (!create) { return false; } - this.key = new Uint8Array(32); - crypto.getRandomValues(this.key); - await this.secretStorage.store(SECRET_STORAGE_NAME.name, encodeUnpaddedBase64(this.key)); + await this.resetKey(); } else { this.key = decodeBase64(keyB64); } diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 906b8b2f031..426b6eda1f8 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -1638,6 +1638,14 @@ export class RustCrypto extends TypedEventEmitter { + return await this.dehydrationManager.isSupported(); + } + /** * Rehydrate the dehydrated device stored on the server * @@ -1675,6 +1683,17 @@ export class RustCrypto extends TypedEventEmitter { return await this.dehydrationManager.scheduleDeviceDehydration(interval, delay); } + + /** + * Reset the dehydration key + * + * Note: this does not create a new dehydrated device. This will need to + * be done either by calling `createAndUploadDehydratedDevice` or + * `scheduleDeviceDehydration`. + */ + public async resetDehydrationKey(): Promise { + return await this.dehydrationManager.resetKey(); + } } class EventDecryptor { From 1def99adab64daeae4b094a7ed5ab262bd4f3525 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Tue, 12 Mar 2024 14:49:04 -0400 Subject: [PATCH 05/21] stop scheduled dehydration when crypto stops --- src/rust-crypto/dehydration.ts | 20 ++++++++++++-------- src/rust-crypto/rust-crypto.ts | 1 + 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/rust-crypto/dehydration.ts b/src/rust-crypto/dehydration.ts index 84de59c9a08..d512bb48e0b 100644 --- a/src/rust-crypto/dehydration.ts +++ b/src/rust-crypto/dehydration.ts @@ -233,14 +233,7 @@ export class RustDehydrationManager { */ public async scheduleDeviceDehydration(interval: number, delay?: number): Promise { // cancel any previously-scheduled tasks - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = undefined; - } - if (this.timeoutId) { - clearTimeout(this.timeoutId); - this.timeoutId = undefined; - } + this.stop(); if (delay) { this.timeoutId = setTimeout(() => { @@ -253,4 +246,15 @@ export class RustDehydrationManager { this.intervalId = setInterval(this.createAndUploadDehydratedDevice.bind(this), interval); } } + + public stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + } } diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 426b6eda1f8..7f66935776a 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -218,6 +218,7 @@ export class RustCrypto extends TypedEventEmitter Date: Fri, 22 Mar 2024 22:47:19 -0400 Subject: [PATCH 06/21] bump matrix-crypto-sdk-wasm version, and fix tests --- package.json | 2 +- spec/unit/rust-crypto/backup.spec.ts | 1 + spec/unit/rust-crypto/rust-crypto.spec.ts | 82 ++++++++++++++++++++++- src/rust-crypto/dehydration.ts | 27 ++++---- yarn.lock | 39 ++--------- 5 files changed, 101 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index 09e51551b62..a33e6630b0d 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ ], "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-wasm": "^4.6.0", + "@matrix-org/matrix-sdk-crypto-wasm": "^4.8.0", "another-json": "^0.2.0", "bs58": "^5.0.0", "content-type": "^1.0.4", diff --git a/spec/unit/rust-crypto/backup.spec.ts b/spec/unit/rust-crypto/backup.spec.ts index 1a9c1435663..3a3bf3244d7 100644 --- a/spec/unit/rust-crypto/backup.spec.ts +++ b/spec/unit/rust-crypto/backup.spec.ts @@ -52,6 +52,7 @@ describe("Upload keys to backup", () => { decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64), } as unknown as RustSdkCryptoJs.BackupKeys), backupRoomKeys: jest.fn(), + dehydratedDevices: jest.fn(), isBackupEnabled: jest.fn().mockResolvedValue(true), enableBackupV1: jest.fn(), verifyBackup: jest.fn().mockResolvedValue({ diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 0e2469dc4a4..5be4e51c3ce 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -93,6 +93,7 @@ describe("initRustCrypto", () => { registerUserIdentityUpdatedCallback: jest.fn(), getSecretsFromInbox: jest.fn().mockResolvedValue([]), deleteSecretsFromInbox: jest.fn(), + dehydratedDevices: jest.fn(), registerReceiveSecretCallback: jest.fn(), registerDevicesUpdatedCallback: jest.fn(), outgoingRequests: jest.fn(), @@ -662,6 +663,7 @@ describe("RustCrypto", () => { // returns objects from outgoingRequestQueue outgoingRequestQueue = []; olmMachine = { + dehydratedDevices: jest.fn(), outgoingRequests: jest.fn().mockImplementation(() => { return Promise.resolve(outgoingRequestQueue.shift() ?? []); }), @@ -814,6 +816,7 @@ describe("RustCrypto", () => { beforeEach(() => { olmMachine = { + dehydratedDevices: jest.fn(), getRoomEventEncryptionInfo: jest.fn(), } as unknown as Mocked; rustCrypto = new RustCrypto( @@ -1009,6 +1012,7 @@ describe("RustCrypto", () => { beforeEach(() => { olmMachine = { + dehydratedDevices: jest.fn(), getDevice: jest.fn(), } as unknown as Mocked; rustCrypto = new RustCrypto( @@ -1253,6 +1257,7 @@ describe("RustCrypto", () => { beforeEach(() => { olmMachine = { getIdentity: jest.fn(), + dehydratedDevices: jest.fn(), } as unknown as Mocked; rustCrypto = new RustCrypto( logger, @@ -1302,6 +1307,7 @@ describe("RustCrypto", () => { fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA); const olmMachine = { + dehydratedDevices: jest.fn(), getIdentity: jest.fn(), // Force the backup to be trusted by the olmMachine verifyBackup: jest.fn().mockResolvedValue({ trusted: jest.fn().mockReturnValue(true) }), @@ -1401,8 +1407,6 @@ describe("RustCrypto", () => { describe("dehydration", () => { it("should rehydrate and dehydrate a device", async () => { - jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); - const secretStorageCallbacks = { getSecretStorageKey: async (keys: any, name: string) => { return [[...Object.keys(keys.keys)][0], new Uint8Array(32)]; @@ -1474,6 +1478,80 @@ describe("RustCrypto", () => { expect(await rustCrypto.rehydrateDeviceIfAvailable()).toBe(true); }); + + it("should schedule regular creation of dehydrated devices", async () => { + jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); + + 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 rustCrypto = await makeTestRustCrypto( + new MatrixHttpApi(new TypedEventEmitter(), { + baseUrl: "http://server/", + prefix: "", + onlyData: true, + }), + testData.TEST_USER_ID, + undefined, + secretStorage, + ); + const olmMachine: OlmMachine = rustCrypto["olmMachine"]; + rustCrypto["checkKeyBackupAndEnable"] = async () => { + return null; + }; + + const outgoingRequestProcessor = { + makeOutgoingRequest: jest.fn(), + } as unknown as OutgoingRequestProcessor; + rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor; + (rustCrypto["crossSigningIdentity"] as any)["outgoingRequestProcessor"] = outgoingRequestProcessor; + (rustCrypto["dehydrationManager"] as any)["outgoingRequestProcessor"] = outgoingRequestProcessor; + const outgoingRequestsManager = new OutgoingRequestsManager(logger, olmMachine, outgoingRequestProcessor); + rustCrypto["outgoingRequestsManager"] = outgoingRequestsManager; + + async function createSecretStorageKey() { + return { + keyInfo: {} as AddSecretStorageKeyOpts, + privateKey: new Uint8Array(32), + }; + } + + // create initial secret storage + await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); + await rustCrypto.bootstrapSecretStorage({ + createSecretStorageKey, + setupNewSecretStorage: true, + setupNewKeyBackup: false, + }); + + // when we schedule dehydration, it should create a dehydrated + // device immediately + const makeDehydrationRequest = (outgoingRequestProcessor.makeOutgoingRequest = jest.fn( + async (req, uiaCallback) => { + expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); + }, + )); + await rustCrypto.scheduleDeviceDehydration(30000); + + expect(makeDehydrationRequest).toHaveBeenCalled(); + + // after we advance the timer, it should create another dehydrated device + const secondDehydrationPromise = new Promise((resolve, reject) => { + outgoingRequestProcessor.makeOutgoingRequest = jest.fn(async (req, uiaCallback) => { + expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); + resolve(); + }); + }); + jest.advanceTimersByTime(35000); + + await secondDehydrationPromise; + + rustCrypto.stop(); + }); }); }); diff --git a/src/rust-crypto/dehydration.ts b/src/rust-crypto/dehydration.ts index d512bb48e0b..1b056a4f868 100644 --- a/src/rust-crypto/dehydration.ts +++ b/src/rust-crypto/dehydration.ts @@ -46,7 +46,6 @@ const SECRET_STORAGE_NAME = new UnstableValue("m.dehydrated_device", "org.matrix * Manages dehydrated devices */ export class RustDehydrationManager { - private readonly dehydratedDevices: RustSdkCryptoJs.DehydratedDevices; private key?: Uint8Array; private intervalId?: ReturnType; private timeoutId?: ReturnType; @@ -54,13 +53,11 @@ export class RustDehydrationManager { public constructor( private readonly logger: Logger, private readonly rustCrypto: RustCrypto, - olmMachine: RustSdkCryptoJs.OlmMachine, + private readonly olmMachine: RustSdkCryptoJs.OlmMachine, private readonly http: MatrixHttpApi, private readonly outgoingRequestProcessor: OutgoingRequestProcessor, private readonly secretStorage: ServerSideSecretStorage, - ) { - this.dehydratedDevices = olmMachine.dehydratedDevices(); - } + ) {} /** Return whether the server supports dehydrated devices */ @@ -158,11 +155,13 @@ export class RustDehydrationManager { this.logger.info("dehydration: device found"); - const rehydratedDevice = await this.dehydratedDevices.rehydrate( - this.key!, - new RustSdkCryptoJs.DeviceId(dehydratedDeviceResp.device_id), - JSON.stringify(dehydratedDeviceResp.device_data), - ); + const rehydratedDevice = await this.olmMachine + .dehydratedDevices() + .rehydrate( + this.key!, + new RustSdkCryptoJs.DeviceId(dehydratedDeviceResp.device_id), + JSON.stringify(dehydratedDeviceResp.device_data), + ); this.logger.info("dehydration: device rehydrated"); @@ -210,9 +209,9 @@ export class RustDehydrationManager { public async createAndUploadDehydratedDevice(): Promise { await this.getKey(true); - // FIXME: should raise error if server doesn't support dehydration + // FIXME: should raise error if server doesn't support dehydration? - const dehydratedDevice = await this.dehydratedDevices.create(); + const dehydratedDevice = await this.olmMachine.dehydratedDevices().create(); // FIXME: should the device display name be localised? passed as a // parameter? const request = await dehydratedDevice.keysForUpload("Dehydrated device", this.key!); @@ -220,8 +219,6 @@ export class RustDehydrationManager { await this.outgoingRequestProcessor.makeOutgoingRequest(request); this.logger.info("dehydration: uploaded device"); - - // FIXME: emit event when done } /** @@ -242,7 +239,7 @@ export class RustDehydrationManager { }, delay); } else { await this.createAndUploadDehydratedDevice(); - // FIXME: should we randomize the time? + // FIXME: should we randomize the time a bit? this.intervalId = setInterval(this.createAndUploadDehydratedDevice.bind(this), interval); } } diff --git a/yarn.lock b/yarn.lock index 4c24eeb51f9..e8e96008ff6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1709,10 +1709,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@matrix-org/matrix-sdk-crypto-wasm@^4.6.0": - version "4.7.0" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-4.7.0.tgz#831588a192fd2c79f5f6dec930392717d3fa3331" - integrity sha512-07UqoxUolP2RJfXGwN1U9+1wWfHde0SilSzvdOU6RagJqQ3SdKERPBcgp20texOvIl65K57kJ81tD//+mj8skQ== +"@matrix-org/matrix-sdk-crypto-wasm@=4.8.0": + version "4.8.0" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-4.8.0.tgz#6179612dbad43b241a02a5d7fc7c0aa9004299be" + integrity sha512-kp9QREHTX3IkGVfTYQwkSnSarXQnx42Y2SvHx1oZ19nA+k1vQtCKBf0OFTFqIXED6c3I10ZzT9SLH8i8m/9WbA== "@matrix-org/olm@3.2.15": version "3.2.15" @@ -5875,16 +5875,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5938,14 +5929,7 @@ string.prototype.trimstart@^1.0.7: define-properties "^1.2.0" es-abstract "^1.22.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6463,16 +6447,7 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 12a934c7368695ca7ed74192453a6b01335c471c Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Sun, 24 Mar 2024 11:57:46 -0400 Subject: [PATCH 07/21] adding dehydratedDevices member to mock OlmDevice isn't necessary any more --- spec/unit/rust-crypto/backup.spec.ts | 1 - spec/unit/rust-crypto/rust-crypto.spec.ts | 6 ------ 2 files changed, 7 deletions(-) diff --git a/spec/unit/rust-crypto/backup.spec.ts b/spec/unit/rust-crypto/backup.spec.ts index 3a3bf3244d7..1a9c1435663 100644 --- a/spec/unit/rust-crypto/backup.spec.ts +++ b/spec/unit/rust-crypto/backup.spec.ts @@ -52,7 +52,6 @@ describe("Upload keys to backup", () => { decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64), } as unknown as RustSdkCryptoJs.BackupKeys), backupRoomKeys: jest.fn(), - dehydratedDevices: jest.fn(), isBackupEnabled: jest.fn().mockResolvedValue(true), enableBackupV1: jest.fn(), verifyBackup: jest.fn().mockResolvedValue({ diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 5be4e51c3ce..51dc6c5b823 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -93,7 +93,6 @@ describe("initRustCrypto", () => { registerUserIdentityUpdatedCallback: jest.fn(), getSecretsFromInbox: jest.fn().mockResolvedValue([]), deleteSecretsFromInbox: jest.fn(), - dehydratedDevices: jest.fn(), registerReceiveSecretCallback: jest.fn(), registerDevicesUpdatedCallback: jest.fn(), outgoingRequests: jest.fn(), @@ -663,7 +662,6 @@ describe("RustCrypto", () => { // returns objects from outgoingRequestQueue outgoingRequestQueue = []; olmMachine = { - dehydratedDevices: jest.fn(), outgoingRequests: jest.fn().mockImplementation(() => { return Promise.resolve(outgoingRequestQueue.shift() ?? []); }), @@ -816,7 +814,6 @@ describe("RustCrypto", () => { beforeEach(() => { olmMachine = { - dehydratedDevices: jest.fn(), getRoomEventEncryptionInfo: jest.fn(), } as unknown as Mocked; rustCrypto = new RustCrypto( @@ -1012,7 +1009,6 @@ describe("RustCrypto", () => { beforeEach(() => { olmMachine = { - dehydratedDevices: jest.fn(), getDevice: jest.fn(), } as unknown as Mocked; rustCrypto = new RustCrypto( @@ -1257,7 +1253,6 @@ describe("RustCrypto", () => { beforeEach(() => { olmMachine = { getIdentity: jest.fn(), - dehydratedDevices: jest.fn(), } as unknown as Mocked; rustCrypto = new RustCrypto( logger, @@ -1307,7 +1302,6 @@ describe("RustCrypto", () => { fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA); const olmMachine = { - dehydratedDevices: jest.fn(), getIdentity: jest.fn(), // Force the backup to be trusted by the olmMachine verifyBackup: jest.fn().mockResolvedValue({ trusted: jest.fn().mockReturnValue(true) }), From 937fac2e3442624cc4b05b74a47faf7304e5956f Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Sun, 24 Mar 2024 12:02:47 -0400 Subject: [PATCH 08/21] fix yarn lock file --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index e8e96008ff6..0343255da3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1709,7 +1709,7 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@matrix-org/matrix-sdk-crypto-wasm@=4.8.0": +"@matrix-org/matrix-sdk-crypto-wasm@^4.8.0": version "4.8.0" resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-4.8.0.tgz#6179612dbad43b241a02a5d7fc7c0aa9004299be" integrity sha512-kp9QREHTX3IkGVfTYQwkSnSarXQnx42Y2SvHx1oZ19nA+k1vQtCKBf0OFTFqIXED6c3I10ZzT9SLH8i8m/9WbA== From 49dedf279980bd87212e40add6cea852cc59211c Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Mon, 25 Mar 2024 17:40:13 -0400 Subject: [PATCH 09/21] more tests --- spec/unit/rust-crypto/rust-crypto.spec.ts | 87 ++++++++++++++++++----- src/rust-crypto/dehydration.ts | 1 + 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 51dc6c5b823..05aafa2b48f 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1409,11 +1409,7 @@ describe("RustCrypto", () => { const secretStorage = new ServerSideSecretStorageImpl(new DummyAccountDataClient(), secretStorageCallbacks); const rustCrypto = await makeTestRustCrypto( - new MatrixHttpApi(new TypedEventEmitter(), { - baseUrl: "http://server/", - prefix: "", - onlyData: true, - }), + makeMatrixHttpApi(), testData.TEST_USER_ID, undefined, secretStorage, @@ -1428,7 +1424,6 @@ describe("RustCrypto", () => { } as unknown as OutgoingRequestProcessor; rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor; (rustCrypto["crossSigningIdentity"] as any)["outgoingRequestProcessor"] = outgoingRequestProcessor; - (rustCrypto["dehydrationManager"] as any)["outgoingRequestProcessor"] = outgoingRequestProcessor; const outgoingRequestsManager = new OutgoingRequestsManager(logger, olmMachine, outgoingRequestProcessor); rustCrypto["outgoingRequestsManager"] = outgoingRequestsManager; @@ -1447,17 +1442,25 @@ describe("RustCrypto", () => { setupNewKeyBackup: false, }); - let dehydratedDeviceBody: any; - const makeDehydrationRequest = (outgoingRequestProcessor.makeOutgoingRequest = jest.fn( - async (req, uiaCallback) => { - expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); - dehydratedDeviceBody = JSON.parse((req as RustSdkCryptoJs.PutDehydratedDeviceRequest).body); + // there isn't any dehydrated device yet + fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { + status: 404, + body: { + errcode: "M_NOT_FOUND", + error: "Not found", }, - )); - await rustCrypto.createAndUploadDehydratedDevice(); + }); + expect(await rustCrypto.rehydrateDeviceIfAvailable()).toBe(false); - expect(makeDehydrationRequest).toHaveBeenCalled(); + // create a dehydrated device + 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 rustCrypto.createAndUploadDehydratedDevice(); + // rehydrate the device that we just created fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { device_id: dehydratedDeviceBody.device_id, device_data: dehydratedDeviceBody.device_data, @@ -1484,11 +1487,7 @@ describe("RustCrypto", () => { const secretStorage = new ServerSideSecretStorageImpl(new DummyAccountDataClient(), secretStorageCallbacks); const rustCrypto = await makeTestRustCrypto( - new MatrixHttpApi(new TypedEventEmitter(), { - baseUrl: "http://server/", - prefix: "", - onlyData: true, - }), + makeMatrixHttpApi(), testData.TEST_USER_ID, undefined, secretStorage, @@ -1546,6 +1545,56 @@ describe("RustCrypto", () => { rustCrypto.stop(); }); + + it("should detect if dehydration is supported", async () => { + const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi()); + fetchMock.config.overwriteRoutes = true; + fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { + status: 404, + body: { + errcode: "M_UNRECOGNIZED", + error: "Unknown endpoint", + }, + }); + expect(await rustCrypto.isDehydrationSupported()).toBe(false); + fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { + device_id: "DEVICE_ID", + device_data: "data", + }); + expect(await rustCrypto.isDehydrationSupported()).toBe(true); + }); + + it("should detect if dehydration key is stored", 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 rustCrypto = await makeTestRustCrypto( + makeMatrixHttpApi(), + testData.TEST_USER_ID, + undefined, + secretStorage, + ); + async function createSecretStorageKey() { + return { + keyInfo: {} as AddSecretStorageKeyOpts, + privateKey: new Uint8Array(32), + }; + } + await rustCrypto.bootstrapSecretStorage({ + createSecretStorageKey, + setupNewSecretStorage: true, + setupNewKeyBackup: false, + }); + + expect(await rustCrypto.isDehydrationKeyStored()).toBe(false); + + await rustCrypto.resetDehydrationKey(); + expect(await rustCrypto.isDehydrationKeyStored()).toBe(true); + }); }); }); diff --git a/src/rust-crypto/dehydration.ts b/src/rust-crypto/dehydration.ts index 1b056a4f868..d9440f29869 100644 --- a/src/rust-crypto/dehydration.ts +++ b/src/rust-crypto/dehydration.ts @@ -79,6 +79,7 @@ export class RustDehydrationManager { if (err.errcode === "M_UNRECOGNIZED") { return false; } + throw error; } return true; } From 7aa13df0579dcdec756d91f21e5f9fb273960aec Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Mon, 25 Mar 2024 18:24:41 -0400 Subject: [PATCH 10/21] fix test --- spec/unit/rust-crypto/rust-crypto.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 05aafa2b48f..349ec80cb83 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1443,6 +1443,7 @@ describe("RustCrypto", () => { }); // there isn't any dehydrated device yet + fetchMock.config.overwriteRoutes = true; fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { status: 404, body: { From 3aa06dd88b6bc054a08872939ae036b7423c0eed Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Mon, 25 Mar 2024 21:36:16 -0400 Subject: [PATCH 11/21] more tests --- spec/unit/rust-crypto/rust-crypto.spec.ts | 72 +++++++++++++++++++---- src/rust-crypto/dehydration.ts | 2 +- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 349ec80cb83..24d100733b6 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1466,15 +1466,27 @@ describe("RustCrypto", () => { device_id: dehydratedDeviceBody.device_id, device_data: dehydratedDeviceBody.device_data, }); + const eventsResponse = jest.fn((url, opts) => { + // rehydrating should make two calls to the /events endpoint. + // The first time will return a single event, and the second + // time will return no events (which will signal to the + // rehydration function that it can stop) + const body = JSON.parse(opts.body as string); + const nextBatch = body.next_batch ?? "0"; + const events = + nextBatch === "0" ? [{ sender: testData.TEST_USER_ID, type: "m.dummy", content: {} }] : []; + return { + events, + next_batch: nextBatch + "1", + }; + }); fetchMock.post( `path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/${encodeURIComponent(dehydratedDeviceBody.device_id)}/events`, - { - events: [], - next_batch: "token", - }, + eventsResponse, ); expect(await rustCrypto.rehydrateDeviceIfAvailable()).toBe(true); + expect(eventsResponse.mock.calls).toHaveLength(2); }); it("should schedule regular creation of dehydrated devices", async () => { @@ -1522,18 +1534,18 @@ describe("RustCrypto", () => { setupNewKeyBackup: false, }); - // when we schedule dehydration, it should create a dehydrated - // device immediately - const makeDehydrationRequest = (outgoingRequestProcessor.makeOutgoingRequest = jest.fn( - async (req, uiaCallback) => { - expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); - }, - )); + // when we schedule dehydration with no delay, it should create a + // dehydrated device immediately + outgoingRequestProcessor.makeOutgoingRequest = jest.fn(async (req, uiaCallback) => { + expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); + }); await rustCrypto.scheduleDeviceDehydration(30000); - expect(makeDehydrationRequest).toHaveBeenCalled(); + expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalled(); // after we advance the timer, it should create another dehydrated device + // we make this a promise so that we can await it to make sure it gets + // called const secondDehydrationPromise = new Promise((resolve, reject) => { outgoingRequestProcessor.makeOutgoingRequest = jest.fn(async (req, uiaCallback) => { expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); @@ -1544,7 +1556,28 @@ describe("RustCrypto", () => { await secondDehydrationPromise; + // when we schedule dehydration with a delay, it should not create + // a dehydrated device immediately + const thirdDehydrationPromise = new Promise((resolve, reject) => { + outgoingRequestProcessor.makeOutgoingRequest = jest.fn(async (req, uiaCallback) => { + expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); + resolve(); + }); + }); + await rustCrypto.scheduleDeviceDehydration(30000, 10000); + expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled(); + jest.advanceTimersByTime(15000); + await thirdDehydrationPromise; + + // when we stop rustCrypto, any pending device dehydration tasks + // should be cancelled + outgoingRequestProcessor.makeOutgoingRequest = jest.fn(async (req, uiaCallback) => { + expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); + }); + await rustCrypto.scheduleDeviceDehydration(30000, 10000); rustCrypto.stop(); + jest.advanceTimersByTime(15000); + expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled(); }); it("should detect if dehydration is supported", async () => { @@ -1595,6 +1628,21 @@ describe("RustCrypto", () => { await rustCrypto.resetDehydrationKey(); expect(await rustCrypto.isDehydrationKeyStored()).toBe(true); + + // rehydrateDeviceIfAvailable should check the server to see if a + // dehydrated device is present, because the dehydration key is set + const response = jest.fn(() => { + return { + status: 404, + body: { + errcode: "M_NOT_FOUND", + error: "Not found", + }, + }; + }); + fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", response); + expect(await rustCrypto.rehydrateDeviceIfAvailable()).toBe(false); + expect(response).toHaveBeenCalled(); }); }); }); diff --git a/src/rust-crypto/dehydration.ts b/src/rust-crypto/dehydration.ts index d9440f29869..650f3288931 100644 --- a/src/rust-crypto/dehydration.ts +++ b/src/rust-crypto/dehydration.ts @@ -191,7 +191,7 @@ export class RustDehydrationManager { const roomKeyInfos: RustSdkCryptoJs.RoomKeyInfo[] = await rehydratedDevice.receiveEvents( JSON.stringify(eventResp.events), ); - roomKeyCount += eventResp.events.length; + roomKeyCount += roomKeyInfos.length; // FIXME: is this actually needed? It looks like the OlmMachine // automatically re-tries decryption From 7c1e82e73421ce361ae26ba3433042ae4ed9b447 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Tue, 26 Mar 2024 10:46:20 -0400 Subject: [PATCH 12/21] fix typo --- spec/unit/rust-crypto/rust-crypto.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 24d100733b6..5e3c1b74ffc 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -766,7 +766,7 @@ describe("RustCrypto", () => { request instanceof RustSdkCryptoJs.UploadSigningKeysRequest || request instanceof RustSdkCryptoJs.PutDehydratedDeviceRequest ) { - // These request types does not implement OutgoingRequest and does not need to be marked as sent. + // These request types do not implement OutgoingRequest and do not need to be marked as sent. return; } if (request.id) { From 663448b550d57c968cbcbc8a22b95acfc41d492a Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Tue, 26 Mar 2024 16:09:36 -0400 Subject: [PATCH 13/21] fix logic for checking if dehydration supported --- spec/unit/rust-crypto/rust-crypto.spec.ts | 8 ++++++++ src/rust-crypto/dehydration.ts | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 5e3c1b74ffc..61b5a419d25 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1591,6 +1591,14 @@ describe("RustCrypto", () => { }, }); expect(await rustCrypto.isDehydrationSupported()).toBe(false); + fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { + status: 404, + body: { + errcode: "M_NOT_FOUND", + error: "Unknown endpoint", + }, + }); + expect(await rustCrypto.isDehydrationSupported()).toBe(true); fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { device_id: "DEVICE_ID", device_data: "data", diff --git a/src/rust-crypto/dehydration.ts b/src/rust-crypto/dehydration.ts index 650f3288931..32316dd2ead 100644 --- a/src/rust-crypto/dehydration.ts +++ b/src/rust-crypto/dehydration.ts @@ -63,7 +63,9 @@ export class RustDehydrationManager { */ public async isSupported(): Promise { // call the endpoint to get a dehydrated device. If it returns an - // M_UNRECOGNIZED error, then dehydration is unsupported + // M_UNRECOGNIZED error, then dehydration is unsupported. If it returns + // a successful response, or an M_NOT_FOUND, then dehydration is supported. + // Any other exceptions are passed through. try { await this.http.authedRequest( Method.Get, @@ -78,6 +80,8 @@ export class RustDehydrationManager { const err = error as MatrixError; if (err.errcode === "M_UNRECOGNIZED") { return false; + } else if (err.errcode === "M_NOT_FOUND") { + return true; } throw error; } From d870915fbed56a3fd22330e59fe3da575d9ac6f5 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Wed, 27 Mar 2024 23:57:42 -0400 Subject: [PATCH 14/21] make changes from review --- .../OutgoingRequestProcessor.spec.ts | 30 ++++ spec/unit/rust-crypto/rust-crypto.spec.ts | 160 ++++++------------ src/crypto-api.ts | 37 +++- ...ydration.ts => DehydratedDeviceManager.ts} | 128 ++++++++------ src/rust-crypto/OutgoingRequestProcessor.ts | 2 +- src/rust-crypto/rust-crypto.ts | 126 ++++++-------- 6 files changed, 239 insertions(+), 244 deletions(-) rename src/rust-crypto/{dehydration.ts => DehydratedDeviceManager.ts} (67%) diff --git a/spec/unit/rust-crypto/OutgoingRequestProcessor.spec.ts b/spec/unit/rust-crypto/OutgoingRequestProcessor.spec.ts index e63243d2913..4074e1c9a32 100644 --- a/spec/unit/rust-crypto/OutgoingRequestProcessor.spec.ts +++ b/spec/unit/rust-crypto/OutgoingRequestProcessor.spec.ts @@ -22,6 +22,7 @@ import { KeysClaimRequest, KeysQueryRequest, KeysUploadRequest, + PutDehydratedDeviceRequest, RoomMessageRequest, SignatureUploadRequest, UploadSigningKeysRequest, @@ -233,6 +234,35 @@ describe("OutgoingRequestProcessor", () => { httpBackend.verifyNoOutstandingRequests(); }); + it("should handle PutDehydratedDeviceRequest", async () => { + // first, mock up a request as we might expect to receive it from the Rust layer ... + const testReq = { foo: "bar" }; + const outgoingRequest = new PutDehydratedDeviceRequest(JSON.stringify(testReq)); + + // ... then poke the request into the OutgoingRequestProcessor under test + const reqProm = processor.makeOutgoingRequest(outgoingRequest); + + // Now: check that it makes a matching HTTP request. + const testResponse = '{"result":1}'; + httpBackend + .when("PUT", "/_matrix") + .check((req) => { + expect(req.path).toEqual( + "https://example.com/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", + ); + expect(JSON.parse(req.rawData)).toEqual(testReq); + expect(req.headers["Accept"]).toEqual("application/json"); + expect(req.headers["Content-Type"]).toEqual("application/json"); + }) + .respond(200, testResponse, true); + + // PutDehydratedDeviceRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected. + + await httpBackend.flushAllExpected(); + await reqProm; + httpBackend.verifyNoOutstandingRequests(); + }); + it("does not explode with unknown requests", async () => { const outgoingRequest = { id: "5678", type: 987 }; const markSentCallPromise = awaitCallToMarkAsSent(); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 61b5a419d25..636f0d98833 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1399,8 +1399,10 @@ describe("RustCrypto", () => { }); }); - describe("dehydration", () => { - it("should rehydrate and dehydrate a device", async () => { + describe("device dehydration", () => { + it("should schedule regular creation of dehydrated devices", async () => { + jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); + const secretStorageCallbacks = { getSecretStorageKey: async (keys: any, name: string) => { return [[...Object.keys(keys.keys)][0], new Uint8Array(32)]; @@ -1414,119 +1416,43 @@ describe("RustCrypto", () => { undefined, secretStorage, ); - const olmMachine: OlmMachine = rustCrypto["olmMachine"]; - rustCrypto["checkKeyBackupAndEnable"] = async () => { - return null; - }; - - const outgoingRequestProcessor = { - makeOutgoingRequest: jest.fn(), - } as unknown as OutgoingRequestProcessor; - rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor; - (rustCrypto["crossSigningIdentity"] as any)["outgoingRequestProcessor"] = outgoingRequestProcessor; - const outgoingRequestsManager = new OutgoingRequestsManager(logger, olmMachine, outgoingRequestProcessor); - rustCrypto["outgoingRequestsManager"] = outgoingRequestsManager; - - async function createSecretStorageKey() { - return { - keyInfo: {} as AddSecretStorageKeyOpts, - privateKey: new Uint8Array(32), - }; - } - - // create initial secret storage - await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); - await rustCrypto.bootstrapSecretStorage({ - createSecretStorageKey, - setupNewSecretStorage: true, - setupNewKeyBackup: false, - }); - // there isn't any dehydrated device yet - fetchMock.config.overwriteRoutes = true; - fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { + // set up various fetch-mocks to handle account setup + fetchMock.get("path:/_matrix/client/v3/room_keys/version", { status: 404, body: { errcode: "M_NOT_FOUND", error: "Not found", }, }); - expect(await rustCrypto.rehydrateDeviceIfAvailable()).toBe(false); - - // create a dehydrated device - 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 rustCrypto.createAndUploadDehydratedDevice(); - - // rehydrate the device that we just created - fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { - device_id: dehydratedDeviceBody.device_id, - device_data: dehydratedDeviceBody.device_data, + let deviceKeys: { device_id: string }; + fetchMock.post("path:/_matrix/client/v3/keys/upload", (_, opts) => { + deviceKeys = JSON.parse(opts.body as string).device_keys; + return { one_time_key_counts: { signed_curve25519: 100 } }; }); - const eventsResponse = jest.fn((url, opts) => { - // rehydrating should make two calls to the /events endpoint. - // The first time will return a single event, and the second - // time will return no events (which will signal to the - // rehydration function that it can stop) - const body = JSON.parse(opts.body as string); - const nextBatch = body.next_batch ?? "0"; - const events = - nextBatch === "0" ? [{ sender: testData.TEST_USER_ID, type: "m.dummy", content: {} }] : []; - return { - events, - next_batch: nextBatch + "1", - }; + fetchMock.post("path:/_matrix/client/v3/keys/query", (_, opts) => { + if (deviceKeys) { + return { + device_keys: { + [testData.TEST_USER_ID]: { + [deviceKeys["device_id"]]: deviceKeys, + }, + }, + }; + } else { + return {}; + } }); - fetchMock.post( - `path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/${encodeURIComponent(dehydratedDeviceBody.device_id)}/events`, - eventsResponse, - ); - - expect(await rustCrypto.rehydrateDeviceIfAvailable()).toBe(true); - expect(eventsResponse.mock.calls).toHaveLength(2); - }); - - it("should schedule regular creation of dehydrated devices", async () => { - jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); - - 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 rustCrypto = await makeTestRustCrypto( - makeMatrixHttpApi(), - testData.TEST_USER_ID, - undefined, - secretStorage, - ); - const olmMachine: OlmMachine = rustCrypto["olmMachine"]; - rustCrypto["checkKeyBackupAndEnable"] = async () => { - return null; - }; - - const outgoingRequestProcessor = { - makeOutgoingRequest: jest.fn(), - } as unknown as OutgoingRequestProcessor; - rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor; - (rustCrypto["crossSigningIdentity"] as any)["outgoingRequestProcessor"] = outgoingRequestProcessor; - (rustCrypto["dehydrationManager"] as any)["outgoingRequestProcessor"] = outgoingRequestProcessor; - const outgoingRequestsManager = new OutgoingRequestsManager(logger, olmMachine, outgoingRequestProcessor); - rustCrypto["outgoingRequestsManager"] = outgoingRequestsManager; + fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {}); + fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {}); + // create initial secret storage async function createSecretStorageKey() { return { keyInfo: {} as AddSecretStorageKeyOpts, privateKey: new Uint8Array(32), }; } - - // create initial secret storage await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); await rustCrypto.bootstrapSecretStorage({ createSecretStorageKey, @@ -1534,22 +1460,28 @@ describe("RustCrypto", () => { setupNewKeyBackup: false, }); + fetchMock.config.overwriteRoutes = true; + // when we schedule dehydration with no delay, it should create a // dehydrated device immediately - outgoingRequestProcessor.makeOutgoingRequest = jest.fn(async (req, uiaCallback) => { - expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); + const firstDehydrationRequest = jest.fn(() => { + return {}; }); + fetchMock.put( + "path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", + firstDehydrationRequest, + ); await rustCrypto.scheduleDeviceDehydration(30000); - expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalled(); + expect(firstDehydrationRequest).toHaveBeenCalled(); // after we advance the timer, it should create another dehydrated device // we make this a promise so that we can await it to make sure it gets // called const secondDehydrationPromise = new Promise((resolve, reject) => { - outgoingRequestProcessor.makeOutgoingRequest = jest.fn(async (req, uiaCallback) => { - expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); + fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => { resolve(); + return {}; }); }); jest.advanceTimersByTime(35000); @@ -1558,26 +1490,32 @@ describe("RustCrypto", () => { // when we schedule dehydration with a delay, it should not create // a dehydrated device immediately + const thirdDehydrationRequest = jest.fn(() => { + return {}; + }); const thirdDehydrationPromise = new Promise((resolve, reject) => { - outgoingRequestProcessor.makeOutgoingRequest = jest.fn(async (req, uiaCallback) => { - expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); + fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => { resolve(); + return thirdDehydrationRequest; }); }); await rustCrypto.scheduleDeviceDehydration(30000, 10000); - expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled(); + expect(thirdDehydrationRequest).not.toHaveBeenCalled(); jest.advanceTimersByTime(15000); await thirdDehydrationPromise; // when we stop rustCrypto, any pending device dehydration tasks // should be cancelled - outgoingRequestProcessor.makeOutgoingRequest = jest.fn(async (req, uiaCallback) => { - expect(req).toBeInstanceOf(RustSdkCryptoJs.PutDehydratedDeviceRequest); + const fourthDehydrationRequest = jest.fn(() => { + return {}; + }); + fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => { + return fourthDehydrationRequest; }); await rustCrypto.scheduleDeviceDehydration(30000, 10000); rustCrypto.stop(); jest.advanceTimersByTime(15000); - expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled(); + expect(fourthDehydrationRequest).not.toHaveBeenCalled(); }); it("should detect if dehydration is supported", async () => { @@ -1595,7 +1533,7 @@ describe("RustCrypto", () => { status: 404, body: { errcode: "M_NOT_FOUND", - error: "Unknown endpoint", + error: "Not found", }, }); expect(await rustCrypto.isDehydrationSupported()).toBe(true); diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 9547c673246..189de955a95 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -506,41 +506,60 @@ export interface CryptoApi { /** * Returns whether MSC3814 dehydrated devices are supported by the crypto * backend and by the server. + * + * This should be called before any of the other dehydrated device + * functions are called, and if it returns `false`, none of the other + * dehydrated device functions should be called. */ isDehydrationSupported(): Promise; /** - * Rehydrate the dehydrated device stored on the server + * Rehydrate the dehydrated device stored on the server. * * Checks if there is a dehydrated device on the server. If so, rehydrates - * the device and processes the to-device events. + * the device and processes the to-device events sent to it. This function + * should be called before a dehydrated device is created for the first + * time after the client logs in. * - * Returns whether or not a dehydrated device was found. + * @returns `true` if a dehydrated device was found; otherwise, `false`. */ rehydrateDeviceIfAvailable(): Promise; /** - * Creates and uploads a new dehydrated device + * Creates and uploads a new dehydrated device. * - * Creates and stores a new key in secret storage if none is available. + * If now dehydration key is available in secret storage, a new key is + * created. Most applications should call `scheduleDeviceDehydration` so + * that the dehydrated device gets replaced periodically with a new one, to + * avoid to-device events stacking up on the server. However, clients that + * want to have more control over the dehydration process may use this + * function instead. */ createAndUploadDehydratedDevice(): Promise; /** - * Schedule periodic creation of dehydrated devices + * Schedule periodic creation of dehydrated devices. + * + * If the delay is omitted or 0, this function's promise will resolve after + * the first dehydrated device is created. * - * @param interval - the time to wait between creating dehydrated devices + * @param interval - the time to wait between creating dehydrated devices. * @param delay - how long to wait before creating the first dehydrated device. * Defaults to creating the device immediately. */ scheduleDeviceDehydration(interval: number, delay?: number): Promise; - /** Return whether the dehydration key is stored in Secret Storage + /** + * Return whether the dehydration key is stored in Secret Storage. */ isDehydrationKeyStored(): Promise; /** - * Reset the dehydration key + * Reset the dehydration key. + * + * Creates a new dehydration key and stores it in secret storage. This + * function should be called, for example, if the user's secret storage is + * reset. * * Note: this does not create a new dehydrated device. This will need to * be done either by calling `createAndUploadDehydratedDevice` or diff --git a/src/rust-crypto/dehydration.ts b/src/rust-crypto/DehydratedDeviceManager.ts similarity index 67% rename from src/rust-crypto/dehydration.ts rename to src/rust-crypto/DehydratedDeviceManager.ts index 32316dd2ead..76b3aec6656 100644 --- a/src/rust-crypto/dehydration.ts +++ b/src/rust-crypto/DehydratedDeviceManager.ts @@ -16,50 +16,71 @@ limitations under the License. import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm"; -import { RustCrypto } from "./rust-crypto"; import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor"; import { encodeUri } from "../utils"; -import { ClientPrefix, IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api"; +import { IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api"; import { IToDeviceEvent } from "../sync-accumulator"; import { ServerSideSecretStorage } from "../secret-storage"; import { crypto } from "../crypto/crypto"; import { decodeBase64, encodeUnpaddedBase64 } from "../base64"; import { Logger } from "../logger"; -import { UnstableValue } from "../NamespacedValue"; -// schema for the API response bodies -interface IDehydratedDeviceResp { +/** + * The response body of `GET /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device`. + */ +interface DehydratedDeviceResp { device_id: string; device_data: { algorithm: string; }; } -interface IDehydratedDeviceEventsResp { +/** + * The response body of `POST /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/events`. + */ +interface DehydratedDeviceEventsResp { events: IToDeviceEvent[]; next_batch: string; } -export const UnstablePrefix = ClientPrefix.Unstable + "/org.matrix.msc3814.v1"; -const SECRET_STORAGE_NAME = new UnstableValue("m.dehydrated_device", "org.matrix.msc3814"); +/** + * The unstable URL prefix for dehydrated device endpoints + */ +export const UnstablePrefix = "/_matrix/client/unstable/org.matrix.msc3814.v1"; +/** + * The name used for the dehydration key in Secret Storage + */ +const SECRET_STORAGE_NAME = "org.matrix.msc3814"; /** * Manages dehydrated devices + * + * We have one of these per `RustCrypto`. It's responsible for + * + * * 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 + * + * @internal */ -export class RustDehydrationManager { +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; + /** the ID of the timeout for creating a new dehydrated device */ private timeoutId?: ReturnType; public constructor( private readonly logger: Logger, - private readonly rustCrypto: RustCrypto, private readonly olmMachine: RustSdkCryptoJs.OlmMachine, private readonly http: MatrixHttpApi, private readonly outgoingRequestProcessor: OutgoingRequestProcessor, private readonly secretStorage: ServerSideSecretStorage, ) {} - /** Return whether the server supports dehydrated devices + /** + * Return whether the server supports dehydrated devices. */ public async isSupported(): Promise { // call the endpoint to get a dehydrated device. If it returns an @@ -67,7 +88,7 @@ export class RustDehydrationManager { // a successful response, or an M_NOT_FOUND, then dehydration is supported. // Any other exceptions are passed through. try { - await this.http.authedRequest( + await this.http.authedRequest( Method.Get, "/dehydrated_device", undefined, @@ -88,42 +109,49 @@ export class RustDehydrationManager { return true; } - /** Return whether the dehydration key is stored in Secret Storage + /** + * Return whether the dehydration key is stored in Secret Storage. */ public async isKeyStored(): Promise { - return Boolean(await this.secretStorage.isStored(SECRET_STORAGE_NAME.name)); + return Boolean(await this.secretStorage.isStored(SECRET_STORAGE_NAME)); } - /** Reset the dehydration key + /** + * Reset the dehydration key. + * + * Creates a new key and stores it in secret storage. */ public async resetKey(): Promise { const key = new Uint8Array(32); crypto.getRandomValues(key); - await this.secretStorage.store(SECRET_STORAGE_NAME.name, encodeUnpaddedBase64(key)); + await this.secretStorage.store(SECRET_STORAGE_NAME, encodeUnpaddedBase64(key)); this.key = key; } - /** Get and cache the encryption key from secret storage + /** + * Get and cache the encryption key from secret storage. * * If `create` is `true`, creates a new key if no existing key is present. + * + * @returns the key, if available, or `null` if no key is available */ - private async getKey(create: boolean): Promise { + private async getKey(create: boolean): Promise { if (this.key === undefined) { - const keyB64 = await this.secretStorage.get(SECRET_STORAGE_NAME.name); + const keyB64 = await this.secretStorage.get(SECRET_STORAGE_NAME); if (keyB64 === undefined) { if (!create) { - return false; + return null; } await this.resetKey(); } else { this.key = decodeBase64(keyB64); } } - return true; + return this.key!; } /** - * Rehydrate the dehydrated device stored on the server + * Rehydrate the dehydrated device stored on the server. * * Checks if there is a dehydrated device on the server. If so, rehydrates * the device and processes the to-device events. @@ -131,13 +159,14 @@ export class RustDehydrationManager { * Returns whether or not a dehydrated device was found. */ public async rehydrateDeviceIfAvailable(): Promise { - if (!(await this.getKey(false))) { + const key = await this.getKey(false); + if (!key) { return false; } let dehydratedDeviceResp; try { - dehydratedDeviceResp = await this.http.authedRequest( + dehydratedDeviceResp = await this.http.authedRequest( Method.Get, "/dehydrated_device", undefined, @@ -163,7 +192,7 @@ export class RustDehydrationManager { const rehydratedDevice = await this.olmMachine .dehydratedDevices() .rehydrate( - this.key!, + key, new RustSdkCryptoJs.DeviceId(dehydratedDeviceResp.device_id), JSON.stringify(dehydratedDeviceResp.device_data), ); @@ -173,11 +202,12 @@ export class RustDehydrationManager { let nextBatch: string | undefined = undefined; let toDeviceCount = 0; let roomKeyCount = 0; - for (;;) { - const path = encodeUri("/dehydrated_device/$device_id/events", { - $device_id: dehydratedDeviceResp.device_id, - }); - const eventResp: IDehydratedDeviceEventsResp = await this.http.authedRequest( + const path = encodeUri("/dehydrated_device/$device_id/events", { + $device_id: dehydratedDeviceResp.device_id, + }); + // eslint-disable-next-line no-constant-condition + while (true) { + const eventResp: DehydratedDeviceEventsResp = await this.http.authedRequest( Method.Post, path, undefined, @@ -187,19 +217,13 @@ export class RustDehydrationManager { }, ); - if (eventResp.events.length == 0) { + if (eventResp.events.length === 0) { break; } toDeviceCount += eventResp.events.length; nextBatch = eventResp.next_batch; - const roomKeyInfos: RustSdkCryptoJs.RoomKeyInfo[] = await rehydratedDevice.receiveEvents( - JSON.stringify(eventResp.events), - ); + const roomKeyInfos = await rehydratedDevice.receiveEvents(JSON.stringify(eventResp.events)); roomKeyCount += roomKeyInfos.length; - - // FIXME: is this actually needed? It looks like the OlmMachine - // automatically re-tries decryption - await this.rustCrypto.onRoomKeysUpdated(roomKeyInfos); } this.logger.info(`dehydration: received ${roomKeyCount} room keys from ${toDeviceCount} to-device events`); @@ -207,19 +231,15 @@ export class RustDehydrationManager { } /** - * Creates and uploads a new dehydrated device + * Creates and uploads a new dehydrated device. * * Creates and stores a new key in secret storage if none is available. */ public async createAndUploadDehydratedDevice(): Promise { - await this.getKey(true); - - // FIXME: should raise error if server doesn't support dehydration? + const key = (await this.getKey(true))!; const dehydratedDevice = await this.olmMachine.dehydratedDevices().create(); - // FIXME: should the device display name be localised? passed as a - // parameter? - const request = await dehydratedDevice.keysForUpload("Dehydrated device", this.key!); + const request = await dehydratedDevice.keysForUpload("Dehydrated device", key); await this.outgoingRequestProcessor.makeOutgoingRequest(request); @@ -227,7 +247,7 @@ export class RustDehydrationManager { } /** - * Schedule periodic creation of dehydrated devices + * Schedule periodic creation of dehydrated devices. * * @param interval - the time to wait between creating dehydrated devices * @param delay - how long to wait before creating the first dehydrated device. @@ -239,16 +259,26 @@ export class RustDehydrationManager { if (delay) { this.timeoutId = setTimeout(() => { - this.scheduleDeviceDehydration(interval); this.timeoutId = undefined; + this.scheduleDeviceDehydration(interval).catch((error) => { + this.logger.error("Error scheduling device dehydration:", error); + }); }, delay); } else { await this.createAndUploadDehydratedDevice(); - // FIXME: should we randomize the time a bit? - this.intervalId = setInterval(this.createAndUploadDehydratedDevice.bind(this), interval); + this.intervalId = setInterval(() => { + this.createAndUploadDehydratedDevice().catch((error) => { + this.logger.error("Error creating dehydrated device:", error); + }); + }, interval); } } + /** + * Stop the dehydrated device manager. + * + * Cancels any scheduled dehydration tasks. + */ public stop(): void { if (this.intervalId) { clearInterval(this.intervalId); diff --git a/src/rust-crypto/OutgoingRequestProcessor.ts b/src/rust-crypto/OutgoingRequestProcessor.ts index ce79bcb674c..d3ae4d9c4a3 100644 --- a/src/rust-crypto/OutgoingRequestProcessor.ts +++ b/src/rust-crypto/OutgoingRequestProcessor.ts @@ -33,7 +33,7 @@ import { logDuration, QueryDict, sleep } from "../utils"; import { AuthDict, UIAuthCallback } from "../interactive-auth"; import { UIAResponse } from "../@types/uia"; import { ToDeviceMessageId } from "../@types/event"; -import { UnstablePrefix as DehydrationUnstablePrefix } from "./dehydration"; +import { UnstablePrefix as DehydrationUnstablePrefix } from "./DehydratedDeviceManager"; /** * Common interface for all the request types returned by `OlmMachine.outgoingRequests`. diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 6e6f50cee12..777762440d3 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -73,7 +73,7 @@ import { ISignatures } from "../@types/signed"; import { encodeBase64 } from "../base64"; import { OutgoingRequestsManager } from "./OutgoingRequestsManager"; import { PerSessionKeyBackupDownloader } from "./PerSessionKeyBackupDownloader"; -import { RustDehydrationManager } from "./dehydration"; +import { DehydratedDeviceManager } from "./DehydratedDeviceManager"; import { VerificationMethod } from "../types"; const ALL_VERIFICATION_METHODS = [ @@ -108,13 +108,10 @@ export class RustCrypto extends TypedEventEmitter(this); - private readonly dehydrationManager: RustDehydrationManager; - public constructor( private readonly logger: Logger, @@ -151,23 +148,19 @@ export class RustCrypto extends TypedEventEmitter { + return await this.dehydratedDeviceManager.isSupported(); + } + + /** + * Implementation of {@link CryptoBackend#rehydrateDeviceIfAvailable}. + */ + public async rehydrateDeviceIfAvailable(): Promise { + return await this.dehydratedDeviceManager.rehydrateDeviceIfAvailable(); + } + + /** + * Implementation of {@link CryptoBackend#createAndUploadDehydratedDevice}. + */ + public async createAndUploadDehydratedDevice(): Promise { + return await this.dehydratedDeviceManager.createAndUploadDehydratedDevice(); + } + + /** + * Implementation of {@link CryptoBackend#isDehydrationKeyStored}. + */ + public async isDehydrationKeyStored(): Promise { + return await this.dehydratedDeviceManager.isKeyStored(); + } + + /** + * Implementation of {@link CryptoBackend#scheduleDeviceDehydration}. + */ + public async scheduleDeviceDehydration(interval: number, delay?: number): Promise { + return await this.dehydratedDeviceManager.scheduleDeviceDehydration(interval, delay); + } + + /** + * Implementation of {@link CryptoBackend#resetDehydrationKey}. + */ + public async resetDehydrationKey(): Promise { + return await this.dehydratedDeviceManager.resetKey(); + } + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // SyncCryptoCallbacks implementation @@ -1654,63 +1689,6 @@ export class RustCrypto extends TypedEventEmitter { return await this.olmMachine.getIdentity(new RustSdkCryptoJs.UserId(this.userId)); } - - /** - * Returns whether dehydrated devices are supported by the crypto backend - * and by the server. - */ - public async isDehydrationSupported(): Promise { - return await this.dehydrationManager.isSupported(); - } - - /** - * Rehydrate the dehydrated device stored on the server - * - * Checks if there is a dehydrated device on the server. If so, rehydrates - * the device and processes the to-device events. - * - * Returns whether or not a dehydrated device was found. - */ - public async rehydrateDeviceIfAvailable(): Promise { - return await this.dehydrationManager.rehydrateDeviceIfAvailable(); - } - - /** - * Creates and uploads a new dehydrated device - * - * Creates and stores a new key in secret storage if none is available. - */ - public async createAndUploadDehydratedDevice(): Promise { - return await this.dehydrationManager.createAndUploadDehydratedDevice(); - } - - /** Return whether the dehydration key is stored in Secret Storage - */ - public async isDehydrationKeyStored(): Promise { - return await this.dehydrationManager.isKeyStored(); - } - - /** - * Schedule periodic creation of dehydrated devices - * - * @param interval - the time to wait between creating dehydrated devices - * @param delay - how long to wait before creating the first dehydrated device. - * Defaults to creating the device immediately. - */ - public async scheduleDeviceDehydration(interval: number, delay?: number): Promise { - return await this.dehydrationManager.scheduleDeviceDehydration(interval, delay); - } - - /** - * Reset the dehydration key - * - * Note: this does not create a new dehydrated device. This will need to - * be done either by calling `createAndUploadDehydratedDevice` or - * `scheduleDeviceDehydration`. - */ - public async resetDehydrationKey(): Promise { - return await this.dehydrationManager.resetKey(); - } } class EventDecryptor { From 964333a3637e535531ed02de5760be52c96355d4 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Thu, 28 Mar 2024 00:51:15 -0400 Subject: [PATCH 15/21] add missing file --- spec/integ/crypto/device-dehydration.spec.ts | 155 +++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 spec/integ/crypto/device-dehydration.spec.ts diff --git a/spec/integ/crypto/device-dehydration.spec.ts b/spec/integ/crypto/device-dehydration.spec.ts new file mode 100644 index 00000000000..6a91ceae73d --- /dev/null +++ b/spec/integ/crypto/device-dehydration.spec.ts @@ -0,0 +1,155 @@ +/* +Copyright 2024 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 "fake-indexeddb/auto"; +import fetchMock from "fetch-mock-jest"; + +import { createClient, ClientEvent, MatrixClient, MatrixEvent } from "../../../src"; +import { AddSecretStorageKeyOpts } from "../../../src/secret-storage"; + +describe("Device dehydration", () => { + it("should rehydrate and dehydrate a device", async () => { + const matrixClient = createClient({ + baseUrl: "http://test.server", + userId: "@alice:localhost", + deviceId: "aliceDevice", + cryptoCallbacks: { + getSecretStorageKey: async (keys: any, name: string) => { + return [[...Object.keys(keys.keys)][0], new Uint8Array(32)]; + }, + }, + }); + + await initializeSecretStorage(matrixClient, "@alice:localhost"); + + const crypto = matrixClient.getCrypto()!; + fetchMock.config.overwriteRoutes = true; + + // try to rehydrate, but there isn't any dehydrated device yet + fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { + status: 404, + body: { + errcode: "M_NOT_FOUND", + error: "Not found", + }, + }); + expect(await crypto.rehydrateDeviceIfAvailable()).toBe(false); + + // create a dehydrated device + 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 crypto.createAndUploadDehydratedDevice(); + + // rehydrate the device that we just created + fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { + device_id: dehydratedDeviceBody.device_id, + device_data: dehydratedDeviceBody.device_data, + }); + const eventsResponse = jest.fn((url, opts) => { + // rehydrating should make two calls to the /events endpoint. + // The first time will return a single event, and the second + // time will return no events (which will signal to the + // rehydration function that it can stop) + const body = JSON.parse(opts.body as string); + const nextBatch = body.next_batch ?? "0"; + const events = nextBatch === "0" ? [{ sender: "@alice:localhost", type: "m.dummy", content: {} }] : []; + return { + events, + next_batch: nextBatch + "1", + }; + }); + fetchMock.post( + `path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/${encodeURIComponent(dehydratedDeviceBody.device_id)}/events`, + eventsResponse, + ); + + expect(await crypto.rehydrateDeviceIfAvailable()).toBe(true); + expect(eventsResponse.mock.calls).toHaveLength(2); + }); +}); + +/** create a new secret storage and cross-signing keys */ +async function initializeSecretStorage(matrixClient: MatrixClient, userId: string): Promise { + fetchMock.get("path:/_matrix/client/v3/room_keys/version", { + status: 404, + body: { + errcode: "M_NOT_FOUND", + error: "Not found", + }, + }); + let deviceKeys: { device_id: string }; + fetchMock.post("path:/_matrix/client/v3/keys/upload", (_, opts) => { + deviceKeys = JSON.parse(opts.body as string).deviceKeys; + return { one_time_key_counts: { signed_curve25519: 100 } }; + }); + fetchMock.post("path:/_matrix/client/v3/keys/query", (_, opts) => { + if (deviceKeys) { + return { + device_keys: { + userId: { + [deviceKeys["device_id"]]: deviceKeys, + }, + }, + }; + } else { + return {}; + } + }); + fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {}); + fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {}); + const accountData: Map = new Map(); + fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => { + const name = url.split("/").pop()!; + const value = accountData.get(name); + if (value) { + return value; + } else { + return { + status: 404, + body: { + errcode: "M_NOT_FOUND", + error: "Not found", + }, + }; + } + }); + fetchMock.put("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => { + const name = url.split("/").pop()!; + const value = JSON.parse(opts.body as string); + accountData.set(name, value); + matrixClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: name, content: value })); + return {}; + }); + + await matrixClient.initRustCrypto(); + + // create initial secret storage + async function createSecretStorageKey() { + return { + keyInfo: {} as AddSecretStorageKeyOpts, + privateKey: new Uint8Array(32), + }; + } + await matrixClient.bootstrapCrossSigning({ setupNewCrossSigning: true }); + await matrixClient.bootstrapSecretStorage({ + createSecretStorageKey, + setupNewSecretStorage: true, + setupNewKeyBackup: false, + }); +} From 3a66418e6048fb3ede421d77168ea2a600f70fc8 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Thu, 28 Mar 2024 00:53:56 -0400 Subject: [PATCH 16/21] move setup into another function --- spec/unit/rust-crypto/rust-crypto.spec.ts | 87 ++++++++++++----------- 1 file changed, 45 insertions(+), 42 deletions(-) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 636f0d98833..8b0617c23af 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1417,48 +1417,7 @@ describe("RustCrypto", () => { secretStorage, ); - // set up various fetch-mocks to handle account setup - fetchMock.get("path:/_matrix/client/v3/room_keys/version", { - status: 404, - body: { - errcode: "M_NOT_FOUND", - error: "Not found", - }, - }); - let deviceKeys: { device_id: string }; - fetchMock.post("path:/_matrix/client/v3/keys/upload", (_, opts) => { - deviceKeys = JSON.parse(opts.body as string).device_keys; - return { one_time_key_counts: { signed_curve25519: 100 } }; - }); - fetchMock.post("path:/_matrix/client/v3/keys/query", (_, opts) => { - if (deviceKeys) { - return { - device_keys: { - [testData.TEST_USER_ID]: { - [deviceKeys["device_id"]]: deviceKeys, - }, - }, - }; - } else { - return {}; - } - }); - fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {}); - fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {}); - - // create initial secret storage - async function createSecretStorageKey() { - return { - keyInfo: {} as AddSecretStorageKeyOpts, - privateKey: new Uint8Array(32), - }; - } - await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); - await rustCrypto.bootstrapSecretStorage({ - createSecretStorageKey, - setupNewSecretStorage: true, - setupNewKeyBackup: false, - }); + await initializeSecretStorage(rustCrypto, testData.TEST_USER_ID); fetchMock.config.overwriteRoutes = true; @@ -1664,3 +1623,47 @@ class DummyAccountDataClient function pad43(x: string): string { return x + ".".repeat(43 - x.length); } + +/** create a new secret storage and cross-signing keys */ +async function initializeSecretStorage(rustCrypto: RustCrypto, userId: string): Promise { + fetchMock.get("path:/_matrix/client/v3/room_keys/version", { + status: 404, + body: { + errcode: "M_NOT_FOUND", + error: "Not found", + }, + }); + let deviceKeys: { device_id: string }; + fetchMock.post("path:/_matrix/client/v3/keys/upload", (_, opts) => { + deviceKeys = JSON.parse(opts.body as string).device_keys; + return { one_time_key_counts: { signed_curve25519: 100 } }; + }); + fetchMock.post("path:/_matrix/client/v3/keys/query", (_, opts) => { + if (deviceKeys) { + return { + device_keys: { + [userId]: { + [deviceKeys["device_id"]]: deviceKeys, + }, + }, + }; + } else { + return {}; + } + }); + fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {}); + fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {}); + + async function createSecretStorageKey() { + return { + keyInfo: {} as AddSecretStorageKeyOpts, + privateKey: new Uint8Array(32), + }; + } + await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); + await rustCrypto.bootstrapSecretStorage({ + createSecretStorageKey, + setupNewSecretStorage: true, + setupNewKeyBackup: false, + }); +} From 0947a8a57a1199698e22419e7dad03ee656d1baa Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Wed, 3 Apr 2024 23:16:51 -0400 Subject: [PATCH 17/21] apply changes from review --- spec/integ/crypto/device-dehydration.spec.ts | 31 ++++------- spec/unit/rust-crypto/rust-crypto.spec.ts | 55 +++++++------------- 2 files changed, 29 insertions(+), 57 deletions(-) diff --git a/spec/integ/crypto/device-dehydration.spec.ts b/spec/integ/crypto/device-dehydration.spec.ts index 6a91ceae73d..6f240ede69a 100644 --- a/spec/integ/crypto/device-dehydration.spec.ts +++ b/spec/integ/crypto/device-dehydration.spec.ts @@ -19,6 +19,8 @@ import fetchMock from "fetch-mock-jest"; import { createClient, ClientEvent, MatrixClient, MatrixEvent } from "../../../src"; import { AddSecretStorageKeyOpts } from "../../../src/secret-storage"; +import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; +import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; describe("Device dehydration", () => { it("should rehydrate and dehydrate a device", async () => { @@ -33,7 +35,7 @@ describe("Device dehydration", () => { }, }); - await initializeSecretStorage(matrixClient, "@alice:localhost"); + await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server"); const crypto = matrixClient.getCrypto()!; fetchMock.config.overwriteRoutes = true; @@ -85,7 +87,11 @@ describe("Device dehydration", () => { }); /** create a new secret storage and cross-signing keys */ -async function initializeSecretStorage(matrixClient: MatrixClient, userId: string): Promise { +async function initializeSecretStorage( + matrixClient: MatrixClient, + userId: string, + homeserverUrl: string, +): Promise { fetchMock.get("path:/_matrix/client/v3/room_keys/version", { status: 404, body: { @@ -93,24 +99,9 @@ async function initializeSecretStorage(matrixClient: MatrixClient, userId: strin error: "Not found", }, }); - let deviceKeys: { device_id: string }; - fetchMock.post("path:/_matrix/client/v3/keys/upload", (_, opts) => { - deviceKeys = JSON.parse(opts.body as string).deviceKeys; - return { one_time_key_counts: { signed_curve25519: 100 } }; - }); - fetchMock.post("path:/_matrix/client/v3/keys/query", (_, opts) => { - if (deviceKeys) { - return { - device_keys: { - userId: { - [deviceKeys["device_id"]]: deviceKeys, - }, - }, - }; - } else { - return {}; - } - }); + const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl); + const e2eKeyResponder = new E2EKeyResponder(homeserverUrl); + e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver); fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {}); fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {}); const accountData: Map = new Map(); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 8b0617c23af..a64ff961203 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -45,6 +45,8 @@ import { TypedEventEmitter, } from "../../../src"; import { mkEvent } from "../../test-utils/test-utils"; +import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; +import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend"; import { IEventDecryptionResult, IMegolmSessionData } from "../../../src/@types/crypto"; import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor"; @@ -1417,16 +1419,14 @@ describe("RustCrypto", () => { secretStorage, ); - await initializeSecretStorage(rustCrypto, testData.TEST_USER_ID); + await initializeSecretStorage(rustCrypto, testData.TEST_USER_ID, "http://server"); fetchMock.config.overwriteRoutes = true; // when we schedule dehydration with no delay, it should create a // dehydrated device immediately - const firstDehydrationRequest = jest.fn(() => { - return {}; - }); - fetchMock.put( + const firstDehydrationRequest = jest.fn().mockReturnValue({}); + fetchMock.putOnce( "path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", firstDehydrationRequest, ); @@ -1434,11 +1434,11 @@ describe("RustCrypto", () => { expect(firstDehydrationRequest).toHaveBeenCalled(); - // after we advance the timer, it should create another dehydrated device - // we make this a promise so that we can await it to make sure it gets - // called + // After we advance the timer, it should create another dehydrated device. + // We make this a promise so that we can await it to make sure it gets + // called. const secondDehydrationPromise = new Promise((resolve, reject) => { - fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => { + fetchMock.putOnce("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => { resolve(); return {}; }); @@ -1449,13 +1449,11 @@ describe("RustCrypto", () => { // when we schedule dehydration with a delay, it should not create // a dehydrated device immediately - const thirdDehydrationRequest = jest.fn(() => { - return {}; - }); + const thirdDehydrationRequest = jest.fn().mockReturnValue({}); const thirdDehydrationPromise = new Promise((resolve, reject) => { - fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => { + fetchMock.putOnce("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => { resolve(); - return thirdDehydrationRequest; + return thirdDehydrationRequest(); }); }); await rustCrypto.scheduleDeviceDehydration(30000, 10000); @@ -1465,10 +1463,8 @@ describe("RustCrypto", () => { // when we stop rustCrypto, any pending device dehydration tasks // should be cancelled - const fourthDehydrationRequest = jest.fn(() => { - return {}; - }); - fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => { + const fourthDehydrationRequest = jest.fn().mockReturnValue({}); + fetchMock.putOnce("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => { return fourthDehydrationRequest; }); await rustCrypto.scheduleDeviceDehydration(30000, 10000); @@ -1625,7 +1621,7 @@ function pad43(x: string): string { } /** create a new secret storage and cross-signing keys */ -async function initializeSecretStorage(rustCrypto: RustCrypto, userId: string): Promise { +async function initializeSecretStorage(rustCrypto: RustCrypto, userId: string, homeserverUrl: string): Promise { fetchMock.get("path:/_matrix/client/v3/room_keys/version", { status: 404, body: { @@ -1633,24 +1629,9 @@ async function initializeSecretStorage(rustCrypto: RustCrypto, userId: string): error: "Not found", }, }); - let deviceKeys: { device_id: string }; - fetchMock.post("path:/_matrix/client/v3/keys/upload", (_, opts) => { - deviceKeys = JSON.parse(opts.body as string).device_keys; - return { one_time_key_counts: { signed_curve25519: 100 } }; - }); - fetchMock.post("path:/_matrix/client/v3/keys/query", (_, opts) => { - if (deviceKeys) { - return { - device_keys: { - [userId]: { - [deviceKeys["device_id"]]: deviceKeys, - }, - }, - }; - } else { - return {}; - } - }); + const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl); + const e2eKeyResponder = new E2EKeyResponder(homeserverUrl); + e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver); fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {}); fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {}); From f66678883a90eafd1d73970f672afc6443230c62 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Sat, 6 Apr 2024 23:02:37 -0400 Subject: [PATCH 18/21] implement simpler API --- spec/integ/crypto/device-dehydration.spec.ts | 44 +++++- spec/unit/rust-crypto/rust-crypto.spec.ts | 149 ------------------- src/crypto-api.ts | 73 +++------ src/crypto/index.ts | 30 +--- src/rust-crypto/DehydratedDeviceManager.ts | 67 +++++---- src/rust-crypto/rust-crypto.ts | 34 +---- 6 files changed, 105 insertions(+), 292 deletions(-) diff --git a/spec/integ/crypto/device-dehydration.spec.ts b/spec/integ/crypto/device-dehydration.spec.ts index 6f240ede69a..1aa5c13b0c7 100644 --- a/spec/integ/crypto/device-dehydration.spec.ts +++ b/spec/integ/crypto/device-dehydration.spec.ts @@ -24,6 +24,8 @@ import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; describe("Device dehydration", () => { it("should rehydrate and dehydrate a device", async () => { + jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); + const matrixClient = createClient({ baseUrl: "http://test.server", userId: "@alice:localhost", @@ -37,10 +39,19 @@ describe("Device dehydration", () => { await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server"); + // count the number of times the dehydration key gets set + let setDehydrationCount = 0; + matrixClient.on(ClientEvent.AccountData, (event: MatrixEvent) => { + if (event.getType() === "org.matrix.msc3814") { + setDehydrationCount++; + } + }); + const crypto = matrixClient.getCrypto()!; fetchMock.config.overwriteRoutes = true; - // try to rehydrate, but there isn't any dehydrated device yet + // start dehydration -- we start with no dehydrated device, and we + // store the dehydrated device that we create fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { status: 404, body: { @@ -48,17 +59,32 @@ describe("Device dehydration", () => { error: "Not found", }, }); - expect(await crypto.rehydrateDeviceIfAvailable()).toBe(false); - - // create a dehydrated device let dehydratedDeviceBody: any; + let dehydrationCount = 0; + let resolveDehydrationPromise: () => void; fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", (_, opts) => { dehydratedDeviceBody = JSON.parse(opts.body as string); + dehydrationCount++; + if (resolveDehydrationPromise) { + resolveDehydrationPromise(); + } return {}; }); - await crypto.createAndUploadDehydratedDevice(); + await crypto.startDehydration(); - // rehydrate the device that we just created + expect(dehydrationCount).toEqual(1); + + // a week later, we should have created another dehydrated device + jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000); + const dehydrationPromise = new Promise((resolve, reject) => { + resolveDehydrationPromise = resolve; + }); + await dehydrationPromise; + expect(dehydrationCount).toEqual(2); + + // restart dehydration -- rehydrate the device that we created above, + // and create a new dehydrated device. We also set `createNewKey`, so + // a new dehydration key will be set fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", { device_id: dehydratedDeviceBody.device_id, device_data: dehydratedDeviceBody.device_data, @@ -80,9 +106,13 @@ describe("Device dehydration", () => { `path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/${encodeURIComponent(dehydratedDeviceBody.device_id)}/events`, eventsResponse, ); + await crypto.startDehydration(true); + expect(dehydrationCount).toEqual(3); - expect(await crypto.rehydrateDeviceIfAvailable()).toBe(true); + expect(setDehydrationCount).toEqual(2); expect(eventsResponse.mock.calls).toHaveLength(2); + + matrixClient.stopClient(); }); }); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index a64ff961203..97d31d29ad2 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -45,8 +45,6 @@ import { TypedEventEmitter, } from "../../../src"; import { mkEvent } from "../../test-utils/test-utils"; -import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; -import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend"; import { IEventDecryptionResult, IMegolmSessionData } from "../../../src/@types/crypto"; import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor"; @@ -1402,77 +1400,6 @@ describe("RustCrypto", () => { }); describe("device dehydration", () => { - it("should schedule regular creation of dehydrated devices", async () => { - jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); - - 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 rustCrypto = await makeTestRustCrypto( - makeMatrixHttpApi(), - testData.TEST_USER_ID, - undefined, - secretStorage, - ); - - await initializeSecretStorage(rustCrypto, testData.TEST_USER_ID, "http://server"); - - fetchMock.config.overwriteRoutes = true; - - // when we schedule dehydration with no delay, it should create a - // dehydrated device immediately - const firstDehydrationRequest = jest.fn().mockReturnValue({}); - fetchMock.putOnce( - "path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", - firstDehydrationRequest, - ); - await rustCrypto.scheduleDeviceDehydration(30000); - - expect(firstDehydrationRequest).toHaveBeenCalled(); - - // After we advance the timer, it should create another dehydrated device. - // We make this a promise so that we can await it to make sure it gets - // called. - const secondDehydrationPromise = new Promise((resolve, reject) => { - fetchMock.putOnce("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => { - resolve(); - return {}; - }); - }); - jest.advanceTimersByTime(35000); - - await secondDehydrationPromise; - - // when we schedule dehydration with a delay, it should not create - // a dehydrated device immediately - const thirdDehydrationRequest = jest.fn().mockReturnValue({}); - const thirdDehydrationPromise = new Promise((resolve, reject) => { - fetchMock.putOnce("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => { - resolve(); - return thirdDehydrationRequest(); - }); - }); - await rustCrypto.scheduleDeviceDehydration(30000, 10000); - expect(thirdDehydrationRequest).not.toHaveBeenCalled(); - jest.advanceTimersByTime(15000); - await thirdDehydrationPromise; - - // when we stop rustCrypto, any pending device dehydration tasks - // should be cancelled - const fourthDehydrationRequest = jest.fn().mockReturnValue({}); - fetchMock.putOnce("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => { - return fourthDehydrationRequest; - }); - await rustCrypto.scheduleDeviceDehydration(30000, 10000); - rustCrypto.stop(); - jest.advanceTimersByTime(15000); - expect(fourthDehydrationRequest).not.toHaveBeenCalled(); - }); - it("should detect if dehydration is supported", async () => { const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi()); fetchMock.config.overwriteRoutes = true; @@ -1498,53 +1425,6 @@ describe("RustCrypto", () => { }); expect(await rustCrypto.isDehydrationSupported()).toBe(true); }); - - it("should detect if dehydration key is stored", 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 rustCrypto = await makeTestRustCrypto( - makeMatrixHttpApi(), - testData.TEST_USER_ID, - undefined, - secretStorage, - ); - async function createSecretStorageKey() { - return { - keyInfo: {} as AddSecretStorageKeyOpts, - privateKey: new Uint8Array(32), - }; - } - await rustCrypto.bootstrapSecretStorage({ - createSecretStorageKey, - setupNewSecretStorage: true, - setupNewKeyBackup: false, - }); - - expect(await rustCrypto.isDehydrationKeyStored()).toBe(false); - - await rustCrypto.resetDehydrationKey(); - expect(await rustCrypto.isDehydrationKeyStored()).toBe(true); - - // rehydrateDeviceIfAvailable should check the server to see if a - // dehydrated device is present, because the dehydration key is set - const response = jest.fn(() => { - return { - status: 404, - body: { - errcode: "M_NOT_FOUND", - error: "Not found", - }, - }; - }); - fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", response); - expect(await rustCrypto.rehydrateDeviceIfAvailable()).toBe(false); - expect(response).toHaveBeenCalled(); - }); }); }); @@ -1619,32 +1499,3 @@ class DummyAccountDataClient function pad43(x: string): string { return x + ".".repeat(43 - x.length); } - -/** create a new secret storage and cross-signing keys */ -async function initializeSecretStorage(rustCrypto: RustCrypto, userId: string, homeserverUrl: string): Promise { - fetchMock.get("path:/_matrix/client/v3/room_keys/version", { - status: 404, - body: { - errcode: "M_NOT_FOUND", - error: "Not found", - }, - }); - const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl); - const e2eKeyResponder = new E2EKeyResponder(homeserverUrl); - e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver); - fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {}); - fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {}); - - async function createSecretStorageKey() { - return { - keyInfo: {} as AddSecretStorageKeyOpts, - privateKey: new Uint8Array(32), - }; - } - await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); - await rustCrypto.bootstrapSecretStorage({ - createSecretStorageKey, - setupNewSecretStorage: true, - setupNewKeyBackup: false, - }); -} diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 189de955a95..930f2b14f62 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -507,65 +507,31 @@ export interface CryptoApi { * Returns whether MSC3814 dehydrated devices are supported by the crypto * backend and by the server. * - * This should be called before any of the other dehydrated device - * functions are called, and if it returns `false`, none of the other - * dehydrated device functions should be called. + * This should be called before calling `startDehydration`, and if this + * returns `false`, `startDehydration` should not be called. */ isDehydrationSupported(): Promise; /** - * Rehydrate the dehydrated device stored on the server. + * Start using device dehydration. * - * Checks if there is a dehydrated device on the server. If so, rehydrates - * the device and processes the to-device events sent to it. This function - * should be called before a dehydrated device is created for the first - * time after the client logs in. + * - Rehydrates a dehydrated device, if one is available. + * - Creates a new dehydration key, if necessary, and store it in Secret + * Storage. + * - If `createNewKey` is set to true, always create a new key. + * - If a dehydration key is not available, create a new one. + * - Creates a new dehydrated device, and schedules periodically creating + * new dehydrated devices. * - * @returns `true` if a dehydrated device was found; otherwise, `false`. - */ - rehydrateDeviceIfAvailable(): Promise; - - /** - * Creates and uploads a new dehydrated device. - * - * If now dehydration key is available in secret storage, a new key is - * created. Most applications should call `scheduleDeviceDehydration` so - * that the dehydrated device gets replaced periodically with a new one, to - * avoid to-device events stacking up on the server. However, clients that - * want to have more control over the dehydration process may use this - * function instead. - */ - createAndUploadDehydratedDevice(): Promise; - - /** - * Schedule periodic creation of dehydrated devices. - * - * If the delay is omitted or 0, this function's promise will resolve after - * the first dehydrated device is created. + * This function must not be called unless `isDehydrationSupported` returns + * `true`, and must not be called until after cross-signing and secret + * storage have been set up. * - * @param interval - the time to wait between creating dehydrated devices. - * @param delay - how long to wait before creating the first dehydrated device. - * Defaults to creating the device immediately. + * @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. */ - scheduleDeviceDehydration(interval: number, delay?: number): Promise; - - /** - * Return whether the dehydration key is stored in Secret Storage. - */ - isDehydrationKeyStored(): Promise; - - /** - * Reset the dehydration key. - * - * Creates a new dehydration key and stores it in secret storage. This - * function should be called, for example, if the user's secret storage is - * reset. - * - * Note: this does not create a new dehydrated device. This will need to - * be done either by calling `createAndUploadDehydratedDevice` or - * `scheduleDeviceDehydration`. - */ - resetDehydrationKey(): Promise; + startDehydration(createNewKey?: boolean): Promise; } /** A reason code for a failure to decrypt an event. */ @@ -840,6 +806,11 @@ export interface CreateSecretStorageOpts { */ setupNewSecretStorage?: boolean; + /** + * Create a dehydrated device if no dehydrated device is already present. + */ + initialiseDeviceDehydration?: boolean; + /** * Function called to get the user's * current key backup passphrase. Should return a promise that resolves with a Uint8Array diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 0c259f41bed..10751ca5632 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -4296,38 +4296,10 @@ export class Crypto extends TypedEventEmitter { - return false; - } - - /** - * Stub function -- dehydration is not implemented here, so throw error - */ - public async createAndUploadDehydratedDevice(): Promise { - throw new Error("Not implemented"); - } - - /** - * Stub function -- dehydration is not implemented here, so throw error - */ - public async scheduleDeviceDehydration(interval: number, delay?: number): Promise { - throw new Error("Not implemented"); - } - - /** - * Stub function -- dehydration is not implemented here, so throw error - */ - public async isDehydrationKeyStored(): Promise { - return false; - } - /** * Stub function -- dehydration is not implemented here, so throw error */ - public async resetDehydrationKey(): Promise { + public async startDehydration(createNewKey?: boolean): Promise { throw new Error("Not implemented"); } } diff --git a/src/rust-crypto/DehydratedDeviceManager.ts b/src/rust-crypto/DehydratedDeviceManager.ts index 76b3aec6656..d5a63a81f2e 100644 --- a/src/rust-crypto/DehydratedDeviceManager.ts +++ b/src/rust-crypto/DehydratedDeviceManager.ts @@ -51,6 +51,11 @@ export const UnstablePrefix = "/_matrix/client/unstable/org.matrix.msc3814.v1"; */ const SECRET_STORAGE_NAME = "org.matrix.msc3814"; +/** + * The interval between creating dehydrated devices. (one week) + */ +const DEHYDRATION_INTERVAL = 7 * 24 * 60 * 60 * 1000; + /** * Manages dehydrated devices * @@ -68,8 +73,6 @@ export class DehydratedDeviceManager { private key?: Uint8Array; /** the ID of the interval for periodically replacing the dehydrated device */ private intervalId?: ReturnType; - /** the ID of the timeout for creating a new dehydrated device */ - private timeoutId?: ReturnType; public constructor( private readonly logger: Logger, @@ -109,6 +112,35 @@ export class DehydratedDeviceManager { return true; } + /** + * Start using device dehydration. + * + * - Rehydrates a dehydrated device, if one is available. + * - Creates a new dehydration key, if necessary, and store it in Secret + * Storage. + * - If `createNewKey` is set to true, always create a new key. + * - If a dehydration key is not available, create 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 async start(createNewKey?: boolean): Promise { + 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 (createNewKey) { + await this.resetKey(); + } + await this.scheduleDeviceDehydration(); + } + /** * Return whether the dehydration key is stored in Secret Storage. */ @@ -187,7 +219,7 @@ export class DehydratedDeviceManager { throw err; } - this.logger.info("dehydration: device found"); + this.logger.info("dehydration: dehydrated device found"); const rehydratedDevice = await this.olmMachine .dehydratedDevices() @@ -250,28 +282,17 @@ export class DehydratedDeviceManager { * Schedule periodic creation of dehydrated devices. * * @param interval - the time to wait between creating dehydrated devices - * @param delay - how long to wait before creating the first dehydrated device. - * Defaults to creating the device immediately. */ - public async scheduleDeviceDehydration(interval: number, delay?: number): Promise { + public async scheduleDeviceDehydration(): Promise { // cancel any previously-scheduled tasks this.stop(); - if (delay) { - this.timeoutId = setTimeout(() => { - this.timeoutId = undefined; - this.scheduleDeviceDehydration(interval).catch((error) => { - this.logger.error("Error scheduling device dehydration:", error); - }); - }, delay); - } else { - await this.createAndUploadDehydratedDevice(); - this.intervalId = setInterval(() => { - this.createAndUploadDehydratedDevice().catch((error) => { - this.logger.error("Error creating dehydrated device:", error); - }); - }, interval); - } + await this.createAndUploadDehydratedDevice(); + this.intervalId = setInterval(() => { + this.createAndUploadDehydratedDevice().catch((error) => { + this.logger.error("Error creating dehydrated device:", error); + }); + }, DEHYDRATION_INTERVAL); } /** @@ -284,9 +305,5 @@ export class DehydratedDeviceManager { clearInterval(this.intervalId); this.intervalId = undefined; } - if (this.timeoutId) { - clearTimeout(this.timeoutId); - this.timeoutId = undefined; - } } } diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 777762440d3..69b85460450 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -1226,38 +1226,10 @@ export class RustCrypto extends TypedEventEmitter { - return await this.dehydratedDeviceManager.rehydrateDeviceIfAvailable(); - } - - /** - * Implementation of {@link CryptoBackend#createAndUploadDehydratedDevice}. - */ - public async createAndUploadDehydratedDevice(): Promise { - return await this.dehydratedDeviceManager.createAndUploadDehydratedDevice(); - } - - /** - * Implementation of {@link CryptoBackend#isDehydrationKeyStored}. - */ - public async isDehydrationKeyStored(): Promise { - return await this.dehydratedDeviceManager.isKeyStored(); - } - - /** - * Implementation of {@link CryptoBackend#scheduleDeviceDehydration}. - */ - public async scheduleDeviceDehydration(interval: number, delay?: number): Promise { - return await this.dehydratedDeviceManager.scheduleDeviceDehydration(interval, delay); - } - - /** - * Implementation of {@link CryptoBackend#resetDehydrationKey}. - */ - public async resetDehydrationKey(): Promise { - return await this.dehydratedDeviceManager.resetKey(); + public async startDehydration(createNewKey?: boolean): Promise { + return await this.dehydratedDeviceManager.start(createNewKey); } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// From 27784d781d38808a4ec26fad63f78d5199dd03ad Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Sun, 7 Apr 2024 21:27:48 -0400 Subject: [PATCH 19/21] fix type and move the code to the right spot --- spec/integ/crypto/device-dehydration.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/integ/crypto/device-dehydration.spec.ts b/spec/integ/crypto/device-dehydration.spec.ts index 1aa5c13b0c7..8f4543d454b 100644 --- a/spec/integ/crypto/device-dehydration.spec.ts +++ b/spec/integ/crypto/device-dehydration.spec.ts @@ -75,10 +75,10 @@ describe("Device dehydration", () => { expect(dehydrationCount).toEqual(1); // a week later, we should have created another dehydrated device - jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000); - const dehydrationPromise = new Promise((resolve, reject) => { + const dehydrationPromise = new Promise((resolve, reject) => { resolveDehydrationPromise = resolve; }); + jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000); await dehydrationPromise; expect(dehydrationCount).toEqual(2); From 9ad28bb7971b10571ab48f3d647313c1b364f0cb Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Tue, 9 Apr 2024 15:30:51 -0400 Subject: [PATCH 20/21] apply suggestions from review --- src/crypto-api.ts | 11 +++-------- src/rust-crypto/DehydratedDeviceManager.ts | 8 +++----- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 930f2b14f62..0f78bd76a1a 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -516,10 +516,10 @@ export interface CryptoApi { * Start using device dehydration. * * - Rehydrates a dehydrated device, if one is available. - * - Creates a new dehydration key, if necessary, and store it in Secret + * - Creates a new dehydration key, if necessary, and stores it in Secret * Storage. - * - If `createNewKey` is set to true, always create a new key. - * - If a dehydration key is not available, create a new one. + * - 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. * @@ -806,11 +806,6 @@ export interface CreateSecretStorageOpts { */ setupNewSecretStorage?: boolean; - /** - * Create a dehydrated device if no dehydrated device is already present. - */ - initialiseDeviceDehydration?: boolean; - /** * Function called to get the user's * current key backup passphrase. Should return a promise that resolves with a Uint8Array diff --git a/src/rust-crypto/DehydratedDeviceManager.ts b/src/rust-crypto/DehydratedDeviceManager.ts index d5a63a81f2e..56f6052b724 100644 --- a/src/rust-crypto/DehydratedDeviceManager.ts +++ b/src/rust-crypto/DehydratedDeviceManager.ts @@ -116,10 +116,10 @@ export class DehydratedDeviceManager { * Start using device dehydration. * * - Rehydrates a dehydrated device, if one is available. - * - Creates a new dehydration key, if necessary, and store it in Secret + * - Creates a new dehydration key, if necessary, and stores it in Secret * Storage. - * - If `createNewKey` is set to true, always create a new key. - * - If a dehydration key is not available, create a new one. + * - 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. * @@ -280,8 +280,6 @@ export class DehydratedDeviceManager { /** * Schedule periodic creation of dehydrated devices. - * - * @param interval - the time to wait between creating dehydrated devices */ public async scheduleDeviceDehydration(): Promise { // cancel any previously-scheduled tasks From 1525a16eaa4fc8bafc7dbdf99f3d91a9ebe35ebe Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Tue, 9 Apr 2024 16:06:46 -0400 Subject: [PATCH 21/21] make sure that cross-signing and secret storage are set up --- spec/integ/crypto/device-dehydration.spec.ts | 5 +++++ src/rust-crypto/rust-crypto.ts | 3 +++ 2 files changed, 8 insertions(+) diff --git a/spec/integ/crypto/device-dehydration.spec.ts b/spec/integ/crypto/device-dehydration.spec.ts index 8f4543d454b..cf319a9878c 100644 --- a/spec/integ/crypto/device-dehydration.spec.ts +++ b/spec/integ/crypto/device-dehydration.spec.ts @@ -18,6 +18,7 @@ import "fake-indexeddb/auto"; import fetchMock from "fetch-mock-jest"; import { createClient, ClientEvent, 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"; @@ -159,6 +160,10 @@ async function initializeSecretStorage( }); await matrixClient.initRustCrypto(); + const crypto = matrixClient.getCrypto()! as RustCrypto; + // we need to process a sync so that the OlmMachine will upload keys + await crypto.preprocessToDeviceMessages([]); + await crypto.onSyncCompleted({}); // create initial secret storage async function createSecretStorageKey() { diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 95f4609fcef..713c113866a 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -1229,6 +1229,9 @@ export class RustCrypto extends TypedEventEmitter { + 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); }