326 lines
11 KiB
JavaScript
326 lines
11 KiB
JavaScript
|
|
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
||
|
|
/*
|
||
|
|
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 { encodeUri } from "../utils.js";
|
||
|
|
import { Method } from "../http-api/index.js";
|
||
|
|
import { decodeBase64 } from "../base64.js";
|
||
|
|
import { CryptoEvent } from "../crypto-api/index.js";
|
||
|
|
import { TypedEventEmitter } from "../models/typed-event-emitter.js";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The response body of `GET /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device`.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The response body of `GET /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/{device_id}/events`.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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 extends TypedEventEmitter {
|
||
|
|
constructor(logger, olmMachine, http, outgoingRequestProcessor, secretStorage) {
|
||
|
|
super();
|
||
|
|
/** the ID of the interval for periodically replacing the dehydrated device */
|
||
|
|
_defineProperty(this, "intervalId", void 0);
|
||
|
|
this.logger = logger;
|
||
|
|
this.olmMachine = olmMachine;
|
||
|
|
this.http = http;
|
||
|
|
this.outgoingRequestProcessor = outgoingRequestProcessor;
|
||
|
|
this.secretStorage = secretStorage;
|
||
|
|
}
|
||
|
|
async cacheKey(key) {
|
||
|
|
await this.olmMachine.dehydratedDevices().saveDehydratedDeviceKey(key);
|
||
|
|
this.emit(CryptoEvent.DehydrationKeyCached);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Return whether the server supports dehydrated devices.
|
||
|
|
*/
|
||
|
|
async isSupported() {
|
||
|
|
// 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(Method.Get, "/dehydrated_device", undefined, undefined, {
|
||
|
|
prefix: UnstablePrefix
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
const err = error;
|
||
|
|
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 and `opts.rehydrate`
|
||
|
|
* is `true`.
|
||
|
|
* - Creates a new dehydration key, if necessary, and stores it in Secret
|
||
|
|
* Storage.
|
||
|
|
* - 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 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.
|
||
|
|
*/
|
||
|
|
async start(opts = {}) {
|
||
|
|
if (typeof opts === "boolean") {
|
||
|
|
opts = {
|
||
|
|
createNewKey: opts
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (opts.onlyIfKeyCached && !(await this.olmMachine.dehydratedDevices().getDehydratedDeviceKey())) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.stop();
|
||
|
|
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.message);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (opts.createNewKey) {
|
||
|
|
await this.resetKey();
|
||
|
|
}
|
||
|
|
await this.scheduleDeviceDehydration();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Return whether the dehydration key is stored in Secret Storage.
|
||
|
|
*/
|
||
|
|
async isKeyStored() {
|
||
|
|
return Boolean(await this.secretStorage.isStored(SECRET_STORAGE_NAME));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reset the dehydration key.
|
||
|
|
*
|
||
|
|
* Creates a new key and stores it in secret storage.
|
||
|
|
*
|
||
|
|
* @returns The newly-generated key.
|
||
|
|
*/
|
||
|
|
async resetKey() {
|
||
|
|
const key = RustSdkCryptoJs.DehydratedDeviceKey.createRandomKey();
|
||
|
|
await this.secretStorage.store(SECRET_STORAGE_NAME, key.toBase64());
|
||
|
|
// Also cache it in the rust SDK's crypto store.
|
||
|
|
await this.cacheKey(key);
|
||
|
|
return 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
|
||
|
|
*/
|
||
|
|
async getKey(create) {
|
||
|
|
const cachedKey = await this.olmMachine.dehydratedDevices().getDehydratedDeviceKey();
|
||
|
|
if (cachedKey) return cachedKey;
|
||
|
|
const keyB64 = await this.secretStorage.get(SECRET_STORAGE_NAME);
|
||
|
|
if (keyB64 === undefined) {
|
||
|
|
if (!create) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return await this.resetKey();
|
||
|
|
}
|
||
|
|
|
||
|
|
// We successfully found the key in secret storage: decode it, and cache it in
|
||
|
|
// the rust SDK's crypto store.
|
||
|
|
const bytes = decodeBase64(keyB64);
|
||
|
|
try {
|
||
|
|
const key = RustSdkCryptoJs.DehydratedDeviceKey.createKeyFromArray(bytes);
|
||
|
|
await this.cacheKey(key);
|
||
|
|
return key;
|
||
|
|
} finally {
|
||
|
|
bytes.fill(0);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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.
|
||
|
|
*/
|
||
|
|
async rehydrateDeviceIfAvailable() {
|
||
|
|
const key = await this.getKey(false);
|
||
|
|
if (!key) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
let dehydratedDeviceResp;
|
||
|
|
try {
|
||
|
|
dehydratedDeviceResp = await this.http.authedRequest(Method.Get, "/dehydrated_device", undefined, undefined, {
|
||
|
|
prefix: UnstablePrefix
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
const err = error;
|
||
|
|
// 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");
|
||
|
|
this.emit(CryptoEvent.RehydrationStarted);
|
||
|
|
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 = undefined;
|
||
|
|
let toDeviceCount = 0;
|
||
|
|
let roomKeyCount = 0;
|
||
|
|
const path = encodeUri("/dehydrated_device/$device_id/events", {
|
||
|
|
$device_id: dehydratedDeviceResp.device_id
|
||
|
|
});
|
||
|
|
do {
|
||
|
|
const eventResp = await this.http.authedRequest(Method.Get, path, nextBatch ? {
|
||
|
|
from: nextBatch
|
||
|
|
} : undefined, undefined, {
|
||
|
|
prefix: UnstablePrefix
|
||
|
|
});
|
||
|
|
toDeviceCount += eventResp.events.length;
|
||
|
|
nextBatch = eventResp.next_batch;
|
||
|
|
if (eventResp.events.length > 0) {
|
||
|
|
const roomKeyInfos = await rehydratedDevice.receiveEvents(JSON.stringify(eventResp.events));
|
||
|
|
roomKeyCount += roomKeyInfos.length;
|
||
|
|
this.emit(CryptoEvent.RehydrationProgress, roomKeyCount, toDeviceCount);
|
||
|
|
}
|
||
|
|
} while (nextBatch !== undefined);
|
||
|
|
this.logger.info(`dehydration: received ${roomKeyCount} room keys from ${toDeviceCount} to-device events`);
|
||
|
|
this.emit(CryptoEvent.RehydrationCompleted);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Creates and uploads a new dehydrated device.
|
||
|
|
*
|
||
|
|
* Creates and stores a new key in secret storage if none is available.
|
||
|
|
*/
|
||
|
|
async createAndUploadDehydratedDevice() {
|
||
|
|
const key = await this.getKey(true);
|
||
|
|
const dehydratedDevice = await this.olmMachine.dehydratedDevices().create();
|
||
|
|
this.emit(CryptoEvent.DehydratedDeviceCreated);
|
||
|
|
const request = await dehydratedDevice.keysForUpload("Dehydrated device", key);
|
||
|
|
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
|
||
|
|
this.emit(CryptoEvent.DehydratedDeviceUploaded);
|
||
|
|
this.logger.info("dehydration: uploaded device");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Schedule periodic creation of dehydrated devices.
|
||
|
|
*/
|
||
|
|
async scheduleDeviceDehydration() {
|
||
|
|
// cancel any previously-scheduled tasks
|
||
|
|
this.stop();
|
||
|
|
await this.createAndUploadDehydratedDevice();
|
||
|
|
this.intervalId = setInterval(() => {
|
||
|
|
this.createAndUploadDehydratedDevice().catch(error => {
|
||
|
|
this.emit(CryptoEvent.DehydratedDeviceRotationError, error.message);
|
||
|
|
this.logger.error("Error creating dehydrated device:", error);
|
||
|
|
});
|
||
|
|
}, DEHYDRATION_INTERVAL);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Stop the dehydrated device manager.
|
||
|
|
*
|
||
|
|
* Cancels any scheduled dehydration tasks.
|
||
|
|
*/
|
||
|
|
stop() {
|
||
|
|
if (this.intervalId) {
|
||
|
|
clearInterval(this.intervalId);
|
||
|
|
this.intervalId = undefined;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Delete the current dehydrated device and stop the dehydrated device manager.
|
||
|
|
*/
|
||
|
|
async delete() {
|
||
|
|
this.stop();
|
||
|
|
try {
|
||
|
|
await this.http.authedRequest(Method.Delete, "/dehydrated_device", undefined, {}, {
|
||
|
|
prefix: UnstablePrefix
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
const err = error;
|
||
|
|
// If dehydrated devices aren't supported, or no dehydrated device
|
||
|
|
// is found, we don't consider it an error, because we we'll end up
|
||
|
|
// with no dehydrated device.
|
||
|
|
if (err.errcode === "M_UNRECOGNIZED") {
|
||
|
|
return;
|
||
|
|
} else if (err.errcode === "M_NOT_FOUND") {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The events fired by the DehydratedDeviceManager
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A map of the {@link DehydratedDeviceEvents} fired by the {@link DehydratedDeviceManager} and their payloads.
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
//# sourceMappingURL=DehydratedDeviceManager.js.map
|