Commit 9aca32ad authored by JC Brand's avatar JC Brand

Handle errors when sending encrypted groupchat messages

updates #1180
parent d0510856
......@@ -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(),
......@@ -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(
`<iq from="dummy@localhost" id="${iq_stanza.nodeTree.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+
`<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
`<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
`</pubsub>`+
`</iq>`);
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'));
/* <iq xmlns="jabber:client" to="jc@opkode.com/converse.js-34183907" type="error" id="945c8ab3-b561-4d8a-92da-77c226bb1689:sendIQ" from="joris@konuro.net">
* <pubsub xmlns="http://jabber.org/protocol/pubsub">
* <items node="eu.siacs.conversations.axolotl.bundles:7580"/>
* </pubsub>
* <error code="401" type="auth">
* <presence-subscription-required xmlns="http://jabber.org/protocol/pubsub#errors"/>
* <not-authorized xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
* </error>
* </iq>
*/
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'], {},
......
......@@ -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'));
return device.getBundle()
.then(bundle => device.save('bundle', _.extend(bundle, {'prekeys': marshalled_keys})));
});
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'));
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(),
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment