From f64fdb80880b525e4a942c3796159059a59b30b5 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Thu, 13 Dec 2018 09:48:43 +0100 Subject: [PATCH] Render the OMEMO lock icon in MUC toolbars as well updates #1180 --- dist/converse.js | 98 ++++++++++++++++-- spec/omemo.js | 190 +++++++++++++++++++++++++++++++++-- src/converse-omemo.js | 78 ++++++++++++-- src/headless/converse-muc.js | 17 ++++ 4 files changed, 358 insertions(+), 25 deletions(-) diff --git a/dist/converse.js b/dist/converse.js index 4ee439fad..761401087 100644 --- a/dist/converse.js +++ b/dist/converse.js @@ -56433,13 +56433,6 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins this.model.on('change:omemo_active', this.renderOMEMOToolbarButton, this); this.model.on('change:omemo_supported', this.onOMEMOSupportedDetermined, this); - this.checkOMEMOSupported(); - }, - - async checkOMEMOSupported() { - const _converse = this.__super__._converse; - const supported = await _converse.contactHasOMEMOSupport(this.model.get('jid')); - this.model.set('omemo_supported', supported); }, showMessage(message) { @@ -56486,6 +56479,33 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins }); } + }, + ChatRoomView: { + events: { + 'click .toggle-omemo': 'toggleOMEMO' + }, + + initialize() { + this.__super__.initialize.apply(this, arguments); + + this.model.on('change:omemo_active', this.renderOMEMOToolbarButton, this); + this.model.on('change:omemo_supported', this.onOMEMOSupportedDetermined, this); + }, + + toggleOMEMO(ev) { + const _converse = this.__super__._converse, + __ = _converse.__; + + if (!this.model.get('omemo_supported')) { + return _converse.api.alert.show(Strophe.LogLevel.ERROR, __('Error'), [__('Cannot use end-to-end encryption in this groupchat, ' + 'either the groupchat has some anonymity or not all participants support OMEMO.')]); + } + + ev.preventDefault(); + this.model.save({ + 'omemo_active': !this.model.get('omemo_active') + }); + } + } }, @@ -56493,7 +56513,8 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins /* The initialize function gets called as soon as the plugin is * loaded by Converse.js's plugin machinery. */ - const _converse = this._converse; + const _converse = this._converse, + __ = _converse.__; _converse.api.promises.add(['OMEMOInitialized']); @@ -57134,6 +57155,49 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins fetchOwnDevices().then(() => restoreOMEMOSession()).then(() => _converse.omemo_store.publishBundle()).then(() => _converse.emit('OMEMOInitialized')).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR)); } + async function onOccupantAdded(chatroom, occupant) { + if (occupant.isSelf() || !chatroom.get('nonanonymous')) { + return; + } + + if (chatroom.get('omemo_active')) { + const supported = await _converse.contactHasOMEMOSupport(occupant.get('jid')); + + if (!supported) { + chatroom.messages.create({ + 'message': __("%1$s doesn't appear to have a client that supports OMEMO. " + "Encrypted chat will no longer be possible in this grouchat.", occupant.get('nick')), + 'type': 'error' + }); + chatroom.save({ + 'omemo_active': false, + 'omemo_supported': false + }); + } + } + } + + async function checkOMEMOSupported(chatbox) { + let supported; + + if (chatbox.get('type') === _converse.CHATROOMS_TYPE) { + supported = chatbox.get('nonanonymous') && chatbox.get('membersonly'); + } else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) { + supported = await _converse.contactHasOMEMOSupport(chatbox.get('jid')); + } + + chatbox.set('omemo_supported', supported); + } + + _converse.api.waitUntil('chatBoxesInitialized').then(() => _converse.chatboxes.on('add', chatbox => { + checkOMEMOSupported(chatbox); + + if (chatbox.get('type') === _converse.CHATROOMS_TYPE) { + chatbox.occupants.on('add', o => onOccupantAdded(chatbox, o)); + chatbox.on('change:nonanonymous', checkOMEMOSupported); + chatbox.on('change:membersonly', checkOMEMOSupported); + } + })); + _converse.api.listen.on('afterTearDown', () => { if (_converse.devicelists) { _converse.devicelists.reset(); @@ -65885,7 +65949,23 @@ Strophe.addNamespace('MUC_REGISTER', "jabber:iq:register"); Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + "#roomconfig"); Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user"); _converse_core__WEBPACK_IMPORTED_MODULE_6__["default"].MUC_NICK_CHANGED_CODE = "303"; -_converse_core__WEBPACK_IMPORTED_MODULE_6__["default"].ROOM_FEATURES = ['passwordprotected', 'unsecured', 'hidden', 'publicroom', 'membersonly', 'open', 'persistent', 'temporary', 'nonanonymous', 'semianonymous', 'moderated', 'unmoderated', 'mam_enabled']; +_converse_core__WEBPACK_IMPORTED_MODULE_6__["default"].ROOM_FEATURES = ['passwordprotected', 'unsecured', 'hidden', 'publicroom', 'membersonly', 'open', 'persistent', 'temporary', 'nonanonymous', 'semianonymous', 'moderated', 'unmoderated', 'mam_enabled']; // No longer used in code, but useful as reference. +// +// const ROOM_FEATURES_MAP = { +// 'passwordprotected': 'unsecured', +// 'unsecured': 'passwordprotected', +// 'hidden': 'publicroom', +// 'publicroom': 'hidden', +// 'membersonly': 'open', +// 'open': 'membersonly', +// 'persistent': 'temporary', +// 'temporary': 'persistent', +// 'nonanonymous': 'semianonymous', +// 'semianonymous': 'nonanonymous', +// 'moderated': 'unmoderated', +// 'unmoderated': 'moderated' +// }; + _converse_core__WEBPACK_IMPORTED_MODULE_6__["default"].ROOMSTATUS = { CONNECTED: 0, CONNECTING: 1, diff --git a/spec/omemo.js b/spec/omemo.js index e5cbb427c..5eebbd097 100644 --- a/spec/omemo.js +++ b/spec/omemo.js @@ -1,12 +1,8 @@ (function (root, factory) { define(["jasmine", "mock", "test-utils"], factory); } (this, function (jasmine, mock, test_utils) { - var Strophe = converse.env.Strophe; - var b64_sha1 = converse.env.b64_sha1; - var $iq = converse.env.$iq; - var $msg = converse.env.$msg; - var _ = converse.env._; - var u = converse.env.utils; + const { $iq, $pres, $msg, _, Strophe } = converse.env; + const u = converse.env.utils; function deviceListFetched (_converse, jid) { @@ -229,7 +225,6 @@ done(); })); - it("can receive a PreKeySignalMessage", mock.initConverseWithPromises( null, ['rosterGroupsFetched', 'chatBoxesFetched'], {}, @@ -824,6 +819,187 @@ done(); })); + it("adds a toolbar button for starting an encrypted groupchat session", + mock.initConverseWithPromises( + null, ['rosterGroupsFetched', 'chatBoxesFetched'], {'view_mode': 'fullscreen'}, + 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 toolbar = view.el.querySelector('.chat-toolbar'); + let toggle = toolbar.querySelector('.toggle-omemo'); + expect(view.model.get('omemo_active')).toBe(undefined); + expect(_.isNull(toggle)).toBe(false); + expect(u.hasClass('fa-unlock', toggle)).toBe(true); + expect(u.hasClass('fa-lock', toggle)).toBe(false); + expect(u.hasClass('disabled', toggle)).toBe(false); + expect(view.model.get('omemo_supported')).toBe(true); + + toggle.click(); + toggle = toolbar.querySelector('.toggle-omemo'); + expect(view.model.get('omemo_active')).toBe(true); + expect(u.hasClass('fa-unlock', toggle)).toBe(false); + expect(u.hasClass('fa-lock', toggle)).toBe(true); + expect(u.hasClass('disabled', toggle)).toBe(false); + expect(view.model.get('omemo_supported')).toBe(true); + + let 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)); + + 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() + .c('device', {'id': 'ae890ac52d0df67ed7cfdf51b644e901'}); + _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(2); + expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228'); + expect(devicelist.devices.at(1).get('id')).toBe('ae890ac52d0df67ed7cfdf51b644e901'); + + expect(view.model.get('omemo_active')).toBe(true); + toggle = toolbar.querySelector('.toggle-omemo'); + expect(_.isNull(toggle)).toBe(false); + expect(u.hasClass('fa-unlock', toggle)).toBe(false); + expect(u.hasClass('fa-lock', toggle)).toBe(true); + expect(u.hasClass('disabled', toggle)).toBe(false); + expect(view.model.get('omemo_supported')).toBe(true); + + // Test that the button gets disabled when the room becomes + // anonymous or semi-anonymous + view.model.save({'nonanonymous': false, 'semianonymous': true}); + await test_utils.waitUntil(() => !view.model.get('omemo_supported')); + toggle = toolbar.querySelector('.toggle-omemo'); + expect(_.isNull(toggle)).toBe(false); + expect(u.hasClass('fa-unlock', toggle)).toBe(true); + expect(u.hasClass('fa-lock', toggle)).toBe(false); + expect(u.hasClass('disabled', toggle)).toBe(true); + expect(view.model.get('omemo_supported')).toBe(false); + + view.model.save({'nonanonymous': true, 'semianonymous': false}); + await test_utils.waitUntil(() => view.model.get('omemo_supported')); + toggle = toolbar.querySelector('.toggle-omemo'); + expect(_.isNull(toggle)).toBe(false); + expect(u.hasClass('fa-unlock', toggle)).toBe(true); + expect(u.hasClass('fa-lock', toggle)).toBe(false); + expect(u.hasClass('disabled', toggle)).toBe(false); + + // Test that the button gets disabled when the room becomes open + view.model.save({'membersonly': false, 'open': true}); + await test_utils.waitUntil(() => !view.model.get('omemo_supported')); + toggle = toolbar.querySelector('.toggle-omemo'); + expect(_.isNull(toggle)).toBe(false); + expect(u.hasClass('fa-unlock', toggle)).toBe(true); + expect(u.hasClass('fa-lock', toggle)).toBe(false); + expect(u.hasClass('disabled', toggle)).toBe(true); + expect(view.model.get('omemo_supported')).toBe(false); + + view.model.save({'membersonly': true, 'open': false}); + await test_utils.waitUntil(() => view.model.get('omemo_supported')); + toggle = toolbar.querySelector('.toggle-omemo'); + expect(_.isNull(toggle)).toBe(false); + expect(u.hasClass('fa-unlock', toggle)).toBe(true); + expect(u.hasClass('fa-lock', toggle)).toBe(false); + expect(u.hasClass('disabled', toggle)).toBe(false); + expect(view.model.get('omemo_supported')).toBe(true); + expect(view.model.get('omemo_active')).toBe(false); + + toggle.click(); + expect(view.model.get('omemo_active')).toBe(true); + + // Someone enters the room who doesn't have OMEMO support, while we + // have OMEMO activated... + contact_jid = 'oldguy@localhost'; + stanza = $pres({ + to: 'dummy@localhost/resource', + from: 'lounge@localhost/oldguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${contact_jid}/_converse.js-290929788`, + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(test_utils.createRequest(stanza)); + 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': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(test_utils.createRequest(stanza)); + + await test_utils.waitUntil(() => !view.model.get('omemo_supported')); + + expect(view.el.querySelector('.chat-error').textContent).toBe( + "oldguy doesn't appear to have a client that supports OMEMO. "+ + "Encrypted chat will no longer be possible in this grouchat." + ); + + toggle = toolbar.querySelector('.toggle-omemo'); + expect(_.isNull(toggle)).toBe(false); + expect(u.hasClass('fa-unlock', toggle)).toBe(true); + expect(u.hasClass('fa-lock', toggle)).toBe(false); + expect(u.hasClass('disabled', toggle)).toBe(true); + + expect( _converse.chatboxviews.el.querySelector('.modal-body p')).toBe(null); + toggle.click(); + const msg = _converse.chatboxviews.el.querySelector('.modal-body p'); + expect(msg.textContent).toBe( + 'Cannot use end-to-end encryption in this groupchat, '+ + 'either the groupchat has some anonymity or not all participants support OMEMO.'); + done(); + })); + it("shows OMEMO device fingerprints in the user details modal", mock.initConverseWithPromises( diff --git a/src/converse-omemo.js b/src/converse-omemo.js index edb6247d8..6bf550a23 100644 --- a/src/converse-omemo.js +++ b/src/converse-omemo.js @@ -87,7 +87,7 @@ converse.plugins.add('converse-omemo', { beforeRender () { const { _converse } = this.__super__, device_id = _converse.omemo_store.get('device_id'); - + if (device_id) { this.current_device = this.devicelist.devices.get(device_id); } @@ -458,14 +458,8 @@ converse.plugins.add('converse-omemo', { this.__super__.initialize.apply(this, arguments); this.model.on('change:omemo_active', this.renderOMEMOToolbarButton, this); this.model.on('change:omemo_supported', this.onOMEMOSupportedDetermined, this); - this.checkOMEMOSupported(); }, - async checkOMEMOSupported () { - const { _converse } = this.__super__; - const supported = await _converse.contactHasOMEMOSupport(this.model.get('jid')); - this.model.set('omemo_supported', supported); - }, showMessage (message) { // We don't show a message if it's only keying material @@ -503,12 +497,38 @@ converse.plugins.add('converse-omemo', { __('Error'), [__("Cannot use end-to-end encryption because %1$s uses a client that doesn't support OMEMO.", this.model.contact.getDisplayName() - )] + )] ) } ev.preventDefault(); this.model.save({'omemo_active': !this.model.get('omemo_active')}); } + }, + + ChatRoomView: { + events: { + 'click .toggle-omemo': 'toggleOMEMO' + }, + + initialize () { + this.__super__.initialize.apply(this, arguments); + this.model.on('change:omemo_active', this.renderOMEMOToolbarButton, this); + this.model.on('change:omemo_supported', this.onOMEMOSupportedDetermined, this); + }, + + toggleOMEMO (ev) { + const { _converse } = this.__super__, { __ } = _converse; + if (!this.model.get('omemo_supported')) { + return _converse.api.alert.show( + Strophe.LogLevel.ERROR, + __('Error'), + [__('Cannot use end-to-end encryption in this groupchat, '+ + 'either the groupchat has some anonymity or not all participants support OMEMO.')] + ); + } + ev.preventDefault(); + this.model.save({'omemo_active': !this.model.get('omemo_active')}); + } } }, @@ -516,7 +536,8 @@ converse.plugins.add('converse-omemo', { /* The initialize function gets called as soon as the plugin is * loaded by Converse.js's plugin machinery. */ - const { _converse } = this; + const { _converse } = this, + { __ } = _converse; _converse.api.promises.add(['OMEMOInitialized']); @@ -1070,6 +1091,45 @@ converse.plugins.add('converse-omemo', { .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR)); } + async function onOccupantAdded (chatroom, occupant) { + if (occupant.isSelf() || !chatroom.get('nonanonymous')) { + return; + } + if (chatroom.get('omemo_active')) { + const supported = await _converse.contactHasOMEMOSupport(occupant.get('jid')); + if (!supported) { + chatroom.messages.create({ + 'message': __("%1$s doesn't appear to have a client that supports OMEMO. " + + "Encrypted chat will no longer be possible in this grouchat.", occupant.get('nick')), + 'type': 'error' + }); + chatroom.save({'omemo_active': false, 'omemo_supported': false}); + } + } + } + + async function checkOMEMOSupported (chatbox) { + let supported; + if (chatbox.get('type') === _converse.CHATROOMS_TYPE) { + supported = chatbox.get('nonanonymous') && chatbox.get('membersonly'); + } else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) { + supported = await _converse.contactHasOMEMOSupport(chatbox.get('jid')); + } + chatbox.set('omemo_supported', supported); + } + + _converse.api.waitUntil('chatBoxesInitialized').then(() => + _converse.chatboxes.on('add', chatbox => { + checkOMEMOSupported(chatbox); + if (chatbox.get('type') === _converse.CHATROOMS_TYPE) { + chatbox.occupants.on('add', o => onOccupantAdded(chatbox, o)); + chatbox.on('change:nonanonymous', checkOMEMOSupported); + chatbox.on('change:membersonly', checkOMEMOSupported); + } + }) + ); + + _converse.api.listen.on('afterTearDown', () => { if (_converse.devicelists) { _converse.devicelists.reset(); diff --git a/src/headless/converse-muc.js b/src/headless/converse-muc.js index 59d3bbfd4..471f83757 100644 --- a/src/headless/converse-muc.js +++ b/src/headless/converse-muc.js @@ -38,6 +38,23 @@ converse.ROOM_FEATURES = [ 'moderated', 'unmoderated', 'mam_enabled' ]; +// No longer used in code, but useful as reference. +// +// const ROOM_FEATURES_MAP = { +// 'passwordprotected': 'unsecured', +// 'unsecured': 'passwordprotected', +// 'hidden': 'publicroom', +// 'publicroom': 'hidden', +// 'membersonly': 'open', +// 'open': 'membersonly', +// 'persistent': 'temporary', +// 'temporary': 'persistent', +// 'nonanonymous': 'semianonymous', +// 'semianonymous': 'nonanonymous', +// 'moderated': 'unmoderated', +// 'unmoderated': 'moderated' +// }; + converse.ROOMSTATUS = { CONNECTED: 0, CONNECTING: 1,