diff --git a/.travis.yml b/.travis.yml index 67382e7601e..823517fc5aa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: node_js node_js: - - node # Latest stable version of nodejs. + - "10.11.0" script: - ./travis.sh diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index ee06ef369c0..47d7d2d67bc 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -1,9 +1,13 @@ - -"use strict"; import 'source-map-support/register'; import Crypto from '../../lib/crypto'; import expect from 'expect'; +import WebStorageSessionStore from '../../lib/store/session/webstorage'; +import MemoryCryptoStore from '../../lib/crypto/store/memory-crypto-store.js'; +import MockStorageApi from '../MockStorageApi'; + +const EventEmitter = require("events").EventEmitter; + const sdk = require("../.."); const Olm = global.Olm; @@ -20,4 +24,96 @@ describe("Crypto", function() { it("Crypto exposes the correct olm library version", function() { expect(Crypto.getOlmVersion()[0]).toEqual(3); }); + + + describe('Session management', function() { + const otkResponse = { + one_time_keys: { + '@alice:home.server': { + aliceDevice: { + 'signed_curve25519:FLIBBLE': { + key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI', + signatures: { + '@alice:home.server': { + 'ed25519:aliceDevice': 'totally a valid signature', + }, + }, + }, + }, + }, + }, + }; + let crypto; + let mockBaseApis; + let mockRoomList; + + let fakeEmitter; + + beforeEach(async function() { + const mockStorage = new MockStorageApi(); + const sessionStore = new WebStorageSessionStore(mockStorage); + const cryptoStore = new MemoryCryptoStore(mockStorage); + + cryptoStore.storeEndToEndDeviceData({ + devices: { + '@bob:home.server': { + 'BOBDEVICE': { + keys: { + 'curve25519:BOBDEVICE': 'this is a key', + }, + }, + }, + }, + trackingStatus: {}, + }); + + mockBaseApis = { + sendToDevice: expect.createSpy(), + }; + mockRoomList = {}; + + fakeEmitter = new EventEmitter(); + + crypto = new Crypto( + mockBaseApis, + sessionStore, + "@alice:home.server", + "FLIBBLE", + sessionStore, + cryptoStore, + mockRoomList, + ); + crypto.registerEventHandlers(fakeEmitter); + await crypto.init(); + }); + + afterEach(async function() { + await crypto.stop(); + }); + + it("restarts wedged Olm sessions", async function() { + const prom = new Promise((resolve) => { + mockBaseApis.claimOneTimeKeys = function() { + resolve(); + return otkResponse; + }; + }); + + fakeEmitter.emit('toDeviceEvent', { + getType: expect.createSpy().andReturn('m.room.message'), + getContent: expect.createSpy().andReturn({ + msgtype: 'm.bad.encrypted', + }), + getWireContent: expect.createSpy().andReturn({ + algorithm: 'm.olm.v1.curve25519-aes-sha2', + sender_key: 'this is a key', + }), + getSender: expect.createSpy().andReturn('@bob:home.server'), + }); + + console.log("waiting"); + await prom; + console.log("done"); + }); + }); }); diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index 6c777859ebf..641adb19c6a 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -18,6 +18,7 @@ import Crypto from '../../../../lib/crypto'; const MatrixEvent = sdk.MatrixEvent; const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; +const MegolmEncryption = algorithms.ENCRYPTION_CLASSES['m.megolm.v1.aes-sha2']; const ROOM_ID = '!ROOM:ID'; @@ -34,9 +35,11 @@ describe("MegolmDecryption", function() { let mockCrypto; let mockBaseApis; - beforeEach(function() { + beforeEach(async function() { testUtils.beforeEach(this); // eslint-disable-line no-invalid-this + await Olm.init(); + mockCrypto = testUtils.mock(Crypto, 'Crypto'); mockBaseApis = {}; @@ -66,7 +69,6 @@ describe("MegolmDecryption", function() { describe('receives some keys:', function() { let groupSession; beforeEach(async function() { - await Olm.init(); groupSession = new global.Olm.OutboundGroupSession(); groupSession.create(); @@ -263,5 +265,92 @@ describe("MegolmDecryption", function() { // test is successful if no exception is thrown }); }); + + it("re-uses sessions for sequential messages", async function() { + const mockStorage = new MockStorageApi(); + const sessionStore = new WebStorageSessionStore(mockStorage); + const cryptoStore = new MemoryCryptoStore(mockStorage); + + const olmDevice = new OlmDevice(sessionStore, cryptoStore); + olmDevice.verifySignature = expect.createSpy(); + await olmDevice.init(); + + mockBaseApis.claimOneTimeKeys = expect.createSpy().andReturn(Promise.resolve({ + one_time_keys: { + '@alice:home.server': { + aliceDevice: { + 'signed_curve25519:flooble': { + key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI', + signatures: { + '@alice:home.server': { + 'ed25519:aliceDevice': 'totally valid', + }, + }, + }, + }, + }, + }, + })); + mockBaseApis.sendToDevice = expect.createSpy().andReturn(Promise.resolve()); + + mockCrypto.downloadKeys.andReturn(Promise.resolve({ + '@alice:home.server': { + aliceDevice: { + deviceId: 'aliceDevice', + isBlocked: expect.createSpy().andReturn(false), + isUnverified: expect.createSpy().andReturn(false), + getIdentityKey: expect.createSpy().andReturn( + 'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE', + ), + getFingerprint: expect.createSpy().andReturn(''), + }, + }, + })); + + const megolmEncryption = new MegolmEncryption({ + userId: '@user:id', + crypto: mockCrypto, + olmDevice: olmDevice, + baseApis: mockBaseApis, + roomId: ROOM_ID, + config: { + rotation_period_ms: 9999999999999, + }, + }); + const mockRoom = { + getEncryptionTargetMembers: expect.createSpy().andReturn( + [{userId: "@alice:home.server"}], + ), + getBlacklistUnverifiedDevices: expect.createSpy().andReturn(false), + }; + const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { + body: "Some text", + }); + expect(mockRoom.getEncryptionTargetMembers).toHaveBeenCalled(); + + // this should have claimed a key for alice as it's starting a new session + expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalled( + [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', + ); + expect(mockCrypto.downloadKeys).toHaveBeenCalledWith( + ['@alice:home.server'], false, + ); + expect(mockBaseApis.sendToDevice).toHaveBeenCalled(); + expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalled( + [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', + ); + + mockBaseApis.claimOneTimeKeys.reset(); + + const ct2 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { + body: "Some more text", + }); + + // this should *not* have claimed a key as it should be using the same session + expect(mockBaseApis.claimOneTimeKeys).toNotHaveBeenCalled(); + + // likewise they should show the same session ID + expect(ct2.session_id).toEqual(ct1.session_id); + }); }); }); diff --git a/src/crypto/OlmDevice.js b/src/crypto/OlmDevice.js index 5ce7438a708..57c0b406e6f 100644 --- a/src/crypto/OlmDevice.js +++ b/src/crypto/OlmDevice.js @@ -295,8 +295,8 @@ OlmDevice.prototype._storeAccount = function(txn, account) { */ OlmDevice.prototype._getSession = function(deviceKey, sessionId, txn, func) { this._cryptoStore.getEndToEndSession( - deviceKey, sessionId, txn, (pickledSession) => { - this._unpickleSession(pickledSession, func); + deviceKey, sessionId, txn, (sessionInfo) => { + this._unpickleSession(sessionInfo, func); }, ); }; @@ -306,15 +306,17 @@ OlmDevice.prototype._getSession = function(deviceKey, sessionId, txn, func) { * function with it. The session object is destroyed once the function * returns. * - * @param {string} pickledSession + * @param {object} sessionInfo * @param {function} func * @private */ -OlmDevice.prototype._unpickleSession = function(pickledSession, func) { +OlmDevice.prototype._unpickleSession = function(sessionInfo, func) { const session = new global.Olm.Session(); try { - session.unpickle(this._pickleKey, pickledSession); - func(session); + session.unpickle(this._pickleKey, sessionInfo.session); + const unpickledSessInfo = Object.assign({}, sessionInfo, {session}); + + func(unpickledSessInfo); } finally { session.free(); } @@ -324,14 +326,17 @@ OlmDevice.prototype._unpickleSession = function(pickledSession, func) { * store our OlmSession in the session store * * @param {string} deviceKey - * @param {OlmSession} session + * @param {object} sessionInfo {session: OlmSession, lastReceivedMessageTs: int} * @param {*} txn Opaque transaction object from cryptoStore.doTxn() * @private */ -OlmDevice.prototype._saveSession = function(deviceKey, session, txn) { - const pickledSession = session.pickle(this._pickleKey); +OlmDevice.prototype._saveSession = function(deviceKey, sessionInfo, txn) { + const sessionId = sessionInfo.session.session_id(); + const pickledSessionInfo = Object.assign(sessionInfo, { + session: sessionInfo.session.pickle(this._pickleKey), + }); this._cryptoStore.storeEndToEndSession( - deviceKey, session.session_id(), pickledSession, txn, + deviceKey, sessionId, pickledSessionInfo, txn, ); }; @@ -461,7 +466,14 @@ OlmDevice.prototype.createOutboundSession = async function( session.create_outbound(account, theirIdentityKey, theirOneTimeKey); newSessionId = session.session_id(); this._storeAccount(txn, account); - this._saveSession(theirIdentityKey, session, txn); + const sessionInfo = { + session, + // Pretend we've received a message at this point, otherwise + // if we try to send a message to the device, it won't use + // this session + lastReceivedMessageTs: Date.now(), + }; + this._saveSession(theirIdentityKey, sessionInfo, txn); } finally { session.free(); } @@ -510,7 +522,13 @@ OlmDevice.prototype.createInboundSession = async function( const payloadString = session.decrypt(messageType, ciphertext); - this._saveSession(theirDeviceIdentityKey, session, txn); + const sessionInfo = { + session, + // this counts as a received message: set last received message time + // to now + lastReceivedMessageTs: Date.now(), + }; + this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); result = { payload: payloadString, @@ -558,13 +576,30 @@ OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityK * @return {Promise} session id, or null if no established session */ OlmDevice.prototype.getSessionIdForDevice = async function(theirDeviceIdentityKey) { - const sessionIds = await this.getSessionIdsForDevice(theirDeviceIdentityKey); - if (sessionIds.length === 0) { + const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey); + if (sessionInfos.length === 0) { return null; } - // Use the session with the lowest ID. - sessionIds.sort(); - return sessionIds[0]; + // Use the session that has most recently received a message + let idxOfBest = 0; + for (let i = 1; i < sessionInfos.length; i++) { + const thisSessInfo = sessionInfos[i]; + const thisLastReceived = thisSessInfo.lastReceivedMessageTs === undefined ? + 0 : thisSessInfo.lastReceivedMessageTs; + + const bestSessInfo = sessionInfos[idxOfBest]; + const bestLastReceived = bestSessInfo.lastReceivedMessageTs === undefined ? + 0 : bestSessInfo.lastReceivedMessageTs; + if ( + thisLastReceived > bestLastReceived || ( + thisLastReceived === bestLastReceived && + thisSessInfo.sessionId < bestSessInfo.sessionId + ) + ) { + idxOfBest = i; + } + } + return sessionInfos[idxOfBest].sessionId; }; /** @@ -587,9 +622,10 @@ OlmDevice.prototype.getSessionInfoForDevice = async function(deviceIdentityKey) this._cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, (sessions) => { const sessionIds = Object.keys(sessions).sort(); for (const sessionId of sessionIds) { - this._unpickleSession(sessions[sessionId], (session) => { + this._unpickleSession(sessions[sessionId], (sessInfo) => { info.push({ - hasReceivedMessage: session.has_received_message(), + lastReceivedMessageTs: sessInfo.lastReceivedMessageTs, + hasReceivedMessage: sessInfo.session.has_received_message(), sessionId: sessionId, }); }); @@ -620,9 +656,9 @@ OlmDevice.prototype.encryptMessage = async function( await this._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { - this._getSession(theirDeviceIdentityKey, sessionId, txn, (session) => { - res = session.encrypt(payloadString); - this._saveSession(theirDeviceIdentityKey, session, txn); + this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { + res = sessionInfo.session.encrypt(payloadString); + this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); }); }, ); @@ -647,9 +683,10 @@ OlmDevice.prototype.decryptMessage = async function( await this._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { - this._getSession(theirDeviceIdentityKey, sessionId, txn, (session) => { - payloadString = session.decrypt(messageType, ciphertext); - this._saveSession(theirDeviceIdentityKey, session, txn); + this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { + payloadString = sessionInfo.session.decrypt(messageType, ciphertext); + sessionInfo.lastReceivedMessageTs = Date.now(); + this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); }); }, ); @@ -679,8 +716,8 @@ OlmDevice.prototype.matchesSession = async function( await this._cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { - this._getSession(theirDeviceIdentityKey, sessionId, txn, (session) => { - matches = session.matches_inbound(ciphertext); + this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { + matches = sessionInfo.session.matches_inbound(ciphertext); }); }, ); @@ -714,7 +751,7 @@ OlmDevice.prototype._saveOutboundGroupSession = function(session) { */ OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) { const pickled = this._outboundGroupSessionStore[sessionId]; - if (pickled === null) { + if (pickled === undefined) { throw new Error("Unknown outbound group session " + sessionId); } @@ -1048,6 +1085,8 @@ OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, se * @param {string} roomId room in which the message was received * @param {string} senderKey base64-encoded curve25519 key of the sender * @param {string} sessionId session identifier + * @param {integer} chainIndex The chain index at which to export the session. + * If omitted, export at the first index we know about. * * @returns {Promise<{chain_index: number, key: string, * forwarding_curve25519_key_chain: Array, @@ -1055,9 +1094,12 @@ OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, se * }>} * details of the session key. The key is a base64-encoded megolm key in * export format. + * + * @throws Error If the given chain index could not be obtained from the known + * index (ie. the given chain index is before the first we have). */ OlmDevice.prototype.getInboundGroupSessionKey = async function( - roomId, senderKey, sessionId, + roomId, senderKey, sessionId, chainIndex, ) { let result; await this._cryptoStore.doTxn( @@ -1068,14 +1110,19 @@ OlmDevice.prototype.getInboundGroupSessionKey = async function( result = null; return; } - const messageIndex = session.first_known_index(); + + if (chainIndex === undefined) { + chainIndex = session.first_known_index(); + } + + const exportedSession = session.export_session(chainIndex); const claimedKeys = sessionData.keysClaimed || {}; const senderEd25519Key = claimedKeys.ed25519 || null; result = { - "chain_index": messageIndex, - "key": session.export_session(messageIndex), + "chain_index": chainIndex, + "key": exportedSession, "forwarding_curve25519_key_chain": sessionData.forwardingCurve25519KeyChain || [], "sender_claimed_ed25519_key": senderEd25519Key, diff --git a/src/crypto/OutgoingRoomKeyRequestManager.js b/src/crypto/OutgoingRoomKeyRequestManager.js index 4c9b7cbf5ba..bfde6019ba0 100644 --- a/src/crypto/OutgoingRoomKeyRequestManager.js +++ b/src/crypto/OutgoingRoomKeyRequestManager.js @@ -244,6 +244,21 @@ export default class OutgoingRoomKeyRequestManager { }); } + /** + * Look for room key requests by target device and state + * + * @param {string} userId Target user ID + * @param {string} deviceId Target device ID + * + * @return {Promise} resolves to a list of all the + * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + */ + getOutgoingSentRoomKeyRequest(userId, deviceId) { + return this._cryptoStore.getOutgoingRoomKeyRequestsByTarget( + userId, deviceId, [ROOM_KEY_REQUEST_STATES.SENT], + ); + } + // start the background timer to send queued requests, if the timer isn't // already running _startTimer() { diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index d8d8fd8f223..0bbf1ca5265 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -144,6 +144,11 @@ function MegolmEncryption(params) { // room). this._setupPromise = Promise.resolve(); + // Map of outbound sessions by sessions ID. Used if we need a particular + // session (the session we're currently using to send is always obtained + // using _setupPromise). + this._outboundSessions = {}; + // default rotation periods this._sessionRotationPeriodMsgs = 100; this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000; @@ -195,6 +200,7 @@ MegolmEncryption.prototype._ensureOutboundSession = function(devicesInRoom) { if (!session) { logger.log(`Starting new megolm session for room ${self._roomId}`); session = await self._prepareNewSession(); + self._outboundSessions[session.sessionId] = session; } // now check if we need to share with any devices @@ -409,8 +415,98 @@ MegolmEncryption.prototype._encryptAndSendKeysToDevices = function( }; /** - * @private + * Re-shares a megolm session key with devices if the key has already been + * sent to them. * + * @param {string} senderKey The key of the originating device for the session + * @param {string} sessionId ID of the outbound session to share + * @param {string} userId ID of the user who owns the target device + * @param {module:crypto/deviceinfo} device The target device + */ +MegolmEncryption.prototype.reshareKeyWithDevice = async function( + senderKey, sessionId, userId, device, +) { + const obSessionInfo = this._outboundSessions[sessionId]; + if (!obSessionInfo) { + logger.debug("Session ID " + sessionId + " not found: not re-sharing keys"); + return; + } + + // The chain index of the key we previously sent this device + if (obSessionInfo.sharedWithDevices[userId] === undefined) { + logger.debug("Session ID " + sessionId + " never shared with user " + userId); + return; + } + const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId]; + if (sentChainIndex === undefined) { + logger.debug( + "Session ID " + sessionId + " never shared with device " + + userId + ":" + device.deviceId, + ); + return; + } + + // get the key from the inbound session: the outbound one will already + // have been ratcheted to the next chain index. + const key = await this._olmDevice.getInboundGroupSessionKey( + this._roomId, senderKey, sessionId, sentChainIndex, + ); + + if (!key) { + logger.warn( + "No outbound session key found for " + sessionId + ": not re-sharing keys", + ); + return; + } + + await olmlib.ensureOlmSessionsForDevices( + this._olmDevice, this._baseApis, { + [userId]: { + [device.deviceId]: device, + }, + }, + ); + + 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, + }, + }; + + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this._olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this._userId, + this._deviceId, + this._olmDevice, + userId, + device, + payload, + ), + + await this._baseApis.sendToDevice("m.room.encrypted", { + [userId]: { + [device.deviceId]: encryptedContent, + }, + }); + logger.debug( + `Re-shared key for session ${sessionId} with ${userId}:${device.deviceId}`, + ); +}; + +/** * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * * @param {object} devicesByUser diff --git a/src/crypto/index.js b/src/crypto/index.js index 082f6d2c968..26292f3bb27 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -41,6 +41,8 @@ export function isCryptoAvailable() { return Boolean(global.Olm); } +const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; + /** * Cryptography bits * @@ -120,6 +122,15 @@ export default function Crypto(baseApis, sessionStore, userId, deviceId, // has happened for a given room. This is delayed // to avoid loading room members as long as possible. this._roomDeviceTrackingState = {}; + + // The timestamp of the last time we forced establishment + // of a new session for each device, in milliseconds. + // { + // userId: { + // deviceId: 1234567890000, + // }, + // } + this._lastNewSessionForced = {}; } utils.inherits(Crypto, EventEmitter); @@ -1133,6 +1144,8 @@ Crypto.prototype._onToDeviceEvent = function(event) { this._onRoomKeyEvent(event); } else if (event.getType() == "m.room_key_request") { this._onRoomKeyRequestEvent(event); + } else if (event.getContent().msgtype === "m.bad.encrypted") { + this._onToDeviceBadEncrypted(event); } else if (event.isBeingDecrypted()) { // once the event has been decrypted, try again event.once('Event.decrypted', (ev) => { @@ -1162,6 +1175,87 @@ Crypto.prototype._onRoomKeyEvent = function(event) { alg.onRoomKeyEvent(event); }; +/** + * Handle a toDevice event that couldn't be decrypted + * + * @private + * @param {module:models/event.MatrixEvent} event undecryptable event + */ +Crypto.prototype._onToDeviceBadEncrypted = async function(event) { + const content = event.getWireContent(); + const sender = event.getSender(); + const algorithm = content.algorithm; + const deviceKey = content.sender_key; + + if (sender === undefined || deviceKey === undefined || deviceKey === undefined) { + return; + } + + // check when we last forced a new session with this device: if we've already done so + // recently, don't do it again. + this._lastNewSessionForced[sender] = this._lastNewSessionForced[sender] || {}; + const lastNewSessionForced = this._lastNewSessionForced[sender][deviceKey] || 0; + if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) { + logger.debug( + "New session already forced with device " + sender + ":" + deviceKey + + " at " + lastNewSessionForced + ": not forcing another", + ); + return; + } + this._lastNewSessionForced[sender][deviceKey] = Date.now(); + + // establish a new olm session with this device since we're failing to decrypt messages + // on a current session. + // Note that an undecryptable message from another device could easily be spoofed - + // is there anything we can do to mitigate this? + const device = this._deviceList.getDeviceByIdentityKey(sender, algorithm, deviceKey); + const devicesByUser = {}; + devicesByUser[sender] = [device]; + await olmlib.ensureOlmSessionsForDevices( + this._olmDevice, this._baseApis, devicesByUser, true, + ); + + // Now send a blank message on that session so the other side knows about it. + // (The keyshare request is sent in the clear so that won't do) + // We send this first such that, as long as the toDevice messages arrive in the + // same order we sent them, the other end will get this first, set up the new session, + // then get the keyshare request and send the key over this new session (because it + // is the session it has most recently received a message on). + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this._olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this._userId, + this._deviceId, + this._olmDevice, + sender, + device, + {type: "m.dummy"}, + ); + + await this._baseApis.sendToDevice("m.room.encrypted", { + [sender]: { + [device.deviceId]: encryptedContent, + }, + }); + + + // Most of the time this probably won't be necessary since we'll have queued up a key request when + // we failed to decrypt the message and will be waiting a bit for the key to arrive before sending + // it. This won't always be the case though so we need to re-send any that have already been sent + // to avoid races. + const requestsToResend = + await this._outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest( + sender, device.deviceId, + ); + for (const keyReq of requestsToResend) { + this.cancelRoomKeyRequest(keyReq.requestBody, true); + } +}; + /** * Handle a change in the membership state of a member of a room * @@ -1287,9 +1381,27 @@ Crypto.prototype._processReceivedRoomKeyRequest = async function(req) { ` for ${roomId} / ${body.session_id} (id ${req.requestId})`); if (userId !== this._userId) { - // TODO: determine if we sent this device the keys already: in - // which case we can do so again. - logger.log("Ignoring room key request from other user for now"); + if (!this._roomEncryptors[roomId]) { + logger.debug(`room key request for unencrypted room ${roomId}`); + return; + } + const encryptor = this._roomEncryptors[roomId]; + const device = this._deviceList.getStoredDevice(userId, deviceId); + if (!device) { + logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`); + return; + } + + try { + await encryptor.reshareKeyWithDevice( + body.sender_key, body.session_id, userId, device, + ); + } catch (e) { + logger.warn( + "Failed to re-share keys for session " + body.session_id + + " with device " + userId + ":" + device.deviceId, e, + ); + } return; } diff --git a/src/crypto/olmlib.js b/src/crypto/olmlib.js index 49ec4c20e83..75b8abb95f0 100644 --- a/src/crypto/olmlib.js +++ b/src/crypto/olmlib.js @@ -116,14 +116,17 @@ module.exports.encryptMessageForDevice = async function( * @param {module:base-apis~MatrixBaseApis} baseApis * * @param {object} devicesByUser - * map from userid to list of devices + * map from userid to list of devices to ensure sessions for + * + * @param {bolean} force If true, establish a new session even if one already exists. + * Optional. * * @return {module:client.Promise} resolves once the sessions are complete, to * an Object mapping from userId to deviceId to * {@link module:crypto~OlmSessionResult} */ module.exports.ensureOlmSessionsForDevices = async function( - olmDevice, baseApis, devicesByUser, + olmDevice, baseApis, devicesByUser, force, ) { const devicesWithoutSession = [ // [userId, deviceId], ... @@ -141,7 +144,7 @@ module.exports.ensureOlmSessionsForDevices = async function( const deviceId = deviceInfo.deviceId; const key = deviceInfo.getIdentityKey(); const sessionId = await olmDevice.getSessionIdForDevice(key); - if (sessionId === null) { + if (sessionId === null || force) { devicesWithoutSession.push([userId, deviceId]); } result[userId][deviceId] = { @@ -177,7 +180,7 @@ module.exports.ensureOlmSessionsForDevices = async function( for (let j = 0; j < devices.length; j++) { const deviceInfo = devices[j]; const deviceId = deviceInfo.deviceId; - if (result[userId][deviceId].sessionId) { + if (result[userId][deviceId].sessionId && !force) { // we already have a result for this device continue; } diff --git a/src/crypto/store/indexeddb-crypto-store-backend.js b/src/crypto/store/indexeddb-crypto-store-backend.js index 96bbec68ec8..70cee0db9f3 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.js +++ b/src/crypto/store/indexeddb-crypto-store-backend.js @@ -206,6 +206,42 @@ export class Backend { return promiseifyTxn(txn).then(() => result); } + getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { + let stateIndex = 0; + const results = []; + + function onsuccess(ev) { + const cursor = ev.target.result; + if (cursor) { + const keyReq = cursor.value; + if (keyReq.recipients.includes({userId, deviceId})) { + results.push(keyReq); + } + cursor.continue(); + } else { + // try the next state in the list + stateIndex++; + if (stateIndex >= wantedStates.length) { + // no matches + return; + } + + const wantedState = wantedStates[stateIndex]; + const cursorReq = ev.target.source.openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + } + } + + const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly"); + const store = txn.objectStore("outgoingRoomKeyRequests"); + + const wantedState = wantedStates[stateIndex]; + const cursorReq = store.index("state").openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + + return promiseifyTxn(txn).then(() => results); + } + /** * Look for an existing room key request by id and state, and update it if * found @@ -314,7 +350,10 @@ export class Backend { getReq.onsuccess = function() { const cursor = getReq.result; if (cursor) { - results[cursor.value.sessionId] = cursor.value.session; + results[cursor.value.sessionId] = { + session: cursor.value.session, + lastReceivedMessageTs: cursor.value.lastReceivedMessageTs, + }; cursor.continue(); } else { try { @@ -332,7 +371,10 @@ export class Backend { getReq.onsuccess = function() { try { if (getReq.result) { - func(getReq.result.session); + func({ + session: getReq.result.session, + lastReceivedMessageTs: getReq.result.lastReceivedMessageTs, + }); } else { func(null); } @@ -342,9 +384,14 @@ export class Backend { }; } - storeEndToEndSession(deviceKey, sessionId, session, txn) { + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { const objectStore = txn.objectStore("sessions"); - objectStore.put({deviceKey, sessionId, session}); + objectStore.put({ + deviceKey, + sessionId, + session: sessionInfo.session, + lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs, + }); } // Inbound group sessions diff --git a/src/crypto/store/indexeddb-crypto-store.js b/src/crypto/store/indexeddb-crypto-store.js index c9210da2307..3a7d6d1d658 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -207,6 +207,24 @@ export default class IndexedDBCryptoStore { }); } + /** + * Look for room key requests by target device and state + * + * @param {string} userId Target user ID + * @param {string} deviceId Target device ID + * @param {Array} wantedStates list of acceptable states + * + * @return {Promise} resolves to a list of all the + * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + */ + getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { + return this._connect().then((backend) => { + return backend.getOutgoingRoomKeyRequestsByTarget( + userId, deviceId, wantedStates, + ); + }); + } + /** * Look for an existing room key request by id and state, and update it if * found @@ -284,7 +302,10 @@ export default class IndexedDBCryptoStore { * @param {string} sessionId The ID of the session to retrieve * @param {*} txn An active transaction. See doTxn(). * @param {function(object)} func Called with A map from sessionId - * to Base64 end-to-end session. + * to session information object with 'session' key being the + * Base64 end-to-end session and lastReceivedMessageTs being the + * timestamp in milliseconds at which the session last received + * a message. */ getEndToEndSession(deviceKey, sessionId, txn, func) { this._backendPromise.value().getEndToEndSession(deviceKey, sessionId, txn, func); @@ -296,7 +317,10 @@ export default class IndexedDBCryptoStore { * @param {string} deviceKey The public key of the other device. * @param {*} txn An active transaction. See doTxn(). * @param {function(object)} func Called with A map from sessionId - * to Base64 end-to-end session. + * to session information object with 'session' key being the + * Base64 end-to-end session and lastReceivedMessageTs being the + * timestamp in milliseconds at which the session last received + * a message. */ getEndToEndSessions(deviceKey, txn, func) { this._backendPromise.value().getEndToEndSessions(deviceKey, txn, func); @@ -306,12 +330,12 @@ export default class IndexedDBCryptoStore { * Store a session between the logged-in user and another device * @param {string} deviceKey The public key of the other device. * @param {string} sessionId The ID for this end-to-end session. - * @param {string} session Base64 encoded end-to-end session. + * @param {string} sessionInfo Session information object * @param {*} txn An active transaction. See doTxn(). */ - storeEndToEndSession(deviceKey, sessionId, session, txn) { + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { this._backendPromise.value().storeEndToEndSession( - deviceKey, sessionId, session, txn, + deviceKey, sessionId, sessionInfo, txn, ); } diff --git a/src/crypto/store/localStorage-crypto-store.js b/src/crypto/store/localStorage-crypto-store.js index ed0b7ede45e..a47861b53df 100644 --- a/src/crypto/store/localStorage-crypto-store.js +++ b/src/crypto/store/localStorage-crypto-store.js @@ -67,7 +67,21 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore { } _getEndToEndSessions(deviceKey, txn, func) { - return getJsonItem(this.store, keyEndToEndSessions(deviceKey)); + const sessions = getJsonItem(this.store, keyEndToEndSessions(deviceKey)); + const fixedSessions = {}; + + // fix up any old sessions to be objects rather than just the base64 pickle + for (const [sid, val] of Object.entries(sessions || {})) { + if (typeof val === 'string') { + fixedSessions[sid] = { + session: val, + }; + } else { + fixedSessions[sid] = val; + } + } + + return fixedSessions; } getEndToEndSession(deviceKey, sessionId, txn, func) { @@ -79,9 +93,9 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore { func(this._getEndToEndSessions(deviceKey) || {}); } - storeEndToEndSession(deviceKey, sessionId, session, txn) { + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { const sessions = this._getEndToEndSessions(deviceKey) || {}; - sessions[sessionId] = session; + sessions[sessionId] = sessionInfo; setJsonItem( this.store, keyEndToEndSessions(deviceKey), sessions, ); diff --git a/src/crypto/store/memory-crypto-store.js b/src/crypto/store/memory-crypto-store.js index 4c2baf9e1be..56b541391d9 100644 --- a/src/crypto/store/memory-crypto-store.js +++ b/src/crypto/store/memory-crypto-store.js @@ -145,6 +145,19 @@ export default class MemoryCryptoStore { return Promise.resolve(null); } + getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { + const results = []; + + for (const req of this._outgoingRoomKeyRequests) { + for (const state of wantedStates) { + if (req.state === state && req.recipients.includes({userId, deviceId})) { + results.push(req); + } + } + } + return Promise.resolve(results); + } + /** * Look for an existing room key request by id and state, and update it if * found @@ -234,13 +247,13 @@ export default class MemoryCryptoStore { func(this._sessions[deviceKey] || {}); } - storeEndToEndSession(deviceKey, sessionId, session, txn) { + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { let deviceSessions = this._sessions[deviceKey]; if (deviceSessions === undefined) { deviceSessions = {}; this._sessions[deviceKey] = deviceSessions; } - deviceSessions[sessionId] = session; + deviceSessions[sessionId] = sessionInfo; } // Inbound Group Sessions