1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-29 16:43:09 +03:00

factor out backup management to a separate module

This commit is contained in:
Hubert Chathi
2021-05-18 18:15:22 -04:00
parent 52a893a811
commit 747723c8fb
4 changed files with 769 additions and 494 deletions

View File

@@ -2,7 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018-2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2021 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.
@@ -57,6 +57,7 @@ import {encodeBase64, decodeBase64} from "./crypto/olmlib";
import { User } from "./models/user";
import {AutoDiscovery} from "./autodiscovery";
import {DEHYDRATION_ALGORITHM} from "./crypto/dehydration";
import { BackupManager } from "./crypto/backup";
const SCROLLBACK_DELAY_MS = 3000;
export const CRYPTO_ENABLED = isCryptoAvailable();
@@ -258,6 +259,10 @@ function keyFromRecoverySession(session, decryptionKey) {
* }
* {string} name the name of the value we want to read out of SSSS, for UI purposes.
*
* @param {function} [opts.cryptoCallbacks.getBackupKey]
* Optional. Function called when the backup key is required. The callback function
* should return a promise that resolves to the backup key as a Uint8Array.
*
* @param {function} [opts.cryptoCallbacks.cacheSecretStorageKey]
* Optional. Function called when a new encryption key for secret storage
* has been created. This allows the application a chance to cache this key if
@@ -1088,7 +1093,7 @@ MatrixClient.prototype.setDeviceVerified = function(userId, deviceId, verified)
// check the key backup status, since whether or not we use this depends on
// whether it has a signature from a verified device
if (userId == this.credentials.userId) {
this._crypto.checkKeyBackup();
this.checkKeyBackup();
}
return prom;
};
@@ -1776,7 +1781,7 @@ MatrixClient.prototype.importRoomKeys = function(keys, opts) {
* in trustInfo.
*/
MatrixClient.prototype.checkKeyBackup = function() {
return this._crypto.checkKeyBackup();
return this._crypto._backupManager.checkKeyBackup();
};
/**
@@ -1818,7 +1823,7 @@ MatrixClient.prototype.getKeyBackupVersion = function() {
* }
*/
MatrixClient.prototype.isKeyBackupTrusted = function(info) {
return this._crypto.isKeyBackupTrusted(info);
return this._crypto._backupManager.isKeyBackupTrusted(info);
};
/**
@@ -1830,10 +1835,7 @@ MatrixClient.prototype.getKeyBackupEnabled = function() {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
if (!this._crypto._checkedForBackup) {
return null;
}
return Boolean(this._crypto.backupKey);
return this._crypto._backupManager.getKeyBackupEnabled();
};
/**
@@ -1847,16 +1849,7 @@ MatrixClient.prototype.enableKeyBackup = function(info) {
throw new Error("End-to-end encryption disabled");
}
this._crypto.backupInfo = info;
if (this._crypto.backupKey) this._crypto.backupKey.free();
this._crypto.backupKey = new global.Olm.PkEncryption();
this._crypto.backupKey.set_recipient_key(info.auth_data.public_key);
this.emit('crypto.keyBackupStatus', true);
// There may be keys left over from a partially completed backup, so
// schedule a send to check.
this._crypto.scheduleKeyBackupSend();
return this._crypto.backupManager.enableKeyBackup();
};
/**
@@ -1867,11 +1860,7 @@ MatrixClient.prototype.disableKeyBackup = function() {
throw new Error("End-to-end encryption disabled");
}
this._crypto.backupInfo = null;
if (this._crypto.backupKey) this._crypto.backupKey.free();
this._crypto.backupKey = null;
this.emit('crypto.keyBackupStatus', false);
this._crypto.backupManager.disableKeyBackup();
};
/**
@@ -1896,26 +1885,19 @@ MatrixClient.prototype.prepareKeyBackupVersion = async function(
throw new Error("End-to-end encryption disabled");
}
const { keyInfo, encodedPrivateKey, privateKey } =
await this.createRecoveryKeyFromPassphrase(password);
// eslint-disable-next-line camelcase
const {algorithm, auth_data, recovery_key, privateKey} =
await this._crypto._backupManager.prepareKeyBackupVersion(password);
if (secureSecretStorage) {
await this.storeSecret("m.megolm_backup.v1", encodeBase64(privateKey));
logger.info("Key backup private key stored in secret storage");
}
// Reshape objects into form expected for key backup
const authData = {
public_key: keyInfo.pubkey,
};
if (keyInfo.passphrase) {
authData.private_key_salt = keyInfo.passphrase.salt;
authData.private_key_iterations = keyInfo.passphrase.iterations;
}
return {
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
auth_data: authData,
recovery_key: encodedPrivateKey,
algorithm,
auth_data,
recovery_key,
};
};
@@ -1941,6 +1923,8 @@ MatrixClient.prototype.createKeyBackupVersion = async function(info) {
throw new Error("End-to-end encryption disabled");
}
await this._crypto._backupManager.createKeyBackupVersion(info);
const data = {
algorithm: info.algorithm,
auth_data: info.auth_data,
@@ -1986,8 +1970,8 @@ MatrixClient.prototype.deleteKeyBackupVersion = function(version) {
// If we're currently backing up to this backup... stop.
// (We start using it automatically in createKeyBackupVersion
// so this is symmetrical).
if (this._crypto.backupInfo && this._crypto.backupInfo.version === version) {
this.disableKeyBackup();
if (this._crypto._backupManager.version) {
this._crypto._backupManager.disableKeyBackup();
}
const path = utils.encodeUri("/room_keys/version/$version", {
@@ -2051,7 +2035,7 @@ MatrixClient.prototype.scheduleAllGroupSessionsForBackup = async function() {
throw new Error("End-to-end encryption disabled");
}
await this._crypto.scheduleAllGroupSessionsForBackup();
await this._crypto._backupManager.scheduleAllGroupSessionsForBackup();
};
/**
@@ -2064,7 +2048,7 @@ MatrixClient.prototype.flagAllGroupSessionsForBackup = function() {
throw new Error("End-to-end encryption disabled");
}
return this._crypto.flagAllGroupSessionsForBackup();
return this._crypto._backupManager.flagAllGroupSessionsForBackup();
};
MatrixClient.prototype.isValidRecoveryKey = function(recoveryKey) {
@@ -2208,7 +2192,7 @@ MatrixClient.prototype.restoreKeyBackupWithCache = async function(
);
};
MatrixClient.prototype._restoreKeyBackup = function(
MatrixClient.prototype._restoreKeyBackup = async function(
privKey, targetRoomId, targetSessionId, backupInfo,
{
cacheCompleteCallback, // For sequencing during tests
@@ -2225,20 +2209,14 @@ MatrixClient.prototype._restoreKeyBackup = function(
targetRoomId, targetSessionId, backupInfo.version,
);
const decryption = new global.Olm.PkDecryption();
let backupPubKey;
try {
backupPubKey = decryption.init_with_private_key(privKey);
} catch (e) {
decryption.free();
throw e;
}
const algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => { return privKey; });
try {
// If the pubkey computed from the private data we've been given
// doesn't match the one in the auth_data, the user has enetered
// doesn't match the one in the auth_data, the user has entered
// a different recovery key / the wrong passphrase.
if (backupPubKey !== backupInfo.auth_data.public_key) {
return Promise.reject({errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY});
if (!await algorithm.keyMatches(privKey)) {
return Promise.reject({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY });
}
// Cache the key, if possible.
@@ -2254,17 +2232,18 @@ MatrixClient.prototype._restoreKeyBackup = function(
});
}
return this._http.authedRequest(
const res = await this._http.authedRequest(
undefined, "GET", path.path, path.queryData, undefined,
{prefix: PREFIX_UNSTABLE},
).then((res) => {
{ prefix: PREFIX_UNSTABLE },
);
if (res.rooms) {
for (const [roomId, roomData] of Object.entries(res.rooms)) {
if (!roomData.sessions) continue;
totalKeyCount += Object.keys(roomData.sessions).length;
const roomKeys = keysFromRecoverySession(
roomData.sessions, decryption, roomId,
const roomKeys = await algorithm.decryptSessions(
roomData.sessions,
);
for (const k of roomKeys) {
k.room_id = roomId;
@@ -2273,13 +2252,18 @@ MatrixClient.prototype._restoreKeyBackup = function(
}
} else if (res.sessions) {
totalKeyCount = Object.keys(res.sessions).length;
keys = keysFromRecoverySession(
res.sessions, decryption, targetRoomId, keys,
keys = await algorithm.decryptSessions(
res.sessions,
);
for (const k of keys) {
k.room_id = targetRoomId;
}
} else {
totalKeyCount = 1;
try {
const key = keyFromRecoverySession(res, decryption);
const [key] = await algorithm.decryptSessions({
[targetSessionId]: res,
});
key.room_id = targetRoomId;
key.session_id = targetSessionId;
keys.push(key);
@@ -2287,19 +2271,20 @@ MatrixClient.prototype._restoreKeyBackup = function(
logger.log("Failed to decrypt megolm session from backup", e);
}
}
} finally {
algorithm.free();
}
return this.importRoomKeys(keys, {
await this.importRoomKeys(keys, {
progressCallback,
untrusted: true,
source: "backup",
});
}).then(() => {
return this._crypto.setTrustedBackupPubKey(backupPubKey);
}).then(() => {
// await this._crypto.setTrustedBackupPubKey(backupPubKey);
await this.checkKeyBackup();
return {total: totalKeyCount, imported: keys.length};
}).finally(() => {
decryption.free();
});
};
MatrixClient.prototype.deleteKeysFromBackup = function(roomId, sessionId, version) {

View File

@@ -415,9 +415,8 @@ MegolmEncryption.prototype._prepareNewSession = async function(sharedHistory) {
);
// don't wait for it to complete
this._crypto.backupGroupSession(
this._roomId, this._olmDevice.deviceCurve25519Key, [],
sessionId, key.key,
this._crypto._backupManager.backupGroupSession(
this._olmDevice.deviceCurve25519Key, sessionId,
);
return new OutboundSessionInfo(sessionId, sharedHistory);
@@ -1428,11 +1427,7 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
});
}).then(() => {
// don't wait for the keys to be backed up for the server
this._crypto.backupGroupSession(
content.room_id, senderKey, forwardingKeyChain,
content.session_id, content.session_key, keysClaimed,
exportFormat,
);
this._crypto._backupManager.backupGroupSession(senderKey, content.session_id);
}).catch((e) => {
logger.error(`Error handling m.room_key_event: ${e}`);
});
@@ -1648,14 +1643,8 @@ MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) {
).then(() => {
if (opts.source !== "backup") {
// don't wait for it to complete
this._crypto.backupGroupSession(
session.room_id,
session.sender_key,
session.forwarding_curve25519_key_chain,
session.session_id,
session.session_key,
session.sender_claimed_keys,
true,
this._crypto._backupManager.backupGroupSession(
session.sender_key, session.session_id,
).catch((e) => {
// This throws if the upload failed, but this is fine
// since it will have written it to the db and will retry.

649
src/crypto/backup.ts Normal file
View File

@@ -0,0 +1,649 @@
/*
Copyright 2021 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.
*/
/**
* @module crypto/backup
*
* Classes for dealing with key backup.
*/
import {MatrixClient} from "../client";
import {logger} from "../logger";
import {MEGOLM_ALGORITHM, verifySignature} from "./olmlib";
import {DeviceInfo} from "./deviceinfo"
import {DeviceTrustLevel} from './CrossSigning';
import {keyFromPassphrase} from './key_passphrase';
import {sleep} from "../utils";
import {IndexedDBCryptoStore} from './store/indexeddb-crypto-store';
import {encodeRecoveryKey} from './recoverykey';
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
type AuthData = Record<string, any>;
type BackupInfo = {
algorithm: string,
auth_data: AuthData, // eslint-disable-line camelcase
[properties: string]: any,
};
type SigInfo = {
deviceId: string,
valid?: boolean | null, // true: valid, false: invalid, null: cannot attempt validation
device?: DeviceInfo | null,
crossSigningId?: boolean,
deviceTrust?: DeviceTrustLevel,
};
type TrustInfo = {
usable: boolean, // is the backup trusted, true iff there is a sig that is valid & from a trusted device
sigs: SigInfo[],
};
/** A function used to get the secret key for a backup.
*/
type GetKey = () => Promise<Uint8Array>;
interface BackupAlgorithmClass {
algorithmName: string;
// initialize from an existing backup
init(authData: AuthData, getKey: GetKey): Promise<BackupAlgorithm>;
// prepare a brand new backup
prepare(
key: string | Uint8Array | null,
): Promise<[Uint8Array, AuthData]>;
}
interface BackupAlgorithm {
encryptSession(data: Record<string, any>): Promise<any>;
decryptSessions(ciphertexts: Record<string, any>): Promise<Record<string, any>[]>;
authData: AuthData;
keyMatches(key: Uint8Array): Promise<boolean>;
free(): void;
}
/**
* Manages the key backup.
*/
export class BackupManager {
private algorithm: BackupAlgorithm | undefined;
private backupInfo: BackupInfo | undefined; // The info dict from /room_keys/version
public checkedForBackup: boolean; // Have we checked the server for a backup we can use?
private sendingBackups: boolean; // Are we currently sending backups?
constructor(private readonly baseApis, readonly getKey: GetKey) {
this.checkedForBackup = false;
this.sendingBackups = false;
}
get version(): string | undefined {
return this.backupInfo && this.backupInfo.version;
}
static async makeAlgorithm(info: BackupInfo, getKey: GetKey): Promise<BackupAlgorithm> {
const Algorithm = algorithmsByName[info.algorithm];
if (!Algorithm) {
throw new Error("Unknown backup algorithm");
}
return await Algorithm.init(info.auth_data, getKey);
}
async enableKeyBackup(info: BackupInfo): Promise<void> {
this.backupInfo = info;
if (this.algorithm) {
this.algorithm.free();
}
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
this.baseApis.emit('crypto.keyBackupStatus', true);
// There may be keys left over from a partially completed backup, so
// schedule a send to check.
this.scheduleKeyBackupSend();
}
/**
* Disable backing up of keys.
*/
disableKeyBackup(): void {
if (this.algorithm) {
this.algorithm.free();
}
this.algorithm = undefined;
this.backupInfo = undefined;
this.baseApis.emit('crypto.keyBackupStatus', false);
}
getKeyBackupEnabled(): boolean | null {
if (!this.checkedForBackup) {
return null;
}
return Boolean(this.algorithm);
}
async prepareKeyBackupVersion(
key?: string | Uint8Array | null,
algorithm?: string | undefined,
): Promise<BackupInfo> {
const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm;
if (!Algorithm) {
throw new Error("Unknown backup algorithm");
}
const [privateKey, authData] = await Algorithm.prepare(key);
const recoveryKey = encodeRecoveryKey(privateKey);
return {
algorithm: Algorithm.algorithmName,
auth_data: authData,
recovery_key: recoveryKey,
privateKey,
};
}
async createKeyBackupVersion(info: BackupInfo): Promise<void> {
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
}
/**
* Check the server for an active key backup and
* if one is present and has a valid signature from
* one of the user's verified devices, start backing up
* to it.
*/
async checkAndStart(): Promise<{backupInfo: any, trustInfo: TrustInfo}> {
logger.log("Checking key backup status...");
if (this.baseApis.isGuest()) {
logger.log("Skipping key backup check since user is guest");
this.checkedForBackup = true;
return null;
}
let backupInfo: BackupInfo;
try {
backupInfo = await this.baseApis.getKeyBackupVersion();
} catch (e) {
logger.log("Error checking for active key backup", e);
if (e.httpStatus === 404) {
// 404 is returned when the key backup does not exist, so that
// counts as successfully checking.
this.checkedForBackup = true;
}
return null;
}
this.checkedForBackup = true;
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
if (trustInfo.usable && !this.backupInfo) {
logger.log(
"Found usable key backup v" + backupInfo.version +
": enabling key backups",
);
await this.enableKeyBackup(backupInfo);
} else if (!trustInfo.usable && this.backupInfo) {
logger.log("No usable key backup: disabling key backup");
this.disableKeyBackup();
} else if (!trustInfo.usable && !this.backupInfo) {
logger.log("No usable key backup: not enabling key backup");
} else if (trustInfo.usable && this.backupInfo) {
// may not be the same version: if not, we should switch
if (backupInfo.version !== this.backupInfo.version) {
logger.log(
"On backup version " + this.backupInfo.version + " but found " +
"version " + backupInfo.version + ": switching.",
);
this.disableKeyBackup();
await this.enableKeyBackup(backupInfo);
// We're now using a new backup, so schedule all the keys we have to be
// uploaded to the new backup. This is a bit of a workaround to upload
// keys to a new backup in *most* cases, but it won't cover all cases
// because we don't remember what backup version we uploaded keys to:
// see https://github.com/vector-im/element-web/issues/14833
await this.scheduleAllGroupSessionsForBackup();
} else {
logger.log("Backup version " + backupInfo.version + " still current");
}
}
return {backupInfo, trustInfo};
}
/**
* Forces a re-check of the key backup and enables/disables it
* as appropriate.
*
* @return {Object} Object with backup info (as returned by
* getKeyBackupVersion) in backupInfo and
* trust information (as returned by isKeyBackupTrusted)
* in trustInfo.
*/
async checkKeyBackup(): Promise<{backupInfo: AuthData, trustInfo: TrustInfo}> {
this.checkedForBackup = false;
return this.checkAndStart();
}
/**
* @param {object} backupInfo key backup info dict from /room_keys/version
* @return {object} {
* usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
* sigs: [
* valid: [bool || null], // true: valid, false: invalid, null: cannot attempt validation
* deviceId: [string],
* device: [DeviceInfo || null],
* ]
* }
*/
async isKeyBackupTrusted(backupInfo: any | undefined): Promise<TrustInfo> {
const ret = {
usable: false,
trusted_locally: false,
sigs: [],
};
if (
!backupInfo ||
!backupInfo.algorithm ||
!backupInfo.auth_data ||
!backupInfo.auth_data.public_key ||
!backupInfo.auth_data.signatures
) {
logger.info("Key backup is absent or missing required data");
return ret;
}
const trustedPubkey = this.baseApis._crypto._sessionStore.getLocalTrustedBackupPubKey();
if (backupInfo.auth_data.public_key === trustedPubkey) {
logger.info("Backup public key " + trustedPubkey + " is trusted locally");
ret.trusted_locally = true;
}
const mySigs = backupInfo.auth_data.signatures[this.baseApis.getUserId()] || [];
for (const keyId of Object.keys(mySigs)) {
const keyIdParts = keyId.split(':');
if (keyIdParts[0] !== 'ed25519') {
logger.log("Ignoring unknown signature type: " + keyIdParts[0]);
continue;
}
// Could be a cross-signing master key, but just say this is the device
// ID for backwards compat
const sigInfo: SigInfo = { deviceId: keyIdParts[1] };
// first check to see if it's from our cross-signing key
const crossSigningId = this.baseApis._crypto._crossSigningInfo.getId();
if (crossSigningId === sigInfo.deviceId) {
sigInfo.crossSigningId = true;
try {
await verifySignature(
this.baseApis._crypto._olmDevice,
backupInfo.auth_data,
this.baseApis.getUserId(),
sigInfo.deviceId,
crossSigningId,
);
sigInfo.valid = true;
} catch (e) {
logger.warn(
"Bad signature from cross signing key " + crossSigningId, e,
);
sigInfo.valid = false;
}
ret.sigs.push(sigInfo);
continue;
}
// Now look for a sig from a device
// At some point this can probably go away and we'll just support
// it being signed by the cross-signing master key
const device = this.baseApis._crypto._deviceList.getStoredDevice(
this.baseApis.getUserId(), sigInfo.deviceId,
);
if (device) {
sigInfo.device = device;
sigInfo.deviceTrust = await this.baseApis.checkDeviceTrust(
this.baseApis.getUserId(), sigInfo.deviceId,
);
try {
await verifySignature(
this.baseApis._crypto._olmDevice,
backupInfo.auth_data,
this.baseApis.getUserId(),
device.deviceId,
device.getFingerprint(),
);
sigInfo.valid = true;
} catch (e) {
logger.info(
"Bad signature from key ID " + keyId + " userID " + this.baseApis.getUserId() +
" device ID " + device.deviceId + " fingerprint: " +
device.getFingerprint(), backupInfo.auth_data, e,
);
sigInfo.valid = false;
}
} else {
sigInfo.valid = null; // Can't determine validity because we don't have the signing device
logger.info("Ignoring signature from unknown key " + keyId);
}
ret.sigs.push(sigInfo);
}
ret.usable = ret.sigs.some((s) => {
return (
s.valid && (
(s.device && s.deviceTrust.isVerified()) ||
(s.crossSigningId)
)
);
});
ret.usable ||= ret.trusted_locally;
return ret;
}
/**
* Schedules sending all keys waiting to be sent to the backup, if not already
* scheduled. Retries if necessary.
*
* @param maxDelay Maximum delay to wait in ms. 0 means no delay.
*/
async scheduleKeyBackupSend(maxDelay = 10000): Promise<void> {
if (this.sendingBackups) return;
this.sendingBackups = true;
try {
// wait between 0 and `maxDelay` seconds, to avoid backup
// requests from different clients hitting the server all at
// the same time when a new key is sent
const delay = Math.random() * maxDelay;
await sleep(delay, undefined);
let numFailures = 0; // number of consecutive failures
for (;;) {
if (!this.algorithm) {
return;
}
try {
const numBackedUp =
await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
if (numBackedUp === 0) {
// no sessions left needing backup: we're done
return;
}
numFailures = 0;
} catch (err) {
numFailures++;
logger.log("Key backup request failed", err);
if (err.data) {
if (
err.data.errcode == 'M_NOT_FOUND' ||
err.data.errcode == 'M_WRONG_ROOM_KEYS_VERSION'
) {
// Re-check key backup status on error, so we can be
// sure to present the current situation when asked.
await this.checkKeyBackup();
// Backup version has changed or this backup version
// has been deleted
this.baseApis._crypto.emit("crypto.keyBackupFailed", err.data.errcode);
throw err;
}
}
}
if (numFailures) {
// exponential backoff if we have failures
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)), undefined);
}
}
} finally {
this.sendingBackups = false;
}
}
/**
* Take some e2e keys waiting to be backed up and send them
* to the backup.
*
* @param {integer} limit Maximum number of keys to back up
* @returns {integer} Number of sessions backed up
*/
private async backupPendingKeys(limit: number): Promise<number> {
const sessions = await this.baseApis._crypto._cryptoStore.getSessionsNeedingBackup(limit);
if (!sessions.length) {
return 0;
}
let remaining = await this.baseApis._crypto._cryptoStore.countSessionsNeedingBackup();
this.baseApis._crypto.emit("crypto.keyBackupSessionsRemaining", remaining);
const data = {};
for (const session of sessions) {
const roomId = session.sessionData.room_id;
if (data[roomId] === undefined) {
data[roomId] = {sessions: {}};
}
const sessionData = await this.baseApis._crypto._olmDevice.exportInboundGroupSession(
session.senderKey, session.sessionId, session.sessionData,
);
sessionData.algorithm = MEGOLM_ALGORITHM;
const forwardedCount =
(sessionData.forwarding_curve25519_key_chain || []).length;
const userId = this.baseApis._crypto._deviceList.getUserByIdentityKey(
MEGOLM_ALGORITHM, session.senderKey,
);
const device = this.baseApis._crypto._deviceList.getDeviceByIdentityKey(
MEGOLM_ALGORITHM, session.senderKey,
);
const verified = this.baseApis._crypto._checkDeviceInfoTrust(userId, device).isVerified();
data[roomId]['sessions'][session.sessionId] = {
first_message_index: sessionData.first_known_index,
forwarded_count: forwardedCount,
is_verified: verified,
session_data: await this.algorithm.encryptSession(sessionData),
};
}
await this.baseApis.sendKeyBackup(
undefined, undefined, this.backupInfo.version,
{rooms: data},
);
await this.baseApis._crypto._cryptoStore.unmarkSessionsNeedingBackup(sessions);
remaining = await this.baseApis._crypto._cryptoStore.countSessionsNeedingBackup();
this.baseApis._crypto.emit("crypto.keyBackupSessionsRemaining", remaining);
return sessions.length;
}
async backupGroupSession(
senderKey: string, sessionId: string,
): Promise<void> {
await this.baseApis._crypto._cryptoStore.markSessionsNeedingBackup([{
senderKey: senderKey,
sessionId: sessionId,
}]);
if (this.backupInfo) {
// don't wait for this to complete: it will delay so
// happens in the background
this.scheduleKeyBackupSend();
}
// if this.backupInfo is not set, then the keys will be backed up when
// this.enableKeyBackup is called
}
/**
* Marks all group sessions as needing to be backed up and schedules them to
* upload in the background as soon as possible.
*/
async scheduleAllGroupSessionsForBackup(): Promise<void> {
await this.flagAllGroupSessionsForBackup();
// Schedule keys to upload in the background as soon as possible.
this.scheduleKeyBackupSend(0 /* maxDelay */);
}
/**
* Marks all group sessions as needing to be backed up without scheduling
* them to upload in the background.
* @returns {Promise<int>} Resolves to the number of sessions now requiring a backup
* (which will be equal to the number of sessions in the store).
*/
async flagAllGroupSessionsForBackup(): Promise<number> {
await this.baseApis._crypto._cryptoStore.doTxn(
'readwrite',
[
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
IndexedDBCryptoStore.STORE_BACKUP,
],
(txn) => {
this.baseApis._crypto._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
if (session !== null) {
this.baseApis._crypto._cryptoStore.markSessionsNeedingBackup([session], txn);
}
});
},
);
const remaining = await this.baseApis._crypto._cryptoStore.countSessionsNeedingBackup();
this.baseApis.emit("crypto.keyBackupSessionsRemaining", remaining);
return remaining;
}
/**
* Counts the number of end to end session keys that are waiting to be backed up
* @returns {Promise<int>} Resolves to the number of sessions requiring backup
*/
countSessionsNeedingBackup(): Promise<number> {
return this.baseApis._crypto._cryptoStore.countSessionsNeedingBackup();
}
}
export class Curve25519 implements BackupAlgorithm {
static algorithmName = "m.megolm_backup.v1.curve25519-aes-sha2";
constructor(
public authData: AuthData,
private publicKey: any, // FIXME: PkEncryption
private getKey: () => Promise<Uint8Array>,
) {}
static async init(
authData: AuthData,
getKey: () => Promise<Uint8Array>,
): Promise<Curve25519> {
if (!authData || !authData.public_key) {
throw new Error("auth_data missing required information");
}
const publicKey = new global.Olm.PkEncryption();
publicKey.set_recipient_key(authData.public_key);
return new Curve25519(authData, publicKey, getKey);
}
static async prepare(
key: string | Uint8Array | null,
): Promise<[Uint8Array, AuthData]> {
const decryption = new global.Olm.PkDecryption();
try {
const authData: AuthData = {};
if (!key) {
authData.public_key = decryption.generate_key();
} else if (key instanceof Uint8Array) {
authData.public_key = decryption.init_with_private_key(key);
} else {
const derivation = await keyFromPassphrase(key);
authData.private_key_salt = derivation.salt;
authData.private_key_iterations = derivation.iterations;
// FIXME: algorithm?
authData.public_key = decryption.init_with_private_key(derivation.key);
}
const publicKey = new global.Olm.PkEncryption();
publicKey.set_recipient_key(authData.public_key);
return [
decryption.get_private_key(),
authData,
]
} finally {
decryption.free();
}
}
async encryptSession(data: Record<string, any>): Promise<any> {
const plainText: Record<string, any> = Object.assign({}, data);
delete plainText.session_id;
delete plainText.room_id;
delete plainText.first_known_index;
return this.publicKey.encrypt(JSON.stringify(plainText));
}
async decryptSessions(sessions: Record<string, Record<string, any>>): Promise<Record<string, any>[]> {
const privKey = await this.getKey();
const decryption = new global.Olm.PkDecryption();
try {
const backupPubKey = decryption.init_with_private_key(privKey);
if (backupPubKey !== this.authData.public_key) {
throw {errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY};
}
const keys = [];
for (const [sessionId, sessionData] of Object.entries(sessions)) {
try {
const decrypted = JSON.parse(decryption.decrypt(
sessionData.session_data.ephemeral,
sessionData.session_data.mac,
sessionData.session_data.ciphertext,
));
decrypted.session_id = sessionId;
keys.push(decrypted);
} catch (e) {
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
}
}
return keys;
} finally {
decryption.free();
}
}
async keyMatches(key: Uint8Array): Promise<boolean> {
const decryption = new global.Olm.PkDecryption();
let pubKey;
try {
pubKey = decryption.init_with_private_key(key);
} finally {
decryption.free();
}
return pubKey === this.authData.public_key;
}
free(): void {
this.publicKey.free();
}
}
export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
[Curve25519.algorithmName]: Curve25519,
};
export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;

