diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index 57f6a3fe4..e1da35db5 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -453,6 +453,99 @@ describe("MegolmDecryption", function() { bobClient2.stopClient(); }); + it("notifies devices when unable to create olm session", async function() { + const aliceClient = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + const bobClient = (new TestClient( + "@bob:example.com", "bobdevice", + )).client; + await Promise.all([ + aliceClient.initCrypto(), + bobClient.initCrypto(), + ]); + const aliceDevice = aliceClient._crypto._olmDevice; + const bobDevice = bobClient._crypto._olmDevice; + + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); + const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); + aliceClient.store.storeRoom(aliceRoom); + bobClient.store.storeRoom(bobRoom); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + await bobClient.setRoomEncryption(roomId, encryptionCfg); + + aliceRoom.getEncryptionTargetMembers = async () => { + return [ + { + userId: "@alice:example.com", + membership: "join", + }, + { + userId: "@bob:example.com", + membership: "join", + }, + ]; + }; + const BOB_DEVICES = { + bobdevice: { + user_id: "@bob:example.com", + device_id: "bobdevice", + algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], + keys: { + "ed25519:bobdevice": bobDevice.deviceEd25519Key, + "curve25519:bobdevice": bobDevice.deviceCurve25519Key, + }, + known: true, + verified: 1, + }, + }; + + aliceClient._crypto._deviceList.storeDevicesForUser( + "@bob:example.com", BOB_DEVICES, + ); + aliceClient._crypto._deviceList.downloadKeys = async function(userIds) { + return this._getDevicesFromStore(userIds); + }; + + aliceClient.claimOneTimeKeys = async () => { + // Bob has no one-time keys + return { + one_time_keys: {}, + }; + }; + + let run = false; + aliceClient.sendToDevice = async (msgtype, contentMap) => { + run = true; + expect(msgtype).toBe("org.matrix.room_key.withheld"); + expect(contentMap).toStrictEqual({ + '@bob:example.com': { + bobdevice: { + algorithm: "m.megolm.v1.aes-sha2", + code: 'm.no_olm', + reason: 'Unable to establish a secure channel.', + sender_key: aliceDevice.deviceCurve25519Key, + }, + }, + }); + }; + + const event = new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$event", + content: {}, + }); + await aliceClient._crypto.encryptEvent(event, aliceRoom); + + expect(run).toBe(true); + }); + it("throws an error describing why it doesn't have a key", async function() { const aliceClient = (new TestClient( "@alice:example.com", "alicedevice", @@ -495,4 +588,103 @@ describe("MegolmDecryption", function() { }, }))).rejects.toThrow("The sender has blocked you."); }); + + it("throws an error describing the lack of an olm session", async function() { + const aliceClient = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + const bobClient = (new TestClient( + "@bob:example.com", "bobdevice", + )).client; + await Promise.all([ + aliceClient.initCrypto(), + bobClient.initCrypto(), + ]); + const bobDevice = bobClient._crypto._olmDevice; + + const roomId = "!someroom"; + + const now = Date.now(); + + aliceClient._crypto._onToDeviceEvent(new MatrixEvent({ + type: "org.matrix.room_key.withheld", + sender: "@bob:example.com", + content: { + algorithm: "m.megolm.v1.aes-sha2", + room_id: roomId, + session_id: "session_id", + sender_key: bobDevice.deviceCurve25519Key, + code: "m.no_olm", + reason: "Unable to establish a secure channel.", + }, + })); + + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + + await expect(aliceClient._crypto.decryptEvent(new MatrixEvent({ + type: "m.room.encrypted", + sender: "@bob:example.com", + event_id: "$event", + room_id: roomId, + content: { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "blablabla", + device_id: "bobdevice", + sender_key: bobDevice.deviceCurve25519Key, + session_id: "session_id", + }, + origin_server_ts: now, + }))).rejects.toThrow("The sender was unable to establish a secure channel."); + }); + + it("throws an error to indicate a wedged olm session", async function() { + const aliceClient = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + const bobClient = (new TestClient( + "@bob:example.com", "bobdevice", + )).client; + await Promise.all([ + aliceClient.initCrypto(), + bobClient.initCrypto(), + ]); + const bobDevice = bobClient._crypto._olmDevice; + + const roomId = "!someroom"; + + const now = Date.now(); + + // pretend we got an event that we can't decrypt + aliceClient._crypto._onToDeviceEvent(new MatrixEvent({ + type: "m.room.encrypted", + sender: "@bob:example.com", + content: { + msgtype: "m.bad.encrypted", + algorithm: "m.megolm.v1.aes-sha2", + session_id: "session_id", + sender_key: bobDevice.deviceCurve25519Key, + }, + })); + + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + + await expect(aliceClient._crypto.decryptEvent(new MatrixEvent({ + type: "m.room.encrypted", + sender: "@bob:example.com", + event_id: "$event", + room_id: roomId, + content: { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "blablabla", + device_id: "bobdevice", + sender_key: bobDevice.deviceCurve25519Key, + session_id: "session_id", + }, + origin_server_ts: now, + }))).rejects.toThrow("The secure channel with the sender was corrupted."); + }); }); diff --git a/src/crypto/OlmDevice.js b/src/crypto/OlmDevice.js index 7a0434682..d613511a4 100644 --- a/src/crypto/OlmDevice.js +++ b/src/crypto/OlmDevice.js @@ -674,6 +674,18 @@ OlmDevice.prototype.matchesSession = async function( return matches; }; +OlmDevice.prototype.recordSessionProblem = async function(deviceKey, type, fixed) { + await this._cryptoStore.storeEndToEndSessionProblem(deviceKey, type, fixed); +}; + +OlmDevice.prototype.sessionMayHaveProblems = async function(deviceKey, timestamp) { + return await this._cryptoStore.getEndToEndSessionProblem(deviceKey, timestamp); +}; + +OlmDevice.prototype.filterOutNotifiedErrorDevices = async function(devices) { + return await this._cryptoStore.filterOutNotifiedErrorDevices(devices); +}; + // Outbound group session // ====================== diff --git a/src/crypto/algorithms/base.js b/src/crypto/algorithms/base.js index d5e38f477..d8b3644fb 100644 --- a/src/crypto/algorithms/base.js +++ b/src/crypto/algorithms/base.js @@ -159,6 +159,16 @@ class DecryptionAlgorithm { shareKeysWithDevice(keyRequest) { throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm"); } + + /** + * Retry decrypting all the events from a sender that haven't been + * decrypted yet. + * + * @param {string} senderKey the sender's key + */ + async retryDecryptionFromSender(senderKey) { + // ignore by default + } } export {DecryptionAlgorithm}; // https://github.com/jsdoc3/jsdoc/issues/1272 diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index 84048b1b8..16fc704b1 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -253,8 +253,10 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( } } + const errorDevices = []; + await self._shareKeyWithDevices( - session, shareMap, + session, shareMap, errorDevices, ); // are there any new blocked devices that we need to notify? @@ -281,6 +283,18 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( } } + + const filteredErrorDevices = + await self._olmDevice.filterOutNotifiedErrorDevices(errorDevices); + for (const {userId, deviceInfo} of filteredErrorDevices) { + blockedMap[userId] = blockedMap[userId] || []; + blockedMap[userId].push({ + code: "m.no_olm", + reason: WITHHELD_MESSAGES["m.no_olm"], + deviceInfo, + }); + } + // notify blocked devices that they're blocked await self._notifyBlockedDevices(session, blockedMap); } @@ -346,10 +360,14 @@ MegolmEncryption.prototype._prepareNewSession = async function() { * @param {object} devicesByUser * map from userid to list of devices * + * @param {array} errorDevices + * array that will be populated with the devices that can't get an + * olm session for + * * @return {array>} */ MegolmEncryption.prototype._splitUserDeviceMap = function( - session, chainIndex, devicemap, devicesByUser, + session, chainIndex, devicemap, devicesByUser, errorDevices, ) { const maxUsersPerRequest = 20; @@ -381,6 +399,8 @@ MegolmEncryption.prototype._splitUserDeviceMap = function( // to claim a one-time-key for dead devices on every message. session.markSharedWithDevice(userId, deviceId, chainIndex); + errorDevices.push({userId, deviceInfo}); + // ensureOlmSessionsForUsers has already done the logging, // so just skip it. continue; @@ -550,6 +570,10 @@ MegolmEncryption.prototype._sendBlockedNotificationsToDevices = async function( const message = Object.assign({}, payload); message.code = blockedInfo.code; message.reason = blockedInfo.reason; + if (message.code === "m.no_olm") { + delete message.room_id; + delete message.session_id; + } if (!contentMap[userId]) { contentMap[userId] = {}; @@ -663,8 +687,14 @@ MegolmEncryption.prototype.reshareKeyWithDevice = async function( * * @param {object} devicesByUser * map from userid to list of devices + * + * @param {array} errorDevices + * array that will be populated with the devices that we can't get an + * olm session for */ -MegolmEncryption.prototype._shareKeyWithDevices = async function(session, devicesByUser) { +MegolmEncryption.prototype._shareKeyWithDevices = async function( + session, devicesByUser, errorDevices, +) { const key = this._olmDevice.getOutboundGroupSessionKey(session.sessionId); const payload = { type: "m.room_key", @@ -682,7 +712,7 @@ MegolmEncryption.prototype._shareKeyWithDevices = async function(session, device ); const userDeviceMaps = this._splitUserDeviceMap( - session, key.chain_index, devicemap, devicesByUser, + session, key.chain_index, devicemap, devicesByUser, errorDevices, ); for (let i = 0; i < userDeviceMaps.length; i++) { @@ -915,6 +945,11 @@ function MegolmDecryption(params) { } utils.inherits(MegolmDecryption, base.DecryptionAlgorithm); +const PROBLEM_DESCRIPTIONS = { + no_olm: "The sender was unable to establish a secure channel.", + unknown: "The secure channel with the sender was corrupted.", +}; + /** * @inheritdoc * @@ -981,6 +1016,28 @@ MegolmDecryption.prototype.decryptEvent = async function(event) { // event is still in the pending list; if not, a retry will have been // scheduled, so we needn't send out the request here.) this._requestKeysForEvent(event); + + // See if there was a problem with the olm session at the time the + // event was sent. Use a fuzz factor of 2 minutes. + const problem = await this._olmDevice.sessionMayHaveProblems( + content.sender_key, event.getTs() - 120000, + ); + if (problem) { + let problemDescription = PROBLEM_DESCRIPTIONS[problem.type] + || PROBLEM_DESCRIPTIONS.unknown; + if (problem.fixed) { + problemDescription += + " Trying to create a new secure channel and re-requesting the keys."; + } + throw new base.DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + problemDescription, + { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + throw new base.DecryptionError( "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", "The sender's device has not sent us the keys for this message.", @@ -1036,11 +1093,16 @@ MegolmDecryption.prototype._requestKeysForEvent = function(event) { */ MegolmDecryption.prototype._addEventToPendingList = function(event) { const content = event.getWireContent(); - const k = content.sender_key + "|" + content.session_id; - if (!this._pendingEvents[k]) { - this._pendingEvents[k] = new Set(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + if (!this._pendingEvents[senderKey]) { + this._pendingEvents[senderKey] = new Map(); } - this._pendingEvents[k].add(event); + const senderPendingEvents = this._pendingEvents[senderKey]; + if (!senderPendingEvents.has(sessionId)) { + senderPendingEvents.set(sessionId, new Set()); + } + senderPendingEvents.get(sessionId).add(event); }; /** @@ -1052,14 +1114,20 @@ MegolmDecryption.prototype._addEventToPendingList = function(event) { */ MegolmDecryption.prototype._removeEventFromPendingList = function(event) { const content = event.getWireContent(); - const k = content.sender_key + "|" + content.session_id; - if (!this._pendingEvents[k]) { + const senderKey = content.sender_key; + const sessionId = content.session_id; + const senderPendingEvents = this._pendingEvents[senderKey]; + const pendingEvents = senderPendingEvents && senderPendingEvents.get(sessionId); + if (!pendingEvents) { return; } - this._pendingEvents[k].delete(event); - if (this._pendingEvents[k].size === 0) { - delete this._pendingEvents[k]; + pendingEvents.delete(event); + if (pendingEvents.size === 0) { + senderPendingEvents.delete(senderKey); + } + if (senderPendingEvents.size === 0) { + delete this._pendingEvents[senderKey]; } }; @@ -1170,11 +1238,69 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { */ MegolmDecryption.prototype.onRoomKeyWithheldEvent = async function(event) { const content = event.getContent(); + const senderKey = content.sender_key; - await this._olmDevice.addInboundGroupSessionWithheld( - content.room_id, content.sender_key, content.session_id, content.code, - content.reason, - ); + if (content.code === "m.no_olm") { + const sender = event.getSender(); + // if the sender says that they haven't been able to establish an olm + // session, let's proactively establish one + + // Note: after we record that the olm session has had a problem, we + // trigger retrying decryption for all the messages from the sender's + // key, so that we can update the error message to indicate the olm + // session problem. + + if (await this._olmDevice.getSessionIdForDevice(senderKey)) { + // a session has already been established, so we don't need to + // create a new one. + await this._olmDevice.recordSessionProblem(senderKey, "no_olm", true); + this.retryDecryptionFromSender(senderKey); + return; + } + const device = this._crypto._deviceList.getDeviceByIdentityKey( + content.algorithm, senderKey, + ); + if (!device) { + logger.info( + "Couldn't find device for identity key " + senderKey + + ": not establishing session", + ); + await this._olmDevice.recordSessionProblem(senderKey, "no_olm", false); + this.retryDecryptionFromSender(senderKey); + return; + } + await olmlib.ensureOlmSessionsForDevices( + this._olmDevice, this._baseApis, {[sender]: [device]}, false, + ); + 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._olmDevice.recordSessionProblem(senderKey, "no_olm", true); + this.retryDecryptionFromSender(senderKey); + + await this._baseApis.sendToDevice("m.room.encrypted", { + [sender]: { + [device.deviceId]: encryptedContent, + }, + }); + } else { + await this._olmDevice.addInboundGroupSessionWithheld( + content.room_id, senderKey, content.session_id, content.code, + content.reason, + ); + } }; /** @@ -1320,13 +1446,20 @@ MegolmDecryption.prototype.importRoomKey = function(session) { * @return {Boolean} whether all messages were successfully decrypted */ MegolmDecryption.prototype._retryDecryption = async function(senderKey, sessionId) { - const k = senderKey + "|" + sessionId; - const pending = this._pendingEvents[k]; + const senderPendingEvents = this._pendingEvents[senderKey]; + if (!senderPendingEvents) { + return true; + } + + const pending = senderPendingEvents.get(sessionId); if (!pending) { return true; } - delete this._pendingEvents[k]; + pending.delete(sessionId); + if (pending.size === 0) { + this._pendingEvents[senderKey]; + } await Promise.all([...pending].map(async (ev) => { try { @@ -1336,7 +1469,32 @@ MegolmDecryption.prototype._retryDecryption = async function(senderKey, sessionI } })); - return !this._pendingEvents[k]; + // ev.attemptDecryption will re-add to this._pendingEvents if an event + // couldn't be decrypted + return !((this._pendingEvents[senderKey] || {})[sessionId]); +}; + +MegolmDecryption.prototype.retryDecryptionFromSender = async function(senderKey) { + const senderPendingEvents = this._pendingEvents[senderKey]; + logger.warn(senderPendingEvents); + if (!senderPendingEvents) { + return true; + } + + delete this._pendingEvents[senderKey]; + + await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { + await Promise.all([...pending].map(async (ev) => { + try { + logger.warn(ev.getId()); + await ev.attemptDecryption(this._crypto); + } catch (e) { + // don't die if something goes wrong + } + })); + })); + + return !this._pendingEvents[senderKey]; }; base.registerAlgorithm( diff --git a/src/crypto/index.js b/src/crypto/index.js index f47d8877e..764ccc76f 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -2502,16 +2502,31 @@ Crypto.prototype._onRoomKeyEvent = function(event) { Crypto.prototype._onRoomKeyWithheldEvent = function(event) { const content = event.getContent(); - if (!content.room_id || !content.session_id || !content.algorithm - || !content.sender_key) { + if ((content.code !== "m.no_olm" && (!content.room_id || !content.session_id)) + || !content.algorithm || !content.sender_key) { logger.error("key withheld event is missing fields"); return; } + logger.info( + `Got room key withheld event from ${event.getSender()} (${content.sender_key}) ` + + `for ${content.algorithm}/${content.room_id}/${content.session_id} ` + + `with reason ${content.code} (${content.reason})`, + ); + const alg = this._getRoomDecryptor(content.room_id, content.algorithm); if (alg.onRoomKeyWithheldEvent) { alg.onRoomKeyWithheldEvent(event); } + if (!content.room_id) { + // retry decryption for all events sent by the sender_key. This will + // update the events to show a message indicating that the olm session was + // wedged. + const roomDecryptors = this._getRoomDecryptors(content.algorithm); + for (const decryptor of roomDecryptors) { + decryptor.retryDecryptionFromSender(content.sender_key); + } + } }; /** @@ -2627,6 +2642,16 @@ Crypto.prototype._onToDeviceBadEncrypted = async function(event) { const algorithm = content.algorithm; const deviceKey = content.sender_key; + // retry decryption for all events sent by the sender_key. This will + // update the events to show a message indicating that the olm session was + // wedged. + const retryDecryption = () => { + const roomDecryptors = this._getRoomDecryptors(olmlib.MEGOLM_ALGORITHM); + for (const decryptor of roomDecryptors) { + decryptor.retryDecryptionFromSender(deviceKey); + } + }; + if (sender === undefined || deviceKey === undefined || deviceKey === undefined) { return; } @@ -2640,6 +2665,8 @@ Crypto.prototype._onToDeviceBadEncrypted = async function(event) { "New session already forced with device " + sender + ":" + deviceKey + " at " + lastNewSessionForced + ": not forcing another", ); + await this._olmDevice.recordSessionProblem(deviceKey, "wedged", true); + retryDecryption(); return; } @@ -2653,6 +2680,8 @@ Crypto.prototype._onToDeviceBadEncrypted = async function(event) { "Couldn't find device for identity key " + deviceKey + ": not re-establishing session", ); + await this._olmDevice.recordSessionProblem(deviceKey, "wedged", false); + retryDecryption(); return; } const devicesByUser = {}; @@ -2684,6 +2713,9 @@ Crypto.prototype._onToDeviceBadEncrypted = async function(event) { {type: "m.dummy"}, ); + await this._olmDevice.recordSessionProblem(deviceKey, "wedged", true); + retryDecryption(); + await this._baseApis.sendToDevice("m.room.encrypted", { [sender]: { [device.deviceId]: encryptedContent, @@ -2965,6 +2997,24 @@ Crypto.prototype._getRoomDecryptor = function(roomId, algorithm) { }; +/** + * Get all the room decryptors for a given encryption algorithm. + * + * @param {string} algorithm The encryption algorithm + * + * @return {array} An array of room decryptors + */ +Crypto.prototype._getRoomDecryptors = function(algorithm) { + const decryptors = []; + for (const d of Object.values(this._roomDecryptors)) { + if (algorithm in d) { + decryptors.push(d[algorithm]); + } + } + return decryptors; +}; + + /** * sign the given object with our ed25519 key * diff --git a/src/crypto/store/indexeddb-crypto-store-backend.js b/src/crypto/store/indexeddb-crypto-store-backend.js index acac5c9f6..bafa9db60 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 utils from '../../utils'; -export const VERSION = 8; +export const VERSION = 9; /** * Implementation of a CryptoStore which is backed by an existing @@ -426,6 +426,74 @@ export class Backend { }); } + async storeEndToEndSessionProblem(deviceKey, type, fixed) { + const txn = this._db.transaction("session_problems", "readwrite"); + const objectStore = txn.objectStore("session_problems"); + objectStore.put({ + deviceKey, + type, + fixed, + time: Date.now(), + }); + return promiseifyTxn(txn); + } + + async getEndToEndSessionProblem(deviceKey, timestamp) { + let result; + const txn = this._db.transaction("session_problems", "readwrite"); + const objectStore = txn.objectStore("session_problems"); + const index = objectStore.index("deviceKey"); + const req = index.getAll(deviceKey); + req.onsuccess = (event) => { + const problems = req.result; + if (!problems.length) { + result = null; + return; + } + problems.sort((a, b) => { + return a.time - b.time; + }); + const lastProblem = problems[problems.length - 1]; + for (const problem of problems) { + if (problem.time > timestamp) { + result = Object.assign({}, problem, {fixed: lastProblem.fixed}); + return; + } + } + if (lastProblem.fixed) { + result = null; + } else { + result = lastProblem; + } + }; + await promiseifyTxn(txn); + return result; + } + + // FIXME: we should probably prune this when devices get deleted + async filterOutNotifiedErrorDevices(devices) { + const txn = this._db.transaction("notified_error_devices", "readwrite"); + const objectStore = txn.objectStore("notified_error_devices"); + + const ret = []; + + await Promise.all(devices.map((device) => { + return new Promise((resolve) => { + const {userId, deviceInfo} = device; + const getReq = objectStore.get([userId, deviceInfo.deviceId]); + getReq.onsuccess = function() { + if (!getReq.result) { + objectStore.put({userId, deviceId: deviceInfo.deviceId}); + ret.push(device); + } + resolve(); + }; + }); + })); + + return ret; + } + // Inbound group sessions getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { @@ -699,6 +767,16 @@ export function upgradeDatabase(db, oldVersion) { keyPath: ["senderCurve25519Key", "sessionId"], }); } + if (oldVersion < 9) { + const problemsStore = db.createObjectStore("session_problems", { + keyPath: ["deviceKey", "time"], + }); + problemsStore.createIndex("deviceKey", "deviceKey"); + + db.createObjectStore("notified_error_devices", { + keyPath: ["userId", "deviceId"], + }); + } // Expand as needed. } diff --git a/src/crypto/store/indexeddb-crypto-store.js b/src/crypto/store/indexeddb-crypto-store.js index 66c353681..52881ff66 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -409,6 +409,24 @@ export default class IndexedDBCryptoStore { }); } + storeEndToEndSessionProblem(deviceKey, type, fixed) { + return this._backendPromise.then(async (backend) => { + await backend.storeEndToEndSessionProblem(deviceKey, type, fixed); + }); + } + + getEndToEndSessionProblem(deviceKey, timestamp) { + return this._backendPromise.then(async (backend) => { + return await backend.getEndToEndSessionProblem(deviceKey, timestamp); + }); + } + + filterOutNotifiedErrorDevices(devices) { + return this._backendPromise.then(async (backend) => { + return await backend.filterOutNotifiedErrorDevices(devices); + }); + } + // Inbound group sessions /** diff --git a/src/crypto/store/localStorage-crypto-store.js b/src/crypto/store/localStorage-crypto-store.js index 1f8220d15..c75d5341b 100644 --- a/src/crypto/store/localStorage-crypto-store.js +++ b/src/crypto/store/localStorage-crypto-store.js @@ -31,6 +31,7 @@ import MemoryCryptoStore from './memory-crypto-store'; const E2E_PREFIX = "crypto."; const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; const KEY_CROSS_SIGNING_KEYS = E2E_PREFIX + "cross_signing_keys"; +const KEY_NOTIFIED_ERROR_DEVICES = E2E_PREFIX + "notified_error_devices"; const KEY_DEVICE_DATA = E2E_PREFIX + "device_data"; const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/"; const KEY_INBOUND_SESSION_WITHHELD_PREFIX = E2E_PREFIX + "inboundgroupsessions.withheld/"; @@ -41,6 +42,10 @@ function keyEndToEndSessions(deviceKey) { return E2E_PREFIX + "sessions/" + deviceKey; } +function keyEndToEndSessionProblems(deviceKey) { + return E2E_PREFIX + "session.problems/" + deviceKey; +} + function keyEndToEndInboundGroupSession(senderKey, sessionId) { return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId; } @@ -128,6 +133,58 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore { ); } + async storeEndToEndSessionProblem(deviceKey, type, fixed) { + const key = keyEndToEndSessionProblems(deviceKey); + const problems = getJsonItem(this.store, key) || []; + problems.push({type, fixed, time: Date.now()}); + problems.sort((a, b) => { + return a.time - b.time; + }); + setJsonItem(this.store, key, problems); + } + + async getEndToEndSessionProblem(deviceKey, timestamp) { + const key = keyEndToEndSessionProblems(deviceKey); + const problems = getJsonItem(this.store, key) || []; + if (!problems.length) { + return null; + } + const lastProblem = problems[problems.length - 1]; + for (const problem of problems) { + if (problem.time > timestamp) { + return Object.assign({}, problem, {fixed: lastProblem.fixed}); + } + } + if (lastProblem.fixed) { + return null; + } else { + return lastProblem; + } + } + + async filterOutNotifiedErrorDevices(devices) { + const notifiedErrorDevices = + getJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES) || {}; + const ret = []; + + for (const device of devices) { + const {userId, deviceInfo} = device; + if (userId in notifiedErrorDevices) { + if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) { + ret.push(device); + notifiedErrorDevices[userId][deviceInfo.deviceId] = true; + } + } else { + ret.push(device); + notifiedErrorDevices[userId] = {[deviceInfo.deviceId]: true }; + } + } + + setJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES, notifiedErrorDevices); + + return ret; + } + // Inbound Group Sessions getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { diff --git a/src/crypto/store/memory-crypto-store.js b/src/crypto/store/memory-crypto-store.js index 952af6696..f88b5e673 100644 --- a/src/crypto/store/memory-crypto-store.js +++ b/src/crypto/store/memory-crypto-store.js @@ -36,6 +36,10 @@ export default class MemoryCryptoStore { // Map of {devicekey -> {sessionId -> session pickle}} this._sessions = {}; + // Map of {devicekey -> array of problems} + this._sessionProblems = {}; + // Map of {userId -> deviceId -> true} + this._notifiedErrorDevices = {}; // Map of {senderCurve25519Key+'/'+sessionId -> session data object} this._inboundGroupSessions = {}; this._inboundGroupSessionsWithheld = {}; @@ -275,6 +279,53 @@ export default class MemoryCryptoStore { deviceSessions[sessionId] = sessionInfo; } + async storeEndToEndSessionProblem(deviceKey, type, fixed) { + const problems = this._sessionProblems[deviceKey] + = this._sessionProblems[deviceKey] || []; + problems.push({type, fixed, time: Date.now()}); + problems.sort((a, b) => { + return a.time - b.time; + }); + } + + async getEndToEndSessionProblem(deviceKey, timestamp) { + const problems = this._sessionProblems[deviceKey] || []; + if (!problems.length) { + return null; + } + const lastProblem = problems[problems.length - 1]; + for (const problem of problems) { + if (problem.time > timestamp) { + return Object.assign({}, problem, {fixed: lastProblem.fixed}); + } + } + if (lastProblem.fixed) { + return null; + } else { + return lastProblem; + } + } + + async filterOutNotifiedErrorDevices(devices) { + const notifiedErrorDevices = this._notifiedErrorDevices; + const ret = []; + + for (const device of devices) { + const {userId, deviceInfo} = device; + if (userId in notifiedErrorDevices) { + if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) { + ret.push(device); + notifiedErrorDevices[userId][deviceInfo.deviceId] = true; + } + } else { + ret.push(device); + notifiedErrorDevices[userId] = {[deviceInfo.deviceId]: true }; + } + } + + return ret; + } + // Inbound Group Sessions getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) {