You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-07-30 04:23:07 +03:00
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
This commit is contained in:
181
spec/integ/crypto/device-dehydration.spec.ts
Normal file
181
spec/integ/crypto/device-dehydration.spec.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "fake-indexeddb/auto";
|
||||||
|
import fetchMock from "fetch-mock-jest";
|
||||||
|
|
||||||
|
import { createClient, ClientEvent, MatrixClient, MatrixEvent } from "../../../src";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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",
|
||||||
|
deviceId: "aliceDevice",
|
||||||
|
cryptoCallbacks: {
|
||||||
|
getSecretStorageKey: async (keys: any, name: string) => {
|
||||||
|
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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.startDehydration();
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
const eventsResponse = jest.fn((url, opts) => {
|
||||||
|
// rehydrating should make two calls to the /events endpoint.
|
||||||
|
// The first time will return a single event, and the second
|
||||||
|
// time will return no events (which will signal to the
|
||||||
|
// rehydration function that it can stop)
|
||||||
|
const body = JSON.parse(opts.body as string);
|
||||||
|
const nextBatch = body.next_batch ?? "0";
|
||||||
|
const events = nextBatch === "0" ? [{ sender: "@alice:localhost", type: "m.dummy", content: {} }] : [];
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
next_batch: nextBatch + "1",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
fetchMock.post(
|
||||||
|
`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(setDehydrationCount).toEqual(2);
|
||||||
|
expect(eventsResponse.mock.calls).toHaveLength(2);
|
||||||
|
|
||||||
|
matrixClient.stopClient();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** create a new secret storage and cross-signing keys */
|
||||||
|
async function initializeSecretStorage(
|
||||||
|
matrixClient: MatrixClient,
|
||||||
|
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", {});
|
||||||
|
const accountData: Map<string, object> = new Map();
|
||||||
|
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
|
||||||
|
const name = url.split("/").pop()!;
|
||||||
|
const value = accountData.get(name);
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
errcode: "M_NOT_FOUND",
|
||||||
|
error: "Not found",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fetchMock.put("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
|
||||||
|
const name = url.split("/").pop()!;
|
||||||
|
const value = JSON.parse(opts.body as string);
|
||||||
|
accountData.set(name, value);
|
||||||
|
matrixClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: name, content: value }));
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
await matrixClient.initRustCrypto();
|
||||||
|
const crypto = matrixClient.getCrypto()! as RustCrypto;
|
||||||
|
// we need to process a sync so that the OlmMachine will upload keys
|
||||||
|
await crypto.preprocessToDeviceMessages([]);
|
||||||
|
await crypto.onSyncCompleted({});
|
||||||
|
|
||||||
|
// create initial secret storage
|
||||||
|
async function createSecretStorageKey() {
|
||||||
|
return {
|
||||||
|
keyInfo: {} as AddSecretStorageKeyOpts,
|
||||||
|
privateKey: new Uint8Array(32),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await matrixClient.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||||
|
await matrixClient.bootstrapSecretStorage({
|
||||||
|
createSecretStorageKey,
|
||||||
|
setupNewSecretStorage: true,
|
||||||
|
setupNewKeyBackup: false,
|
||||||
|
});
|
||||||
|
}
|
@ -22,6 +22,7 @@ import {
|
|||||||
KeysClaimRequest,
|
KeysClaimRequest,
|
||||||
KeysQueryRequest,
|
KeysQueryRequest,
|
||||||
KeysUploadRequest,
|
KeysUploadRequest,
|
||||||
|
PutDehydratedDeviceRequest,
|
||||||
RoomMessageRequest,
|
RoomMessageRequest,
|
||||||
SignatureUploadRequest,
|
SignatureUploadRequest,
|
||||||
UploadSigningKeysRequest,
|
UploadSigningKeysRequest,
|
||||||
@ -233,6 +234,35 @@ describe("OutgoingRequestProcessor", () => {
|
|||||||
httpBackend.verifyNoOutstandingRequests();
|
httpBackend.verifyNoOutstandingRequests();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should handle PutDehydratedDeviceRequest", async () => {
|
||||||
|
// first, mock up a request as we might expect to receive it from the Rust layer ...
|
||||||
|
const testReq = { foo: "bar" };
|
||||||
|
const outgoingRequest = new PutDehydratedDeviceRequest(JSON.stringify(testReq));
|
||||||
|
|
||||||
|
// ... then poke the request into the OutgoingRequestProcessor under test
|
||||||
|
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
|
||||||
|
|
||||||
|
// Now: check that it makes a matching HTTP request.
|
||||||
|
const testResponse = '{"result":1}';
|
||||||
|
httpBackend
|
||||||
|
.when("PUT", "/_matrix")
|
||||||
|
.check((req) => {
|
||||||
|
expect(req.path).toEqual(
|
||||||
|
"https://example.com/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device",
|
||||||
|
);
|
||||||
|
expect(JSON.parse(req.rawData)).toEqual(testReq);
|
||||||
|
expect(req.headers["Accept"]).toEqual("application/json");
|
||||||
|
expect(req.headers["Content-Type"]).toEqual("application/json");
|
||||||
|
})
|
||||||
|
.respond(200, testResponse, true);
|
||||||
|
|
||||||
|
// PutDehydratedDeviceRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected.
|
||||||
|
|
||||||
|
await httpBackend.flushAllExpected();
|
||||||
|
await reqProm;
|
||||||
|
httpBackend.verifyNoOutstandingRequests();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not explode with unknown requests", async () => {
|
it("does not explode with unknown requests", async () => {
|
||||||
const outgoingRequest = { id: "5678", type: 987 };
|
const outgoingRequest = { id: "5678", type: 987 };
|
||||||
const markSentCallPromise = awaitCallToMarkAsSent();
|
const markSentCallPromise = awaitCallToMarkAsSent();
|
||||||
|
@ -762,8 +762,11 @@ describe("RustCrypto", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else if (request instanceof RustSdkCryptoJs.UploadSigningKeysRequest) {
|
} else if (
|
||||||
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
|
request instanceof RustSdkCryptoJs.UploadSigningKeysRequest ||
|
||||||
|
request instanceof RustSdkCryptoJs.PutDehydratedDeviceRequest
|
||||||
|
) {
|
||||||
|
// These request types do not implement OutgoingRequest and do not need to be marked as sent.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.id) {
|
if (request.id) {
|
||||||
@ -1395,6 +1398,34 @@ describe("RustCrypto", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("device dehydration", () => {
|
||||||
|
it("should detect if dehydration is supported", async () => {
|
||||||
|
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi());
|
||||||
|
fetchMock.config.overwriteRoutes = true;
|
||||||
|
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
errcode: "M_UNRECOGNIZED",
|
||||||
|
error: "Unknown endpoint",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await rustCrypto.isDehydrationSupported()).toBe(false);
|
||||||
|
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
errcode: "M_NOT_FOUND",
|
||||||
|
error: "Not found",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
|
||||||
|
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||||
|
device_id: "DEVICE_ID",
|
||||||
|
device_data: "data",
|
||||||
|
});
|
||||||
|
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Build a MatrixHttpApi instance */
|
/** Build a MatrixHttpApi instance */
|
||||||
|
@ -496,6 +496,42 @@ export interface CryptoApi {
|
|||||||
* @param version - The backup version to delete.
|
* @param version - The backup version to delete.
|
||||||
*/
|
*/
|
||||||
deleteKeyBackupVersion(version: string): Promise<void>;
|
deleteKeyBackupVersion(version: string): Promise<void>;
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
//
|
||||||
|
// Dehydrated devices
|
||||||
|
//
|
||||||
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether MSC3814 dehydrated devices are supported by the crypto
|
||||||
|
* backend and by the server.
|
||||||
|
*
|
||||||
|
* This should be called before calling `startDehydration`, and if this
|
||||||
|
* returns `false`, `startDehydration` should not be called.
|
||||||
|
*/
|
||||||
|
isDehydrationSupported(): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start using device dehydration.
|
||||||
|
*
|
||||||
|
* - Rehydrates a dehydrated device, if one is available.
|
||||||
|
* - 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 a dehydration key is not available, creates a new one.
|
||||||
|
* - Creates a new dehydrated device, and schedules periodically creating
|
||||||
|
* new dehydrated devices.
|
||||||
|
*
|
||||||
|
* 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 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.
|
||||||
|
*/
|
||||||
|
startDehydration(createNewKey?: boolean): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A reason code for a failure to decrypt an event. */
|
/** A reason code for a failure to decrypt an event. */
|
||||||
|
@ -4287,6 +4287,21 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
|||||||
public getRoomEncryption(roomId: string): IRoomEncryption | null {
|
public getRoomEncryption(roomId: string): IRoomEncryption | null {
|
||||||
return this.roomList.getRoomEncryption(roomId);
|
return this.roomList.getRoomEncryption(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether dehydrated devices are supported by the crypto backend
|
||||||
|
* and by the server.
|
||||||
|
*/
|
||||||
|
public async isDehydrationSupported(): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub function -- dehydration is not implemented here, so throw error
|
||||||
|
*/
|
||||||
|
public async startDehydration(createNewKey?: boolean): Promise<void> {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -51,6 +51,9 @@ export class Device {
|
|||||||
/** display name of the device */
|
/** display name of the device */
|
||||||
public readonly displayName?: string;
|
public readonly displayName?: string;
|
||||||
|
|
||||||
|
/** whether the device is a dehydrated device */
|
||||||
|
public readonly dehydrated: boolean = false;
|
||||||
|
|
||||||
public constructor(opts: DeviceParameters) {
|
public constructor(opts: DeviceParameters) {
|
||||||
this.deviceId = opts.deviceId;
|
this.deviceId = opts.deviceId;
|
||||||
this.userId = opts.userId;
|
this.userId = opts.userId;
|
||||||
@ -59,6 +62,7 @@ export class Device {
|
|||||||
this.verified = opts.verified || DeviceVerification.Unverified;
|
this.verified = opts.verified || DeviceVerification.Unverified;
|
||||||
this.signatures = opts.signatures || new Map();
|
this.signatures = opts.signatures || new Map();
|
||||||
this.displayName = opts.displayName;
|
this.displayName = opts.displayName;
|
||||||
|
this.dehydrated = !!opts.dehydrated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
307
src/rust-crypto/DehydratedDeviceManager.ts
Normal file
307
src/rust-crypto/DehydratedDeviceManager.ts
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||||
|
|
||||||
|
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||||
|
import { encodeUri } from "../utils";
|
||||||
|
import { IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
|
||||||
|
import { IToDeviceEvent } from "../sync-accumulator";
|
||||||
|
import { ServerSideSecretStorage } from "../secret-storage";
|
||||||
|
import { crypto } from "../crypto/crypto";
|
||||||
|
import { decodeBase64, encodeUnpaddedBase64 } from "../base64";
|
||||||
|
import { Logger } from "../logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response body of `GET /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device`.
|
||||||
|
*/
|
||||||
|
interface DehydratedDeviceResp {
|
||||||
|
device_id: string;
|
||||||
|
device_data: {
|
||||||
|
algorithm: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* The response body of `POST /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/events`.
|
||||||
|
*/
|
||||||
|
interface DehydratedDeviceEventsResp {
|
||||||
|
events: IToDeviceEvent[];
|
||||||
|
next_batch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unstable URL prefix for dehydrated device endpoints
|
||||||
|
*/
|
||||||
|
export const UnstablePrefix = "/_matrix/client/unstable/org.matrix.msc3814.v1";
|
||||||
|
/**
|
||||||
|
* The name used for the dehydration key in Secret Storage
|
||||||
|
*/
|
||||||
|
const SECRET_STORAGE_NAME = "org.matrix.msc3814";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interval between creating dehydrated devices. (one week)
|
||||||
|
*/
|
||||||
|
const DEHYDRATION_INTERVAL = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages dehydrated devices
|
||||||
|
*
|
||||||
|
* We have one of these per `RustCrypto`. It's responsible for
|
||||||
|
*
|
||||||
|
* * determining server support for dehydrated devices
|
||||||
|
* * creating new dehydrated devices when requested, including periodically
|
||||||
|
* replacing the dehydrated device with a new one
|
||||||
|
* * rehydrating a device when requested, and when present
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export class DehydratedDeviceManager {
|
||||||
|
/** the secret key used for dehydrating and rehydrating */
|
||||||
|
private key?: Uint8Array;
|
||||||
|
/** the ID of the interval for periodically replacing the dehydrated device */
|
||||||
|
private intervalId?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly logger: Logger,
|
||||||
|
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
|
||||||
|
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||||
|
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||||
|
private readonly secretStorage: ServerSideSecretStorage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return whether the server supports dehydrated devices.
|
||||||
|
*/
|
||||||
|
public async isSupported(): Promise<boolean> {
|
||||||
|
// call the endpoint to get a dehydrated device. If it returns an
|
||||||
|
// M_UNRECOGNIZED error, then dehydration is unsupported. If it returns
|
||||||
|
// a successful response, or an M_NOT_FOUND, then dehydration is supported.
|
||||||
|
// Any other exceptions are passed through.
|
||||||
|
try {
|
||||||
|
await this.http.authedRequest<DehydratedDeviceResp>(
|
||||||
|
Method.Get,
|
||||||
|
"/dehydrated_device",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
prefix: UnstablePrefix,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as MatrixError;
|
||||||
|
if (err.errcode === "M_UNRECOGNIZED") {
|
||||||
|
return false;
|
||||||
|
} else if (err.errcode === "M_NOT_FOUND") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start using device dehydration.
|
||||||
|
*
|
||||||
|
* - Rehydrates a dehydrated device, if one is available.
|
||||||
|
* - 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 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.
|
||||||
|
*/
|
||||||
|
public async start(createNewKey?: boolean): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if (createNewKey) {
|
||||||
|
await this.resetKey();
|
||||||
|
}
|
||||||
|
await this.scheduleDeviceDehydration();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return whether the dehydration key is stored in Secret Storage.
|
||||||
|
*/
|
||||||
|
public async isKeyStored(): Promise<boolean> {
|
||||||
|
return Boolean(await this.secretStorage.isStored(SECRET_STORAGE_NAME));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the dehydration key.
|
||||||
|
*
|
||||||
|
* Creates a new key and stores it in secret storage.
|
||||||
|
*/
|
||||||
|
public async resetKey(): Promise<void> {
|
||||||
|
const key = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(key);
|
||||||
|
await this.secretStorage.store(SECRET_STORAGE_NAME, encodeUnpaddedBase64(key));
|
||||||
|
this.key = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get and cache the encryption key from secret storage.
|
||||||
|
*
|
||||||
|
* If `create` is `true`, creates a new key if no existing key is present.
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.key!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rehydrate the dehydrated device stored on the server.
|
||||||
|
*
|
||||||
|
* Checks if there is a dehydrated device on the server. If so, rehydrates
|
||||||
|
* the device and processes the to-device events.
|
||||||
|
*
|
||||||
|
* Returns whether or not a dehydrated device was found.
|
||||||
|
*/
|
||||||
|
public async rehydrateDeviceIfAvailable(): Promise<boolean> {
|
||||||
|
const key = await this.getKey(false);
|
||||||
|
if (!key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dehydratedDeviceResp;
|
||||||
|
try {
|
||||||
|
dehydratedDeviceResp = await this.http.authedRequest<DehydratedDeviceResp>(
|
||||||
|
Method.Get,
|
||||||
|
"/dehydrated_device",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
prefix: UnstablePrefix,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as MatrixError;
|
||||||
|
// We ignore M_NOT_FOUND (there is no dehydrated device, so nothing
|
||||||
|
// us to do) and M_UNRECOGNIZED (the server does not understand the
|
||||||
|
// endpoint). We pass through any other errors.
|
||||||
|
if (err.errcode === "M_NOT_FOUND" || err.errcode === "M_UNRECOGNIZED") {
|
||||||
|
this.logger.info("dehydration: No dehydrated device");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info("dehydration: dehydrated device found");
|
||||||
|
|
||||||
|
const rehydratedDevice = await this.olmMachine
|
||||||
|
.dehydratedDevices()
|
||||||
|
.rehydrate(
|
||||||
|
key,
|
||||||
|
new RustSdkCryptoJs.DeviceId(dehydratedDeviceResp.device_id),
|
||||||
|
JSON.stringify(dehydratedDeviceResp.device_data),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.info("dehydration: device rehydrated");
|
||||||
|
|
||||||
|
let nextBatch: string | undefined = undefined;
|
||||||
|
let toDeviceCount = 0;
|
||||||
|
let roomKeyCount = 0;
|
||||||
|
const path = encodeUri("/dehydrated_device/$device_id/events", {
|
||||||
|
$device_id: dehydratedDeviceResp.device_id,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
const eventResp: DehydratedDeviceEventsResp = await this.http.authedRequest<DehydratedDeviceEventsResp>(
|
||||||
|
Method.Post,
|
||||||
|
path,
|
||||||
|
undefined,
|
||||||
|
nextBatch ? { next_batch: nextBatch } : {},
|
||||||
|
{
|
||||||
|
prefix: UnstablePrefix,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (eventResp.events.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
toDeviceCount += eventResp.events.length;
|
||||||
|
nextBatch = eventResp.next_batch;
|
||||||
|
const roomKeyInfos = await rehydratedDevice.receiveEvents(JSON.stringify(eventResp.events));
|
||||||
|
roomKeyCount += roomKeyInfos.length;
|
||||||
|
}
|
||||||
|
this.logger.info(`dehydration: received ${roomKeyCount} room keys from ${toDeviceCount} to-device events`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and uploads a new dehydrated device.
|
||||||
|
*
|
||||||
|
* Creates and stores a new key in secret storage if none is available.
|
||||||
|
*/
|
||||||
|
public async createAndUploadDehydratedDevice(): Promise<void> {
|
||||||
|
const key = (await this.getKey(true))!;
|
||||||
|
|
||||||
|
const dehydratedDevice = await this.olmMachine.dehydratedDevices().create();
|
||||||
|
const request = await dehydratedDevice.keysForUpload("Dehydrated device", key);
|
||||||
|
|
||||||
|
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
|
||||||
|
|
||||||
|
this.logger.info("dehydration: uploaded device");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule periodic creation of dehydrated devices.
|
||||||
|
*/
|
||||||
|
public async scheduleDeviceDehydration(): Promise<void> {
|
||||||
|
// cancel any previously-scheduled tasks
|
||||||
|
this.stop();
|
||||||
|
|
||||||
|
await this.createAndUploadDehydratedDevice();
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.createAndUploadDehydratedDevice().catch((error) => {
|
||||||
|
this.logger.error("Error creating dehydrated device:", error);
|
||||||
|
});
|
||||||
|
}, DEHYDRATION_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the dehydrated device manager.
|
||||||
|
*
|
||||||
|
* Cancels any scheduled dehydration tasks.
|
||||||
|
*/
|
||||||
|
public stop(): void {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -20,6 +20,7 @@ import {
|
|||||||
KeysQueryRequest,
|
KeysQueryRequest,
|
||||||
KeysUploadRequest,
|
KeysUploadRequest,
|
||||||
OlmMachine,
|
OlmMachine,
|
||||||
|
PutDehydratedDeviceRequest,
|
||||||
RoomMessageRequest,
|
RoomMessageRequest,
|
||||||
SignatureUploadRequest,
|
SignatureUploadRequest,
|
||||||
ToDeviceRequest,
|
ToDeviceRequest,
|
||||||
@ -32,6 +33,7 @@ import { logDuration, QueryDict, sleep } from "../utils";
|
|||||||
import { AuthDict, UIAuthCallback } from "../interactive-auth";
|
import { AuthDict, UIAuthCallback } from "../interactive-auth";
|
||||||
import { UIAResponse } from "../@types/uia";
|
import { UIAResponse } from "../@types/uia";
|
||||||
import { ToDeviceMessageId } from "../@types/event";
|
import { ToDeviceMessageId } from "../@types/event";
|
||||||
|
import { UnstablePrefix as DehydrationUnstablePrefix } from "./DehydratedDeviceManager";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
|
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
|
||||||
@ -62,7 +64,7 @@ export class OutgoingRequestProcessor {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async makeOutgoingRequest<T>(
|
public async makeOutgoingRequest<T>(
|
||||||
msg: OutgoingRequest | UploadSigningKeysRequest,
|
msg: OutgoingRequest | UploadSigningKeysRequest | PutDehydratedDeviceRequest,
|
||||||
uiaCallback?: UIAuthCallback<T>,
|
uiaCallback?: UIAuthCallback<T>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let resp: string;
|
let resp: string;
|
||||||
@ -102,6 +104,11 @@ export class OutgoingRequestProcessor {
|
|||||||
);
|
);
|
||||||
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
|
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
|
||||||
return;
|
return;
|
||||||
|
} else if (msg instanceof PutDehydratedDeviceRequest) {
|
||||||
|
const path = DehydrationUnstablePrefix + "/dehydrated_device";
|
||||||
|
await this.rawJsonRequest(Method.Put, path, {}, msg.body);
|
||||||
|
// PutDehydratedDeviceRequest does not implement OutgoingRequest and does not need to be marked as sent.
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
|
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
|
||||||
resp = "";
|
resp = "";
|
||||||
|
@ -80,6 +80,7 @@ export function rustDeviceToJsDevice(device: RustSdkCryptoJs.Device, userId: Rus
|
|||||||
verified,
|
verified,
|
||||||
signatures,
|
signatures,
|
||||||
displayName: device.displayName,
|
displayName: device.displayName,
|
||||||
|
dehydrated: device.isDehydrated,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,6 +73,7 @@ import { ISignatures } from "../@types/signed";
|
|||||||
import { encodeBase64 } from "../base64";
|
import { encodeBase64 } from "../base64";
|
||||||
import { OutgoingRequestsManager } from "./OutgoingRequestsManager";
|
import { OutgoingRequestsManager } from "./OutgoingRequestsManager";
|
||||||
import { PerSessionKeyBackupDownloader } from "./PerSessionKeyBackupDownloader";
|
import { PerSessionKeyBackupDownloader } from "./PerSessionKeyBackupDownloader";
|
||||||
|
import { DehydratedDeviceManager } from "./DehydratedDeviceManager";
|
||||||
import { VerificationMethod } from "../types";
|
import { VerificationMethod } from "../types";
|
||||||
|
|
||||||
const ALL_VERIFICATION_METHODS = [
|
const ALL_VERIFICATION_METHODS = [
|
||||||
@ -107,9 +108,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
|||||||
private crossSigningIdentity: CrossSigningIdentity;
|
private crossSigningIdentity: CrossSigningIdentity;
|
||||||
private readonly backupManager: RustBackupManager;
|
private readonly backupManager: RustBackupManager;
|
||||||
private outgoingRequestsManager: OutgoingRequestsManager;
|
private outgoingRequestsManager: OutgoingRequestsManager;
|
||||||
|
|
||||||
private readonly perSessionBackupDownloader: PerSessionKeyBackupDownloader;
|
private readonly perSessionBackupDownloader: PerSessionKeyBackupDownloader;
|
||||||
|
private readonly dehydratedDeviceManager: DehydratedDeviceManager;
|
||||||
private readonly reemitter = new TypedReEmitter<RustCryptoEvents, RustCryptoEventMap>(this);
|
private readonly reemitter = new TypedReEmitter<RustCryptoEvents, RustCryptoEventMap>(this);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -148,14 +148,19 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
|||||||
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
|
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
|
||||||
|
|
||||||
this.backupManager = new RustBackupManager(olmMachine, http, this.outgoingRequestProcessor);
|
this.backupManager = new RustBackupManager(olmMachine, http, this.outgoingRequestProcessor);
|
||||||
|
|
||||||
this.perSessionBackupDownloader = new PerSessionKeyBackupDownloader(
|
this.perSessionBackupDownloader = new PerSessionKeyBackupDownloader(
|
||||||
this.logger,
|
this.logger,
|
||||||
this.olmMachine,
|
this.olmMachine,
|
||||||
this.http,
|
this.http,
|
||||||
this.backupManager,
|
this.backupManager,
|
||||||
);
|
);
|
||||||
|
this.dehydratedDeviceManager = new DehydratedDeviceManager(
|
||||||
|
this.logger,
|
||||||
|
olmMachine,
|
||||||
|
http,
|
||||||
|
this.outgoingRequestProcessor,
|
||||||
|
secretStorage,
|
||||||
|
);
|
||||||
this.eventDecryptor = new EventDecryptor(this.logger, olmMachine, this.perSessionBackupDownloader);
|
this.eventDecryptor = new EventDecryptor(this.logger, olmMachine, this.perSessionBackupDownloader);
|
||||||
|
|
||||||
this.reemitter.reEmit(this.backupManager, [
|
this.reemitter.reEmit(this.backupManager, [
|
||||||
@ -212,6 +217,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
|||||||
this.backupManager.stop();
|
this.backupManager.stop();
|
||||||
this.outgoingRequestsManager.stop();
|
this.outgoingRequestsManager.stop();
|
||||||
this.perSessionBackupDownloader.stop();
|
this.perSessionBackupDownloader.stop();
|
||||||
|
this.dehydratedDeviceManager.stop();
|
||||||
|
|
||||||
// make sure we close() the OlmMachine; doing so means that all the Rust objects will be
|
// make sure we close() the OlmMachine; doing so means that all the Rust objects will be
|
||||||
// cleaned up; in particular, the indexeddb connections will be closed, which means they
|
// cleaned up; in particular, the indexeddb connections will be closed, which means they
|
||||||
@ -1212,6 +1218,23 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
|||||||
return await this.backupManager.importBackedUpRoomKeys(keys, opts);
|
return await this.backupManager.importBackedUpRoomKeys(keys, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of {@link CryptoBackend#isDehydrationSupported}.
|
||||||
|
*/
|
||||||
|
public async isDehydrationSupported(): Promise<boolean> {
|
||||||
|
return await this.dehydratedDeviceManager.isSupported();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of {@link CryptoBackend#startDehydration}.
|
||||||
|
*/
|
||||||
|
public async startDehydration(createNewKey?: 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);
|
||||||
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
//
|
//
|
||||||
// SyncCryptoCallbacks implementation
|
// SyncCryptoCallbacks implementation
|
||||||
|
11
yarn.lock
11
yarn.lock
@ -6535,16 +6535,7 @@ string-length@^4.0.1:
|
|||||||
char-regex "^1.0.2"
|
char-regex "^1.0.2"
|
||||||
strip-ansi "^6.0.0"
|
strip-ansi "^6.0.0"
|
||||||
|
|
||||||
"string-width-cjs@npm:string-width@^4.2.0":
|
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||||
version "4.2.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
|
||||||
dependencies:
|
|
||||||
emoji-regex "^8.0.0"
|
|
||||||
is-fullwidth-code-point "^3.0.0"
|
|
||||||
strip-ansi "^6.0.1"
|
|
||||||
|
|
||||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
|
||||||
version "4.2.3"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
|
Reference in New Issue
Block a user