Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide more options for starting dehydration #4664

Merged
merged 2 commits into from
Jan 30, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions spec/unit/rust-crypto/rust-crypto.spec.ts
Original file line number Diff line number Diff line change
@@ -1894,6 +1894,193 @@ describe("RustCrypto", () => {
await rehydrationCompletedPromise;
await rustCrypto2.stop();
});

describe("start dehydration options", () => {
let rustCrypto: RustCrypto;
let secretStorage: ServerSideSecretStorageImpl;
let dehydratedDeviceInfo: Record<string, any> | undefined;

// Function that is called when `GET /dehydrated_device` is called
// (i.e. when we try to rehydrate a device)
const getDehydratedDeviceMock = jest.fn(() => {
if (dehydratedDeviceInfo) {
return {
status: 200,
body: dehydratedDeviceInfo,
};
} else {
return {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
};
}
});
// Function that is called when `PUT /dehydrated_device` is called
// (i.e. when we create a new dehydrated device)
const putDehydratedDeviceMock = jest.fn((path, opts) => {
const content = JSON.parse(opts.body as string);
dehydratedDeviceInfo = {
device_id: content.device_id,
device_data: content.device_data,
};
return {
status: 200,
body: {
device_id: content.device_id,
},
};
});

beforeEach(async () => {
// Set up a RustCrypto object with secret storage and cross-signing.
const secretStorageCallbacks = {
getSecretStorageKey: async (keys: any, name: string) => {
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
},
} as SecretStorageCallbacks;
secretStorage = new ServerSideSecretStorageImpl(new DummyAccountDataClient(), secretStorageCallbacks);

const e2eKeyReceiver = new E2EKeyReceiver("http://server");
const e2eKeyResponder = new E2EKeyResponder("http://server");
e2eKeyResponder.addKeyReceiver(TEST_USER, e2eKeyReceiver);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
});
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {
status: 200,
body: {},
});
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {
status: 200,
body: {},
});
rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage);

// dehydration requires secret storage and cross signing
async function createSecretStorageKey() {
return {
keyInfo: {} as AddSecretStorageKeyOpts,
privateKey: new Uint8Array(32),
};
}
await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true });
await rustCrypto.bootstrapSecretStorage({
createSecretStorageKey,
setupNewSecretStorage: true,
setupNewKeyBackup: false,
});
// we need to process a sync so that the OlmMachine will upload keys
await rustCrypto.preprocessToDeviceMessages([]);
await rustCrypto.onSyncCompleted({});

// set up mocks needed for device dehydration
dehydratedDeviceInfo = undefined;
fetchMock.get(
"path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device",
getDehydratedDeviceMock,
);
fetchMock.put(
"path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device",
putDehydratedDeviceMock,
);
fetchMock.post(/_matrix\/client\/unstable\/org.matrix.msc3814.v1\/dehydrated_device\/.*\/events/, {
status: 200,
body: {
events: [],
next_batch: "foo",
},
});
getDehydratedDeviceMock.mockClear();
putDehydratedDeviceMock.mockClear();
});

afterEach(() => {
rustCrypto.stop();
});

// Several tests require a dehydrated device and dehydration key
// already set up.
async function setUpInitialDehydratedDevice() {
await rustCrypto.startDehydration();
getDehydratedDeviceMock.mockClear();
putDehydratedDeviceMock.mockClear();
return await secretStorage.get("org.matrix.msc3814");
}

it("should create a new key and dehydrate a device when no options given", async () => {
// With the default options, when we don't have an existing key ...
await rustCrypto.startDehydration();
// ... we create a new dehydration key ...
expect(await secretStorage.get("org.matrix.msc3814")).toBeTruthy();
// ... and create a new dehydrated device.
expect(putDehydratedDeviceMock).toHaveBeenCalled();
});

it("should rehydrate a device if available and keep existing key when no options given", async () => {
const origDehydrationKey = await setUpInitialDehydratedDevice();

// If we already have a dehydration key and dehydrated device...
await rustCrypto.startDehydration();
// ... we should fetch the device to rehydrate it ...
expect(getDehydratedDeviceMock).toHaveBeenCalled();
// ... create a new dehydrated device ...
expect(putDehydratedDeviceMock).toHaveBeenCalled();
// ... and keep the same dehydration key.
expect(await secretStorage.get("org.matrix.msc3814")).toEqual(origDehydrationKey);
});

it("should do nothing if onlyIfKeyCached is true and we have no key cached", async () => {
// Since there is no key cached, so should do nothing. i.e. it
// should not make any HTTP requests and should not create a new key.
await rustCrypto.startDehydration({ onlyIfKeyCached: true });
expect(getDehydratedDeviceMock).not.toHaveBeenCalled();
expect(putDehydratedDeviceMock).not.toHaveBeenCalled();
expect(await secretStorage.get("org.matrix.msc3814")).toBeFalsy();
});

