diff --git a/package.json b/package.json index 88f87f90b..668bc370c 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "babel-runtime": "^6.26.0", "bluebird": "^3.5.0", "browser-request": "^0.3.3", + "bs58": "^4.0.1", "content-type": "^1.0.2", "request": "^2.53.0" }, diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index 4d949e05e..1b28ad683 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -1,20 +1,24 @@ "use strict"; import 'source-map-support/register'; +import Crypto from '../../lib/crypto'; +import expect from 'expect'; const sdk = require("../.."); -let Crypto; -if (sdk.CRYPTO_ENABLED) { - Crypto = require("../../lib/crypto"); -} -import expect from 'expect'; +const Olm = global.Olm; describe("Crypto", function() { if (!sdk.CRYPTO_ENABLED) { return; } + + beforeEach(function(done) { + Olm.init().then(done); + }); + it("Crypto exposes the correct olm library version", function() { + console.log(Crypto); expect(Crypto.getOlmVersion()[0]).toEqual(2); }); }); diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index cf8e58f2e..6c777859e 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -13,20 +13,16 @@ 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'); -} +import OlmDevice from '../../../../lib/crypto/OlmDevice'; +import Crypto from '../../../../lib/crypto'; const MatrixEvent = sdk.MatrixEvent; const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; const ROOM_ID = '!ROOM:ID'; +const Olm = global.Olm; + describe("MegolmDecryption", function() { if (!global.Olm) { console.warn('Not running megolm unit tests: libolm not present'); @@ -69,7 +65,8 @@ describe("MegolmDecryption", function() { describe('receives some keys:', function() { let groupSession; - beforeEach(function() { + beforeEach(async function() { + await Olm.init(); groupSession = new global.Olm.OutboundGroupSession(); groupSession.create(); @@ -98,7 +95,7 @@ describe("MegolmDecryption", function() { }, }; - return event.attemptDecryption(mockCrypto).then(() => { + await event.attemptDecryption(mockCrypto).then(() => { megolmDecryption.onRoomKeyEvent(event); }); }); diff --git a/spec/unit/crypto/backup.spec.js b/spec/unit/crypto/backup.spec.js new file mode 100644 index 000000000..3a3ed3a97 --- /dev/null +++ b/spec/unit/crypto/backup.spec.js @@ -0,0 +1,480 @@ +/* +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 Olm = global.Olm; + +const MatrixClient = sdk.MatrixClient; +const MatrixEvent = sdk.MatrixEvent; +const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; + +const ROOM_ID = '!ROOM:ID'; + +const SESSION_ID = 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc'; +const ENCRYPTED_EVENT = new MatrixEvent({ + type: 'm.room.encrypted', + room_id: '!ROOM:ID', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + sender_key: 'SENDER_CURVE25519', + session_id: SESSION_ID, + ciphertext: 'AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N' + + 'CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl' + + 'mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs', + }, + event_id: '$event1', + origin_server_ts: 1507753886000, +}); + +const KEY_BACKUP_DATA = { + 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', + }, +}; + +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(async function() { + await Olm.init(); + testUtils.beforeEach(this); // eslint-disable-line no-invalid-this + + mockCrypto = testUtils.mock(Crypto, 'Crypto'); + mockCrypto.backupKey = new Olm.PkEncryption(); + mockCrypto.backupKey.set_recipient_key( + "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + ); + mockCrypto.backupInfo = { + algorithm: "m.megolm_backup.v1", + version: 1, + auth_data: { + public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + }, + }; + + 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 calls the key back up', function() { + const groupSession = new 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); + }; + mockCrypto.cancelRoomKeyRequest = function() {}; + + mockCrypto.backupGroupSession = expect.createSpy(); + + return event.attemptDecryption(mockCrypto).then(() => { + return megolmDecryption.onRoomKeyEvent(event); + }).then(() => { + expect(mockCrypto.backupGroupSession).toHaveBeenCalled(); + }); + }); + + it('sends backups to the server', function() { + this.timeout(12000); // eslint-disable-line no-invalid-this + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + const ibGroupSession = new Olm.InboundGroupSession(); + ibGroupSession.create(groupSession.session_key()); + + 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)); + const 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() + .then(() => { + return cryptoStore.doTxn( + "readwrite", + [cryptoStore.STORE_SESSION], + (txn) => { + cryptoStore.addEndToEndInboundGroupSession( + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + groupSession.session_id(), + { + forwardingCurve25519KeyChain: undefined, + keysClaimed: { + ed25519: "SENDER_ED25519", + }, + room_id: ROOM_ID, + session: ibGroupSession.pickle(olmDevice._pickleKey), + }, + txn); + }); + }) + .then(() => { + client.enableKeyBackup({ + algorithm: "m.megolm_backup.v1", + version: 1, + auth_data: { + public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + }, + }); + let numCalls = 0; + return new Promise((resolve, reject) => { + client._http.authedRequest = function( + callback, method, path, queryParams, data, opts, + ) { + expect(++numCalls <= 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("PUT"); + expect(path).toBe("/room_keys/keys"); + expect(queryParams.version).toBe(1); + expect(data.rooms[ROOM_ID].sessions).toExist(); + expect(data.rooms[ROOM_ID].sessions).toIncludeKey( + groupSession.session_id(), + ); + resolve(); + return Promise.resolve({}); + }; + client._crypto.backupGroupSession( + "roomId", + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + [], + groupSession.session_id(), + groupSession.session_key(), + ); + }) + .then(() => { + expect(numCalls).toBe(1); + }); + }); + }); + + it('retries when a backup fails', function() { + this.timeout(12000); // eslint-disable-line no-invalid-this + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + const ibGroupSession = new Olm.InboundGroupSession(); + ibGroupSession.create(groupSession.session_key()); + + 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)); + const 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() + .then(() => { + return cryptoStore.doTxn( + "readwrite", + [cryptoStore.STORE_SESSION], + (txn) => { + cryptoStore.addEndToEndInboundGroupSession( + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + groupSession.session_id(), + { + forwardingCurve25519KeyChain: undefined, + keysClaimed: { + ed25519: "SENDER_ED25519", + }, + room_id: ROOM_ID, + session: ibGroupSession.pickle(olmDevice._pickleKey), + }, + txn); + }); + }) + .then(() => { + client.enableKeyBackup({ + algorithm: "foobar", + version: 1, + auth_data: { + public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + }, + }); + let numCalls = 0; + return new Promise((resolve, reject) => { + client._http.authedRequest = function( + callback, method, path, queryParams, data, opts, + ) { + expect(++numCalls <= 2); + if (numCalls >= 3) { + // exit out of retry loop if there's something wrong + reject(new Error("authedRequest called too many timmes")); + return Promise.resolve({}); + } + expect(method).toBe("PUT"); + expect(path).toBe("/room_keys/keys"); + expect(queryParams.version).toBe(1); + expect(data.rooms[ROOM_ID].sessions).toExist(); + expect(data.rooms[ROOM_ID].sessions).toIncludeKey( + groupSession.session_id(), + ); + if (numCalls > 1) { + resolve(); + return Promise.resolve({}); + } else { + return Promise.reject( + new Error("this is an expected failure"), + ); + } + }; + client._crypto.backupGroupSession( + "roomId", + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + [], + groupSession.session_id(), + groupSession.session_key(), + ); + }) + .then(() => { + expect(numCalls).toBe(2); + }); + }); + }); + }); + + 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() { + client._http.authedRequest = function() { + return Promise.resolve(KEY_BACKUP_DATA); + }; + return client.restoreKeyBackups( + "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", + ROOM_ID, + SESSION_ID, + ).then(() => { + return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); + }).then((res) => { + expect(res.clearEvent.content).toEqual('testytest'); + }); + }); + + it('can restore backup by room', function() { + client._http.authedRequest = function() { + return Promise.resolve({ + rooms: { + [ROOM_ID]: { + sessions: { + [SESSION_ID]: KEY_BACKUP_DATA, + }, + }, + }, + }); + }; + return client.restoreKeyBackups( + "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", + ).then(() => { + return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); + }).then((res) => { + expect(res.clearEvent.content).toEqual('testytest'); + }); + }); + }); +}); diff --git a/src/client.js b/src/client.js index be4e5857b..eb964650a 100644 --- a/src/client.js +++ b/src/client.js @@ -41,23 +41,44 @@ const SyncApi = require("./sync"); const MatrixBaseApis = require("./base-apis"); const MatrixError = httpApi.MatrixError; const ContentHelpers = require("./content-helpers"); +const olmlib = require("./crypto/olmlib"); import ReEmitter from './ReEmitter'; import RoomList from './crypto/RoomList'; +import Crypto from './crypto'; +import { isCryptoAvailable } from './crypto'; +import { encodeRecoveryKey, decodeRecoveryKey } from './crypto/recoverykey'; + // Disable warnings for now: we use deprecated bluebird functions // and need to migrate, but they spam the console with warnings. Promise.config({warnings: false}); const SCROLLBACK_DELAY_MS = 3000; -let CRYPTO_ENABLED = false; +const CRYPTO_ENABLED = isCryptoAvailable(); -try { - var Crypto = require("./crypto"); - CRYPTO_ENABLED = true; -} catch (e) { - console.warn("Unable to load crypto module: crypto will be disabled: " + e); +function keysFromRecoverySession(sessions, decryptionKey, roomId) { + const keys = []; + for (const [sessionId, sessionData] of Object.entries(sessions)) { + try { + const decrypted = keyFromRecoverySession(sessionData, decryptionKey); + decrypted.session_id = sessionId; + decrypted.room_id = roomId; + keys.push(decrypted); + } catch (e) { + console.log("Failed to decrypt session from backup"); + } + } + return keys; +} + +function keyFromRecoverySession(session, decryptionKey) { + return JSON.parse(decryptionKey.decrypt( + session.session_data.ephemeral, + session.session_data.mac, + session.session_data.ciphertext, + )); } /** @@ -133,6 +154,8 @@ function MatrixClient(opts) { MatrixBaseApis.call(this, opts); + this.olmVersion = null; // Populated after initCrypto is done + this.reEmitter = new ReEmitter(this); this.store = opts.store || new StubStore(); @@ -185,10 +208,6 @@ function MatrixClient(opts) { this._forceTURN = opts.forceTURN || false; - if (CRYPTO_ENABLED) { - this.olmVersion = Crypto.getOlmVersion(); - } - // List of which rooms have encryption enabled: separate from crypto because // we still want to know which rooms are encrypted even if crypto is disabled: // we don't want to start sending unencrypted events to them. @@ -378,6 +397,13 @@ MatrixClient.prototype.setNotifTimelineSet = function(notifTimelineSet) { * successfully initialised. */ MatrixClient.prototype.initCrypto = async function() { + if (!isCryptoAvailable()) { + throw new Error( + `End-to-end encryption not supported in this js-sdk build: did ` + + `you remember to load the olm library?`, + ); + } + if (this._crypto) { console.warn("Attempt to re-initialise e2e encryption on MatrixClient"); return; @@ -395,13 +421,6 @@ MatrixClient.prototype.initCrypto = async function() { // initialise the list of encrypted rooms (whether or not crypto is enabled) await this._roomList.init(); - if (!CRYPTO_ENABLED) { - throw new Error( - `End-to-end encryption not supported in this js-sdk build: did ` + - `you remember to load the olm library?`, - ); - } - const userId = this.getUserId(); if (userId === null) { throw new Error( @@ -433,6 +452,9 @@ MatrixClient.prototype.initCrypto = async function() { await crypto.init(); + this.olmVersion = Crypto.getOlmVersion(); + + // if crypto initialisation was successful, tell it to attach its event // handlers. crypto.registerEventHandlers(this); @@ -536,7 +558,15 @@ MatrixClient.prototype.setDeviceVerified = function(userId, deviceId, verified) if (verified === undefined) { verified = true; } - return _setDeviceVerification(this, userId, deviceId, verified, null); + const prom = _setDeviceVerification(this, userId, deviceId, verified, null); + + // if one of the user's own devices is being marked as verified / unverified, + // 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(); + } + return prom; }; /** @@ -740,6 +770,303 @@ MatrixClient.prototype.importRoomKeys = function(keys) { return this._crypto.importRoomKeys(keys); }; +/** + * Get information about the current key backup. + * @returns {Promise} Information object from API or null + */ +MatrixClient.prototype.getKeyBackupVersion = function() { + 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; + return Promise.reject(err); + } else if (!(typeof res.auth_data === "object") + || !res.auth_data.public_key) { + const err = "Invalid backup data returned"; + return Promise.reject(err); + } else { + return res; + } + }).catch((e) => { + if (e.errcode === 'M_NOT_FOUND') { + return null; + } else { + throw e; + } + }); +}; + +/** + * @param {object} info key backup info dict from getKeyBackupVersion() + * @return {object} { + * usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device + * sigs: [ + * valid: [bool], + * device: [DeviceInfo], + * ] + * } + */ +MatrixClient.prototype.isKeyBackupTrusted = function(info) { + return this._crypto.isKeyBackupTrusted(info); +}; + +/** + * @returns {bool} true if the client is configured to back up keys to + * the server, otherwise false. + */ +MatrixClient.prototype.getKeyBackupEnabled = function() { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + return Boolean(this._crypto.backupKey); +}; + +/** + * Enable backing up of keys, using data previously returned from + * getKeyBackupVersion. + * + * @param {object} info Backup information object as returned by getKeyBackupVersion + */ +MatrixClient.prototype.enableKeyBackup = function(info) { + if (this._crypto === null) { + 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('keyBackupStatus', true); + + this._crypto._maybeSendKeyBackup(); +}; + +/** + * Disable backing up of keys. + */ +MatrixClient.prototype.disableKeyBackup = function() { + if (this._crypto === null) { + 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('keyBackupStatus', false); +}; + +/** + * Set up the data required to create a new backup version. The backup version + * will not be created and enabled until createKeyBackupVersion is called. + * + * @returns {object} Object that can be passed to createKeyBackupVersion and + * additionally has a 'recovery_key' member with the user-facing recovery key string. + */ +MatrixClient.prototype.prepareKeyBackupVersion = function() { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const decryption = new global.Olm.PkDecryption(); + try { + const publicKey = decryption.generate_key(); + return { + algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, + auth_data: { + public_key: publicKey, + }, + recovery_key: encodeRecoveryKey(decryption.get_private_key()), + }; + } finally { + decryption.free(); + } +}; + +/** + * Create a new key backup version and enable it, using the information return + * from prepareKeyBackupVersion. + * + * @param {object} info Info object from prepareKeyBackupVersion + * @returns {Promise} Object with 'version' param indicating the version created + */ +MatrixClient.prototype.createKeyBackupVersion = function(info) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const data = { + algorithm: info.algorithm, + auth_data: info.auth_data, // FIXME: should this be cloned? + }; + return this._crypto._signObject(data.auth_data).then(() => { + return this._http.authedRequest( + undefined, "POST", "/room_keys/version", undefined, data, + ); + }).then((res) => { + this.enableKeyBackup({ + algorithm: info.algorithm, + auth_data: info.auth_data, + version: res.version, + }); + return res; + }); +}; + +MatrixClient.prototype.deleteKeyBackupVersion = function(version) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + // 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(); + } + + const path = utils.encodeUri("/room_keys/version/$version", { + $version: version, + }); + + return this._http.authedRequest( + undefined, "DELETE", path, undefined, undefined, + ); +}; + +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} data Object keys to send + * @return {module:client.Promise} a promise that will resolve when the keys + * are uploaded + */ +MatrixClient.prototype.sendKeyBackup = function(roomId, sessionId, version, data) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const path = this._makeKeyBackupPath(roomId, sessionId, version); + return this._http.authedRequest( + undefined, "PUT", path.path, path.queryData, data, + ); +}; + +MatrixClient.prototype.backupAllGroupSessions = function(version) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + return this._crypto.backupAllGroupSessions(version); +}; + +MatrixClient.prototype.isValidRecoveryKey = function(recoveryKey) { + try { + decodeRecoveryKey(recoveryKey); + return true; + } catch (e) { + return false; + } +}; + +MatrixClient.prototype.restoreKeyBackups = function( + recoveryKey, targetRoomId, targetSessionId, version, +) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + let totalKeyCount = 0; + let keys = []; + + const path = this._makeKeyBackupPath(targetRoomId, targetSessionId, version); + + // FIXME: see the FIXME in createKeyBackupVersion + const privkey = decodeRecoveryKey(recoveryKey); + const decryption = new global.Olm.PkDecryption(); + try { + decryption.init_with_private_key(privkey); + } catch(e) { + decryption.free(); + throw e; + } + + return this._http.authedRequest( + undefined, "GET", path.path, path.queryData, + ).then((res) => { + 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, roomKeys, + ); + for (const k of roomKeys) { + k.room_id = roomId; + keys.push(k); + } + } + } else if (res.sessions) { + totalKeyCount = Object.keys(res.sessions).length; + keys = keysFromRecoverySession( + res.sessions, decryption, targetRoomId, keys, + ); + } else { + totalKeyCount = 1; + try { + const key = keyFromRecoverySession(res, decryption); + key.room_id = targetRoomId; + key.session_id = targetSessionId; + keys.push(key); + } catch (e) { + console.log("Failed to decrypt session from backup"); + } + } + + return this.importRoomKeys(keys); + }).then(() => { + return {total: totalKeyCount, imported: keys.length}; + }).finally(() => { + decryption.free(); + }); +}; + +MatrixClient.prototype.deleteKeysFromBackup = function(roomId, sessionId, version) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const path = this._makeKeyBackupPath(roomId, sessionId, version); + return this._http.authedRequest( + undefined, "DELETE", path.path, path.queryData, + ); +}; + // Group ops // ========= // Operations on groups that come down the sync stream (ie. ones the @@ -3738,6 +4065,24 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED; * }); */ +/** + * Fires whenever the status of e2e key backup changes, as returned by getKeyBackupEnabled() + * @event module:client~MatrixClient#"keyBackupStatus" + * @param {bool} enabled true if key backup has been enabled, otherwise false + * @example + * matrixClient.on("keyBackupStatus", function(enabled){ + * if (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/DeviceList.js b/src/crypto/DeviceList.js index fa55f2fa6..c3a86ae1e 100644 --- a/src/crypto/DeviceList.js +++ b/src/crypto/DeviceList.js @@ -71,6 +71,9 @@ export default class DeviceList { // } this._devices = {}; + // map of identity keys to the user who owns it + this._userByIdentityKey = {}; + // which users we are tracking device status for. // userId -> TRACKING_STATUS_* this._deviceTrackingStatus = {}; // loaded from storage in load() @@ -128,6 +131,19 @@ export default class DeviceList { deviceData.trackingStatus : {}; this._syncToken = deviceData ? deviceData.syncToken : null; } + this._userByIdentityKey = {}; + for (const user in this._devices) { + if (!this._devices.hasOwnProperty(user)) { + continue; + } + const userDevices = this._devices[user]; + for (const device in userDevices) { + if (!userDevices.hasOwnProperty(device)) { + continue; + } + this._userByIdentityKey[userDevices[device].senderKey] = user; + } + } }); }, ); @@ -357,13 +373,24 @@ export default class DeviceList { /** * Find a device by curve25519 identity key * - * @param {string} userId owner of the device + * @param {string} userId owner of the device (optional) * @param {string} algorithm encryption algorithm * @param {string} senderKey curve25519 key to match * * @return {module:crypto/deviceinfo?} */ getDeviceByIdentityKey(userId, algorithm, senderKey) { + if (arguments.length === 2) { + // if userId is omitted, shift the other arguments, and look up the + // userid + senderKey = algorithm; + algorithm = userId; + userId = this._userByIdentityKey[senderKey]; + if (!userId) { + return null; + } + } + if ( algorithm !== olmlib.OLM_ALGORITHM && algorithm !== olmlib.MEGOLM_ALGORITHM @@ -409,6 +436,12 @@ export default class DeviceList { */ storeDevicesForUser(u, devs) { this._devices[u] = devs; + for (const device in devs) { + if (!devs.hasOwnProperty(device)) { + continue; + } + this._userByIdentityKey[devs[device].senderKey] = u; + } this._dirty = true; } @@ -526,6 +559,12 @@ export default class DeviceList { */ _setRawStoredDevicesForUser(userId, devices) { this._devices[userId] = devices; + for (const device in devices) { + if (!devices.hasOwnProperty(device)) { + continue; + } + this._userByIdentityKey[devices[device].senderKey] = userId; + } } /** diff --git a/src/crypto/OlmDevice.js b/src/crypto/OlmDevice.js index cda14779c..74e46e2a4 100644 --- a/src/crypto/OlmDevice.js +++ b/src/crypto/OlmDevice.js @@ -17,17 +17,6 @@ limitations under the License. import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; -/** - * olm.js wrapper - * - * @module crypto/OlmDevice - */ -const Olm = global.Olm; -if (!Olm) { - throw new Error("global.Olm is not defined"); -} - - // The maximum size of an event is 65K, and we base64 the content, so this is a // reasonable approximation to the biggest plaintext we can encrypt. const MAX_PLAINTEXT_LENGTH = 65536 * 3 / 4; @@ -91,6 +80,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 = {}; @@ -127,7 +127,7 @@ OlmDevice.prototype.init = async function() { await this._migrateFromSessionStore(); let e2eKeys; - const account = new Olm.Account(); + const account = new global.Olm.Account(); try { await _initialiseAccount( this._sessionStore, this._cryptoStore, this._pickleKey, account, @@ -161,7 +161,7 @@ async function _initialiseAccount(sessionStore, cryptoStore, pickleKey, account) * @return {array} The version of Olm. */ OlmDevice.getOlmVersion = function() { - return Olm.get_library_version(); + return global.Olm.get_library_version(); }; OlmDevice.prototype._migrateFromSessionStore = async function() { @@ -268,7 +268,7 @@ OlmDevice.prototype._migrateFromSessionStore = async function() { */ OlmDevice.prototype._getAccount = function(txn, func) { this._cryptoStore.getAccount(txn, (pickledAccount) => { - const account = new Olm.Account(); + const account = new global.Olm.Account(); try { account.unpickle(this._pickleKey, pickledAccount); func(account); @@ -321,7 +321,7 @@ OlmDevice.prototype._getSession = function(deviceKey, sessionId, txn, func) { * @private */ OlmDevice.prototype._unpickleSession = function(pickledSession, func) { - const session = new Olm.Session(); + const session = new global.Olm.Session(); try { session.unpickle(this._pickleKey, pickledSession); func(session); @@ -354,7 +354,7 @@ OlmDevice.prototype._saveSession = function(deviceKey, session, txn) { * @private */ OlmDevice.prototype._getUtility = function(func) { - const utility = new Olm.Utility(); + const utility = new global.Olm.Utility(); try { return func(utility); } finally { @@ -466,7 +466,7 @@ OlmDevice.prototype.createOutboundSession = async function( ], (txn) => { this._getAccount(txn, (account) => { - const session = new Olm.Session(); + const session = new global.Olm.Session(); try { session.create_outbound(account, theirIdentityKey, theirOneTimeKey); newSessionId = session.session_id(); @@ -510,7 +510,7 @@ OlmDevice.prototype.createInboundSession = async function( ], (txn) => { this._getAccount(txn, (account) => { - const session = new Olm.Session(); + const session = new global.Olm.Session(); try { session.create_inbound_from( account, theirDeviceIdentityKey, ciphertext, @@ -728,7 +728,7 @@ OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) { throw new Error("Unknown outbound group session " + sessionId); } - const session = new Olm.OutboundGroupSession(); + const session = new global.Olm.OutboundGroupSession(); try { session.unpickle(this._pickleKey, pickled); return func(session); @@ -744,7 +744,7 @@ OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) { * @return {string} sessionId for the outbound session. */ OlmDevice.prototype.createOutboundGroupSession = function() { - const session = new Olm.OutboundGroupSession(); + const session = new global.Olm.OutboundGroupSession(); try { session.create(); this._saveOutboundGroupSession(session); @@ -816,7 +816,7 @@ OlmDevice.prototype.getOutboundGroupSessionKey = function(sessionId) { * @return {*} result of func */ OlmDevice.prototype._unpickleInboundGroupSession = function(sessionData, func) { - const session = new Olm.InboundGroupSession(); + const session = new global.Olm.InboundGroupSession(); try { session.unpickle(this._pickleKey, sessionData.session); return func(session); @@ -897,7 +897,7 @@ OlmDevice.prototype.addInboundGroupSession = async function( } // new session. - const session = new Olm.InboundGroupSession(); + const session = new global.Olm.InboundGroupSession(); try { if (exportFormat) { session.import_session(sessionKey); @@ -1119,6 +1119,7 @@ OlmDevice.prototype.exportInboundGroupSession = function( "session_id": sessionId, "session_key": session.export_session(messageIndex), "forwarding_curve25519_key_chain": session.forwardingCurve25519KeyChain || [], + "first_known_index": session.first_known_index(), }; }); }; diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index bda57fe33..d1115f00e 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -263,6 +263,14 @@ MegolmEncryption.prototype._prepareNewSession = async function() { key.key, {ed25519: this._olmDevice.deviceEd25519Key}, ); + if (this._crypto.backupInfo) { + // don't wait for it to complete + this._crypto.backupGroupSession( + this._roomId, this._olmDevice.deviceCurve25519Key, [], + sessionId, key.key, + ); + } + return new OutboundSessionInfo(sessionId); }; @@ -824,7 +832,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, @@ -839,6 +847,15 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { // have another go at decrypting events sent with this session. this._retryDecryption(senderKey, sessionId); + }).then(() => { + if (this._crypto.backupInfo) { + // don't wait for it to complete + this._crypto.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}`); }); @@ -956,6 +973,18 @@ MegolmDecryption.prototype.importRoomKey = function(session) { session.sender_claimed_keys, true, ).then(() => { + if (this._crypto.backupInfo) { + // 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, + ); + } // have another go at decrypting events sent with this session. this._retryDecryption(session.sender_key, session.session_id); }); diff --git a/src/crypto/index.js b/src/crypto/index.js index f00477d4b..2f1c39bd9 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -36,6 +36,10 @@ const DeviceList = require('./DeviceList').default; import OutgoingRoomKeyRequestManager from './OutgoingRoomKeyRequestManager'; import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; +export function isCryptoAvailable() { + return Boolean(global.Olm); +} + /** * Cryptography bits * @@ -62,7 +66,7 @@ import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; * * @param {RoomList} roomList An initialised RoomList object */ -function Crypto(baseApis, sessionStore, userId, deviceId, +export default function Crypto(baseApis, sessionStore, userId, deviceId, clientStore, cryptoStore, roomList) { this._baseApis = baseApis; this._sessionStore = sessionStore; @@ -72,6 +76,14 @@ 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.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._olmDevice = new OlmDevice(sessionStore, cryptoStore); this._deviceList = new DeviceList( baseApis, cryptoStore, sessionStore, this._olmDevice, @@ -124,6 +136,10 @@ utils.inherits(Crypto, EventEmitter); * Returns a promise which resolves once the crypto module is ready for use. */ Crypto.prototype.init = async function() { + // Olm is just an object with a .then, not a fully-fledged promise, so + // pass it into bluebird to make it a proper promise. + await global.Olm.init(); + const sessionStoreHasAccount = Boolean(this._sessionStore.getEndToEndAccount()); let cryptoStoreHasAccount; await this._cryptoStore.doTxn( @@ -174,6 +190,115 @@ Crypto.prototype.init = async function() { ); this._deviceList.saveIfDirty(); } + + this._checkAndStartKeyBackup(); +}; + +/** + * 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() { + console.log("Checking key backup status..."); + let backupInfo; + try { + backupInfo = await this._baseApis.getKeyBackupVersion(); + } catch (e) { + console.log("Error checking for active key backup", e); + if (Number.isFinite(e.httpStatus) && e.httpStatus / 100 === 4) { + // well that's told us. we won't try again. + this._checkedForBackup = true; + } + return; + } + this._checkedForBackup = true; + + const trustInfo = await this.isKeyBackupTrusted(backupInfo); + + if (trustInfo.usable && !this.backupInfo) { + console.log("Found usable key backup: enabling key backups"); + this._baseApis.enableKeyBackup(backupInfo); + } else if (!trustInfo.usable && this.backupInfo) { + console.log("No usable key backup: disabling key backup"); + this._baseApis.disableKeyBackup(); + } else if (!trustInfo.usable && !this.backupInfo) { + console.log("No usable key backup: not enabling key backup"); + } +}; + +/** + * Forces a re-check of the key backup and enables/disables it + * as appropriate + * + * @param {object} backupInfo Backup info from /room_keys/version endpoint + */ +Crypto.prototype.checkKeyBackup = async function(backupInfo) { + this._checkedForBackup = false; + await 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], + * device: [DeviceInfo], + * ] + * } + */ +Crypto.prototype.isKeyBackupTrusted = async function(backupInfo) { + const ret = { + usable: false, + sigs: [], + }; + + if ( + !backupInfo || + !backupInfo.algorithm || + !backupInfo.auth_data || + !backupInfo.auth_data.public_key || + !backupInfo.auth_data.signatures + ) { + console.log("Key backup is absent or missing required data"); + return ret; + } + + const mySigs = backupInfo.auth_data.signatures[this._userId]; + if (!mySigs || mySigs.length === 0) { + console.log("Ignoring key backup because it lacks any signatures from this user"); + return ret; + } + + for (const keyId of Object.keys(mySigs)) { + const device = this._deviceList.getStoredDevice( + this._userId, keyId.split(':')[1], // XXX: is this how we're supposed to get the device ID? + ); + if (!device) { + console.log("Ignoring signature from unknown key " + keyId); + continue; + } + const sigInfo = { device }; + try { + await olmlib.verifySignature( + this._olmDevice, + backupInfo.auth_data, + this._userId, + device.deviceId, + device.getFingerprint(), + ); + sigInfo.valid = true; + } catch (e) { + console.log("Bad signature from device " + device.deviceId, e); + sigInfo.valid = false; + } + ret.sigs.push(sigInfo); + } + + ret.usable = ret.sigs.some((s) => s.valid && s.device.isVerified()); + return ret; }; /** @@ -815,6 +940,7 @@ Crypto.prototype.exportRoomKeys = async function() { const sess = this._olmDevice.exportInboundGroupSession( s.senderKey, s.sessionId, s.sessionData, ); + delete sess.first_known_index; sess.algorithm = olmlib.MEGOLM_ALGORITHM; exportedSessions.push(sess); }); @@ -843,6 +969,126 @@ Crypto.prototype.importRoomKeys = function(keys) { }, ); }; + +Crypto.prototype._maybeSendKeyBackup = async function() { + if (!this._sendingBackups) { + this._sendingBackups = true; + try { + // wait between 0 and 10 seconds, to avoid backup requests from + // different clients hitting the server all at the same time when a + // new key is sent + await new Promise((resolve, reject) => { + setTimeout(resolve, Math.random() * 10000); + }); + let numFailures = 0; // number of consecutive failures + while (1) { + if (!this.backupKey) { + return; + } + // FIXME: figure out what limit is reasonable + const sessions = await this._cryptoStore.getSessionsNeedingBackup(10); + if (!sessions.length) { + return; + } + 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.forwardingCurve25519KeyChain || []).length; + + const device = this._deviceList.getDeviceByIdentityKey( + olmlib.MEGOLM_ALGORITHM, session.senderKey, + ); + + data[roomId]['sessions'][session.sessionId] = { + first_message_index: firstKnownIndex, + forwarded_count: forwardedCount, + is_verified: !!(device && device.isVerified()), + session_data: encrypted, + }; + } + + try { + await this._baseApis.sendKeyBackup( + undefined, undefined, this.backupInfo.version, + {rooms: data}, + ); + numFailures = 0; + await this._cryptoStore.unmarkSessionsNeedingBackup(sessions); + } catch (err) { + numFailures++; + console.log("send failed", err); + if (err.httpStatus === 400 + || err.httpStatus === 403 + || err.httpStatus === 401) { + // retrying probably won't help much, so we should give up + // FIXME: disable backups? + return; + } + } + if (numFailures) { + // exponential backoff if we have failures + await new Promise((resolve, reject) => { + setTimeout( + resolve, + 1000 * Math.pow(2, Math.min(numFailures - 1, 4)), + ); + }); + } + } + } finally { + this._sendingBackups = false; + } + } +}; + +Crypto.prototype.backupGroupSession = async function( + roomId, senderKey, forwardingCurve25519KeyChain, + sessionId, sessionKey, keysClaimed, + exportFormat, +) { + if (!this.backupInfo) { + throw new Error("Key backups are not enabled"); + } + + await this._cryptoStore.markSessionsNeedingBackup([{ + senderKey: senderKey, + sessionId: sessionId, + }]); + + await this._maybeSendKeyBackup(); +}; + +Crypto.prototype.backupAllGroupSessions = async function(version) { + await this._cryptoStore.doTxn( + 'readwrite', + [IndexedDBCryptoStore.STORE_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP], + (txn) => { + this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => { + if (session !== null) { + this._cryptoStore.markSessionsNeedingBackup([session], txn); + } + }); + }, + ); + + await this._maybeSendKeyBackup(); +}; + /* eslint-disable valid-jsdoc */ //https://github.com/eslint/eslint/issues/7307 /** * Encrypt an event according to the configuration of the room. @@ -1150,6 +1396,12 @@ Crypto.prototype._onRoomKeyEvent = function(event) { return; } + if (!this._checkedForBackup) { + // don't bother awaiting on this - the important thing is that we retry if we + // haven't managed to check before + this._checkAndStartKeyBackup(); + } + const alg = this._getRoomDecryptor(content.room_id, content.algorithm); alg.onRoomKeyEvent(event); }; @@ -1518,6 +1770,3 @@ class IncomingRoomKeyRequestCancellation { * @event module:client~MatrixClient#"crypto.warning" * @param {string} type One of the strings listed above */ - -/** */ -module.exports = Crypto; diff --git a/src/crypto/olmlib.js b/src/crypto/olmlib.js index 56799c513..bbe942036 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.curve25519-aes-sha2"; + /** * Encrypt an event payload for an Olm device diff --git a/src/crypto/recoverykey.js b/src/crypto/recoverykey.js new file mode 100644 index 000000000..bb85697e8 --- /dev/null +++ b/src/crypto/recoverykey.js @@ -0,0 +1,67 @@ +/* +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. +*/ + +import bs58 from 'bs58'; + +// picked arbitrarily but to try & avoid clashing with any bitcoin ones +// (also base58 encoded, albeit with a lot of hashing) +const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01]; + +export function encodeRecoveryKey(key) { + const buf = new Uint8Array(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1); + buf.set(OLM_RECOVERY_KEY_PREFIX, 0); + buf.set(key, OLM_RECOVERY_KEY_PREFIX.length); + + let parity = 0; + for (let i = 0; i < buf.length - 1; ++i) { + parity ^= buf[i]; + } + buf[buf.length - 1] = parity; + const base58key = bs58.encode(buf); + + + return base58key.match(/.{1,4}/g).join(" "); +} + +export function decodeRecoveryKey(recoverykey) { + const result = bs58.decode(recoverykey.replace(/ /g, '')); + + let parity = 0; + for (const b of result) { + parity ^= b; + } + if (parity !== 0) { + throw new Error("Incorrect parity"); + } + + for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) { + if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) { + throw new Error("Incorrect prefix"); + } + } + + if ( + result.length !== + OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH + 1 + ) { + throw new Error("Incorrect length"); + } + + return result.slice( + OLM_RECOVERY_KEY_PREFIX.length, + OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH, + ); +} diff --git a/src/crypto/store/indexeddb-crypto-store-backend.js b/src/crypto/store/indexeddb-crypto-store-backend.js index 4a7f48789..d5b66c30f 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.js +++ b/src/crypto/store/indexeddb-crypto-store-backend.js @@ -460,6 +460,71 @@ export class Backend { }; } + // session backups + + getSessionsNeedingBackup(limit) { + return new Promise((resolve, reject) => { + const sessions = []; + + const txn = this._db.transaction( + ["sessions_needing_backup", "inbound_group_sessions"], + "readonly", + ); + txn.onerror = reject; + txn.oncomplete = function() { + resolve(sessions); + }; + const objectStore = txn.objectStore("sessions_needing_backup"); + const sessionStore = txn.objectStore("inbound_group_sessions"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function() { + const cursor = getReq.result; + if (cursor) { + const sessionGetReq = sessionStore.get(cursor.key); + sessionGetReq.onsuccess = function() { + sessions.push({ + senderKey: sessionGetReq.result.senderCurve25519Key, + sessionId: sessionGetReq.result.sessionId, + sessionData: sessionGetReq.result.session, + }); + }; + if (!limit || sessions.length < limit) { + cursor.continue(); + } + } + }; + }); + } + + unmarkSessionsNeedingBackup(sessions) { + const txn = this._db.transaction("sessions_needing_backup", "readwrite"); + const objectStore = txn.objectStore("sessions_needing_backup"); + return Promise.all(sessions.map((session) => { + return new Promise((resolve, reject) => { + const req = objectStore.delete([session.senderKey, session.sessionId]); + req.onsuccess = resolve; + req.onerror = reject; + }); + })); + } + + markSessionsNeedingBackup(sessions, txn) { + if (!txn) { + txn = this._db.transaction("sessions_needing_backup", "readwrite"); + } + const objectStore = txn.objectStore("sessions_needing_backup"); + return Promise.all(sessions.map((session) => { + return new Promise((resolve, reject) => { + const req = objectStore.put({ + senderCurve25519Key: session.senderKey, + sessionId: session.sessionId, + }); + req.onsuccess = resolve; + req.onerror = reject; + }); + })); + } + doTxn(mode, stores, func) { const txn = this._db.transaction(stores, mode); const promise = promiseifyTxn(txn); @@ -498,6 +563,11 @@ export function upgradeDatabase(db, oldVersion) { if (oldVersion < 6) { db.createObjectStore("rooms"); } + if (oldVersion < 7) { + db.createObjectStore("sessions_needing_backup", { + keyPath: ["senderCurve25519Key", "sessionId"], + }); + } // Expand as needed. } diff --git a/src/crypto/store/indexeddb-crypto-store.js b/src/crypto/store/indexeddb-crypto-store.js index 0e0654deb..052b4dd33 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -420,6 +420,43 @@ export default class IndexedDBCryptoStore { this._backendPromise.value().getEndToEndRooms(txn, func); } + // session backups + + /** + * Get the inbound group sessions that need to be backed up. + * @param {integer} limit The maximum number of sessions to retrieve. 0 + * for no limit. + * @returns {Promise} resolves to an array of inbound group sessions + */ + getSessionsNeedingBackup(limit) { + return this._connect().then((backend) => { + return backend.getSessionsNeedingBackup(limit); + }); + } + + /** + * Unmark sessions as needing to be backed up. + * @param {[object]} sessions The sessions that need to be backed up. + * @returns {Promise} resolves when the sessions are unmarked + */ + unmarkSessionsNeedingBackup(sessions) { + return this._connect().then((backend) => { + return backend.unmarkSessionsNeedingBackup(sessions); + }); + } + + /** + * Mark sessions as needing to be backed up. + * @param {[object]} sessions The sessions that need to be backed up. + * @param {*} txn An active transaction. See doTxn(). (optional) + * @returns {Promise} resolves when the sessions are marked + */ + markSessionsNeedingBackup(sessions, txn) { + return this._connect().then((backend) => { + return backend.markSessionsNeedingBackup(sessions, txn); + }); + } + /** * Perform a transaction on the crypto store. Any store methods * that require a transaction (txn) object to be passed in may @@ -453,3 +490,4 @@ IndexedDBCryptoStore.STORE_SESSIONS = 'sessions'; IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions'; IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data'; IndexedDBCryptoStore.STORE_ROOMS = 'rooms'; +IndexedDBCryptoStore.STORE_BACKUP = 'sessions_needing_backup'; diff --git a/src/crypto/store/localStorage-crypto-store.js b/src/crypto/store/localStorage-crypto-store.js index 3f2f0d09a..cad6a7d64 100644 --- a/src/crypto/store/localStorage-crypto-store.js +++ b/src/crypto/store/localStorage-crypto-store.js @@ -32,6 +32,7 @@ const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; const KEY_DEVICE_DATA = E2E_PREFIX + "device_data"; const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/"; const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/"; +const KEY_SESSIONS_NEEDING_BACKUP = E2E_PREFIX + "sessionsneedingbackup"; function keyEndToEndSessions(deviceKey) { return E2E_PREFIX + "sessions/" + deviceKey; @@ -165,6 +166,57 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore { func(result); } + getSessionsNeedingBackup(limit) { + const sessionsNeedingBackup + = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + const sessions = []; + + for (const session in sessionsNeedingBackup) { + if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) { + const senderKey = session.substr(0, 43); + const sessionId = session.substr(44); + this.getEndToEndInboundGroupSession( + senderKey, sessionId, null, + (sessionData) => { + sessions.push({ + senderKey: senderKey, + sessionId: sessionId, + sessionData: sessionData, + }); + }, + ); + if (limit && session.length >= limit) { + break; + } + } + } + return Promise.resolve(sessions); + } + + unmarkSessionsNeedingBackup(sessions) { + const sessionsNeedingBackup + = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + for (const session of sessions) { + delete sessionsNeedingBackup[session.senderKey + '/' + session.sessionId]; + } + setJsonItem( + this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup, + ); + return Promise.resolve(); + } + + markSessionsNeedingBackup(sessions) { + const sessionsNeedingBackup + = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + for (const session of sessions) { + sessionsNeedingBackup[session.senderKey + '/' + session.sessionId] = true; + } + setJsonItem( + this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup, + ); + return Promise.resolve(); + } + /** * Delete all data from this store. * diff --git a/src/crypto/store/memory-crypto-store.js b/src/crypto/store/memory-crypto-store.js index 469cdb49b..6af312219 100644 --- a/src/crypto/store/memory-crypto-store.js +++ b/src/crypto/store/memory-crypto-store.js @@ -41,6 +41,8 @@ export default class MemoryCryptoStore { this._deviceData = null; // roomId -> Opaque roomInfo object this._rooms = {}; + // Set of {senderCurve25519Key+'/'+sessionId} + this._sessionsNeedingBackup = {}; } /** @@ -295,6 +297,41 @@ export default class MemoryCryptoStore { func(this._rooms); } + getSessionsNeedingBackup(limit) { + const sessions = []; + for (const session in this._sessionsNeedingBackup) { + if (this._inboundGroupSessions[session]) { + sessions.push({ + senderKey: session.substr(0, 43), + sessionId: session.substr(44), + sessionData: this._inboundGroupSessions[session], + }); + if (limit && session.length >= limit) { + break; + } + } + } + return Promise.resolve(sessions); + } + + unmarkSessionsNeedingBackup(sessions) { + for (const session of sessions) { + const sessionKey = session.senderKey + '/' + session.sessionId; + delete this._sessionsNeedingBackup[sessionKey]; + } + return Promise.resolve(); + } + + markSessionsNeedingBackup(sessions) { + for (const session of sessions) { + const sessionKey = session.senderKey + '/' + session.sessionId; + this._sessionsNeedingBackup[sessionKey] = true; + } + return Promise.resolve(); + } + + // Session key backups + doTxn(mode, stores, func) { return Promise.resolve(func(null)); } diff --git a/travis.sh b/travis.sh index 68d915def..4c47f00e7 100755 --- a/travis.sh +++ b/travis.sh @@ -5,7 +5,7 @@ set -ex npm run lint # install Olm so that we can run the crypto tests. -npm install https://matrix.org/packages/npm/olm/olm-2.2.2.tgz +npm install https://matrix.org/packages/npm/olm/olm-2.3.0.tgz npm run test