From 9aca32ad9744e1df328bc9f7567f6f3c84b162b9 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 19 Dec 2018 17:18:36 +0100 Subject: [PATCH] Handle errors when sending encrypted groupchat messages updates #1180 --- dist/converse.js | 81 ++++++++++++++++++-------- spec/omemo.js | 128 ++++++++++++++++++++++++++++++++++++++++++ src/converse-omemo.js | 72 +++++++++++++++++------- 3 files changed, 236 insertions(+), 45 deletions(-) diff --git a/dist/converse.js b/dist/converse.js index 47d49d65c..909fcce00 100644 --- a/dist/converse.js +++ b/dist/converse.js @@ -55980,6 +55980,15 @@ const KEY_ALGO = { 'length': 128 }; +class IQError extends Error { + constructor(message, iq) { + super(message, iq); + this.name = 'IQError'; + this.iq = iq; + } + +} + function parseBundle(bundle_el) { /* Given an XML element representing a user's OMEMO bundle, parse it * and return a map. @@ -56011,7 +56020,7 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins return !_.isNil(window.libsignal) && !f.includes('converse-omemo', _converse.blacklisted_plugins); }, - dependencies: ["converse-chatview"], + dependencies: ["converse-chatview", "converse-pubsub"], overrides: { ProfileModal: { events: { @@ -56267,6 +56276,31 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins })); }, + handleMessageSendError(e) { + const _converse = this.__super__._converse, + __ = _converse.__; + + if (e.name === 'IQError') { + this.save('omemo_supported', false); + const err_msgs = []; + + if (sizzle(`presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, e.iq).length) { + err_msgs.push(__("Sorry, we're unable to send an encrypted message because %1$s " + "requires you to be subscribed to their presence in order to see their OMEMO information", e.iq.getAttribute('from'))); + } else if (sizzle(`remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq).length) { + err_msgs.push(__("Sorry, we're unable to send an encrypted message because the remote server for %1$s could not be found", e.iq.getAttribute('from'))); + } else { + err_msgs.push(__("Unable to send an encrypted message due to an unexpected error.")); + err_msgs.push(e.iq.outerHTML); + } + + _converse.api.alert.show(Strophe.LogLevel.ERROR, __('Error'), err_msgs); + + _converse.log(e, Strophe.LogLevel.ERROR); + } else { + throw e; + } + }, + async sendMessage(attrs) { const _converse = this.__super__._converse, __ = _converse.__; @@ -56274,19 +56308,13 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins if (this.get('omemo_active') && attrs.message) { attrs['is_encrypted'] = true; attrs['plaintext'] = attrs.message; - const devices = await _converse.getBundlesAndBuildSessions(this); - const stanza = await _converse.createOMEMOMessageStanza(this, this.messages.create(attrs), devices); try { + const devices = await _converse.getBundlesAndBuildSessions(this); + const stanza = await _converse.createOMEMOMessageStanza(this, this.messages.create(attrs), devices); this.sendMessageStanza(stanza); } catch (e) { - this.messages.create({ - 'message': __("Sorry, could not send the message due to an error.") + ` ${e.message}`, - 'type': 'error' - }); - - _converse.log(e, Strophe.LogLevel.ERROR); - + this.handleMessageSendError(e); return false; } @@ -56770,7 +56798,7 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins return _converse.api.pubsub.publish(null, node, item, options); }, - generateMissingPreKeys() { + async generateMissingPreKeys() { const current_keys = this.getPreKeys(), missing_keys = _.difference(_.invokeMap(_.range(0, _converse.NUM_PREKEYS), Number.prototype.toString), _.keys(current_keys)); @@ -56780,20 +56808,21 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins return Promise.resolve(); } - return Promise.all(_.map(missing_keys, id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10)))).then(keys => { - _.forEach(keys, k => this.storePreKey(k.keyId, k.keyPair)); + const keys = await Promise.all(_.map(missing_keys, id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10)))); - const marshalled_keys = _.map(this.getPreKeys(), k => ({ - 'id': k.keyId, - 'key': u.arrayBufferToBase64(k.pubKey) - })), - devicelist = _converse.devicelists.get(_converse.bare_jid), - device = devicelist.devices.get(this.get('device_id')); + _.forEach(keys, k => this.storePreKey(k.keyId, k.keyPair)); - return device.getBundle().then(bundle => device.save('bundle', _.extend(bundle, { - 'prekeys': marshalled_keys - }))); - }); + const marshalled_keys = _.map(this.getPreKeys(), k => ({ + 'id': k.keyId, + 'key': u.arrayBufferToBase64(k.pubKey) + })), + devicelist = _converse.devicelists.get(_converse.bare_jid), + device = devicelist.devices.get(this.get('device_id')); + + const bundle = await device.getBundle(); + device.save('bundle', _.extend(bundle, { + 'prekeys': marshalled_keys + })); }, async generateBundle() { @@ -56892,7 +56921,11 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins try { iq = await _converse.api.sendIQ(stanza); } catch (iq) { - return _converse.log(iq.outerHTML, Strophe.LogLevel.ERROR); + throw new IQError("Could not fetch bundle", iq); + } + + if (iq.querySelector('error')) { + throw new IQError("Could not fetch bundle", iq); } const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop(), diff --git a/spec/omemo.js b/spec/omemo.js index 1136dde72..ac383191f 100644 --- a/spec/omemo.js +++ b/spec/omemo.js @@ -371,6 +371,134 @@ done(); })); + it("gracefully handles auth errors when trying to send encrypted groupchat messages", + mock.initConverseWithPromises( + null, ['rosterGroupsFetched', 'chatBoxesFetched'], {}, + async function (done, _converse) { + + // MEMO encryption works only in members only conferences + // that are non-anonymous. + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_membersonly', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + await test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy', features); + const view = _converse.chatboxviews.get('lounge@localhost'); + await test_utils.waitUntil(() => initializedOMEMO(_converse)); + + const contact_jid = 'newguy@localhost'; + let stanza = $pres({ + 'to': 'dummy@localhost/resource', + 'from': 'lounge@localhost/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@localhost/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(test_utils.createRequest(stanza)); + + const toolbar = view.el.querySelector('.chat-toolbar'); + const toggle = toolbar.querySelector('.toggle-omemo'); + toggle.click(); + expect(view.model.get('omemo_active')).toBe(true); + expect(view.model.get('omemo_supported')).toBe(true); + + const textarea = view.el.querySelector('.chat-textarea'); + textarea.value = 'This message will be encrypted'; + view.keyPressed({ + target: textarea, + preventDefault: _.noop, + keyCode: 13 // Enter + }); + let iq_stanza = await test_utils.waitUntil(() => deviceListFetched(_converse, contact_jid)); + expect(iq_stanza.toLocaleString()).toBe( + ``+ + ``+ + ``+ + ``+ + ``); + + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.nodeTree.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up() + + _converse.connection._dataRecv(test_utils.createRequest(stanza)); + await test_utils.waitUntil(() => _converse.omemo_store); + expect(_converse.devicelists.length).toBe(2); + + const devicelist = _converse.devicelists.get(contact_jid); + expect(devicelist.devices.length).toBe(1); + expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228'); + + iq_stanza = await test_utils.waitUntil(() => bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe')); + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.nodeTree.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up() + .c('signedPreKeySignature').t(btoa('200000')).up() + .c('identityKey').t(btoa('300000')).up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993')); + iq_stanza = await test_utils.waitUntil(() => bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228')); + + /* + * + * + * + * + * + * + * + * + */ + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.nodeTree.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': 'http://jabber.org/protocol/pubsub'}) + .c('items', {'node': "eu.siacs.conversations.axolotl.bundles:4e30f35051b7b8b42abe083742187228"}).up().up() + .c('error', {'code': '401', 'type': 'auth'}) + .c('presence-subscription-required', {'xmlns':"http://jabber.org/protocol/pubsub#errors" }).up() + .c('not-authorized', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(test_utils.createRequest(stanza)); + + await test_utils.waitUntil(() => document.querySelectorAll('.alert-danger').length, 2000); + const header = document.querySelector('.alert-danger .modal-title'); + expect(header.textContent).toBe("Error"); + expect(u.ancestor(header, '.modal-content').querySelector('.modal-body p').textContent.trim()) + .toBe("Sorry, we're unable to send an encrypted message because newguy@localhost requires you "+ + "to be subscribed to their presence in order to see their OMEMO information"); + + expect(view.model.get('omemo_supported')).toBe(false); + expect(view.el.querySelector('.chat-textarea').value).toBe('This message will be encrypted'); + done(); + })); + it("can receive a PreKeySignalMessage", mock.initConverseWithPromises( null, ['rosterGroupsFetched', 'chatBoxesFetched'], {}, diff --git a/src/converse-omemo.js b/src/converse-omemo.js index 6e4c159d3..a4a307347 100644 --- a/src/converse-omemo.js +++ b/src/converse-omemo.js @@ -27,6 +27,15 @@ const KEY_ALGO = { }; +class IQError extends Error { + constructor (message, iq) { + super(message, iq); + this.name = 'IQError'; + this.iq = iq; + } +} + + function parseBundle (bundle_el) { /* Given an XML element representing a user's OMEMO bundle, parse it * and return a map. @@ -61,7 +70,7 @@ converse.plugins.add('converse-omemo', { return !_.isNil(window.libsignal) && !f.includes('converse-omemo', _converse.blacklisted_plugins); }, - dependencies: ["converse-chatview"], + dependencies: ["converse-chatview", "converse-pubsub"], overrides: { @@ -309,6 +318,31 @@ converse.plugins.add('converse-omemo', { .then(payload => ({'payload': payload, 'device': device})); }, + handleMessageSendError (e) { + const { _converse } = this.__super__, + { __ } = _converse; + if (e.name === 'IQError') { + this.save('omemo_supported', false); + + const err_msgs = []; + if (sizzle(`presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, e.iq).length) { + err_msgs.push(__("Sorry, we're unable to send an encrypted message because %1$s "+ + "requires you to be subscribed to their presence in order to see their OMEMO information", + e.iq.getAttribute('from'))); + } else if (sizzle(`remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq).length) { + err_msgs.push(__("Sorry, we're unable to send an encrypted message because the remote server for %1$s could not be found", + e.iq.getAttribute('from'))); + } else { + err_msgs.push(__("Unable to send an encrypted message due to an unexpected error.")); + err_msgs.push(e.iq.outerHTML); + } + _converse.api.alert.show(Strophe.LogLevel.ERROR, __('Error'), err_msgs); + _converse.log(e, Strophe.LogLevel.ERROR); + } else { + throw e; + } + }, + async sendMessage (attrs) { const { _converse } = this.__super__, { __ } = _converse; @@ -316,16 +350,12 @@ converse.plugins.add('converse-omemo', { if (this.get('omemo_active') && attrs.message) { attrs['is_encrypted'] = true; attrs['plaintext'] = attrs.message; - const devices = await _converse.getBundlesAndBuildSessions(this); - const stanza = await _converse.createOMEMOMessageStanza(this, this.messages.create(attrs), devices); try { + const devices = await _converse.getBundlesAndBuildSessions(this); + const stanza = await _converse.createOMEMOMessageStanza(this, this.messages.create(attrs), devices); this.sendMessageStanza(stanza); } catch (e) { - this.messages.create({ - 'message': __("Sorry, could not send the message due to an error.") + ` ${e.message}`, - 'type': 'error', - }); - _converse.log(e, Strophe.LogLevel.ERROR); + this.handleMessageSendError(e); return false; } return true; @@ -346,7 +376,6 @@ converse.plugins.add('converse-omemo', { this.model.on('change:omemo_supported', this.onOMEMOSupportedDetermined, this); }, - showMessage (message) { // We don't show a message if it's only keying material if (!message.get('is_only_key')) { @@ -768,7 +797,7 @@ converse.plugins.add('converse-omemo', { return _converse.api.pubsub.publish(null, node, item, options); }, - generateMissingPreKeys () { + async generateMissingPreKeys () { const current_keys = this.getPreKeys(), missing_keys = _.difference(_.invokeMap(_.range(0, _converse.NUM_PREKEYS), Number.prototype.toString), _.keys(current_keys)); @@ -776,16 +805,14 @@ converse.plugins.add('converse-omemo', { _converse.log("No missing prekeys to generate for our own device", Strophe.LogLevel.WARN); return Promise.resolve(); } - return Promise.all(_.map(missing_keys, id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10)))) - .then(keys => { - _.forEach(keys, k => this.storePreKey(k.keyId, k.keyPair)); - const marshalled_keys = _.map(this.getPreKeys(), k => ({'id': k.keyId, 'key': u.arrayBufferToBase64(k.pubKey)})), - devicelist = _converse.devicelists.get(_converse.bare_jid), - device = devicelist.devices.get(this.get('device_id')); + const keys = await Promise.all(_.map(missing_keys, id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10)))); + _.forEach(keys, k => this.storePreKey(k.keyId, k.keyPair)); + const marshalled_keys = _.map(this.getPreKeys(), k => ({'id': k.keyId, 'key': u.arrayBufferToBase64(k.pubKey)})), + devicelist = _converse.devicelists.get(_converse.bare_jid), + device = devicelist.devices.get(this.get('device_id')); - return device.getBundle() - .then(bundle => device.save('bundle', _.extend(bundle, {'prekeys': marshalled_keys}))); - }); + const bundle = await device.getBundle(); + device.save('bundle', _.extend(bundle, {'prekeys': marshalled_keys})); }, async generateBundle () { @@ -871,8 +898,11 @@ converse.plugins.add('converse-omemo', { let iq; try { iq = await _converse.api.sendIQ(stanza) - } catch(iq) { - return _converse.log(iq.outerHTML, Strophe.LogLevel.ERROR); + } catch (iq) { + throw new IQError("Could not fetch bundle", iq); + } + if (iq.querySelector('error')) { + throw new IQError("Could not fetch bundle", iq); } const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop(), bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop(),