it("should start dehydration when onlyIfKeyCached is true, and we have a cached key", async () => {
const origDehydrationKey = await setUpInitialDehydratedDevice();

// If `onlyIfKeyCached` is `true`, and we already have have a
// key, we should behave the same as if no options were given.
await rustCrypto.startDehydration({ onlyIfKeyCached: true });
expect(getDehydratedDeviceMock).toHaveBeenCalled();
expect(putDehydratedDeviceMock).toHaveBeenCalled();
expect(await secretStorage.get("org.matrix.msc3814")).toEqual(origDehydrationKey);
});

it("should not rehydrate if rehydrate is set to false", async () => {
const origDehydrationKey = await setUpInitialDehydratedDevice();

// If `rehydrate` is set to `false` ...
await rustCrypto.startDehydration({ rehydrate: false });
// ... we should not try to rehydrate ...
expect(getDehydratedDeviceMock).not.toHaveBeenCalled();
// ... but we should still create a new dehydrated device ...
expect(putDehydratedDeviceMock).toHaveBeenCalled();
// ... and we should keep the same dehydration key.
expect(await secretStorage.get("org.matrix.msc3814")).toEqual(origDehydrationKey);
});

it("should create a new key if createNewKey is set to true", async () => {
const origDehydrationKey = await setUpInitialDehydratedDevice();

// If `createNewKey` is set to `true` ...
await rustCrypto.startDehydration({ createNewKey: true });
// ... we should rehydrate and dehydrate as normal ...
expect(getDehydratedDeviceMock).toHaveBeenCalled();
expect(putDehydratedDeviceMock).toHaveBeenCalled();
// ... and we should create a new dehydration key.
expect(await secretStorage.get("org.matrix.msc3814")).not.toEqual(origDehydrationKey);
});
});
});