View File

@@ -2,7 +2,7 @@
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018-2019 New Vector Ltd
Copyright 2019-2020 The Matrix.org Foundation C.I.C.
Copyright 2019-2021 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.
@@ -58,6 +58,7 @@ import {KeySignatureUploadError} from "../errors";
import {decryptAES, encryptAES} from './aes';
import {DehydrationManager} from './dehydration';
import { MatrixEvent } from "../models/event";
import { BackupManager } from "./backup";
const DeviceVerification = DeviceInfo.DeviceVerification;
@@ -85,7 +86,6 @@ export function isCryptoAvailable() {
}
const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000;
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
/**
* Cryptography bits
@@ -154,13 +154,36 @@ export function Crypto(baseApis, sessionStore, userId, deviceId,
} else {
this._verificationMethods = defaultVerificationMethods;
}
// track whether this device's megolm keys are being backed up incrementally
// to the server or not.
// XXX: this should probably have a single source of truth from OlmAccount
this.backupInfo = null; // The info dict from /room_keys/version
this.backupKey = null; // The encryption key object
this._checkedForBackup = false; // Have we checked the server for a backup we can use?
this._sendingBackups = false; // Are we currently sending backups?
this._backupManager = new BackupManager(baseApis, async (algorithm) => {
// try to get key from cache
const cachedKey = await this.getSessionBackupPrivateKey();
if (cachedKey) {
return cachedKey;
}
// try to get key from secret storage
const storedKey = await this.getSecret("m.megolm_backup.v1");
if (storedKey) {
// ensure that the key is in the right format. If not, fix the key and
// store the fixed version
const fixedKey = fixBackupKey(storedKey);
if (fixedKey) {
const [keyId] = await this._crypto.getSecretStorageKey();
await this.storeSecret("m.megolm_backup.v1", fixedKey, [keyId]);
}
return decodeBase64(fixedKey || storedKey);
}
// try to get key from app
if (this._baseApis._cryptoCallbacks && this._baseApis._cryptoCallbacks.getBackupKey) {
return await this._baseApis._cryptoCallbacks.getBackupKey(algorithm);
}
throw new Error("Unable to get private key");
});
this._olmDevice = new OlmDevice(cryptoStore);
this._deviceList = new DeviceList(
@@ -331,7 +354,7 @@ Crypto.prototype.init = async function(opts) {
this._deviceList.startTrackingDeviceList(this._userId);
logger.log("Crypto: checking for key backup...");
this._checkAndStartKeyBackup();
this._backupManager.checkAndStart();
};
/**
@@ -458,7 +481,7 @@ Crypto.prototype.isSecretStorageReady = async function() {
this._secretStorage,
);
const sessionBackupInStorage = (
!this._baseApis.getKeyBackupEnabled() ||
!this._backupManager.getKeyBackupEnabled() ||
this._baseApis.isKeyBackupKeyStored()
);
@@ -522,9 +545,11 @@ Crypto.prototype.bootstrapCrossSigning = async function({
builder.addKeySignature(this._userId, this._deviceId, deviceSignature);
// Sign message key backup with cross-signing master key
if (this.backupInfo) {
await crossSigningInfo.signObject(this.backupInfo.auth_data, "master");
builder.addSessionBackup(this.backupInfo);
if (this._backupManager.backupInfo) {
await crossSigningInfo.signObject(
this._backupManager.backupInfo.auth_data, "master",
);
builder.addSessionBackup(this._backupManager.backupInfo);
}
};
@@ -766,6 +791,7 @@ Crypto.prototype.bootstrapSecretStorage = async function({
keyBackupInfo.auth_data.private_key_salt &&
keyBackupInfo.auth_data.private_key_iterations
) {
// FIXME: ???
opts.passphrase = {
algorithm: "m.pbkdf2",
iterations: keyBackupInfo.auth_data.private_key_iterations,
@@ -1477,7 +1503,7 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function({
}
// Now we may be able to trust our key backup
await this.checkKeyBackup();
await this._backupManager.checkKeyBackup();
// FIXME: if we previously trusted the backup, should we automatically sign
// the backup with the new key (if not already signed)?
};
@@ -1539,206 +1565,11 @@ Crypto.prototype._checkDeviceVerifications = async function(userId) {
logger.info(`Finished device verification upgrade for ${userId}`);
};
/**
* Check the server for an active key backup and
* if one is present and has a valid signature from
* one of the user's verified devices, start backing up
* to it.
*/
Crypto.prototype._checkAndStartKeyBackup = async function() {
logger.log("Checking key backup status...");
if (this._baseApis.isGuest()) {
logger.log("Skipping key backup check since user is guest");
this._checkedForBackup = true;
return null;
}
let backupInfo;
try {
backupInfo = await this._baseApis.getKeyBackupVersion();
} catch (e) {
logger.log("Error checking for active key backup", e);
if (e.httpStatus === 404) {
// 404 is returned when the key backup does not exist, so that
// counts as successfully checking.
this._checkedForBackup = true;
}
return null;
}
this._checkedForBackup = true;
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
if (trustInfo.usable && !this.backupInfo) {
logger.log(
"Found usable key backup v" + backupInfo.version +
": enabling key backups",
);
this._baseApis.enableKeyBackup(backupInfo);
} else if (!trustInfo.usable && this.backupInfo) {
logger.log("No usable key backup: disabling key backup");
this._baseApis.disableKeyBackup();
} else if (!trustInfo.usable && !this.backupInfo) {
logger.log("No usable key backup: not enabling key backup");
} else if (trustInfo.usable && this.backupInfo) {
// may not be the same version: if not, we should switch
if (backupInfo.version !== this.backupInfo.version) {
logger.log(
"On backup version " + this.backupInfo.version + " but found " +
"version " + backupInfo.version + ": switching.",
);
this._baseApis.disableKeyBackup();
this._baseApis.enableKeyBackup(backupInfo);
// We're now using a new backup, so schedule all the keys we have to be
// uploaded to the new backup. This is a bit of a workaround to upload
// keys to a new backup in *most* cases, but it won't cover all cases
// because we don't remember what backup version we uploaded keys to:
// see https://github.com/vector-im/element-web/issues/14833
await this.scheduleAllGroupSessionsForBackup();
} else {
logger.log("Backup version " + backupInfo.version + " still current");
}
}
return {backupInfo, trustInfo};
};
Crypto.prototype.setTrustedBackupPubKey = async function(trustedPubKey) {
// This should be redundant post cross-signing is a thing, so just
// plonk it in localStorage for now.
this._sessionStore.setLocalTrustedBackupPubKey(trustedPubKey);
await this.checkKeyBackup();
};
/**
* Forces a re-check of the key backup and enables/disables it
* as appropriate.
*
* @return {Object} Object with backup info (as returned by
* getKeyBackupVersion) in backupInfo and
* trust information (as returned by isKeyBackupTrusted)
* in trustInfo.
*/
Crypto.prototype.checkKeyBackup = async function() {
this._checkedForBackup = false;
return this._checkAndStartKeyBackup();
};
/**
* @param {object} backupInfo key backup info dict from /room_keys/version
* @return {object} {
* usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
* sigs: [
* valid: [bool || null], // true: valid, false: invalid, null: cannot attempt validation
* deviceId: [string],
* device: [DeviceInfo || null],
* ]
* }
*/
Crypto.prototype.isKeyBackupTrusted = async function(backupInfo) {
const ret = {
usable: false,
trusted_locally: false,
sigs: [],
};
if (
!backupInfo ||
!backupInfo.algorithm ||
!backupInfo.auth_data ||
!backupInfo.auth_data.public_key ||
!backupInfo.auth_data.signatures
) {
logger.info("Key backup is absent or missing required data");
return ret;
}
const trustedPubkey = this._sessionStore.getLocalTrustedBackupPubKey();
if (backupInfo.auth_data.public_key === trustedPubkey) {
logger.info("Backup public key " + trustedPubkey + " is trusted locally");
ret.trusted_locally = true;
}
const mySigs = backupInfo.auth_data.signatures[this._userId] || [];
for (const keyId of Object.keys(mySigs)) {
const keyIdParts = keyId.split(':');
if (keyIdParts[0] !== 'ed25519') {
logger.log("Ignoring unknown signature type: " + keyIdParts[0]);
continue;
}
// Could be a cross-signing master key, but just say this is the device
// ID for backwards compat
const sigInfo = { deviceId: keyIdParts[1] };
// first check to see if it's from our cross-signing key
const crossSigningId = this._crossSigningInfo.getId();
if (crossSigningId === sigInfo.deviceId) {
sigInfo.crossSigningId = true;
try {
await olmlib.verifySignature(
this._olmDevice,
backupInfo.auth_data,
this._userId,
sigInfo.deviceId,
crossSigningId,
);
sigInfo.valid = true;
} catch (e) {
logger.warning(
"Bad signature from cross signing key " + crossSigningId, e,
);
sigInfo.valid = false;
}
ret.sigs.push(sigInfo);
continue;
}
// Now look for a sig from a device
// At some point this can probably go away and we'll just support
// it being signed by the cross-signing master key
const device = this._deviceList.getStoredDevice(
this._userId, sigInfo.deviceId,
);
if (device) {
sigInfo.device = device;
sigInfo.deviceTrust = await this.checkDeviceTrust(
this._userId, sigInfo.deviceId,
);
try {
await olmlib.verifySignature(
this._olmDevice,
backupInfo.auth_data,
this._userId,
device.deviceId,
device.getFingerprint(),
);
sigInfo.valid = true;
} catch (e) {
logger.info(
"Bad signature from key ID " + keyId + " userID " + this._userId +
" device ID " + device.deviceId + " fingerprint: " +
device.getFingerprint(), backupInfo.auth_data, e,
);
sigInfo.valid = false;
}
} else {
sigInfo.valid = null; // Can't determine validity because we don't have the signing device
logger.info("Ignoring signature from unknown key " + keyId);
}
ret.sigs.push(sigInfo);
}
ret.usable = ret.sigs.some((s) => {
return (
s.valid && (
(s.device && s.deviceTrust.isVerified()) ||
(s.crossSigningId)
)
);
});
ret.usable |= ret.trusted_locally;
return ret;
await this._backupManager.checkKeyBackup();
};
/**
@@ -2789,191 +2620,12 @@ Crypto.prototype.importRoomKeys = function(keys, opts = {}) {
}));
};
/**
* Schedules sending all keys waiting to be sent to the backup, if not already
* scheduled. Retries if necessary.
*
* @param {number} maxDelay Maximum delay to wait in ms. 0 means no delay.
*/
Crypto.prototype.scheduleKeyBackupSend = async function(maxDelay = 10000) {
if (this._sendingBackups) return;
this._sendingBackups = true;
try {
// wait between 0 and `maxDelay` seconds, to avoid backup
// requests from different clients hitting the server all at
// the same time when a new key is sent
const delay = Math.random() * maxDelay;
await sleep(delay);
let numFailures = 0; // number of consecutive failures
while (1) {
if (!this.backupKey) {
return;
}
try {
const numBackedUp =
await this._backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
if (numBackedUp === 0) {
// no sessions left needing backup: we're done
return;
}
numFailures = 0;
} catch (err) {
numFailures++;
logger.log("Key backup request failed", err);
if (err.data) {
if (
err.data.errcode == 'M_NOT_FOUND' ||
err.data.errcode == 'M_WRONG_ROOM_KEYS_VERSION'
) {
// Re-check key backup status on error, so we can be
// sure to present the current situation when asked.
await this.checkKeyBackup();
// Backup version has changed or this backup version
// has been deleted
this.emit("crypto.keyBackupFailed", err.data.errcode);
throw err;
}
}
}
if (numFailures) {
// exponential backoff if we have failures
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
}
}
} finally {
this._sendingBackups = false;
}
};
/**
* Take some e2e keys waiting to be backed up and send them
* to the backup.
*
* @param {integer} limit Maximum number of keys to back up
* @returns {integer} Number of sessions backed up
*/
Crypto.prototype._backupPendingKeys = async function(limit) {
const sessions = await this._cryptoStore.getSessionsNeedingBackup(limit);
if (!sessions.length) {
return 0;
}
let remaining = await this._cryptoStore.countSessionsNeedingBackup();
this.emit("crypto.keyBackupSessionsRemaining", remaining);
const data = {};
for (const session of sessions) {
const roomId = session.sessionData.room_id;
if (data[roomId] === undefined) {
data[roomId] = {sessions: {}};
}
const sessionData = await this._olmDevice.exportInboundGroupSession(
session.senderKey, session.sessionId, session.sessionData,
);
sessionData.algorithm = olmlib.MEGOLM_ALGORITHM;
delete sessionData.session_id;
delete sessionData.room_id;
const firstKnownIndex = sessionData.first_known_index;
delete sessionData.first_known_index;
const encrypted = this.backupKey.encrypt(JSON.stringify(sessionData));
const forwardedCount =
(sessionData.forwarding_curve25519_key_chain || []).length;
const userId = this._deviceList.getUserByIdentityKey(
olmlib.MEGOLM_ALGORITHM, session.senderKey,
);
const device = this._deviceList.getDeviceByIdentityKey(
olmlib.MEGOLM_ALGORITHM, session.senderKey,
);
const verified = this._checkDeviceInfoTrust(userId, device).isVerified();
data[roomId]['sessions'][session.sessionId] = {
first_message_index: firstKnownIndex,
forwarded_count: forwardedCount,
is_verified: verified,
session_data: encrypted,
};
}
await this._baseApis.sendKeyBackup(
undefined, undefined, this.backupInfo.version,
{rooms: data},
);
await this._cryptoStore.unmarkSessionsNeedingBackup(sessions);
remaining = await this._cryptoStore.countSessionsNeedingBackup();
this.emit("crypto.keyBackupSessionsRemaining", remaining);
return sessions.length;
};
Crypto.prototype.backupGroupSession = async function(
roomId, senderKey, forwardingCurve25519KeyChain,
sessionId, sessionKey, keysClaimed,
exportFormat,
) {
await this._cryptoStore.markSessionsNeedingBackup([{
senderKey: senderKey,
sessionId: sessionId,
}]);
if (this.backupInfo) {
// don't wait for this to complete: it will delay so
// happens in the background
this.scheduleKeyBackupSend();
}
// if this.backupInfo is not set, then the keys will be backed up when
// client.enableKeyBackup is called
};
/**
* Marks all group sessions as needing to be backed up and schedules them to
* upload in the background as soon as possible.
*/
Crypto.prototype.scheduleAllGroupSessionsForBackup = async function() {
await this.flagAllGroupSessionsForBackup();
// Schedule keys to upload in the background as soon as possible.
this.scheduleKeyBackupSend(0 /* maxDelay */);
};
/**
* Marks all group sessions as needing to be backed up without scheduling
* them to upload in the background.
* @returns {Promise<int>} Resolves to the number of sessions now requiring a backup
* (which will be equal to the number of sessions in the store).
*/
Crypto.prototype.flagAllGroupSessionsForBackup = async function() {
await this._cryptoStore.doTxn(
'readwrite',
[
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
IndexedDBCryptoStore.STORE_BACKUP,
],
(txn) => {
this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
if (session !== null) {
this._cryptoStore.markSessionsNeedingBackup([session], txn);
}
});
},
);
const remaining = await this._cryptoStore.countSessionsNeedingBackup();
this.emit("crypto.keyBackupSessionsRemaining", remaining);
return remaining;
};
/**
* Counts the number of end to end session keys that are waiting to be backed up
* @returns {Promise<int>} Resolves to the number of sessions requiring backup
*/
Crypto.prototype.countSessionsNeedingBackup = function() {
return this._cryptoStore.countSessionsNeedingBackup();
return this._backupManager.countSessionsNeedingBackup();
};
/**
@@ -3347,10 +2999,10 @@ Crypto.prototype._onRoomKeyEvent = function(event) {
return;
}
if (!this._checkedForBackup) {
if (!this._backupManager.checkedForBackup) {
// don't bother awaiting on this - the important thing is that we retry if we
// haven't managed to check before
this._checkAndStartKeyBackup();
this._backupManager.checkAndStart();
}
const alg = this._getRoomDecryptor(content.room_id, content.algorithm);