diff --git a/src/client.js b/src/client.js index 0b00ab36b9e..46aa8b0481d 100644 --- a/src/client.js +++ b/src/client.js @@ -42,6 +42,7 @@ const MatrixBaseApis = require("./base-apis"); const MatrixError = httpApi.MatrixError; import ReEmitter from './ReEmitter'; +import RoomList from './crypto/RoomList'; const SCROLLBACK_DELAY_MS = 3000; let CRYPTO_ENABLED = false; @@ -181,6 +182,11 @@ function MatrixClient(opts) { 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. + this._roomList = new RoomList(this._cryptoStore, this._sessionStore); } utils.inherits(MatrixClient, EventEmitter); utils.extend(MatrixClient.prototype, MatrixBaseApis.prototype); @@ -351,13 +357,6 @@ MatrixClient.prototype.initCrypto = async function() { return; } - 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?`, - ); - } - if (!this._sessionStore) { // this is temporary, the sessionstore is supposed to be going away throw new Error(`Cannot enable encryption: no sessionStore provided`); @@ -367,6 +366,16 @@ MatrixClient.prototype.initCrypto = async function() { throw new Error(`Cannot enable encryption: no cryptoStore provided`); } + // 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( @@ -387,6 +396,7 @@ MatrixClient.prototype.initCrypto = async function() { userId, this.deviceId, this.store, this._cryptoStore, + this._roomList, ); this.reEmitter.reEmit(crypto, [ @@ -646,11 +656,7 @@ MatrixClient.prototype.isRoomEncrypted = function(roomId) { // we don't have an m.room.encrypted event, but that might be because // the server is hiding it from us. Check the store to see if it was // previously encrypted. - if (!this._sessionStore) { - return false; - } - - return Boolean(this._sessionStore.getEndToEndRoom(roomId)); + return this._roomList.isRoomEncrypted(roomId); }; /** diff --git a/src/crypto/RoomList.js b/src/crypto/RoomList.js new file mode 100644 index 00000000000..5bb437fb774 --- /dev/null +++ b/src/crypto/RoomList.js @@ -0,0 +1,81 @@ +/* +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. +*/ + +/** + * @module crypto/RoomList + * + * Manages the list of encrypted rooms + */ + +import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; + +/** + * @alias module:crypto/RoomList + */ +export default class RoomList { + constructor(cryptoStore, sessionStore) { + this._cryptoStore = cryptoStore; + this._sessionStore = sessionStore; + + // Object of roomId -> room e2e info object (body of the m.room.encryption event) + this._roomEncryption = {}; + } + + async init() { + let removeSessionStoreRooms = false; + await this._cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { + this._cryptoStore.getEndToEndRooms(txn, (result) => { + if (result === null || Object.keys(result).length === 0) { + // migrate from session store, if there's data there + const sessStoreRooms = this._sessionStore.getAllEndToEndRooms(); + if (sessStoreRooms !== null) { + for (const roomId of Object.keys(sessStoreRooms)) { + this._cryptoStore.storeEndToEndRoom( + roomId, sessStoreRooms[roomId], txn, + ); + } + } + this._roomEncryption = sessStoreRooms; + removeSessionStoreRooms = true; + } else { + this._roomEncryption = result; + } + }); + }, + ); + if (removeSessionStoreRooms) { + this._sessionStore.removeAllEndToEndRooms(); + } + } + + getRoomEncryption(roomId) { + return this._roomEncryption[roomId] || null; + } + + isRoomEncrypted(roomId) { + return Boolean(this.getRoomEncryption(roomId)); + } + + async setRoomEncryption(roomId, roomInfo) { + this._roomEncryption[roomId] = roomInfo; + await this._cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { + this._cryptoStore.storeEndToEndRoom(roomId, roomInfo, txn); + }, + ); + } +} diff --git a/src/crypto/index.js b/src/crypto/index.js index fab3afa8c7e..d773285dbed 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -59,15 +59,18 @@ import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; * * @param {module:crypto/store/base~CryptoStore} cryptoStore * storage for the crypto layer. + * + * @param {RoomList} roomList An initialised RoomList object */ function Crypto(baseApis, sessionStore, userId, deviceId, - clientStore, cryptoStore) { + clientStore, cryptoStore, roomList) { this._baseApis = baseApis; this._sessionStore = sessionStore; this._userId = userId; this._deviceId = deviceId; this._clientStore = clientStore; this._cryptoStore = cryptoStore; + this._roomList = roomList; this._olmDevice = new OlmDevice(sessionStore, cryptoStore); this._deviceList = new DeviceList( @@ -587,7 +590,6 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) { return device; }; - /** * Configure a room to use encryption (ie, save a flag in the sessionstore). * @@ -601,13 +603,11 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) { Crypto.prototype.setRoomEncryption = async function(roomId, config, inhibitDeviceQuery) { // if we already have encryption in this room, we should ignore this event // (for now at least. maybe we should alert the user somehow?) - const existingConfig = this._sessionStore.getEndToEndRoom(roomId); - if (existingConfig) { - if (JSON.stringify(existingConfig) != JSON.stringify(config)) { - console.error("Ignoring m.room.encryption event which requests " + - "a change of config in " + roomId); - return; - } + const existingConfig = this._roomList.getRoomEncryption(roomId); + if (existingConfig && JSON.stringify(existingConfig) != JSON.stringify(config)) { + console.error("Ignoring m.room.encryption event which requests " + + "a change of config in " + roomId); + return; } const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm]; @@ -615,7 +615,7 @@ Crypto.prototype.setRoomEncryption = async function(roomId, config, inhibitDevic throw new Error("Unable to encrypt with " + config.algorithm); } - this._sessionStore.storeEndToEndRoom(roomId, config); + await this._roomList.setRoomEncryption(roomId, config); const alg = new AlgClass({ userId: this._userId, @@ -693,16 +693,6 @@ Crypto.prototype.ensureOlmSessionsForUsers = function(users) { ); }; -/** - * Whether encryption is enabled for a room. - * @param {string} roomId the room id to query. - * @return {bool} whether encryption is enabled. - */ -Crypto.prototype.isRoomEncrypted = function(roomId) { - return Boolean(this._roomEncryptors[roomId]); -}; - - /** * Get a list containing all of the room keys * diff --git a/src/crypto/store/indexeddb-crypto-store-backend.js b/src/crypto/store/indexeddb-crypto-store-backend.js index 41130a65366..1dcb429d792 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.js +++ b/src/crypto/store/indexeddb-crypto-store-backend.js @@ -18,7 +18,7 @@ limitations under the License. import Promise from 'bluebird'; import utils from '../../utils'; -export const VERSION = 5; +export const VERSION = 6; /** * Implementation of a CryptoStore which is backed by an existing @@ -425,6 +425,30 @@ export class Backend { objectStore.put(deviceData, "-"); } + storeEndToEndRoom(roomId, roomInfo, txn) { + const objectStore = txn.objectStore("rooms"); + objectStore.put(roomInfo, roomId); + } + + getEndToEndRooms(txn, func) { + const rooms = {}; + const objectStore = txn.objectStore("rooms"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function() { + const cursor = getReq.result; + if (cursor) { + rooms[cursor.key] = cursor.value; + cursor.continue(); + } else { + try { + func(rooms); + } catch (e) { + abortWithException(txn, e); + } + } + }; + } + doTxn(mode, stores, func) { const txn = this._db.transaction(stores, mode); const promise = promiseifyTxn(txn); @@ -460,6 +484,9 @@ export function upgradeDatabase(db, oldVersion) { if (oldVersion < 5) { db.createObjectStore("device_data"); } + if (oldVersion < 6) { + db.createObjectStore("rooms"); + } // Expand as needed. } diff --git a/src/crypto/store/indexeddb-crypto-store.js b/src/crypto/store/indexeddb-crypto-store.js index f2907be2632..249b29b63cd 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -386,6 +386,27 @@ export default class IndexedDBCryptoStore { this._backendPromise.value().getEndToEndDeviceData(txn, func); } + // End to End Rooms + + /** + * Store the end-to-end state for a room. + * @param {string} roomId The room's ID. + * @param {object} roomInfo The end-to-end info for the room. + * @param {*} txn An active transaction. See doTxn(). + */ + storeEndToEndRoom(roomId, roomInfo, txn) { + this._backendPromise.value().storeEndToEndRoom(roomId, roomInfo, txn); + } + + /** + * Get an object of roomId->roomInfo for all e2e rooms in the store + * @param {*} txn An active transaction. See doTxn(). + * @param {function(Object)} func Function called with the end to end encrypted rooms + */ + getEndToEndRooms(txn, func) { + this._backendPromise.value().getEndToEndRooms(txn, func); + } + /** * Perform a transaction on the crypto store. Any store methods * that require a transaction (txn) object to be passed in may @@ -418,3 +439,4 @@ IndexedDBCryptoStore.STORE_ACCOUNT = 'account'; IndexedDBCryptoStore.STORE_SESSIONS = 'sessions'; IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions'; IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data'; +IndexedDBCryptoStore.STORE_ROOMS = 'rooms'; diff --git a/src/crypto/store/localStorage-crypto-store.js b/src/crypto/store/localStorage-crypto-store.js index 760c1b85d26..3f2f0d09a5b 100644 --- a/src/crypto/store/localStorage-crypto-store.js +++ b/src/crypto/store/localStorage-crypto-store.js @@ -31,6 +31,7 @@ const E2E_PREFIX = "crypto."; 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/"; function keyEndToEndSessions(deviceKey) { return E2E_PREFIX + "sessions/" + deviceKey; @@ -40,6 +41,10 @@ function keyEndToEndInboundGroupSession(senderKey, sessionId) { return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId; } +function keyEndToEndRoomsPrefix(roomId) { + return KEY_ROOMS_PREFIX + roomId; +} + /** * @implements {module:crypto/store/base~CryptoStore} */ @@ -140,6 +145,26 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore { ); } + storeEndToEndRoom(roomId, roomInfo, txn) { + setJsonItem( + this.store, keyEndToEndRoomsPrefix(roomId), roomInfo, + ); + } + + getEndToEndRooms(txn, func) { + const result = {}; + const prefix = keyEndToEndRoomsPrefix(''); + + for (let i = 0; i < this.store.length; ++i) { + const key = this.store.key(i); + if (key.startsWith(prefix)) { + const roomId = key.substr(prefix.length); + result[roomId] = getJsonItem(this.store, key); + } + } + func(result); + } + /** * 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 541d9fc235e..469cdb49bc7 100644 --- a/src/crypto/store/memory-crypto-store.js +++ b/src/crypto/store/memory-crypto-store.js @@ -39,6 +39,8 @@ export default class MemoryCryptoStore { this._inboundGroupSessions = {}; // Opaque device data object this._deviceData = null; + // roomId -> Opaque roomInfo object + this._rooms = {}; } /** @@ -283,6 +285,15 @@ export default class MemoryCryptoStore { this._deviceData = deviceData; } + // E2E rooms + + storeEndToEndRoom(roomId, roomInfo, txn) { + this._rooms[roomId] = roomInfo; + } + + getEndToEndRooms(txn, func) { + func(this._rooms); + } doTxn(mode, stores, func) { return Promise.resolve(func(null)); diff --git a/src/store/session/webstorage.js b/src/store/session/webstorage.js index 981bfbd7709..d7a4d1ac9fa 100644 --- a/src/store/session/webstorage.js +++ b/src/store/session/webstorage.js @@ -174,21 +174,21 @@ WebStorageSessionStore.prototype = { }, /** - * Store the end-to-end state for a room. - * @param {string} roomId The room's ID. - * @param {object} roomInfo The end-to-end info for the room. + * Get the end-to-end state for all rooms + * @return {object} roomId -> object with the end-to-end info for the room. */ - storeEndToEndRoom: function(roomId, roomInfo) { - setJsonItem(this.store, keyEndToEndRoom(roomId), roomInfo); + getAllEndToEndRooms: function() { + const roomKeys = getKeysWithPrefix(this.store, keyEndToEndRoom('')); + const results = {}; + for (const k of roomKeys) { + const unprefixedKey = k.substr(keyEndToEndRoom('').length); + results[unprefixedKey] = getJsonItem(this.store, k); + } + return results; }, - /** - * Get the end-to-end state for a room - * @param {string} roomId The room's ID. - * @return {object} The end-to-end info for the room. - */ - getEndToEndRoom: function(roomId) { - return getJsonItem(this.store, keyEndToEndRoom(roomId)); + removeAllEndToEndRooms: function() { + removeByPrefix(this.store, keyEndToEndRoom('')); }, }; @@ -224,10 +224,6 @@ function getJsonItem(store, key) { return null; } -function setJsonItem(store, key, val) { - store.setItem(key, JSON.stringify(val)); -} - function getKeysWithPrefix(store, prefix) { const results = []; for (let i = 0; i < store.length; ++i) {