describe("import & export secrets bundle", () => {
44 changes: 38 additions & 6 deletions src/crypto-api/index.ts
Original file line number Diff line number Diff line change
@@ -41,6 +41,37 @@ import { MatrixEvent } from "../models/event.ts";
* @packageDocumentation
*/

/**
* The options to start device dehydration.
*/
export interface StartDehydrationOpts {
/**
* Force creation of a new dehydration key, even if there is already an
* existing dehydration key. If `false`, and `onlyIfKeyCached` is `false`, a
* new key will be created if there is no existing dehydration key, whether
* already cached in our local storage or stored in Secret Storage.
*
* Checking for the presence of the key in Secret Storage may result in the
* `getSecretStorageKey` callback being called.
*
* Defaults to `false`.
*/
createNewKey?: boolean;
/**
* Only start dehydration if we have a dehydration key cached in our local
* storage. If `true`, Secret Storage will not be checked. Defaults to
* `false`.
*/
onlyIfKeyCached?: boolean;
uhoreg marked this conversation as resolved.
Show resolved Hide resolved
/**
* Try to rehydrate a device before creating a new dehydrated device.
* Setting this to `false` may be useful for situations where the client is
* known to pre-date the dehydrated device, and so rehydration is
* unnecessary. Defaults to `true`.
*/
rehydrate?: boolean;
}

/**
* Public interface to the cryptography parts of the js-sdk
*
@@ -649,10 +680,11 @@ export interface CryptoApi {
/**
* Start using device dehydration.
*
* - Rehydrates a dehydrated device, if one is available.
* - Rehydrates a dehydrated device, if one is available and `opts.rehydrate`
* is `true`.
* - Creates a new dehydration key, if necessary, and stores it in Secret
* Storage.
* - If `createNewKey` is set to true, always creates a new key.
* - If `opts.createNewKey` is set to true, always creates a new key.
* - If a dehydration key is not available, creates a new one.
* - Creates a new dehydrated device, and schedules periodically creating
* new dehydrated devices.
@@ -661,11 +693,11 @@ export interface CryptoApi {
* `true`, and must not be called until after cross-signing and secret
* storage have been set up.
*
* @param createNewKey - whether to force creation of a new dehydration key.
* This can be used, for example, if Secret Storage is being reset. Defaults
* to false.
* @param opts - options for device dehydration. For backwards compatibility
* with old code, a boolean can be given here, which will be treated as
* the `createNewKey` option. However, this is deprecated.
*/
startDehydration(createNewKey?: boolean): Promise<void>;
startDehydration(opts?: StartDehydrationOpts | boolean): Promise<void>;

///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
3 changes: 2 additions & 1 deletion src/crypto/index.ts
Original file line number Diff line number Diff line change
@@ -105,6 +105,7 @@ import {
CryptoEventHandlerMap as CryptoApiCryptoEventHandlerMap,
KeyBackupRestoreResult,
KeyBackupRestoreOpts,
StartDehydrationOpts,
} from "../crypto-api/index.ts";
import { Device, DeviceMap } from "../models/device.ts";
import { deviceInfoToDevice } from "./device-converter.ts";
@@ -4327,7 +4328,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
/**
* Stub function -- dehydration is not implemented here, so throw error
*/
public async startDehydration(createNewKey?: boolean): Promise<void> {
public async startDehydration(createNewKey?: StartDehydrationOpts | boolean): Promise<void> {
throw new Error("Not implemented");
}

39 changes: 25 additions & 14 deletions src/rust-crypto/DehydratedDeviceManager.ts
Original file line number Diff line number Diff line change
@@ -23,7 +23,7 @@ import { IToDeviceEvent } from "../sync-accumulator.ts";
import { ServerSideSecretStorage } from "../secret-storage.ts";
import { decodeBase64 } from "../base64.ts";
import { Logger } from "../logger.ts";
import { CryptoEvent, CryptoEventHandlerMap } from "../crypto-api/index.ts";
import { CryptoEvent, CryptoEventHandlerMap, StartDehydrationOpts } from "../crypto-api/index.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";

/**
@@ -121,28 +121,39 @@ export class DehydratedDeviceManager extends TypedEventEmitter<DehydratedDevices
/**
* Start using device dehydration.
*
* - Rehydrates a dehydrated device, if one is available.
* - Rehydrates a dehydrated device, if one is available and `opts.rehydrate`
* is `true`.
* - Creates a new dehydration key, if necessary, and stores it in Secret
* Storage.
* - If `createNewKey` is set to true, always creates a new key.
* - If `opts.createNewKey` is set to true, always creates a new key.
* - If a dehydration key is not available, creates a new one.
* - Creates a new dehydrated device, and schedules periodically creating
* new dehydrated devices.
*
* @param createNewKey - whether to force creation of a new dehydration key.
* This can be used, for example, if Secret Storage is being reset.
* @param opts - options for device dehydration. For backwards compatibility
* with old code, a boolean can be given here, which will be treated as
* the `createNewKey` option. However, this is deprecated.
*/
public async start(createNewKey?: boolean): Promise<void> {
public async start(opts: StartDehydrationOpts | boolean = {}): Promise<void> {
if (typeof opts === "boolean") {
opts = { createNewKey: opts };
}

if (opts.onlyIfKeyCached && !(await this.olmMachine.dehydratedDevices().getDehydratedDeviceKey())) {
return;
}
this.stop();
try {
await this.rehydrateDeviceIfAvailable();
} catch (e) {
// If rehydration fails, there isn't much we can do about it. Log
// the error, and create a new device.
this.logger.info("dehydration: Error rehydrating device:", e);
this.emit(CryptoEvent.RehydrationError, (e as Error).message);
if (opts.rehydrate !== false) {
try {
await this.rehydrateDeviceIfAvailable();
} catch (e) {
// If rehydration fails, there isn't much we can do about it. Log
// the error, and create a new device.
this.logger.info("dehydration: Error rehydrating device:", e);
this.emit(CryptoEvent.RehydrationError, (e as Error).message);
}
}
if (createNewKey) {
if (opts.createNewKey) {
await this.resetKey();
}
await this.scheduleDeviceDehydration();
5 changes: 3 additions & 2 deletions src/rust-crypto/rust-crypto.ts
Original file line number Diff line number Diff line change
@@ -67,6 +67,7 @@ import {
CryptoEventHandlerMap,
KeyBackupRestoreOpts,
KeyBackupRestoreResult,
StartDehydrationOpts,
} from "../crypto-api/index.ts";
import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter.ts";
import { IDownloadKeyResult, IQueryKeysRequest } from "../client.ts";
@@ -1425,11 +1426,11 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/**
* Implementation of {@link CryptoApi#startDehydration}.
*/
public async startDehydration(createNewKey?: boolean): Promise<void> {
public async startDehydration(opts: StartDehydrationOpts | boolean = {}): Promise<void> {
if (!(await this.isCrossSigningReady()) || !(await this.isSecretStorageReady())) {
throw new Error("Device dehydration requires cross-signing and secret storage to be set up");
}
return await this.dehydratedDeviceManager.start(createNewKey);
return await this.dehydratedDeviceManager.start(opts || {});
}

/**