diff --git a/spec/unit/crypto/backup.spec.js b/spec/unit/crypto/backup.spec.js new file mode 100644 index 000000000..f832f0511 --- /dev/null +++ b/spec/unit/crypto/backup.spec.js @@ -0,0 +1,245 @@ +/* +Copyright 2018 New Vector Ltd + +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. +*/ +try { + global.Olm = require('olm'); +} catch (e) { + console.warn("unable to run megolm backup tests: libolm not available"); +} + +import expect from 'expect'; +import Promise from 'bluebird'; + +import sdk from '../../..'; +import algorithms from '../../../lib/crypto/algorithms'; +import WebStorageSessionStore from '../../../lib/store/session/webstorage'; +import MemoryCryptoStore from '../../../lib/crypto/store/memory-crypto-store.js'; +import MockStorageApi from '../../MockStorageApi'; +import testUtils from '../../test-utils'; + +// Crypto and OlmDevice won't import unless we have global.Olm +let OlmDevice; +let Crypto; +if (global.Olm) { + OlmDevice = require('../../../lib/crypto/OlmDevice'); + Crypto = require('../../../lib/crypto'); +} + +const MatrixClient = sdk.MatrixClient; +const MatrixEvent = sdk.MatrixEvent; +const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; + +const ROOM_ID = '!ROOM:ID'; + +describe("MegolmBackup", function() { + if (!global.Olm) { + console.warn('Not running megolm backup unit tests: libolm not present'); + return; + } + + let olmDevice; + let mockOlmLib; + let mockCrypto; + let mockStorage; + let sessionStore; + let cryptoStore; + let megolmDecryption; + beforeEach(function () { + testUtils.beforeEach(this); // eslint-disable-line no-invalid-this + + mockCrypto = testUtils.mock(Crypto, 'Crypto'); + mockCrypto.backupKey = new global.Olm.PkEncryption(); + mockCrypto.backupKey.set_recipient_key( + "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmoK" + ); + + mockStorage = new MockStorageApi(); + sessionStore = new WebStorageSessionStore(mockStorage); + cryptoStore = new MemoryCryptoStore(mockStorage); + + olmDevice = new OlmDevice(sessionStore, cryptoStore); + + // we stub out the olm encryption bits + mockOlmLib = {}; + mockOlmLib.ensureOlmSessionsForDevices = expect.createSpy(); + mockOlmLib.encryptMessageForDevice = + expect.createSpy().andReturn(Promise.resolve()); + }); + + describe("backup", function() { + let mockBaseApis; + + beforeEach(function() { + mockBaseApis = {}; + + megolmDecryption = new MegolmDecryption({ + userId: '@user:id', + crypto: mockCrypto, + olmDevice: olmDevice, + baseApis: mockBaseApis, + roomId: ROOM_ID, + }); + + megolmDecryption.olmlib = mockOlmLib; + }); + + it('automatically backs up keys', function() { + const groupSession = new global.Olm.OutboundGroupSession(); + groupSession.create(); + + // construct a fake decrypted key event via the use of a mocked + // 'crypto' implementation. + const event = new MatrixEvent({ + type: 'm.room.encrypted', + }); + const decryptedData = { + clearEvent: { + type: 'm.room_key', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + room_id: ROOM_ID, + session_id: groupSession.session_id(), + session_key: groupSession.session_key(), + }, + }, + senderCurve25519Key: "SENDER_CURVE25519", + claimedEd25519Key: "SENDER_ED25519", + }; + + mockCrypto.decryptEvent = function() { + return Promise.resolve(decryptedData); + }; + + const sessionId = groupSession.session_id(); + const cipherText = groupSession.encrypt(JSON.stringify({ + room_id: ROOM_ID, + content: 'testytest', + })); + const msgevent = new MatrixEvent({ + type: 'm.room.encrypted', + room_id: ROOM_ID, + content: { + algorithm: 'm.megolm.v1.aes-sha2', + sender_key: "SENDER_CURVE25519", + session_id: sessionId, + ciphertext: cipherText, + }, + event_id: "$event1", + origin_server_ts: 1507753886000, + }); + + mockBaseApis.sendKeyBackup = expect.createSpy(); + + return event.attemptDecryption(mockCrypto).then(() => { + return megolmDecryption.onRoomKeyEvent(event); + }).then(() => { + expect(mockBaseApis.sendKeyBackup).toHaveBeenCalled(); + }); + }); + }); + + describe("restore", function () { + let client; + + beforeEach(function() { + const scheduler = [ + "getQueueForEvent", "queueEvent", "removeEventFromQueue", + "setProcessFunction", + ].reduce((r, k) => { r[k] = expect.createSpy(); return r; }, {}); + const store = [ + "getRoom", "getRooms", "getUser", "getSyncToken", "scrollback", + "save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom", "storeUser", + "getFilterIdByName", "setFilterIdByName", "getFilter", "storeFilter", + "getSyncAccumulator", "startup", "deleteAllData", + ].reduce((r, k) => { r[k] = expect.createSpy(); return r; }, {}); + store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null)); + store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null)); + store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null)); + client = new MatrixClient({ + baseUrl: "https://my.home.server", + idBaseUrl: "https://identity.server", + accessToken: "my.access.token", + request: function() {}, // NOP + store: store, + scheduler: scheduler, + userId: "@alice:bar", + deviceId: "device", + sessionStore: sessionStore, + cryptoStore: cryptoStore, + }); + + megolmDecryption = new MegolmDecryption({ + userId: '@user:id', + crypto: mockCrypto, + olmDevice: olmDevice, + baseApis: client, + roomId: ROOM_ID, + }); + + megolmDecryption.olmlib = mockOlmLib; + + return client.initCrypto(); + }); + + it('can restore from backup', function () { + const event = new MatrixEvent({ + type: 'm.room.encrypted', + room_id: '!ROOM:ID', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + sender_key: 'SENDER_CURVE25519', + session_id: 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc', + ciphertext: 'AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N' + + 'CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl' + + 'mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs' + }, + event_id: '$event1', + origin_server_ts: 1507753886000, + }); + client._http.authedRequest = function () { + return Promise.resolve({ + first_message_index: 0, + forwarded_count: 0, + is_verified: false, + session_data: { + ciphertext: '2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw' + + '6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ' + + 'Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9' + + 'SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy' + + 'Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF' + + 'ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV' + + '4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv' + + 'C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe' + + 'Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf' + + 'QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy' + + 'iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg', + mac: '5lxYBHQU80M', + ephemeral: '/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14', + } + }); + }; + return client.restoreKeyBackups( + "qx37WTQrjZLz5tId/uBX9B3/okqAbV1ofl9UnHKno1eipByCpXleAAlAZoJgYnCDOQZD" + + "QWzo3luTSfkF9pU1mOILCbbouubs6TVeDyPfgGD9i86J8irHjA", + ROOM_ID, + 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc' + ).then(() => { + return megolmDecryption.decryptEvent(event); + }).then((res) => { + expect(res.clearEvent.content).toEqual('testytest'); + }); + }); + }); +}); diff --git a/src/client.js b/src/client.js index 54f9647f2..1770c14f6 100644 --- a/src/client.js +++ b/src/client.js @@ -732,6 +732,177 @@ MatrixClient.prototype.importRoomKeys = function(keys) { return this._crypto.importRoomKeys(keys); }; +/** + * Get information about the current key backup. + */ +MatrixClient.prototype.getKeyBackupVersion = function(callback) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + return this._http.authedRequest( + undefined, "GET", "/room_keys/version", + ).then((res) => { + if (res.algorithm !== olmlib.MEGOLM_BACKUP_ALGORITHM) { + const err = "Unknown backup algorithm: " + res.algorithm; + callback(err); + return Promise.reject(err); + } else if (!(typeof res.auth_data === "object") + || !res.auth_data.public_key) { + const err = "Invalid backup data returned"; + callback(err); + return Promise.reject(err); + } else { + if (callback) { + callback(null, res); + } + return res; + } + }); +} + +/** + * Enable backing up of keys, using data previously returned from + * getKeyBackupVersion. + */ +MatrixClient.prototype.enableKeyBackup = function(info) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + this._crypto.backupKey = new global.Olm.PkEncryption(); + this._crypto.backupKey.set_recipient_key(info.auth_data.public_key); +} + +/** + * Disable backing up of keys. + */ +MatrixClient.prototype.disableKeyBackup = function() { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + this._crypto.backupKey = undefined; +} + +/** + * Create a new key backup version and enable it. + */ +MatrixClient.prototype.createKeyBackupVersion = function(callback) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const decryption = new global.Olm.PkDecryption(); + const public_key = decryption.generate_key(); + const encryption = new global.Olm.PkEncryption(); + encryption.set_recipient_key(public_key); + const data = { + algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, + auth_data: { + public_key: public_key, + } + }; + this._crypto._signObject(data.auth_data); + return this._http.authedRequest( + undefined, "POST", "/room_keys/version", undefined, data, + ).then((res) => { + this._crypto.backupKey = encryption; + // FIXME: pickle isn't the right thing to use, but we don't have + // anything else yet + const recovery_key = decryption.pickle("secret_key"); + callback(null, recovery_key); + return recovery_key; + }); +} + +MatrixClient.prototype._makeKeyBackupPath = function(roomId, sessionId, version) { + let path; + if (sessionId !== undefined) { + path = utils.encodeUri("/room_keys/keys/$roomId/$sessionId", { + $roomId: roomId, + $sessionId: sessionId, + }); + } else if (roomId !== undefined) { + path = utils.encodeUri("/room_keys/keys/$roomId", { + $roomId: roomId, + }); + } else { + path = "/room_keys/keys"; + } + const queryData = version === undefined ? undefined : {version : version}; + return { + path: path, + queryData: queryData, + } +} + +/** + * Back up session keys to the homeserver. + * @param {string} roomId ID of the room that the keys are for Optional. + * @param {string} sessionId ID of the session that the keys are for Optional. + * @param {integer} version backup version Optional. + * @param {object} key data + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} a promise that will resolve when the keys + * are uploaded + */ +MatrixClient.prototype.sendKeyBackup = function(roomId, sessionId, version, data, callback) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const path = this._makeKeyBackupPath(roomId, sessionId, version); + return this._http.authedRequest( + callback, "PUT", path.path, path.queryData, data, + ); +}; + +MatrixClient.prototype.restoreKeyBackups = function(decryptionKey, roomId, sessionId, version, callback) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + // FIXME: see the FIXME in createKeyBackupVersion + const decryption = new global.Olm.PkDecryption(); + decryption.unpickle("secret_key", decryptionKey); + + const path = this._makeKeyBackupPath(roomId, sessionId, version); + return this._http.authedRequest( + undefined, "GET", path.path, path.queryData, + ).then((res) => { + const keys = []; + // FIXME: for each room, session, if response has multiple + // decrypt response.data.session_data + const session_data = res.session_data; + const key = JSON.parse(decryption.decrypt( + session_data.ephemeral, + session_data.mac, + session_data.ciphertext + )); + // set room_id and session_id + key.room_id = roomId; + key.session_id = sessionId; + keys.push(key); + return this.importRoomKeys(keys); + }).then(() => { + if (callback) { + callback(); + } + }) +}; + +MatrixClient.prototype.deleteKeyBackups = function(roomId, sessionId, version, callback) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const path = this._makeKeyBackupPath(roomId, sessionId, version); + return this._http.authedRequest( + callback, "DELETE", path.path, path.queryData, + ) +}; + // Group ops // ========= // Operations on groups that come down the sync stream (ie. ones the @@ -3695,6 +3866,12 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED; * }); */ +/** + * Fires when we want to suggest to the user that they restore their megolm keys + * from backup or by cross-signing the device. + * + * @event module:client~MatrixClient#"crypto.suggestKeyRestore" + */ // EventEmitter JSDocs diff --git a/src/crypto/OlmDevice.js b/src/crypto/OlmDevice.js index cda14779c..950faa8e2 100644 --- a/src/crypto/OlmDevice.js +++ b/src/crypto/OlmDevice.js @@ -91,6 +91,17 @@ function OlmDevice(sessionStore, cryptoStore) { this.deviceEd25519Key = null; this._maxOneTimeKeys = null; + // track which of our other devices (if any) have cross-signed this device + // XXX: this should probably have a single source of truth in the /devices + // API store or whatever we use to track our self-signed devices. + this.crossSelfSigs = []; + + // track whether we have already suggested to the user that they should + // restore their keys from backup or by cross-signing the device. + // We use this to avoid repeatedly emitting the suggestion event. + // XXX: persist this somewhere! + this.suggestedKeyRestore = false; + // we don't bother stashing outboundgroupsessions in the sessionstore - // instead we keep them here. this._outboundGroupSessionStore = {}; diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index a4eb32ab0..5f667d56f 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -814,7 +814,7 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { } console.log(`Adding key for megolm session ${senderKey}|${sessionId}`); - this._olmDevice.addInboundGroupSession( + return this._olmDevice.addInboundGroupSession( content.room_id, senderKey, forwardingKeyChain, sessionId, content.session_key, keysClaimed, exportFormat, @@ -829,6 +829,12 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { // have another go at decrypting events sent with this session. this._retryDecryption(senderKey, sessionId); + }).then(() => { + return this.backupGroupSession( + content.room_id, senderKey, forwardingKeyChain, + content.session_id, content.session_key, keysClaimed, + exportFormat, + ); }).catch((e) => { console.error(`Error handling m.room_key_event: ${e}`); }); @@ -951,6 +957,54 @@ MegolmDecryption.prototype.importRoomKey = function(session) { }); }; +MegolmDecryption.prototype.backupGroupSession = async function( + roomId, senderKey, forwardingCurve25519KeyChain, + sessionId, sessionKey, keysClaimed, + exportFormat, +) { + // new session. + const session = new Olm.InboundGroupSession(); + let first_known_index; + try { + if (exportFormat) { + session.import_session(sessionKey); + } else { + session.create(sessionKey); + } + if (sessionId != session.session_id()) { + throw new Error( + "Mismatched group session ID from senderKey: " + + senderKey, + ); + } + + if (!exportFormat) { + sessionKey = session.export_session(); + } + const first_known_index = session.first_known_index(); + + const sessionData = { + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: senderKey, + sender_claimed_keys: keysClaimed, + forwardingCurve25519KeyChain: forwardingCurve25519KeyChain, + session_key: sessionKey + }; + const encrypted = this._crypto.backupKey.encrypt(JSON.stringify(sessionData)); + const data = { + first_message_index: first_known_index, + forwarded_count: forwardingCurve25519KeyChain.length, + is_verified: false, // FIXME: how do we determine this? + session_data: encrypted + }; + return this._baseApis.sendKeyBackup(roomId, sessionId, data); + } catch (e) { + return Promise.reject(e); + } finally { + session.free(); + } +} + /** * Have another go at decrypting events after we receive a key * diff --git a/src/crypto/index.js b/src/crypto/index.js index 8b35bc0a4..a26ed6089 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -72,6 +72,11 @@ function Crypto(baseApis, sessionStore, userId, deviceId, this._cryptoStore = cryptoStore; this._roomList = roomList; + // 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.backupKey = null; + this._olmDevice = new OlmDevice(sessionStore, cryptoStore); this._deviceList = new DeviceList( baseApis, cryptoStore, sessionStore, this._olmDevice, diff --git a/src/crypto/olmlib.js b/src/crypto/olmlib.js index 56799c513..f03714f16 100644 --- a/src/crypto/olmlib.js +++ b/src/crypto/olmlib.js @@ -35,6 +35,11 @@ module.exports.OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; */ module.exports.MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; +/** + * matrix algorithm tag for megolm backups + */ +module.exports.MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1"; + /** * Encrypt an event payload for an Olm device diff --git a/src/sync.js b/src/sync.js index 4aa2d2482..2a2789722 100644 --- a/src/sync.js +++ b/src/sync.js @@ -1088,6 +1088,17 @@ SyncApi.prototype._processSyncResponse = async function( async function processRoomEvent(e) { client.emit("event", e); if (e.isState() && e.getType() == "m.room.encryption" && self.opts.crypto) { + + /* + // XXX: get device + if (!device.getSuggestedKeyRestore() && + !device.backupKey && !device.selfCrossSigs.length) + { + client.emit("crypto.suggestKeyRestore"); + device.setSuggestedKeyRestore(true); + } + */ + await self.opts.crypto.onCryptoEvent(e); } }