Skip to content

Commit 936e7c3

Browse files
authored
Add support for device dehydration v2 (Element R) (#4062)
* initial implementation of device dehydration * add dehydrated flag for devices * add missing dehydration.ts file, add test, add function to schedule dehydration * add more dehydration utility functions * stop scheduled dehydration when crypto stops * bump matrix-crypto-sdk-wasm version, and fix tests * adding dehydratedDevices member to mock OlmDevice isn't necessary any more * fix yarn lock file * more tests * fix test * more tests * fix typo * fix logic for checking if dehydration supported * make changes from review * add missing file * move setup into another function * apply changes from review * implement simpler API * fix type and move the code to the right spot * apply suggestions from review * make sure that cross-signing and secret storage are set up
1 parent 82ed7bd commit 936e7c3

11 files changed

+643
-17
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/*
2+
Copyright 2024 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import "fake-indexeddb/auto";
18+
import fetchMock from "fetch-mock-jest";
19+
20+
import { createClient, ClientEvent, MatrixClient, MatrixEvent } from "../../../src";
21+
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
22+
import { AddSecretStorageKeyOpts } from "../../../src/secret-storage";
23+
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
24+
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
25+
26+
describe("Device dehydration", () => {
27+
it("should rehydrate and dehydrate a device", async () => {
28+
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
29+
30+
const matrixClient = createClient({
31+
baseUrl: "http://test.server",
32+
userId: "@alice:localhost",
33+
deviceId: "aliceDevice",
34+
cryptoCallbacks: {
35+
getSecretStorageKey: async (keys: any, name: string) => {
36+
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
37+
},
38+
},
39+
});
40+
41+
await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server");
42+
43+
// count the number of times the dehydration key gets set
44+
let setDehydrationCount = 0;
45+
matrixClient.on(ClientEvent.AccountData, (event: MatrixEvent) => {
46+
if (event.getType() === "org.matrix.msc3814") {
47+
setDehydrationCount++;
48+
}
49+
});
50+
51+
const crypto = matrixClient.getCrypto()!;
52+
fetchMock.config.overwriteRoutes = true;
53+
54+
// start dehydration -- we start with no dehydrated device, and we
55+
// store the dehydrated device that we create
56+
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
57+
status: 404,
58+
body: {
59+
errcode: "M_NOT_FOUND",
60+
error: "Not found",
61+
},
62+
});
63+
let dehydratedDeviceBody: any;
64+
let dehydrationCount = 0;
65+
let resolveDehydrationPromise: () => void;
66+
fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", (_, opts) => {
67+
dehydratedDeviceBody = JSON.parse(opts.body as string);
68+
dehydrationCount++;
69+
if (resolveDehydrationPromise) {
70+
resolveDehydrationPromise();
71+
}
72+
return {};
73+
});
74+
await crypto.startDehydration();
75+
76+
expect(dehydrationCount).toEqual(1);
77+
78+
// a week later, we should have created another dehydrated device
79+
const dehydrationPromise = new Promise<void>((resolve, reject) => {
80+
resolveDehydrationPromise = resolve;
81+
});
82+
jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000);
83+
await dehydrationPromise;
84+
expect(dehydrationCount).toEqual(2);
85+
86+
// restart dehydration -- rehydrate the device that we created above,
87+
// and create a new dehydrated device. We also set `createNewKey`, so
88+
// a new dehydration key will be set
89+
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
90+
device_id: dehydratedDeviceBody.device_id,
91+
device_data: dehydratedDeviceBody.device_data,
92+
});
93+
const eventsResponse = jest.fn((url, opts) => {
94+
// rehydrating should make two calls to the /events endpoint.
95+
// The first time will return a single event, and the second
96+
// time will return no events (which will signal to the
97+
// rehydration function that it can stop)
98+
const body = JSON.parse(opts.body as string);
99+
const nextBatch = body.next_batch ?? "0";
100+
const events = nextBatch === "0" ? [{ sender: "@alice:localhost", type: "m.dummy", content: {} }] : [];
101+
return {
102+
events,
103+
next_batch: nextBatch + "1",
104+
};
105+
});
106+
fetchMock.post(
107+
`path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/${encodeURIComponent(dehydratedDeviceBody.device_id)}/events`,
108+
eventsResponse,
109+
);
110+
await crypto.startDehydration(true);
111+
expect(dehydrationCount).toEqual(3);
112+
113+
expect(setDehydrationCount).toEqual(2);
114+
expect(eventsResponse.mock.calls).toHaveLength(2);
115+
116+
matrixClient.stopClient();
117+
});
118+
});
119+
120+
/** create a new secret storage and cross-signing keys */
121+
async function initializeSecretStorage(
122+
matrixClient: MatrixClient,
123+
userId: string,
124+
homeserverUrl: string,
125+
): Promise<void> {
126+
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
127+
status: 404,
128+
body: {
129+
errcode: "M_NOT_FOUND",
130+
error: "Not found",
131+
},
132+
});
133+
const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
134+
const e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
135+
e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver);
136+
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {});
137+
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {});
138+
const accountData: Map<string, object> = new Map();
139+
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
140+
const name = url.split("/").pop()!;
141+
const value = accountData.get(name);
142+
if (value) {
143+
return value;
144+
} else {
145+
return {
146+
status: 404,
147+
body: {
148+
errcode: "M_NOT_FOUND",
149+
error: "Not found",
150+
},
151+
};
152+
}
153+
});
154+
fetchMock.put("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
155+
const name = url.split("/").pop()!;
156+
const value = JSON.parse(opts.body as string);
157+
accountData.set(name, value);
158+
matrixClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: name, content: value }));
159+
return {};
160+
});
161+
162+
await matrixClient.initRustCrypto();
163+
const crypto = matrixClient.getCrypto()! as RustCrypto;
164+
// we need to process a sync so that the OlmMachine will upload keys
165+
await crypto.preprocessToDeviceMessages([]);
166+
await crypto.onSyncCompleted({});
167+
168+
// create initial secret storage
169+
async function createSecretStorageKey() {
170+
return {
171+
keyInfo: {} as AddSecretStorageKeyOpts,
172+
privateKey: new Uint8Array(32),
173+
};
174+
}
175+
await matrixClient.bootstrapCrossSigning({ setupNewCrossSigning: true });
176+
await matrixClient.bootstrapSecretStorage({
177+
createSecretStorageKey,
178+
setupNewSecretStorage: true,
179+
setupNewKeyBackup: false,
180+
});
181+
}

