From ceb162eb012032e872f0009eb955e3a1b318428f Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Wed, 10 Mar 2021 19:51:22 -0500 Subject: [PATCH] initial work on room history key sharing, take 2 --- src/client.js | 33 +++++ src/crypto/OlmDevice.js | 22 +++ src/crypto/algorithms/megolm.js | 133 +++++++++++++++--- .../store/indexeddb-crypto-store-backend.js | 35 ++++- src/crypto/store/indexeddb-crypto-store.js | 14 ++ 5 files changed, 220 insertions(+), 17 deletions(-) diff --git a/src/client.js b/src/client.js index 16c79895d..2671404ef 100644 --- a/src/client.js +++ b/src/client.js @@ -2291,6 +2291,39 @@ MatrixClient.prototype.deleteKeysFromBackup = function(roomId, sessionId, versio ); }; +/** + * Share the decryption keys with the given users for the given messages. + * + * @param {string} roomId the room for which keys should be shared. + * @param {array} userIds a list of users to share with. The keys will be sent to + * all of the user's current devices. + */ +MatrixClient.prototype.sendShareableKeys = async function(roomId, userIds) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const roomEncryption = this._roomList.getRoomEncryption(roomId); + if (!roomEncryption) { + // unknown room, or unencrypted room + logger.error("Unknown room. Not sharing decryption keys"); + return; + } + + const deviceInfos = await this._crypto.downloadKeys(userIds); + const devicesByUser = {}; + for (const [userId, devices] of Object.entries(deviceInfos)) { + devicesByUser[userId] = Object.values(devices); + } + + const alg = this._crypto._getRoomDecryptor(roomId, roomEncryption.algorithm); + if (alg.sendShareableInboundSessions) { + await alg.sendShareableInboundSessions(devicesByUser); + } else { + logger.warning("Algorithm does not support sharing previous keys", roomEncryption.algorithm); + } +}; + // Group ops // ========= // Operations on groups that come down the sync stream (ie. ones the diff --git a/src/crypto/OlmDevice.js b/src/crypto/OlmDevice.js index 0989d19f3..495f0eaf0 100644 --- a/src/crypto/OlmDevice.js +++ b/src/crypto/OlmDevice.js @@ -1048,6 +1048,7 @@ OlmDevice.prototype.addInboundGroupSession = async function( 'readwrite', [ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + IndexedDBCryptoStore.STORE_SHAREABLE_INBOUND_GROUP_SESSIONS, ], (txn) => { /* if we already have this session, consider updating it */ this._getInboundGroupSession( @@ -1104,6 +1105,12 @@ OlmDevice.prototype.addInboundGroupSession = async function( this._cryptoStore.storeEndToEndInboundGroupSession( senderKey, sessionId, sessionData, txn, ); + + if (!existingSession && extraSessionData.shareable) { + this._cryptoStore.addShareableInboundGroupSession( + roomId, senderKey, sessionId, txn, + ); + } } finally { session.free(); } @@ -1383,6 +1390,7 @@ OlmDevice.prototype.getInboundGroupSessionKey = async function( "forwarding_curve25519_key_chain": sessionData.forwardingCurve25519KeyChain || [], "sender_claimed_ed25519_key": senderEd25519Key, + "shareable": sessionData.shareable || false, }; }, ); @@ -1415,10 +1423,24 @@ OlmDevice.prototype.exportInboundGroupSession = function( "session_key": session.export_session(messageIndex), "forwarding_curve25519_key_chain": session.forwardingCurve25519KeyChain || [], "first_known_index": session.first_known_index(), + "io.element.unstable.shareable": sessionData.shareable || false, }; }); }; +OlmDevice.prototype.getShareableInboundGroupSessions = async function(roomId) { + let result; + await this._cryptoStore.doTxn( + 'readonly', [ + IndexedDBCryptoStore.STORE_SHAREABLE_INBOUND_GROUP_SESSIONS, + ], (txn) => { + result = this._cryptoStore.getShareableInboundGroupSessions(roomId, txn); + }, + logger.withPrefix("[getShareableInboundGroupSessionsForRoom]"), + ); + return result; +}; + // Utilities // ========= diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index 7ea048d2b..ca4059f94 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -36,6 +36,20 @@ import { import {WITHHELD_MESSAGES} from '../OlmDevice'; +// determine whether the key can be shared with invitees +function isRoomKeyShareable(room) { + const visibilityEvent = room.currentState + .getStateEvents("m.room.history_visibility", ""); + // NOTE: if the room visibility is unset, it would normally default to + // "world_readable". + // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) + // But we will be paranoid here, and treat it as a situation where the key + // should not be shareable + const visibility = visibilityEvent && visibilityEvent.getContent() && + visibilityEvent.getContent().history_visibility; + return ["world_readable", "shared"].includes(visibility); +} + /** * @private * @constructor @@ -50,12 +64,13 @@ import {WITHHELD_MESSAGES} from '../OlmDevice'; * devices with which we have shared the session key * userId -> {deviceId -> msgindex} */ -function OutboundSessionInfo(sessionId) { +function OutboundSessionInfo(sessionId, shareable = false) { this.sessionId = sessionId; this.useCount = 0; this.creationTime = new Date().getTime(); this.sharedWithDevices = {}; this.blockedDevicesNotified = {}; + this.shareable = shareable; } @@ -183,6 +198,7 @@ utils.inherits(MegolmEncryption, EncryptionAlgorithm); /** * @private * + * @param {module:models/room} room * @param {Object} devicesInRoom The devices in this room, indexed by user ID * @param {Object} blocked The devices that are blocked, indexed by user ID * @param {boolean} [singleOlmCreationPhase] Only perform one round of olm @@ -192,7 +208,7 @@ utils.inherits(MegolmEncryption, EncryptionAlgorithm); * OutboundSessionInfo when setup is complete. */ MegolmEncryption.prototype._ensureOutboundSession = async function( - devicesInRoom, blocked, singleOlmCreationPhase, + room, devicesInRoom, blocked, singleOlmCreationPhase, ) { let session; @@ -204,6 +220,13 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( const prepareSession = async (oldSession) => { session = oldSession; + const shareable = isRoomKeyShareable(room); + + // history visibility changed + if (session && shareable !== session.shareable) { + session = null; + } + // need to make a brand new session? if (session && session.needsRotation(this._sessionRotationPeriodMsgs, this._sessionRotationPeriodMs) @@ -219,7 +242,7 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( if (!session) { logger.log(`Starting new megolm session for room ${this._roomId}`); - session = await this._prepareNewSession(); + session = await this._prepareNewSession(shareable); logger.log(`Started new megolm session ${session.sessionId} ` + `for room ${this._roomId}`); this._outboundSessions[session.sessionId] = session; @@ -374,15 +397,18 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( /** * @private * + * @param {boolean} shareable + * * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session */ -MegolmEncryption.prototype._prepareNewSession = async function() { +MegolmEncryption.prototype._prepareNewSession = async function(shareable) { const sessionId = this._olmDevice.createOutboundGroupSession(); const key = this._olmDevice.getOutboundGroupSessionKey(sessionId); await this._olmDevice.addInboundGroupSession( this._roomId, this._olmDevice.deviceCurve25519Key, [], sessionId, - key.key, {ed25519: this._olmDevice.deviceEd25519Key}, + key.key, {ed25519: this._olmDevice.deviceEd25519Key}, false, + {shareable: shareable}, ); // don't wait for it to complete @@ -391,7 +417,7 @@ MegolmEncryption.prototype._prepareNewSession = async function() { sessionId, key.key, ); - return new OutboundSessionInfo(sessionId); + return new OutboundSessionInfo(sessionId, shareable); }; /** @@ -680,6 +706,7 @@ MegolmEncryption.prototype.reshareKeyWithDevice = async function( sender_key: senderKey, sender_claimed_ed25519_key: key.sender_claimed_ed25519_key, forwarding_curve25519_key_chain: key.forwarding_curve25519_key_chain, + "io.element.unstable.shareable": key.shareable || false, }, }; @@ -901,7 +928,7 @@ MegolmEncryption.prototype.prepareToEncrypt = function(room) { } logger.debug(`Ensuring outbound session in ${this._roomId}`); - await this._ensureOutboundSession(devicesInRoom, blocked, true); + await this._ensureOutboundSession(room, devicesInRoom, blocked, true); logger.debug(`Ready to encrypt events for ${this._roomId}`); } catch (e) { @@ -945,7 +972,7 @@ MegolmEncryption.prototype.encryptMessage = async function(room, eventType, cont this._checkForUnknownDevices(devicesInRoom); } - const session = await this._ensureOutboundSession(devicesInRoom, blocked); + const session = await this._ensureOutboundSession(room, devicesInRoom, blocked); const payloadJson = { room_id: this._roomId, type: eventType, @@ -1573,14 +1600,15 @@ MegolmDecryption.prototype._buildKeyForwardingMessage = async function( return { type: "m.forwarded_room_key", content: { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: roomId, - sender_key: senderKey, - sender_claimed_ed25519_key: key.sender_claimed_ed25519_key, - session_id: sessionId, - session_key: key.key, - chain_index: key.chain_index, - forwarding_curve25519_key_chain: key.forwarding_curve25519_key_chain, + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": roomId, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "io.element.unstable.shareable": key.shareable || false, }, }; }; @@ -1681,6 +1709,79 @@ MegolmDecryption.prototype.retryDecryptionFromSender = async function(senderKey) return !this._pendingEvents[senderKey]; }; +MegolmDecryption.prototype.sendShareableInboundSessions = async function(devicesByUser) { + await olmlib.ensureOlmSessionsForDevices( + this._olmDevice, this._baseApis, devicesByUser, + ); + + logger.log("sendShareableInboundSessions to users", Object.keys(devicesByUser)); + + const shareableSessions = await this._olmDevice.getShareableInboundGroupSessions( + this._roomId, + ); + logger.log("shareable sessions", shareableSessions); + for (const [senderKey, sessionId] of shareableSessions) { + const payload = this._buildKeyForwardingMessage( + this._roomId, senderKey, sessionId, + ); + + const promises = []; + const contentMap = {}; + for (const [userId, devices] of Object.entries(devicesByUser)) { + contentMap[userId] = {}; + for (const deviceInfo of devices) { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this._olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + contentMap[userId][deviceInfo.deviceId] = encryptedContent; + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this._userId, + this._deviceId, + this._olmDevice, + userId, + deviceInfo, + payload, + ), + ); + } + } + await Promise.all(promises); + + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { + logger.log( + "No ciphertext for device " + + userId + ":" + deviceId + ": pruning", + ); + delete contentMap[userId][deviceId]; + } + } + // No devices left for that user? Strip that too. + if (Object.keys(contentMap[userId]).length === 0) { + logger.log("Pruned all devices for user " + userId); + delete contentMap[userId]; + } + } + + // Is there anything left? + if (Object.keys(contentMap).length === 0) { + logger.log("No users left to send to: aborting"); + return; + } + + await this._baseApis.sendToDevice("m.room.encrypted", contentMap); + } +}; + registerAlgorithm( olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption, ); diff --git a/src/crypto/store/indexeddb-crypto-store-backend.js b/src/crypto/store/indexeddb-crypto-store-backend.js index 6ecffe7be..e9d45e11e 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.js +++ b/src/crypto/store/indexeddb-crypto-store-backend.js @@ -19,7 +19,7 @@ limitations under the License. import {logger} from '../../logger'; import * as utils from "../../utils"; -export const VERSION = 9; +export const VERSION = 10; /** * Implementation of a CryptoStore which is backed by an existing @@ -758,6 +758,34 @@ export class Backend { })); } + addShareableInboundGroupSession(roomId, senderKey, sessionId, txn) { + if (!txn) { + txn = this._db.transaction("shareable_inbound_group_sessions", "readwrite"); + } + const objectStore = txn.objectStore("shareable_inbound_group_sessions"); + const req = objectStore.get([roomId]); + req.onsuccess = () => { + const {sessions} = req.result || {sessions: []}; + sessions.push([senderKey, sessionId]); + objectStore.put({roomId, sessions}); + }; + } + + getShareableInboundGroupSessions(roomId, txn) { + if (!txn) { + txn = this._db.transaction("shareable_inbound_group_sessions", "readonly"); + } + const objectStore = txn.objectStore("shareable_inbound_group_sessions"); + const req = objectStore.get([roomId]); + return new Promise((resolve, reject) => { + req.onsuccess = () => { + const {sessions} = req.result || {sessions: []}; + resolve(sessions); + }; + req.onerror = reject; + }); + } + doTxn(mode, stores, func, log = logger) { const txnId = this._nextTxnId++; const startTime = Date.now(); @@ -827,6 +855,11 @@ export function upgradeDatabase(db, oldVersion) { keyPath: ["userId", "deviceId"], }); } + if (oldVersion < 10) { + db.createObjectStore("shareable_inbound_group_sessions", { + keyPath: ["roomId"], + }); + } // Expand as needed. } diff --git a/src/crypto/store/indexeddb-crypto-store.js b/src/crypto/store/indexeddb-crypto-store.js index 50f3c2678..c7856de7f 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -582,6 +582,18 @@ export class IndexedDBCryptoStore { return this._backend.markSessionsNeedingBackup(sessions, txn); } + /* FIXME: jsdoc + */ + addShareableInboundGroupSession(roomId, senderKey, sessionId, txn) { + return this._backend.addShareableInboundGroupSession( + roomId, senderKey, sessionId, txn, + ); + } + + getShareableInboundGroupSessions(roomId, txn) { + return this._backend.getShareableInboundGroupSessions(roomId, txn); + } + /** * Perform a transaction on the crypto store. Any store methods * that require a transaction (txn) object to be passed in may @@ -614,6 +626,8 @@ IndexedDBCryptoStore.STORE_SESSIONS = 'sessions'; IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions'; IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD = 'inbound_group_sessions_withheld'; +IndexedDBCryptoStore.STORE_SHAREABLE_INBOUND_GROUP_SESSIONS + = 'shareable_inbound_group_sessions'; IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data'; IndexedDBCryptoStore.STORE_ROOMS = 'rooms'; IndexedDBCryptoStore.STORE_BACKUP = 'sessions_needing_backup';