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

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

Merged
merged 17 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 47 additions & 1 deletion spec/integ/crypto/device-dehydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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) => {
Expand Down Expand Up @@ -74,14 +82,19 @@ 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) => {
resolveDehydrationPromise = resolve;
});
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
Expand Down Expand Up @@ -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();
});
});
Expand Down
13 changes: 13 additions & 0 deletions spec/test-utils/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
106 changes: 105 additions & 1 deletion spec/unit/rust-crypto/rust-crypto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
8 changes: 8 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
}

Expand Down
59 changes: 59 additions & 0 deletions src/crypto-api/CryptoEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
8 changes: 8 additions & 0 deletions src/crypto-api/CryptoEventHandlerMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading