Skip to content

Add support for device dehydration v2 (Element R) #4062

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

Merged
merged 27 commits into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b855200
initial implementation of device dehydration
uhoreg Feb 12, 2024
4c6254e
Merge branch 'develop' into dehydration_v2
uhoreg Feb 16, 2024
8e632f3
add dehydrated flag for devices
uhoreg Feb 23, 2024
4f231a6
add missing dehydration.ts file, add test, add function to schedule d…
uhoreg Mar 5, 2024
560511d
add more dehydration utility functions
uhoreg Mar 8, 2024
1def99a
stop scheduled dehydration when crypto stops
uhoreg Mar 12, 2024
71eea91
Merge branch 'develop' into dehydration_v2
uhoreg Mar 22, 2024
4fe5a64
bump matrix-crypto-sdk-wasm version, and fix tests
uhoreg Mar 23, 2024
d6fdce3
Merge branch 'develop' into dehydration_v2
uhoreg Mar 24, 2024
12a934c
adding dehydratedDevices member to mock OlmDevice isn't necessary any…
uhoreg Mar 24, 2024
937fac2
fix yarn lock file
uhoreg Mar 24, 2024
49dedf2
more tests
uhoreg Mar 25, 2024
b0f0703
Merge branch 'develop' into dehydration_v2
uhoreg Mar 25, 2024
7aa13df
fix test
uhoreg Mar 25, 2024
3aa06dd
more tests
uhoreg Mar 26, 2024
7c1e82e
fix typo
uhoreg Mar 26, 2024
663448b
fix logic for checking if dehydration supported
uhoreg Mar 26, 2024
d870915
make changes from review
uhoreg Mar 28, 2024
964333a
add missing file
uhoreg Mar 28, 2024
3a66418
move setup into another function
uhoreg Mar 28, 2024
0947a8a
apply changes from review
uhoreg Apr 4, 2024
f666788
implement simpler API
uhoreg Apr 7, 2024
15c8c5c
Merge branch 'develop' into dehydration_v2
uhoreg Apr 8, 2024
27784d7
fix type and move the code to the right spot
uhoreg Apr 8, 2024
9ad28bb
apply suggestions from review
uhoreg Apr 9, 2024
1525a16
make sure that cross-signing and secret storage are set up
uhoreg Apr 9, 2024
a995c46
Merge branch 'develop' into dehydration_v2
uhoreg Apr 9, 2024
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^4.8.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^4.9.0",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
Expand Down
44 changes: 37 additions & 7 deletions spec/integ/crypto/device-dehydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -37,28 +39,52 @@ 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: {
errcode: "M_NOT_FOUND",
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
const dehydrationPromise = new Promise<void>((resolve, reject) => {
resolveDehydrationPromise = resolve;
});
jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000);
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,
Expand All @@ -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();
});
});

Expand Down
149 changes: 0 additions & 149 deletions spec/unit/rust-crypto/rust-crypto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void>((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<void>((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;
Expand All @@ -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();
});
});
});

Expand Down Expand Up @@ -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<void> {
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,
});
}
73 changes: 22 additions & 51 deletions src/crypto-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;

/**
* 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<boolean>;

/**
* 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<void>;

/**
* 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<void>;

/**
* Return whether the dehydration key is stored in Secret Storage.
*/
isDehydrationKeyStored(): Promise<boolean>;

/**
* 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<void>;
startDehydration(createNewKey?: boolean): Promise<void>;
}

/** A reason code for a failure to decrypt an event. */
Expand Down Expand Up @@ -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
Expand Down
Loading