441 lines
19 KiB
JavaScript
441 lines
19 KiB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
/*
|
|
Copyright 2023 - 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 { CryptoEvent } from "../crypto-api/index.js";
|
|
import { ClientPrefix, MatrixError, Method } from "../http-api/index.js";
|
|
import { encodeUri, sleep } from "../utils.js";
|
|
// The minimum time to wait between two retries in case of errors. To avoid hammering the server.
|
|
const KEY_BACKUP_BACKOFF = 5000; // ms
|
|
|
|
/**
|
|
* Enumerates the different kind of errors that can occurs when downloading and importing a key from backup.
|
|
*/
|
|
var KeyDownloadErrorCode = /*#__PURE__*/function (KeyDownloadErrorCode) {
|
|
/** The requested key is not in the backup. */
|
|
KeyDownloadErrorCode["MISSING_DECRYPTION_KEY"] = "MISSING_DECRYPTION_KEY";
|
|
/** A network error occurred while trying to download the key from backup. */
|
|
KeyDownloadErrorCode["NETWORK_ERROR"] = "NETWORK_ERROR";
|
|
/** The loop has been stopped. */
|
|
KeyDownloadErrorCode["STOPPED"] = "STOPPED";
|
|
return KeyDownloadErrorCode;
|
|
}(KeyDownloadErrorCode || {});
|
|
class KeyDownloadError extends Error {
|
|
constructor(code) {
|
|
super(`Failed to get key from backup: ${code}`);
|
|
this.code = code;
|
|
this.name = "KeyDownloadError";
|
|
}
|
|
}
|
|
class KeyDownloadRateLimitError extends Error {
|
|
constructor(retryMillis) {
|
|
super(`Failed to get key from backup: rate limited`);
|
|
this.retryMillis = retryMillis;
|
|
this.name = "KeyDownloadRateLimitError";
|
|
}
|
|
}
|
|
|
|
/** Details of a megolm session whose key we are trying to fetch. */
|
|
|
|
/** Holds the current backup decryptor and version that should be used.
|
|
*
|
|
* This is intended to be used as an immutable object (a new instance should be created if the configuration changes),
|
|
* and some of the logic relies on that, so the properties are marked as `readonly`.
|
|
*/
|
|
|
|
/**
|
|
* Used when an 'unable to decrypt' error occurs. It attempts to download the key from the backup.
|
|
*
|
|
* The current backup API lacks pagination, which can lead to lengthy key retrieval times for large histories (several 10s of minutes).
|
|
* To mitigate this, keys are downloaded on demand as decryption errors occurs.
|
|
* While this approach may result in numerous requests, it improves user experience by reducing wait times for message decryption.
|
|
*
|
|
* The PerSessionKeyBackupDownloader is resistant to backup configuration changes: it will automatically resume querying when
|
|
* the backup is configured correctly.
|
|
*/
|
|
export class PerSessionKeyBackupDownloader {
|
|
/**
|
|
* Creates a new instance of PerSessionKeyBackupDownloader.
|
|
*
|
|
* @param backupManager - The backup manager to use.
|
|
* @param olmMachine - The olm machine to use.
|
|
* @param http - The http instance to use.
|
|
* @param logger - The logger to use.
|
|
*/
|
|
constructor(logger, olmMachine, http, backupManager) {
|
|
_defineProperty(this, "stopped", false);
|
|
/**
|
|
* The version and decryption key to use with current backup if all set up correctly.
|
|
*
|
|
* Will not be set unless `hasConfigurationProblem` is `false`.
|
|
*/
|
|
_defineProperty(this, "configuration", null);
|
|
/** We remember when a session was requested and not found in backup to avoid query again too soon.
|
|
* Map of session_id to timestamp */
|
|
_defineProperty(this, "sessionLastCheckAttemptedTime", new Map());
|
|
/** The logger to use */
|
|
_defineProperty(this, "logger", void 0);
|
|
/** Whether the download loop is running. */
|
|
_defineProperty(this, "downloadLoopRunning", false);
|
|
/** The list of requests that are queued. */
|
|
_defineProperty(this, "queuedRequests", []);
|
|
/** Remembers if we have a configuration problem. */
|
|
_defineProperty(this, "hasConfigurationProblem", false);
|
|
/** The current server backup version check promise. To avoid doing a server call if one is in flight. */
|
|
_defineProperty(this, "currentBackupVersionCheck", null);
|
|
/**
|
|
* Called when the backup status changes (CryptoEvents)
|
|
* This will trigger a check of the backup configuration.
|
|
*/
|
|
_defineProperty(this, "onBackupStatusChanged", () => {
|
|
// we want to force check configuration, so we clear the current one.
|
|
this.hasConfigurationProblem = false;
|
|
this.configuration = null;
|
|
this.getOrCreateBackupConfiguration().then(configuration => {
|
|
if (configuration) {
|
|
// restart the download loop if it was stopped
|
|
this.downloadKeysLoop();
|
|
}
|
|
});
|
|
});
|
|
this.olmMachine = olmMachine;
|
|
this.http = http;
|
|
this.backupManager = backupManager;
|
|
this.logger = logger.getChild("[PerSessionKeyBackupDownloader]");
|
|
backupManager.on(CryptoEvent.KeyBackupStatus, this.onBackupStatusChanged);
|
|
backupManager.on(CryptoEvent.KeyBackupFailed, this.onBackupStatusChanged);
|
|
backupManager.on(CryptoEvent.KeyBackupDecryptionKeyCached, this.onBackupStatusChanged);
|
|
}
|
|
|
|
/**
|
|
* Check if key download is successfully configured and active.
|
|
*
|
|
* @returns `true` if key download is correctly configured and active; otherwise `false`.
|
|
*/
|
|
isKeyBackupDownloadConfigured() {
|
|
return this.configuration !== null;
|
|
}
|
|
|
|
/**
|
|
* Return the details of the latest backup on the server, when we last checked.
|
|
*
|
|
* This is just a convenience method to expose {@link RustBackupManager.getServerBackupInfo}.
|
|
*/
|
|
async getServerBackupInfo() {
|
|
return await this.backupManager.getServerBackupInfo();
|
|
}
|
|
|
|
/**
|
|
* Called when a MissingRoomKey or UnknownMessageIndex decryption error is encountered.
|
|
*
|
|
* This will try to download the key from the backup if there is a trusted active backup.
|
|
* In case of success the key will be imported and the onRoomKeysUpdated callback will be called
|
|
* internally by the rust-sdk and decryption will be retried.
|
|
*
|
|
* @param roomId - The room ID of the room where the error occurred.
|
|
* @param megolmSessionId - The megolm session ID that is missing.
|
|
*/
|
|
onDecryptionKeyMissingError(roomId, megolmSessionId) {
|
|
// Several messages encrypted with the same session may be decrypted at the same time,
|
|
// so we need to be resistant and not query several time the same session.
|
|
if (this.isAlreadyInQueue(roomId, megolmSessionId)) {
|
|
// There is already a request queued for this session, no need to queue another one.
|
|
this.logger.trace(`Not checking key backup for session ${megolmSessionId} as it is already queued`);
|
|
return;
|
|
}
|
|
if (this.wasRequestedRecently(megolmSessionId)) {
|
|
// We already tried to download this session recently and it was not in backup, no need to try again.
|
|
this.logger.trace(`Not checking key backup for session ${megolmSessionId} as it was already requested recently`);
|
|
return;
|
|
}
|
|
|
|
// We always add the request to the queue, even if we have a configuration problem (can't access backup).
|
|
// This is to make sure that if the configuration problem is resolved, we will try to download the key.
|
|
// This will happen after an initial sync, at this point the backup will not yet be trusted and the decryption
|
|
// key will not be available, but it will be just after the verification.
|
|
// We don't need to persist it because currently on refresh the sdk will retry to decrypt the messages in error.
|
|
this.queuedRequests.push({
|
|
roomId,
|
|
megolmSessionId
|
|
});
|
|
|
|
// Start the download loop if it's not already running.
|
|
this.downloadKeysLoop();
|
|
}
|
|
stop() {
|
|
this.stopped = true;
|
|
this.backupManager.off(CryptoEvent.KeyBackupStatus, this.onBackupStatusChanged);
|
|
this.backupManager.off(CryptoEvent.KeyBackupFailed, this.onBackupStatusChanged);
|
|
this.backupManager.off(CryptoEvent.KeyBackupDecryptionKeyCached, this.onBackupStatusChanged);
|
|
}
|
|
/** Returns true if the megolm session is already queued for download. */
|
|
isAlreadyInQueue(roomId, megolmSessionId) {
|
|
return this.queuedRequests.some(info => {
|
|
return info.roomId == roomId && info.megolmSessionId == megolmSessionId;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Marks the session as not found in backup, to avoid retrying to soon for a key not in backup
|
|
*
|
|
* @param megolmSessionId - The megolm session ID that is missing.
|
|
*/
|
|
markAsNotFoundInBackup(megolmSessionId) {
|
|
const now = Date.now();
|
|
this.sessionLastCheckAttemptedTime.set(megolmSessionId, now);
|
|
// if too big make some cleaning to keep under control
|
|
if (this.sessionLastCheckAttemptedTime.size > 100) {
|
|
this.sessionLastCheckAttemptedTime = new Map(Array.from(this.sessionLastCheckAttemptedTime).filter((sid, ts) => {
|
|
return Math.max(now - ts, 0) < KEY_BACKUP_BACKOFF;
|
|
}));
|
|
}
|
|
}
|
|
|
|
/** Returns true if the session was requested recently. */
|
|
wasRequestedRecently(megolmSessionId) {
|
|
const lastCheck = this.sessionLastCheckAttemptedTime.get(megolmSessionId);
|
|
if (!lastCheck) return false;
|
|
return Math.max(Date.now() - lastCheck, 0) < KEY_BACKUP_BACKOFF;
|
|
}
|
|
async getBackupDecryptionKey() {
|
|
try {
|
|
return await this.olmMachine.getBackupKeys();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Requests a key from the server side backup.
|
|
*
|
|
* @param version - The backup version to use.
|
|
* @param roomId - The room ID of the room where the error occurred.
|
|
* @param sessionId - The megolm session ID that is missing.
|
|
*/
|
|
async requestRoomKeyFromBackup(version, roomId, sessionId) {
|
|
const path = encodeUri("/room_keys/keys/$roomId/$sessionId", {
|
|
$roomId: roomId,
|
|
$sessionId: sessionId
|
|
});
|
|
return await this.http.authedRequest(Method.Get, path, {
|
|
version
|
|
}, undefined, {
|
|
prefix: ClientPrefix.V3
|
|
});
|
|
}
|
|
async downloadKeysLoop() {
|
|
if (this.downloadLoopRunning) return;
|
|
|
|
// If we have a configuration problem, we don't want to try to download.
|
|
// If any configuration change is detected, we will retry and restart the loop.
|
|
if (this.hasConfigurationProblem) return;
|
|
this.downloadLoopRunning = true;
|
|
try {
|
|
while (this.queuedRequests.length > 0) {
|
|
// we just peek the first one without removing it, so if a new request for same key comes in while we're
|
|
// processing this one, it won't queue another request.
|
|
const request = this.queuedRequests[0];
|
|
try {
|
|
// The backup could have changed between the time we queued the request and now, so we need to check
|
|
const configuration = await this.getOrCreateBackupConfiguration();
|
|
if (!configuration) {
|
|
// Backup is not configured correctly, so stop the loop.
|
|
this.downloadLoopRunning = false;
|
|
return;
|
|
}
|
|
const result = await this.queryKeyBackup(request.roomId, request.megolmSessionId, configuration);
|
|
if (this.stopped) {
|
|
return;
|
|
}
|
|
// We got the encrypted key from backup, let's try to decrypt and import it.
|
|
try {
|
|
await this.decryptAndImport(request, result, configuration);
|
|
} catch (e) {
|
|
this.logger.error(`Error while decrypting and importing key backup for session ${request.megolmSessionId}`, e);
|
|
}
|
|
// now remove the request from the queue as we've processed it.
|
|
this.queuedRequests.shift();
|
|
} catch (err) {
|
|
if (err instanceof KeyDownloadError) {
|
|
switch (err.code) {
|
|
case KeyDownloadErrorCode.MISSING_DECRYPTION_KEY:
|
|
this.markAsNotFoundInBackup(request.megolmSessionId);
|
|
// continue for next one
|
|
this.queuedRequests.shift();
|
|
break;
|
|
case KeyDownloadErrorCode.NETWORK_ERROR:
|
|
// We don't want to hammer if there is a problem, so wait a bit.
|
|
await sleep(KEY_BACKUP_BACKOFF);
|
|
break;
|
|
case KeyDownloadErrorCode.STOPPED:
|
|
// If the downloader was stopped, we don't want to retry.
|
|
this.downloadLoopRunning = false;
|
|
return;
|
|
}
|
|
} else if (err instanceof KeyDownloadRateLimitError) {
|
|
// we want to retry after the backoff time
|
|
await sleep(err.retryMillis);
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
// all pending request have been processed, we can stop the loop.
|
|
this.downloadLoopRunning = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query the backup for a key.
|
|
*
|
|
* @param targetRoomId - ID of the room that the session is used in.
|
|
* @param targetSessionId - ID of the session for which to check backup.
|
|
* @param configuration - The backup configuration to use.
|
|
*/
|
|
async queryKeyBackup(targetRoomId, targetSessionId, configuration) {
|
|
this.logger.debug(`Checking key backup for session ${targetSessionId}`);
|
|
if (this.stopped) throw new KeyDownloadError(KeyDownloadErrorCode.STOPPED);
|
|
try {
|
|
const res = await this.requestRoomKeyFromBackup(configuration.backupVersion, targetRoomId, targetSessionId);
|
|
this.logger.debug(`Got key from backup for sessionId:${targetSessionId}`);
|
|
return res;
|
|
} catch (e) {
|
|
if (this.stopped) throw new KeyDownloadError(KeyDownloadErrorCode.STOPPED);
|
|
this.logger.info(`No luck requesting key backup for session ${targetSessionId}: ${e}`);
|
|
if (e instanceof MatrixError) {
|
|
const errCode = e.data.errcode;
|
|
if (errCode == "M_NOT_FOUND") {
|
|
// Unfortunately the spec doesn't give us a way to differentiate between a missing key and a wrong version.
|
|
// Synapse will return:
|
|
// - "error": "Unknown backup version" if the version is wrong.
|
|
// - "error": "No room_keys found" if the key is missing.
|
|
// It's useful to know if the key is missing or if the version is wrong.
|
|
// As it's not spec'ed, we fall back on considering the key is not in backup.
|
|
// Notice that this request will be lost if instead the backup got out of sync (updated from other session).
|
|
throw new KeyDownloadError(KeyDownloadErrorCode.MISSING_DECRYPTION_KEY);
|
|
}
|
|
if (e.isRateLimitError()) {
|
|
let waitTime;
|
|
try {
|
|
waitTime = e.getRetryAfterMs() ?? undefined;
|
|
} catch (error) {
|
|
this.logger.warn("Error while retrieving a rate-limit retry delay", error);
|
|
}
|
|
if (waitTime && waitTime > 0) {
|
|
this.logger.info(`Rate limited by server, waiting ${waitTime}ms`);
|
|
}
|
|
throw new KeyDownloadRateLimitError(waitTime ?? KEY_BACKUP_BACKOFF);
|
|
}
|
|
}
|
|
throw new KeyDownloadError(KeyDownloadErrorCode.NETWORK_ERROR);
|
|
}
|
|
}
|
|
async decryptAndImport(sessionInfo, data, configuration) {
|
|
const sessionsToImport = {
|
|
[sessionInfo.megolmSessionId]: data
|
|
};
|
|
const keys = await configuration.decryptor.decryptSessions(sessionsToImport);
|
|
for (const k of keys) {
|
|
k.room_id = sessionInfo.roomId;
|
|
}
|
|
await this.backupManager.importBackedUpRoomKeys(keys, configuration.backupVersion);
|
|
}
|
|
|
|
/**
|
|
* Gets the current backup configuration or create one if it doesn't exist.
|
|
*
|
|
* When a valid configuration is found it is cached and returned for subsequent calls.
|
|
* Otherwise, if a check is forced or a check has not yet been done, a new check is done.
|
|
*
|
|
* @returns The backup configuration to use or null if there is a configuration problem.
|
|
*/
|
|
async getOrCreateBackupConfiguration() {
|
|
if (this.configuration) {
|
|
return this.configuration;
|
|
}
|
|
|
|
// We already tried to check the configuration and it failed.
|
|
// We don't want to try again immediately, we will retry if a configuration change is detected.
|
|
if (this.hasConfigurationProblem) {
|
|
return null;
|
|
}
|
|
|
|
// This method can be called rapidly by several emitted CryptoEvent, so we need to make sure that we don't
|
|
// query the server several times.
|
|
if (this.currentBackupVersionCheck != null) {
|
|
this.logger.debug(`Already checking server version, use current promise`);
|
|
return await this.currentBackupVersionCheck;
|
|
}
|
|
this.currentBackupVersionCheck = this.internalCheckFromServer();
|
|
try {
|
|
return await this.currentBackupVersionCheck;
|
|
} finally {
|
|
this.currentBackupVersionCheck = null;
|
|
}
|
|
}
|
|
async internalCheckFromServer() {
|
|
let currentServerVersion = null;
|
|
try {
|
|
currentServerVersion = await this.backupManager.getServerBackupInfo();
|
|
} catch (e) {
|
|
this.logger.debug(`Backup: error while checking server version: ${e}`);
|
|
this.hasConfigurationProblem = true;
|
|
return null;
|
|
}
|
|
this.logger.debug(`Got current backup version from server: ${currentServerVersion?.version}`);
|
|
if (currentServerVersion?.algorithm != "m.megolm_backup.v1.curve25519-aes-sha2") {
|
|
this.logger.info(`Unsupported algorithm ${currentServerVersion?.algorithm}`);
|
|
this.hasConfigurationProblem = true;
|
|
return null;
|
|
}
|
|
if (!currentServerVersion?.version) {
|
|
this.logger.info(`No current key backup`);
|
|
this.hasConfigurationProblem = true;
|
|
return null;
|
|
}
|
|
const activeVersion = await this.backupManager.getActiveBackupVersion();
|
|
if (activeVersion == null || currentServerVersion.version != activeVersion) {
|
|
// Either the current backup version on server side is not trusted, or it is out of sync with the active version on the client side.
|
|
this.logger.info(`The current backup version on the server (${currentServerVersion.version}) is not trusted. Version we are currently backing up to: ${activeVersion}`);
|
|
this.hasConfigurationProblem = true;
|
|
return null;
|
|
}
|
|
const backupKeys = await this.getBackupDecryptionKey();
|
|
if (!backupKeys?.decryptionKey) {
|
|
this.logger.debug(`Not checking key backup for session (no decryption key)`);
|
|
this.hasConfigurationProblem = true;
|
|
return null;
|
|
}
|
|
if (activeVersion != backupKeys.backupVersion) {
|
|
this.logger.debug(`Version for which we have a decryption key (${backupKeys.backupVersion}) doesn't match the version we are backing up to (${activeVersion})`);
|
|
this.hasConfigurationProblem = true;
|
|
return null;
|
|
}
|
|
const authData = currentServerVersion.auth_data;
|
|
if (authData.public_key != backupKeys.decryptionKey.megolmV1PublicKey.publicKeyBase64) {
|
|
this.logger.debug(`Key backup on server does not match our decryption key`);
|
|
this.hasConfigurationProblem = true;
|
|
return null;
|
|
}
|
|
const backupDecryptor = this.backupManager.createBackupDecryptor(backupKeys.decryptionKey);
|
|
this.hasConfigurationProblem = false;
|
|
this.configuration = {
|
|
decryptor: backupDecryptor,
|
|
backupVersion: activeVersion
|
|
};
|
|
return this.configuration;
|
|
}
|
|
}
|
|
//# sourceMappingURL=PerSessionKeyBackupDownloader.js.map
|