spec/unit/rust-crypto/OutgoingRequestProcessor.spec.ts

+30
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
KeysClaimRequest,
2323
KeysQueryRequest,
2424
KeysUploadRequest,
25+
PutDehydratedDeviceRequest,
2526
RoomMessageRequest,
2627
SignatureUploadRequest,
2728
UploadSigningKeysRequest,
@@ -233,6 +234,35 @@ describe("OutgoingRequestProcessor", () => {
233234
httpBackend.verifyNoOutstandingRequests();
234235
});
235236

237+
it("should handle PutDehydratedDeviceRequest", async () => {
238+
// first, mock up a request as we might expect to receive it from the Rust layer ...
239+
const testReq = { foo: "bar" };
240+
const outgoingRequest = new PutDehydratedDeviceRequest(JSON.stringify(testReq));
241+
242+
// ... then poke the request into the OutgoingRequestProcessor under test
243+
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
244+
245+
// Now: check that it makes a matching HTTP request.
246+
const testResponse = '{"result":1}';
247+
httpBackend
248+
.when("PUT", "/_matrix")
249+
.check((req) => {
250+
expect(req.path).toEqual(
251+
"https://example.com/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device",
252+
);
253+
expect(JSON.parse(req.rawData)).toEqual(testReq);
254+
expect(req.headers["Accept"]).toEqual("application/json");
255+
expect(req.headers["Content-Type"]).toEqual("application/json");
256+
})
257+
.respond(200, testResponse, true);
258+
259+
// PutDehydratedDeviceRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected.
260+
261+
await httpBackend.flushAllExpected();
262+
await reqProm;
263+
httpBackend.verifyNoOutstandingRequests();
264+
});
265+
236266
it("does not explode with unknown requests", async () => {
237267
const outgoingRequest = { id: "5678", type: 987 };
238268
const markSentCallPromise = awaitCallToMarkAsSent();

spec/unit/rust-crypto/rust-crypto.spec.ts

+33-2
Original file line numberDiff line numberDiff line change
@@ -762,8 +762,11 @@ describe("RustCrypto", () => {
762762
},
763763
},
764764
};
765-
} else if (request instanceof RustSdkCryptoJs.UploadSigningKeysRequest) {
766-
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
765+
} else if (
766+
request instanceof RustSdkCryptoJs.UploadSigningKeysRequest ||
767+
request instanceof RustSdkCryptoJs.PutDehydratedDeviceRequest
768+
) {
769+
// These request types do not implement OutgoingRequest and do not need to be marked as sent.
767770
return;
768771
}
769772
if (request.id) {
@@ -1395,6 +1398,34 @@ describe("RustCrypto", () => {
13951398
});
13961399
});
13971400
});
1401+
1402+
describe("device dehydration", () => {
1403+
it("should detect if dehydration is supported", async () => {
1404+
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi());
1405+
fetchMock.config.overwriteRoutes = true;
1406+
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
1407+
status: 404,
1408+
body: {
1409+
errcode: "M_UNRECOGNIZED",
1410+
error: "Unknown endpoint",
1411+
},
1412+
});
1413+
expect(await rustCrypto.isDehydrationSupported()).toBe(false);
1414+
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
1415+
status: 404,
1416+
body: {
1417+
errcode: "M_NOT_FOUND",
1418+
error: "Not found",
1419+
},
1420+
});
1421+
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
1422+
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
1423+
device_id: "DEVICE_ID",
1424+
device_data: "data",
1425+
});
1426+
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
1427+
});
1428+
});
13981429
});
13991430

