diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index 0a28e0f82..8b56b93b3 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -257,6 +257,9 @@ describe("MegolmDecryption", function() { }); it("re-uses sessions for sequential messages", async function() { + mockCrypto._backupManager = { + backupGroupSession: () => {}, + }; const mockStorage = new MockStorageApi(); const cryptoStore = new MemoryCryptoStore(mockStorage); diff --git a/spec/unit/crypto/backup.spec.js b/spec/unit/crypto/backup.spec.js index 70e1470e8..eeee60fae 100644 --- a/spec/unit/crypto/backup.spec.js +++ b/spec/unit/crypto/backup.spec.js @@ -28,6 +28,7 @@ import * as testUtils from "../../test-utils"; import { OlmDevice } from "../../../src/crypto/OlmDevice"; import { Crypto } from "../../../src/crypto"; import { resetCrossSigningKeys } from "./crypto-utils"; +import { BackupManager } from "../../../src/crypto/backup"; const Olm = global.Olm; @@ -73,7 +74,7 @@ const KEY_BACKUP_DATA = { }; const BACKUP_INFO = { - algorithm: "m.megolm_backup.v1", + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", version: 1, auth_data: { public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", @@ -138,6 +139,7 @@ describe("MegolmBackup", function() { let megolmDecryption; beforeEach(async function() { mockCrypto = testUtils.mock(Crypto, 'Crypto'); + mockCrypto._backupManager = testUtils.mock(BackupManager, "BackupManager"); mockCrypto.backupKey = new Olm.PkEncryption(); mockCrypto.backupKey.set_recipient_key( "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", @@ -215,12 +217,14 @@ describe("MegolmBackup", function() { }; mockCrypto.cancelRoomKeyRequest = function() {}; - mockCrypto.backupGroupSession = jest.fn(); + mockCrypto._backupManager = { + backupGroupSession: jest.fn(), + }; return event.attemptDecryption(mockCrypto).then(() => { return megolmDecryption.onRoomKeyEvent(event); }).then(() => { - expect(mockCrypto.backupGroupSession).toHaveBeenCalled(); + expect(mockCrypto._backupManager.backupGroupSession).toHaveBeenCalled(); }); }); @@ -264,7 +268,7 @@ describe("MegolmBackup", function() { }) .then(() => { client.enableKeyBackup({ - algorithm: "m.megolm_backup.v1", + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", version: 1, auth_data: { public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", @@ -292,12 +296,9 @@ describe("MegolmBackup", function() { resolve(); return Promise.resolve({}); }; - client.crypto.backupGroupSession( - "roomId", + client.crypto._backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - [], groupSession.session_id(), - groupSession.session_key(), ); }).then(() => { expect(numCalls).toBe(1); @@ -335,39 +336,48 @@ describe("MegolmBackup", function() { }); await resetCrossSigningKeys(client); let numCalls = 0; - await new Promise((resolve, reject) => { - client.http.authedRequest = function( - callback, method, path, queryParams, data, opts, - ) { - ++numCalls; - expect(numCalls).toBeLessThanOrEqual(1); - if (numCalls >= 2) { - // exit out of retry loop if there's something wrong - reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({}); - } - expect(method).toBe("POST"); - expect(path).toBe("/room_keys/version"); - try { - // make sure auth_data is signed by the master key - olmlib.pkVerify( - data.auth_data, client.getCrossSigningId(), "@alice:bar", - ); - } catch (e) { - reject(e); - return Promise.resolve({}); - } - resolve(); - return Promise.resolve({}); - }; + await Promise.all([ + new Promise((resolve, reject) => { + let backupInfo; + client.http.authedRequest = function( + callback, method, path, queryParams, data, opts, + ) { + ++numCalls; + expect(numCalls).toBeLessThanOrEqual(2); + if (numCalls === 1) { + expect(method).toBe("POST"); + expect(path).toBe("/room_keys/version"); + try { + // make sure auth_data is signed by the master key + olmlib.pkVerify( + data.auth_data, client.getCrossSigningId(), "@alice:bar", + ); + } catch (e) { + reject(e); + return Promise.resolve({}); + } + backupInfo = data; + return Promise.resolve({}); + } else if (numCalls === 2) { + expect(method).toBe("GET"); + expect(path).toBe("/room_keys/version"); + resolve(); + return Promise.resolve(backupInfo); + } else { + // exit out of retry loop if there's something wrong + reject(new Error("authedRequest called too many times")); + return Promise.resolve({}); + } + }; + }), client.createKeyBackupVersion({ - algorithm: "m.megolm_backup.v1", + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", auth_data: { public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", }, - }); - }); - expect(numCalls).toBe(1); + }), + ]); + expect(numCalls).toBe(2); }); it('retries when a backup fails', function() { @@ -434,7 +444,7 @@ describe("MegolmBackup", function() { }) .then(() => { client.enableKeyBackup({ - algorithm: "foobar", + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", version: 1, auth_data: { public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", @@ -468,12 +478,9 @@ describe("MegolmBackup", function() { ); } }; - client.crypto.backupGroupSession( - "roomId", + client.crypto._backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - [], groupSession.session_id(), - groupSession.session_key(), ); }).then(() => { expect(numCalls).toBe(2); @@ -569,5 +576,21 @@ describe("MegolmBackup", function() { const cachedKey = await client.crypto.getSessionBackupPrivateKey(); expect(cachedKey).not.toBeNull(); }); + + it("fails if an known algorithm is used", async function() { + const BAD_BACKUP_INFO = Object.assign({}, BACKUP_INFO, { + algorithm: "this.algorithm.does.not.exist", + }); + client._http.authedRequest = function() { + return Promise.resolve(KEY_BACKUP_DATA); + }; + + await expect(client.restoreKeyBackupWithRecoveryKey( + "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", + ROOM_ID, + SESSION_ID, + BAD_BACKUP_INFO, + )).rejects.toThrow(); + }); }); }); diff --git a/spec/unit/crypto/secrets.spec.js b/spec/unit/crypto/secrets.spec.js index 8cc134be9..fc82a3259 100644 --- a/spec/unit/crypto/secrets.spec.js +++ b/spec/unit/crypto/secrets.spec.js @@ -376,7 +376,7 @@ describe("Secrets", function() { ]); this.emit("accountData", event); }; - bob.crypto.checkKeyBackup = async () => {}; + bob.crypto._backupManager.checkKeyBackup = async () => {}; const crossSigning = bob.crypto._crossSigningInfo; const secretStorage = bob.crypto._secretStorage; diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index 0d97b2450..138566892 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -413,9 +413,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); @@ -1425,11 +1424,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}`); }); @@ -1645,14 +1640,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. diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts new file mode 100644 index 000000000..d513c24aa --- /dev/null +++ b/src/crypto/backup.ts @@ -0,0 +1,651 @@ +/* +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; + +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; + +interface BackupAlgorithmClass { + algorithmName: string; + // initialize from an existing backup + init(authData: AuthData, getKey: GetKey): Promise; + + // prepare a brand new backup + prepare( + key: string | Uint8Array | null, + ): Promise<[Uint8Array, AuthData]>; +} + +interface BackupAlgorithm { + encryptSession(data: Record): Promise; + decryptSessions(ciphertexts: Record): Promise[]>; + authData: AuthData; + keyMatches(key: Uint8Array): Promise; + 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, public readonly getKey: GetKey) { + this.checkedForBackup = false; + this.sendingBackups = false; + } + + public get version(): string | undefined { + return this.backupInfo && this.backupInfo.version; + } + + public static async makeAlgorithm(info: BackupInfo, getKey: GetKey): Promise { + const Algorithm = algorithmsByName[info.algorithm]; + if (!Algorithm) { + throw new Error("Unknown backup algorithm"); + } + return await Algorithm.init(info.auth_data, getKey); + } + + public async enableKeyBackup(info: BackupInfo): Promise { + 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. + */ + public disableKeyBackup(): void { + if (this.algorithm) { + this.algorithm.free(); + } + this.algorithm = undefined; + + this.backupInfo = undefined; + + this.baseApis.emit('crypto.keyBackupStatus', false); + } + + public getKeyBackupEnabled(): boolean | null { + if (!this.checkedForBackup) { + return null; + } + return Boolean(this.algorithm); + } + + public async prepareKeyBackupVersion( + key?: string | Uint8Array | null, + algorithm?: string | undefined, + ): Promise { + 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, + }; + } + + public async createKeyBackupVersion(info: BackupInfo): Promise { + 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. + */ + public async checkAndStart(): Promise<{backupInfo: BackupInfo, 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. + */ + public async checkKeyBackup(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> { + this.checkedForBackup = false; + return this.checkAndStart(); + } + + /** + * Check if the given backup info is trusted. + * + * @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], + * ] + * } + */ + public async isKeyBackupTrusted(backupInfo: BackupInfo): Promise { + 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.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. + */ + public async scheduleKeyBackupSend(maxDelay = 10000): Promise { + 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 { + 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; + } + + public async backupGroupSession( + senderKey: string, sessionId: string, + ): Promise { + 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. + */ + public async scheduleAllGroupSessionsForBackup(): Promise { + 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} Resolves to the number of sessions now requiring a backup + * (which will be equal to the number of sessions in the store). + */ + public async flagAllGroupSessionsForBackup(): Promise { + 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} Resolves to the number of sessions requiring backup + */ + public countSessionsNeedingBackup(): Promise { + return this.baseApis._crypto._cryptoStore.countSessionsNeedingBackup(); + } +} + +export class Curve25519 implements BackupAlgorithm { + public static algorithmName = "m.megolm_backup.v1.curve25519-aes-sha2"; + + constructor( + public authData: AuthData, + private publicKey: any, // FIXME: PkEncryption + private getKey: () => Promise, + ) {} + + public static async init( + authData: AuthData, + getKey: () => Promise, + ): Promise { + 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); + } + + public 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; + 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(); + } + } + + public async encryptSession(data: Record): Promise { + const plainText: Record = Object.assign({}, data); + delete plainText.session_id; + delete plainText.room_id; + delete plainText.first_known_index; + return this.publicKey.encrypt(JSON.stringify(plainText)); + } + + public async decryptSessions(sessions: Record>): Promise[]> { + 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) { + // eslint-disable-next-line no-throw-literal + 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(); + } + } + + public async keyMatches(key: Uint8Array): Promise { + 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; + } + + public free(): void { + this.publicKey.free(); + } +} + +export const algorithmsByName: Record = { + [Curve25519.algorithmName]: Curve25519, +}; + +export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519; diff --git a/src/crypto/index.js b/src/crypto/index.js index a36e78a2a..a1171b5f5 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -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. @@ -26,7 +26,6 @@ import { EventEmitter } from 'events'; import { ReEmitter } from '../ReEmitter'; import { logger } from '../logger'; import * as utils from "../utils"; -import { sleep } from "../utils"; import { OlmDevice } from "./OlmDevice"; import * as olmlib from "./olmlib"; import { DeviceList } from "./DeviceList"; @@ -58,6 +57,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 +85,6 @@ export function isCryptoAvailable() { } const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; -const KEY_BACKUP_KEYS_PER_REQUEST = 200; /** * Cryptography bits @@ -154,13 +153,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 olmlib.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 +353,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 +480,7 @@ Crypto.prototype.isSecretStorageReady = async function() { this._secretStorage, ); const sessionBackupInStorage = ( - !this._baseApis.getKeyBackupEnabled() || + !this._backupManager.getKeyBackupEnabled() || this._baseApis.isKeyBackupKeyStored() ); @@ -522,9 +544,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 +790,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 +1502,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 +1564,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(); }; /** @@ -2785,191 +2615,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} 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} Resolves to the number of sessions requiring backup */ Crypto.prototype.countSessionsNeedingBackup = function() { - return this._cryptoStore.countSessionsNeedingBackup(); + return this._backupManager.countSessionsNeedingBackup(); }; /** @@ -3345,10 +2996,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); diff --git a/src/models/room-state.js b/src/models/room-state.js index e991df57c..f9e76cfc3 100644 --- a/src/models/room-state.js +++ b/src/models/room-state.js @@ -349,6 +349,11 @@ RoomState.prototype.setStateEvents = function(stateEvents) { self._updateMember(member); self.emit("RoomState.members", event, self, member); } else if (event.getType() === "m.room.power_levels") { + // events with unknown state keys should be ignored + // and should not aggregate onto members power levels + if (event.getStateKey() !== "") { + return; + } const members = Object.values(self.members); members.forEach(function(member) { // We only propagate `RoomState.members` event if the