diff --git a/src/client.js b/src/client.js index 53fca78acd7..f845a5a08c9 100644 --- a/src/client.js +++ b/src/client.js @@ -2303,6 +2303,39 @@ MatrixClient.prototype.deleteKeysFromBackup = function(roomId, sessionId, versio ); }; +/** + * Share shared-history decryption keys with the given users. + * + * @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.sendSharedHistoryKeys = 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.sendSharedHistoryInboundSessions) { + await alg.sendSharedHistoryInboundSessions(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 0989d19f383..df0733e6569 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_SHARED_HISTORY_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.sharedHistory) { + this._cryptoStore.addSharedHistoryInboundGroupSession( + 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, + "shared_history": sessionData.sharedHistory || 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(), + "org.matrix.msc3061.shared_history": sessionData.sharedHistory || false, }; }); }; +OlmDevice.prototype.getSharedHistoryInboundGroupSessions = async function(roomId) { + let result; + await this._cryptoStore.doTxn( + 'readonly', [ + IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS, + ], (txn) => { + result = this._cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn); + }, + logger.withPrefix("[getSharedHistoryInboundGroupSessionsForRoom]"), + ); + return result; +}; + // Utilities // ========= diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index 7ea048d2b83..0bf77cd295d 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -36,11 +36,27 @@ import { import {WITHHELD_MESSAGES} from '../OlmDevice'; +// determine whether the key can be shared with invitees +function isRoomSharedHistory(room) { + const visibilityEvent = room.currentState && + 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 room + // is not shared-history + const visibility = visibilityEvent && visibilityEvent.getContent() && + visibilityEvent.getContent().history_visibility; + return ["world_readable", "shared"].includes(visibility); +} + /** * @private * @constructor * * @param {string} sessionId + * @param {boolean} sharedHistory whether the session can be freely shared with + * other group members, according to the room history visibility settings * * @property {string} sessionId * @property {Number} useCount number of times this session has been used @@ -50,12 +66,13 @@ import {WITHHELD_MESSAGES} from '../OlmDevice'; * devices with which we have shared the session key * userId -> {deviceId -> msgindex} */ -function OutboundSessionInfo(sessionId) { +function OutboundSessionInfo(sessionId, sharedHistory = false) { this.sessionId = sessionId; this.useCount = 0; this.creationTime = new Date().getTime(); this.sharedWithDevices = {}; this.blockedDevicesNotified = {}; + this.sharedHistory = sharedHistory; } @@ -183,6 +200,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 +210,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 +222,13 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( const prepareSession = async (oldSession) => { session = oldSession; + const sharedHistory = isRoomSharedHistory(room); + + // history visibility changed + if (session && sharedHistory !== session.sharedHistory) { + session = null; + } + // need to make a brand new session? if (session && session.needsRotation(this._sessionRotationPeriodMsgs, this._sessionRotationPeriodMs) @@ -219,7 +244,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(sharedHistory); logger.log(`Started new megolm session ${session.sessionId} ` + `for room ${this._roomId}`); this._outboundSessions[session.sessionId] = session; @@ -250,11 +275,12 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( const payload = { type: "m.room_key", content: { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: this._roomId, - session_id: session.sessionId, - session_key: key.key, - chain_index: key.chain_index, + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this._roomId, + "session_id": session.sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "org.matrix.msc3061.shared_history": sharedHistory, }, }; const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( @@ -374,15 +400,18 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( /** * @private * + * @param {boolean} sharedHistory + * * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session */ -MegolmEncryption.prototype._prepareNewSession = async function() { +MegolmEncryption.prototype._prepareNewSession = async function(sharedHistory) { 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, + {sharedHistory: sharedHistory}, ); // don't wait for it to complete @@ -391,7 +420,7 @@ MegolmEncryption.prototype._prepareNewSession = async function() { sessionId, key.key, ); - return new OutboundSessionInfo(sessionId); + return new OutboundSessionInfo(sessionId, sharedHistory); }; /** @@ -672,14 +701,15 @@ MegolmEncryption.prototype.reshareKeyWithDevice = async function( const payload = { type: "m.forwarded_room_key", content: { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: this._roomId, - session_id: sessionId, - session_key: key.key, - chain_index: key.chain_index, - sender_key: senderKey, - sender_claimed_ed25519_key: key.sender_claimed_ed25519_key, - forwarding_curve25519_key_chain: key.forwarding_curve25519_key_chain, + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this._roomId, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key.shared_history || false, }, }; @@ -901,7 +931,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 +975,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, @@ -1370,10 +1400,14 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { keysClaimed = event.getKeysClaimed(); } + const extraSessionData = {}; + if (content["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } return this._olmDevice.addInboundGroupSession( content.room_id, senderKey, forwardingKeyChain, sessionId, content.session_key, keysClaimed, - exportFormat, + exportFormat, extraSessionData, ).then(() => { // have another go at decrypting events sent with this session. this._retryDecryption(senderKey, sessionId) @@ -1573,14 +1607,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, + "org.matrix.msc3061.shared_history": key.shared_history || false, }, }; }; @@ -1594,6 +1629,13 @@ MegolmDecryption.prototype._buildKeyForwardingMessage = async function( * @param {string} [opts.source] where the key came from */ MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) { + const extraSessionData = {}; + if (opts.untrusted) { + extraSessionData.untrusted = true; + } + if (session["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } return this._olmDevice.addInboundGroupSession( session.room_id, session.sender_key, @@ -1602,7 +1644,7 @@ MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) { session.session_key, session.sender_claimed_keys, true, - opts.untrusted ? { untrusted: opts.untrusted } : {}, + extraSessionData, ).then(() => { if (opts.source !== "backup") { // don't wait for it to complete @@ -1681,6 +1723,80 @@ MegolmDecryption.prototype.retryDecryptionFromSender = async function(senderKey) return !this._pendingEvents[senderKey]; }; +MegolmDecryption.prototype.sendSharedHistoryInboundSessions = async function(devicesByUser) { + await olmlib.ensureOlmSessionsForDevices( + this._olmDevice, this._baseApis, devicesByUser, + ); + + logger.log("sendSharedHistoryInboundSessions to users", Object.keys(devicesByUser)); + + const sharedHistorySessions = + await this._olmDevice.getSharedHistoryInboundGroupSessions( + this._roomId, + ); + logger.log("shared-history sessions", sharedHistorySessions); + for (const [senderKey, sessionId] of sharedHistorySessions) { + const payload = await 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 c3203240fdb..58bfee57641 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; const PROFILE_TRANSACTIONS = false; /** @@ -759,6 +759,38 @@ export class Backend { })); } + addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn) { + if (!txn) { + txn = this._db.transaction( + "shared_history_inbound_group_sessions", "readwrite", + ); + } + const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); + const req = objectStore.get([roomId]); + req.onsuccess = () => { + const {sessions} = req.result || {sessions: []}; + sessions.push([senderKey, sessionId]); + objectStore.put({roomId, sessions}); + }; + } + + getSharedHistoryInboundGroupSessions(roomId, txn) { + if (!txn) { + txn = this._db.transaction( + "shared_history_inbound_group_sessions", "readonly", + ); + } + const objectStore = txn.objectStore("shared_history_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) { let startTime; let description; @@ -834,6 +866,11 @@ export function upgradeDatabase(db, oldVersion) { keyPath: ["userId", "deviceId"], }); } + if (oldVersion < 10) { + db.createObjectStore("shared_history_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 50f3c26789d..4cfe3612867 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -582,6 +582,29 @@ export class IndexedDBCryptoStore { return this._backend.markSessionsNeedingBackup(sessions, txn); } + /** + * Add a shared-history group session for a room. + * @param {string} roomId The room that the key belongs to + * @param {string} senderKey The sender's curve 25519 key + * @param {string} sessionId The ID of the session + * @param {*} txn An active transaction. See doTxn(). (optional) + */ + addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn) { + this._backend.addSharedHistoryInboundGroupSession( + roomId, senderKey, sessionId, txn, + ); + } + + /** + * Get the shared-history group session for a room. + * @param {string} roomId The room that the key belongs to + * @param {*} txn An active transaction. See doTxn(). (optional) + * @returns {Promise} Resolves to an array of [senderKey, sessionId] + */ + getSharedHistoryInboundGroupSessions(roomId, txn) { + return this._backend.getSharedHistoryInboundGroupSessions(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 +637,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_SHARED_HISTORY_INBOUND_GROUP_SESSIONS + = 'shared_history_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/memory-crypto-store.js b/src/crypto/store/memory-crypto-store.js index 5170fb2c30c..ac4e7be0b28 100644 --- a/src/crypto/store/memory-crypto-store.js +++ b/src/crypto/store/memory-crypto-store.js @@ -51,6 +51,8 @@ export class MemoryCryptoStore { this._rooms = {}; // Set of {senderCurve25519Key+'/'+sessionId} this._sessionsNeedingBackup = {}; + // roomId -> array of [senderKey, sessionId] + this._sharedHistoryInboundGroupSessions = {}; } /** @@ -467,6 +469,16 @@ export class MemoryCryptoStore { return Promise.resolve(); } + addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId) { + const sessions = this._sharedHistoryInboundGroupSessions[roomId] || []; + sessions.push([senderKey, sessionId]); + this._sharedHistoryInboundGroupSessions[roomId] = sessions; + } + + getSharedHistoryInboundGroupSessions(roomId) { + return Promise.resolve(this._sharedHistoryInboundGroupSessions[roomId] || []); + } + // Session key backups doTxn(mode, stores, func) {