14001431
/** Build a MatrixHttpApi instance */

src/crypto-api.ts

+36
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,42 @@ export interface CryptoApi {
496496
* @param version - The backup version to delete.
497497
*/
498498
deleteKeyBackupVersion(version: string): Promise<void>;
499+
500+
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
501+
//
502+
// Dehydrated devices
503+
//
504+
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
505+
506+
/**
507+
* Returns whether MSC3814 dehydrated devices are supported by the crypto
508+
* backend and by the server.
509+
*
510+
* This should be called before calling `startDehydration`, and if this
511+
* returns `false`, `startDehydration` should not be called.
512+
*/
513+
isDehydrationSupported(): Promise<boolean>;
514+
515+
/**
516+
* Start using device dehydration.
517+
*
518+
* - Rehydrates a dehydrated device, if one is available.
519+
* - Creates a new dehydration key, if necessary, and stores it in Secret
520+
* Storage.
521+
* - If `createNewKey` is set to true, always creates a new key.
522+
* - If a dehydration key is not available, creates a new one.
523+
* - Creates a new dehydrated device, and schedules periodically creating
524+
* new dehydrated devices.
525+
*
526+
* This function must not be called unless `isDehydrationSupported` returns
527+
* `true`, and must not be called until after cross-signing and secret
528+
* storage have been set up.
529+
*
530+
* @param createNewKey - whether to force creation of a new dehydration key.
531+
* This can be used, for example, if Secret Storage is being reset. Defaults
532+
* to false.
533+
*/
534+
startDehydration(createNewKey?: boolean): Promise<void>;
499535
}
500536

501537
/** A reason code for a failure to decrypt an event. */

src/crypto/index.ts

+15
Original file line numberDiff line numberDiff line change
@@ -4287,6 +4287,21 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
42874287
public getRoomEncryption(roomId: string): IRoomEncryption | null {
42884288
return this.roomList.getRoomEncryption(roomId);
42894289
}
4290+
4291+
/**
4292+
* Returns whether dehydrated devices are supported by the crypto backend
4293+
* and by the server.
4294+
*/
4295+
public async isDehydrationSupported(): Promise<boolean> {
4296+
return false;
4297+
}
4298+
4299+
/**
4300+
* Stub function -- dehydration is not implemented here, so throw error
4301+
*/
4302+
public async startDehydration(createNewKey?: boolean): Promise<void> {
4303+
throw new Error("Not implemented");
4304+
}
42904305
}
42914306

42924307
/**

src/models/device.ts

+4
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ export class Device {
5151
/** display name of the device */
5252
public readonly displayName?: string;
5353

54+
/** whether the device is a dehydrated device */
55+
public readonly dehydrated: boolean = false;
56+
5457
public constructor(opts: DeviceParameters) {
5558
this.deviceId = opts.deviceId;
5659
this.userId = opts.userId;
@@ -59,6 +62,7 @@ export class Device {
5962
this.verified = opts.verified || DeviceVerification.Unverified;
6063
this.signatures = opts.signatures || new Map();
6164
this.displayName = opts.displayName;
65+
this.dehydrated = !!opts.dehydrated;
6266
}
6367

6468
/**

0 commit comments

Comments
 (0)