1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-31 15:24:23 +03:00

Device Dehydration | js-sdk: store/load dehydration key (#4599)

* feat(dehydrated): Use the dehydrated key cache API

* feat(dehydrated): Add signalling to device dehydration manager

* feat(dehydrated): fix unneeded call getCachedKey

* Upgrade to `matrix-sdk-crypto-wasm` v13.0.0

* review: quick fix and doc

* apply changes from review

* apply changes from review

* fix comment

* add some tests and emit an event on rehydration failure

* factor out event counter into a test util, since it may be useful elsewhere

* adjust test to cover a few more lines

* fix documentation

* Apply suggestions from code review

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* fix missing bracket

* add test for getting the dehydration key from SSSS

---------

Co-authored-by: Hubert Chathi <hubertc@matrix.org>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
Valere
2025-01-27 23:05:23 +01:00
committed by GitHub
parent 44158bc843
commit 7d8cbd6ef0
10 changed files with 323 additions and 28 deletions

View File

@ -50,7 +50,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^12.1.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^13.0.0",
"@matrix-org/olm": "3.2.15",
"another-json": "^0.2.0",
"bs58": "^6.0.0",

View File

@ -17,11 +17,13 @@ limitations under the License.
import "fake-indexeddb/auto";
import fetchMock from "fetch-mock-jest";
import { createClient, ClientEvent, MatrixClient, MatrixEvent } from "../../../src";
import { ClientEvent, createClient, MatrixClient, MatrixEvent } from "../../../src";
import { CryptoEvent } from "../../../src/crypto-api/index";
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
import { AddSecretStorageKeyOpts } from "../../../src/secret-storage";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { emitPromise, EventCounter } from "../../test-utils/test-utils";
describe("Device dehydration", () => {
it("should rehydrate and dehydrate a device", async () => {
@ -40,6 +42,12 @@ describe("Device dehydration", () => {
await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server");
const creationEventCounter = new EventCounter(matrixClient, CryptoEvent.DehydratedDeviceCreated);
const dehydrationKeyCachedEventCounter = new EventCounter(matrixClient, CryptoEvent.DehydrationKeyCached);
const rehydrationStartedCounter = new EventCounter(matrixClient, CryptoEvent.RehydrationStarted);
const rehydrationCompletedCounter = new EventCounter(matrixClient, CryptoEvent.RehydrationCompleted);
const rehydrationProgressCounter = new EventCounter(matrixClient, CryptoEvent.RehydrationProgress);
// count the number of times the dehydration key gets set
let setDehydrationCount = 0;
matrixClient.on(ClientEvent.AccountData, (event: MatrixEvent) => {
@ -74,6 +82,8 @@ describe("Device dehydration", () => {
await crypto.startDehydration();
expect(dehydrationCount).toEqual(1);
expect(creationEventCounter.counter).toEqual(1);
expect(dehydrationKeyCachedEventCounter.counter).toEqual(1);
// a week later, we should have created another dehydrated device
const dehydrationPromise = new Promise<void>((resolve, reject) => {
@ -81,7 +91,10 @@ describe("Device dehydration", () => {
});
jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000);
await dehydrationPromise;
expect(dehydrationKeyCachedEventCounter.counter).toEqual(1);
expect(dehydrationCount).toEqual(2);
expect(creationEventCounter.counter).toEqual(2);
// restart dehydration -- rehydrate the device that we created above,
// and create a new dehydrated device. We also set `createNewKey`, so
@ -113,6 +126,39 @@ describe("Device dehydration", () => {
expect(setDehydrationCount).toEqual(2);
expect(eventsResponse.mock.calls).toHaveLength(2);
expect(rehydrationStartedCounter.counter).toEqual(1);
expect(rehydrationCompletedCounter.counter).toEqual(1);
expect(creationEventCounter.counter).toEqual(3);
expect(rehydrationProgressCounter.counter).toEqual(1);
expect(dehydrationKeyCachedEventCounter.counter).toEqual(2);
// test that if we get an error when we try to rotate, it emits an event
fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
status: 500,
body: {
errcode: "M_UNKNOWN",
error: "Unknown error",
},
});
const rotationErrorEventPromise = emitPromise(matrixClient, CryptoEvent.DehydratedDeviceRotationError);
jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000);
await rotationErrorEventPromise;
// Restart dehydration, but return an error for GET /dehydrated_device so that rehydration fails.
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
status: 500,
body: {
errcode: "M_UNKNOWN",
error: "Unknown error",
},
});
fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", (_, opts) => {
return {};
});
const rehydrationErrorEventPromise = emitPromise(matrixClient, CryptoEvent.RehydrationError);
await crypto.startDehydration(true);
await rehydrationErrorEventPromise;
matrixClient.stopClient();
});
});

View File

@ -566,6 +566,19 @@ if (globalThis.Olm) {
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise((r) => e.once(k, r));
/**
* Counts the number of times that an event was emitted.
*/
export class EventCounter {
public counter;
constructor(emitter: EventEmitter, event: string) {
this.counter = 0;
emitter.on(event, () => {
this.counter++;
});
}
}
/**
* Advance the fake timers in a loop until the given promise resolves or rejects.
*

View File

@ -44,7 +44,7 @@ import {
MemoryCryptoStore,
TypedEventEmitter,
} from "../../../src";
import { mkEvent } from "../../test-utils/test-utils";
import { emitPromise, mkEvent } from "../../test-utils/test-utils";
import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
import { IEventDecryptionResult, IMegolmSessionData } from "../../../src/@types/crypto";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
@ -1739,6 +1739,110 @@ describe("RustCrypto", () => {
});
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
});
it("should load the dehydration key from SSSS if available", async () => {
fetchMock.config.overwriteRoutes = true;
const secretStorageCallbacks = {
getSecretStorageKey: async (keys: any, name: string) => {
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
},
} as SecretStorageCallbacks;
const secretStorage = new ServerSideSecretStorageImpl(new DummyAccountDataClient(), secretStorageCallbacks);
// Create a RustCrypto to set up device dehydration.
const e2eKeyReceiver1 = new E2EKeyReceiver("http://server");
const e2eKeyResponder1 = new E2EKeyResponder("http://server");
e2eKeyResponder1.addKeyReceiver(TEST_USER, e2eKeyReceiver1);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
});
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {
status: 200,
body: {},
});
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {
status: 200,
body: {},
});
const rustCrypto1 = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage);
// dehydration requires secret storage and cross signing
async function createSecretStorageKey() {
return {
keyInfo: {} as AddSecretStorageKeyOpts,
privateKey: new Uint8Array(32),
};
}
await rustCrypto1.bootstrapCrossSigning({ setupNewCrossSigning: true });
await rustCrypto1.bootstrapSecretStorage({
createSecretStorageKey,
setupNewSecretStorage: true,
setupNewKeyBackup: false,
});
// we need to process a sync so that the OlmMachine will upload keys
await rustCrypto1.preprocessToDeviceMessages([]);
await rustCrypto1.onSyncCompleted({});
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
});
let dehydratedDeviceBody: any;
fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", (_, opts) => {
dehydratedDeviceBody = JSON.parse(opts.body as string);
return {};
});
await rustCrypto1.startDehydration();
await rustCrypto1.stop();
// Create another RustCrypto, using the same SecretStorage, to
// rehydrate the device.
const e2eKeyReceiver2 = new E2EKeyReceiver("http://server");
const e2eKeyResponder2 = new E2EKeyResponder("http://server");
e2eKeyResponder2.addKeyReceiver(TEST_USER, e2eKeyReceiver2);
const rustCrypto2 = await makeTestRustCrypto(
makeMatrixHttpApi(),
TEST_USER,
"ANOTHERDEVICE",
secretStorage,
);
// dehydration requires secret storage and cross signing
await rustCrypto2.bootstrapCrossSigning({ setupNewCrossSigning: true });
// we need to process a sync so that the OlmMachine will upload keys
await rustCrypto2.preprocessToDeviceMessages([]);
await rustCrypto2.onSyncCompleted({});
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
device_id: dehydratedDeviceBody.device_id,
device_data: dehydratedDeviceBody.device_data,
});
fetchMock.post(
`path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/${encodeURIComponent(dehydratedDeviceBody.device_id)}/events`,
{
events: [],
next_batch: "token",
},
);
// We check that a RehydrationCompleted event gets emitted, which
// means that the device was successfully rehydrated.
const rehydrationCompletedPromise = emitPromise(rustCrypto2, CryptoEvent.RehydrationCompleted);
await rustCrypto2.startDehydration();
await rehydrationCompletedPromise;
await rustCrypto2.stop();
});
});
describe("import & export secrets bundle", () => {

View File

@ -2311,6 +2311,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
CryptoEvent.KeysChanged,
CryptoEvent.DevicesUpdated,
CryptoEvent.WillUpdateDevices,
CryptoEvent.DehydratedDeviceCreated,
CryptoEvent.DehydratedDeviceUploaded,
CryptoEvent.RehydrationStarted,
CryptoEvent.RehydrationProgress,
CryptoEvent.RehydrationCompleted,
CryptoEvent.RehydrationError,
CryptoEvent.DehydrationKeyCached,
CryptoEvent.DehydratedDeviceRotationError,
]);
}

View File

@ -90,4 +90,63 @@ export enum CryptoEvent {
* `progress === total === -1`.
*/
LegacyCryptoStoreMigrationProgress = "crypto.legacyCryptoStoreMigrationProgress",
/**
* Fires when a new dehydrated device is created locally.
*
* After the client calls {@link CryptoApi.startDehydration}, this event
* will be fired every time a new dehydrated device is created. It may fire
* before `startDehydration` returns.
*/
DehydratedDeviceCreated = "dehydration.DehydratedDeviceCreated",
/**
* Fires when a new dehydrated device is successfully uploaded to the server.
*
* This should fire shortly after {@link DehydratedDeviceCreated} fires. If
* upload is unsuccessful, this will be reported either by an error thrown
* by {@link CryptoApi.startDehydration} (for errors that happen before
* `startDehydration` returns), or by firing {@link DehydratedDeviceRotationError}
* (for errors that happen during regular rotation of the dehydrated device)
*/
DehydratedDeviceUploaded = "dehydration.DehydratedDeviceUploaded",
/**
* Fires when rehydration has started.
*
* After the client calls {@link CryptoApi.startDehydration}, this event will
* fire if a dehydrated device is found and we attempt to rehydrate it.
*/
RehydrationStarted = "dehydration.RehydrationStarted",
/**
* Fires during rehydration, to inform the application of rehydration progress.
*
* The payload is a pair `[roomKeyCount: number, toDeviceCount: number]`,
* where `roomKeyCount` is the number of room keys that have been received
* so far, and `toDeviceCount` is the number of to-device messages received
* so far (including the messages containing room keys).
*/
RehydrationProgress = "dehydration.RehydrationProgress",
/** Fires when rehydration has completed successfully. */
RehydrationCompleted = "dehydration.RehydrationCompleted",
/** Fires when there was an error in rehydration.
*
* The payload is an error message as a string.
*/
RehydrationError = "dehydration.RehydrationError",
/**
* Fires when a dehydrated device key has been cached in the local database.
*/
DehydrationKeyCached = "dehydration.DehydrationKeyCached",
/**
* Fires when an error occurs during periodic rotation of the dehydrated device.
*
* The payload is an error message as a string.
*/
DehydratedDeviceRotationError = "dehydration.DehydratedDeviceRotationError",
}

View File

@ -30,4 +30,12 @@ export type CryptoEventHandlerMap = {
[CryptoEvent.WillUpdateDevices]: (users: string[], initialFetch: boolean) => void;
[CryptoEvent.DevicesUpdated]: (users: string[], initialFetch: boolean) => void;
[CryptoEvent.LegacyCryptoStoreMigrationProgress]: (progress: number, total: number) => void;
[CryptoEvent.DehydratedDeviceCreated]: () => void;
[CryptoEvent.DehydratedDeviceUploaded]: () => void;
[CryptoEvent.RehydrationStarted]: () => void;
[CryptoEvent.RehydrationProgress]: (roomKeyCount: number, toDeviceCount: number) => void;
[CryptoEvent.RehydrationCompleted]: () => void;
[CryptoEvent.RehydrationError]: (msg: string) => void;
[CryptoEvent.DehydrationKeyCached]: () => void;
[CryptoEvent.DehydratedDeviceRotationError]: (msg: string) => void;
} & RustBackupCryptoEventMap;

View File

@ -21,8 +21,10 @@ import { encodeUri } from "../utils.ts";
import { IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api/index.ts";
import { IToDeviceEvent } from "../sync-accumulator.ts";
import { ServerSideSecretStorage } from "../secret-storage.ts";
import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
import { decodeBase64 } from "../base64.ts";
import { Logger } from "../logger.ts";
import { CryptoEvent, CryptoEventHandlerMap } from "../crypto-api/index.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
/**
* The response body of `GET /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device`.
@ -67,9 +69,7 @@ const DEHYDRATION_INTERVAL = 7 * 24 * 60 * 60 * 1000;
*
* @internal
*/
export class DehydratedDeviceManager {
/** the secret key used for dehydrating and rehydrating */
private key?: Uint8Array;
export class DehydratedDeviceManager extends TypedEventEmitter<DehydratedDevicesEvents, DehydratedDevicesEventMap> {
/** the ID of the interval for periodically replacing the dehydrated device */
private intervalId?: ReturnType<typeof setInterval>;
@ -79,7 +79,14 @@ export class DehydratedDeviceManager {
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
private readonly secretStorage: ServerSideSecretStorage,
) {}
) {
super();
}
private async cacheKey(key: RustSdkCryptoJs.DehydratedDeviceKey): Promise<void> {
await this.olmMachine.dehydratedDevices().saveDehydratedDeviceKey(key);
this.emit(CryptoEvent.DehydrationKeyCached);
}
/**
* Return whether the server supports dehydrated devices.
@ -133,6 +140,7 @@ export class DehydratedDeviceManager {
// If rehydration fails, there isn't much we can do about it. Log
// the error, and create a new device.
this.logger.info("dehydration: Error rehydrating device:", e);
this.emit(CryptoEvent.RehydrationError, (e as Error).message);
}
if (createNewKey) {
await this.resetKey();
@ -151,12 +159,15 @@ export class DehydratedDeviceManager {
* Reset the dehydration key.
*
* Creates a new key and stores it in secret storage.
*
* @returns The newly-generated key.
*/
public async resetKey(): Promise<void> {
const key = new Uint8Array(32);
globalThis.crypto.getRandomValues(key);
await this.secretStorage.store(SECRET_STORAGE_NAME, encodeUnpaddedBase64(key));
this.key = key;
public async resetKey(): Promise<RustSdkCryptoJs.DehydratedDeviceKey> {
const key = RustSdkCryptoJs.DehydratedDeviceKey.createRandomKey();
await this.secretStorage.store(SECRET_STORAGE_NAME, key.toBase64());
// Also cache it in the rust SDK's crypto store.
await this.cacheKey(key);
return key;
}
/**
@ -166,19 +177,27 @@ export class DehydratedDeviceManager {
*
* @returns the key, if available, or `null` if no key is available
*/
private async getKey(create: boolean): Promise<Uint8Array | null> {
if (this.key === undefined) {
const keyB64 = await this.secretStorage.get(SECRET_STORAGE_NAME);
if (keyB64 === undefined) {
if (!create) {
return null;
}
await this.resetKey();
} else {
this.key = decodeBase64(keyB64);
private async getKey(create: boolean): Promise<RustSdkCryptoJs.DehydratedDeviceKey | null> {
const cachedKey = await this.olmMachine.dehydratedDevices().getDehydratedDeviceKey();
if (cachedKey) return cachedKey;
const keyB64 = await this.secretStorage.get(SECRET_STORAGE_NAME);
if (keyB64 === undefined) {
if (!create) {
return null;
}
return await this.resetKey();
}
// We successfully found the key in secret storage: decode it, and cache it in
// the rust SDK's crypto store.
const bytes = decodeBase64(keyB64);
try {
const key = RustSdkCryptoJs.DehydratedDeviceKey.createKeyFromArray(bytes);
await this.cacheKey(key);
return key;
} finally {
bytes.fill(0);
}
return this.key!;
}
/**
@ -219,6 +238,7 @@ export class DehydratedDeviceManager {
}
this.logger.info("dehydration: dehydrated device found");
this.emit(CryptoEvent.RehydrationStarted);
const rehydratedDevice = await this.olmMachine
.dehydratedDevices()
@ -255,8 +275,11 @@ export class DehydratedDeviceManager {
nextBatch = eventResp.next_batch;
const roomKeyInfos = await rehydratedDevice.receiveEvents(JSON.stringify(eventResp.events));
roomKeyCount += roomKeyInfos.length;
this.emit(CryptoEvent.RehydrationProgress, roomKeyCount, toDeviceCount);
}
this.logger.info(`dehydration: received ${roomKeyCount} room keys from ${toDeviceCount} to-device events`);
this.emit(CryptoEvent.RehydrationCompleted);
return true;
}
@ -270,9 +293,11 @@ export class DehydratedDeviceManager {
const key = (await this.getKey(true))!;
const dehydratedDevice = await this.olmMachine.dehydratedDevices().create();
this.emit(CryptoEvent.DehydratedDeviceCreated);
const request = await dehydratedDevice.keysForUpload("Dehydrated device", key);
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
this.emit(CryptoEvent.DehydratedDeviceUploaded);
this.logger.info("dehydration: uploaded device");
}
@ -287,6 +312,7 @@ export class DehydratedDeviceManager {
await this.createAndUploadDehydratedDevice();
this.intervalId = setInterval(() => {
this.createAndUploadDehydratedDevice().catch((error) => {
this.emit(CryptoEvent.DehydratedDeviceRotationError, error.message);
this.logger.error("Error creating dehydrated device:", error);
});
}, DEHYDRATION_INTERVAL);
@ -304,3 +330,23 @@ export class DehydratedDeviceManager {
}
}
}
/**
* The events fired by the DehydratedDeviceManager
* @internal
*/
type DehydratedDevicesEvents =
| CryptoEvent.DehydratedDeviceCreated
| CryptoEvent.DehydratedDeviceUploaded
| CryptoEvent.RehydrationStarted
| CryptoEvent.RehydrationProgress
| CryptoEvent.RehydrationCompleted
| CryptoEvent.RehydrationError
| CryptoEvent.DehydrationKeyCached
| CryptoEvent.DehydratedDeviceRotationError;
/**
* A map of the {@link DehydratedDeviceEvents} fired by the {@link DehydratedDeviceManager} and their payloads.
* @internal
*/
type DehydratedDevicesEventMap = Pick<CryptoEventHandlerMap, DehydratedDevicesEvents>;

View File

@ -183,12 +183,23 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
);
this.eventDecryptor = new EventDecryptor(this.logger, olmMachine, this.perSessionBackupDownloader);
// re-emit the events emitted by managers
this.reemitter.reEmit(this.backupManager, [
CryptoEvent.KeyBackupStatus,
CryptoEvent.KeyBackupSessionsRemaining,
CryptoEvent.KeyBackupFailed,
CryptoEvent.KeyBackupDecryptionKeyCached,
]);
this.reemitter.reEmit(this.dehydratedDeviceManager, [
CryptoEvent.DehydratedDeviceCreated,
CryptoEvent.DehydratedDeviceUploaded,
CryptoEvent.RehydrationStarted,
CryptoEvent.RehydrationProgress,
CryptoEvent.RehydrationCompleted,
CryptoEvent.RehydrationError,
CryptoEvent.DehydrationKeyCached,
CryptoEvent.DehydratedDeviceRotationError,
]);
this.crossSigningIdentity = new CrossSigningIdentity(olmMachine, this.outgoingRequestProcessor, secretStorage);

View File

@ -1477,10 +1477,10 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@matrix-org/matrix-sdk-crypto-wasm@^12.1.0":
version "12.1.0"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-12.1.0.tgz#2aef64eab2d30c0a1ace9c0fe876f53aa2949f14"
integrity sha512-NhJFu/8FOGjnW7mDssRUzaMSwXrYOcCqgAjZyAw9KQ9unNADKEi7KoIKe7GtrG2PWtm36y2bUf+hB8vhSY6Wdw==
"@matrix-org/matrix-sdk-crypto-wasm@^13.0.0":
version "13.0.0"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-13.0.0.tgz#658bed951e4c8a06a6dd545575a79cf32022d4ba"
integrity sha512-2gtpjnxL42sdJAgkwitpMMI4cw7Gcjf5sW0MXoe+OAlXPlxIzyM+06F5JJ8ENvBeHkuV2RqtFIRrh8i90HLsMw==
"@matrix-org/olm@3.2.15":
version "3.2.15"