Browse Source

OMEMO Refactoring

When calling `getDeviceList`, wait for the devices to be fetched

Otherwise a race condition might occur, whereby a new device gets
created in the collection, and then removed again as the collection is
replaced with the values fetched from the browser-storage cache.

Also created `converse-omemo-fingerprints` component to asynchronously
render fingerprints in the user details modal. Was done as part of this
commit because due to `getDeviceList` being async, the relevant test for
the modal were also failing
JC Brand 3 năm trước cách đây
mục cha
commit
bad2577e5e

+ 0 - 9
src/headless/utils/core.js

@@ -415,15 +415,6 @@ u.getSelectValues = function (select) {
     return result;
 };
 
-u.formatFingerprint = function (fp) {
-    fp = fp.replace(/^05/, '');
-    for (let i=1; i<8; i++) {
-        const idx = i*8+i-1;
-        fp = fp.slice(0, idx) + ' ' + fp.slice(idx);
-    }
-    return fp;
-};
-
 u.getRandomInt = function (max) {
     return Math.floor(Math.random() * Math.floor(max));
 };

+ 1 - 41
src/modals/templates/user-details.js

@@ -4,46 +4,6 @@ import { html } from 'lit';
 import { modal_close_button, modal_header_close_button } from 'plugins/modal/templates/buttons.js'
 
 
-const device_fingerprint = (o) => {
-    const i18n_trusted = __('Trusted');
-    const i18n_untrusted = __('Untrusted');
-    if (o.device.get('bundle') && o.device.get('bundle').fingerprint) {
-        return html`
-            <li class="list-group-item">
-                <form class="fingerprint-trust">
-                <div class="btn-group btn-group-toggle">
-                    <label class="btn btn--small ${(o.device.get('trusted') !== -1) ? 'btn-primary active' : 'btn-secondary'}">
-                        <input type="radio" name="${o.device.get('id')}" value="1" ?checked=${o.device.get('trusted') !== -1}>${i18n_trusted}
-                    </label>
-                    <label class="btn btn--small ${(o.device.get('trusted') !== -1) ? 'btn-primary active' : 'btn-secondary'}">
-                        <input type="radio" name="${o.device.get('id')}" value="-1" ?checked=${o.device.get('trusted') === -1}>${i18n_untrusted}
-                    </label>
-                </div>
-                <code class="fingerprint">${o.utils.formatFingerprint(o.device.get('bundle').fingerprint)}</code>
-                </form>
-            </li>
-        `;
-    } else {
-        return ''
-    }
-}
-
-
-const fingerprints = (o) => {
-    const i18n_fingerprints = __('OMEMO Fingerprints');
-    const i18n_no_devices = __("No OMEMO-enabled devices found");
-    const devices = o.view.devicelist.devices;
-    return html`
-        <hr/>
-        <ul class="list-group fingerprints">
-            <li class="list-group-item active">${i18n_fingerprints}</li>
-            ${ devices.length ?
-                devices.map(device => device_fingerprint(Object.assign({device}, o))) :
-                html`<li class="list-group-item"> ${i18n_no_devices} </li>` }
-        </ul>
-    `;
-}
-
 const remove_button = (o) => {
     const i18n_remove_contact = __('Remove as contact');
     return html`
@@ -86,7 +46,7 @@ export default (o) => {
                     ${ o.email ? html`<p><label>${i18n_email}:</label> <a href="mailto:${o.email}">${o.email}</a></p>` : '' }
                     ${ o.role ? html`<p><label>${i18n_role}:</label> ${o.role}</p>` : '' }
 
-                    ${ (o._converse.pluggable.plugins['converse-omemo'].enabled(o._converse)) ? fingerprints(o) : '' }
+                    <converse-omemo-fingerprints jid=${o.jid}></converse-omemo-fingerprints>
                 </div>
                 <div class="modal-footer">
                     ${modal_close_button}

+ 5 - 6
src/modals/user-details.js

@@ -27,7 +27,6 @@ const UserDetailsModal = BootstrapModal.extend({
 
     events: {
         'click button.refresh-contact': 'refreshContact',
-        'click .fingerprint-trust .btn input': 'toggleDeviceTrust'
     },
 
     initialize () {
@@ -36,11 +35,11 @@ const UserDetailsModal = BootstrapModal.extend({
         this.listenTo(this.model, 'change', this.render);
         this.registerContactEventHandlers();
         /**
-            * Triggered once the UserDetailsModal has been initialized
-            * @event _converse#userDetailsModalInitialized
-            * @type { _converse.ChatBox }
-            * @example _converse.api.listen.on('userDetailsModalInitialized', chatbox => { ... });
-            */
+         * Triggered once the UserDetailsModal has been initialized
+         * @event _converse#userDetailsModalInitialized
+         * @type { _converse.ChatBox }
+         * @example _converse.api.listen.on('userDetailsModalInitialized', chatbox => { ... });
+         */
         api.trigger('userDetailsModalInitialized', this.model);
     },
 

+ 6 - 3
src/plugins/omemo/devicelist.js

@@ -1,6 +1,7 @@
 import log from '@converse/headless/log';
 import { Model } from '@converse/skeletor/src/model.js';
 import { _converse, api, converse } from '@converse/headless/core';
+import { getOpenPromise } from '@converse/openpromise';
 import { initStorage } from '@converse/headless/utils/storage.js';
 import { restoreOMEMOSession } from './utils.js';
 
@@ -14,15 +15,17 @@ const { Strophe, $build, $iq, sizzle } = converse.env;
 const DeviceList = Model.extend({
     idAttribute: 'jid',
 
-    initialize () {
-        this.initDevices();
+    async initialize () {
+        this.initialized = getOpenPromise();
+        await this.initDevices();
+        this.initialized.resolve();
     },
 
     initDevices () {
         this.devices = new _converse.Devices();
         const id = `converse.devicelist-${_converse.bare_jid}-${this.get('jid')}`;
         initStorage(this.devices, id);
-        this.fetchDevices();
+        return this.fetchDevices();
     },
 
     async onDevicesFound (collection) {

+ 4 - 3
src/plugins/omemo/devicelists.js

@@ -12,12 +12,13 @@ const DeviceLists = Collection.extend({
     /**
      * Returns the {@link _converse.DeviceList} for a particular JID.
      * The device list will be created if it doesn't exist already.
-     * @private
      * @method _converse.DeviceLists#getDeviceList
      * @param { String } jid - The Jabber ID for which the device list will be returned.
      */
-    getDeviceList (jid) {
-        return this.get(jid) || this.create({ 'jid': jid });
+    async getDeviceList (jid) {
+        const list = this.get(jid) || this.create({ 'jid': jid });
+        await list.initialized;
+        return list;
     }
 });
 

+ 34 - 0
src/plugins/omemo/fingerprints.js

@@ -0,0 +1,34 @@
+import tpl_fingerprints from './templates/fingerprints.js';
+import { CustomElement } from 'shared/components/element.js';
+import { _converse, api } from "@converse/headless/core";
+
+export class Fingerprints extends CustomElement {
+
+    static get properties () {
+        return {
+            'jid': { type: String }
+        }
+    }
+
+    async initialize () {
+        this.devicelist = await _converse.devicelists.getDeviceList(this.jid);
+        this.listenTo(this.devicelist.devices, 'change:bundle', this.requestUpdate);
+        this.listenTo(this.devicelist.devices, 'change:trusted', this.requestUpdate);
+        this.listenTo(this.devicelist.devices, 'remove', this.requestUpdate);
+        this.listenTo(this.devicelist.devices, 'add', this.requestUpdate);
+        this.listenTo(this.devicelist.devices, 'reset', this.requestUpdate);
+        this.requestUpdate();
+    }
+
+    render () {
+        return this.devicelist ? tpl_fingerprints(this) : '';
+    }
+
+    toggleDeviceTrust (ev) {
+        const radio = ev.target;
+        const device = this.devicelist.devices.get(radio.getAttribute('name'));
+        device.save('trusted', parseInt(radio.value, 10));
+    }
+}
+
+api.elements.define('converse-omemo-fingerprints', Fingerprints);

+ 2 - 3
src/plugins/omemo/index.js

@@ -1,8 +1,8 @@
 /**
- * @module converse-omemo
  * @copyright The Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
+import './fingerprints.js';
 import 'modals/user-details.js';
 import 'plugins/profile/index.js';
 import ChatBox from './overrides/chatbox.js';
@@ -13,7 +13,6 @@ import DeviceLists from './devicelists.js';
 import Devices from './devices.js';
 import OMEMOStore from './store.js';
 import ProfileModal from './overrides/profile-modal.js';
-import UserDetailsModal from './overrides/user-details-modal.js';
 import log from '@converse/headless/log';
 import omemo_api from './api.js';
 import { OMEMOEnabledChatBox } from './mixins/chatbox.js';
@@ -53,7 +52,7 @@ converse.plugins.add('converse-omemo', {
 
     dependencies: ['converse-chatview', 'converse-pubsub', 'converse-profile'],
 
-    overrides: { ProfileModal, UserDetailsModal, ChatBox },
+    overrides: { ProfileModal, ChatBox },
 
     initialize () {
         api.settings.extend({ 'omemo_default': false });

+ 0 - 26
src/plugins/omemo/overrides/user-details-modal.js

@@ -1,26 +0,0 @@
-import { _converse } from '@converse/headless/core';
-
-const UserDetailsModal = {
-    events: {
-        'click .fingerprint-trust .btn input': 'toggleDeviceTrust'
-    },
-
-    initialize () {
-        const jid = this.model.get('jid');
-        this.devicelist = _converse.devicelists.getDeviceList(jid);
-        this.listenTo(this.devicelist.devices, 'change:bundle', this.render);
-        this.listenTo(this.devicelist.devices, 'change:trusted', this.render);
-        this.listenTo(this.devicelist.devices, 'remove', this.render);
-        this.listenTo(this.devicelist.devices, 'add', this.render);
-        this.listenTo(this.devicelist.devices, 'reset', this.render);
-        return this.__super__.initialize.apply(this, arguments);
-    },
-
-    toggleDeviceTrust (ev) {
-        const radio = ev.target;
-        const device = this.devicelist.devices.get(radio.getAttribute('name'));
-        device.save('trusted', parseInt(radio.value, 10));
-    }
-}
-
-export default UserDetailsModal;

+ 46 - 0
src/plugins/omemo/templates/fingerprints.js

@@ -0,0 +1,46 @@
+import { __ } from 'i18n';
+import { html } from 'lit';
+import { formatFingerprint } from '../utils.js';
+
+const device_fingerprint = (el, device) => {
+    const i18n_trusted = __('Trusted');
+    const i18n_untrusted = __('Untrusted');
+    if (device.get('bundle') && device.get('bundle').fingerprint) {
+        return html`
+            <li class="list-group-item">
+                <form class="fingerprint-trust">
+                    <div class="btn-group btn-group-toggle">
+                        <label class="btn btn--small ${(device.get('trusted') === 1) ? 'btn-primary active' : 'btn-secondary'}"
+                                @click=${el.toggleDeviceTrust}>
+                            <input type="radio" name="${device.get('id')}" value="1"
+                                ?checked=${device.get('trusted') !== -1}>${i18n_trusted}
+                        </label>
+                        <label class="btn btn--small ${(device.get('trusted') === -1) ? 'btn-primary active' : 'btn-secondary'}"
+                                @click=${el.toggleDeviceTrust}>
+                            <input type="radio" name="${device.get('id')}" value="-1"
+                                ?checked=${device.get('trusted') === -1}>${i18n_untrusted}
+                        </label>
+                    </div>
+                    <code class="fingerprint">${formatFingerprint(device.get('bundle').fingerprint)}</code>
+                </form>
+            </li>
+        `;
+    } else {
+        return ''
+    }
+}
+
+export default (el) => {
+    const i18n_fingerprints = __('OMEMO Fingerprints');
+    const i18n_no_devices = __("No OMEMO-enabled devices found");
+    const devices = el.devicelist.devices;
+    return html`
+        <hr/>
+        <ul class="list-group fingerprints">
+            <li class="list-group-item active">${i18n_fingerprints}</li>
+            ${ devices.length ?
+                devices.map(device => device_fingerprint(el, device)) :
+                html`<li class="list-group-item"> ${i18n_no_devices} </li>` }
+        </ul>
+    `;
+}

+ 115 - 58
src/plugins/omemo/tests/omemo.js

@@ -21,7 +21,7 @@ describe("The OMEMO module", function() {
         let sent_stanza;
         await mock.waitForRoster(_converse, 'current', 1);
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        await u.waitUntil(() => mock.initializedOMEMO(_converse));
+        await mock.initializedOMEMO(_converse);
         await mock.openChatBoxFor(_converse, contact_jid);
         let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
         let stanza = $iq({
@@ -357,13 +357,13 @@ describe("The OMEMO module", function() {
     it("updates device lists based on PEP messages",
             mock.initConverse([], {'allow_non_roster_messaging': true}, async function (_converse) {
 
+        await mock.waitForRoster(_converse, 'current', 1);
+
         await mock.waitUntilDiscoConfirmed(
             _converse, _converse.bare_jid,
             [{'category': 'pubsub', 'type': 'pep'}],
             ['http://jabber.org/protocol/pubsub#publish-options']
         );
-
-        await mock.waitForRoster(_converse, 'current', 1);
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
 
         // Wait until own devices are fetched
@@ -410,7 +410,9 @@ describe("The OMEMO module", function() {
         _converse.connection._dataRecv(mock.createRequest(stanza));
         await _converse.api.waitUntil('OMEMOInitialized');
 
-        stanza = $msg({
+
+        // A PEP message is received with a device list.
+        _converse.connection._dataRecv(mock.createRequest($msg({
             'from': contact_jid,
             'to': _converse.bare_jid,
             'type': 'headline',
@@ -419,14 +421,40 @@ describe("The OMEMO module", function() {
             .c('items', {'node': 'eu.siacs.conversations.axolotl.devicelist'})
                 .c('item')
                     .c('list', {'xmlns': 'eu.siacs.conversations.axolotl'})
-                        .c('device', {'id': '1234'})
+                        .c('device', {'id': '1234'}).up()
                         .c('device', {'id': '4223'})
-        _converse.connection._dataRecv(mock.createRequest(stanza));
+        ));
+
+        // Since we haven't yet fetched any devices for this user, the
+        // devicelist model for them isn't yet initialized.
+        // It will be created and then automatically the devices will
+        // be requested from the server via IQ stanza.
+        //
+        // This is perhaps a bit wasteful since we're already (AFIAK) getting the info we need
+        // from the PEP headline message, but the code is simpler this way.
+        const iq_devicelist_get = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+        _converse.connection._dataRecv(mock.createRequest($iq({
+                'from': contact_jid,
+                'id': iq_devicelist_get.getAttribute('id'),
+                'to': _converse.connection.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': '1234'}).up()
+                            .c('device', {'id': '4223'})
+        ));
 
-        expect(_converse.devicelists.length).toBe(2);
-        let devices = _converse.devicelists.get(contact_jid).devices;
-        expect(devices.length).toBe(2);
-        expect(devices.models.map(d => d.attributes.id).sort().join()).toBe('1234,4223');
+        await u.waitUntil(() => _converse.devicelists.length === 2);
+
+        const list = _converse.devicelists.get(contact_jid);
+        await list.initialized;
+        await u.waitUntil(() => list.devices.length === 2);
+
+        let devices = list.devices;
+        expect(list.devices.length).toBe(2);
+        expect(list.devices.models.map(d => d.attributes.id).sort().join()).toBe('1234,4223');
 
         stanza = $msg({
             'from': contact_jid,
@@ -437,12 +465,12 @@ describe("The OMEMO module", function() {
             .c('items', {'node': 'eu.siacs.conversations.axolotl.devicelist'})
                 .c('item')
                     .c('list', {'xmlns': 'eu.siacs.conversations.axolotl'})
-                        .c('device', {'id': '4223'})
+                        .c('device', {'id': '4223'}).up()
                         .c('device', {'id': '4224'})
         _converse.connection._dataRecv(mock.createRequest(stanza));
 
         expect(_converse.devicelists.length).toBe(2);
-        expect(devices.length).toBe(3);
+        await u.waitUntil(() => list.devices.length === 3);
         expect(devices.models.map(d => d.attributes.id).sort().join()).toBe('1234,4223,4224');
         expect(devices.get('1234').get('active')).toBe(false);
         expect(devices.get('4223').get('active')).toBe(true);
@@ -465,7 +493,7 @@ describe("The OMEMO module", function() {
 
         expect(_converse.devicelists.length).toBe(2);
         devices = _converse.devicelists.get(_converse.bare_jid).devices;
-        expect(devices.length).toBe(3);
+        await u.waitUntil(() => devices.length === 3);
         expect(devices.models.map(d => d.attributes.id).sort().join()).toBe('123456789,555,777');
         expect(devices.get('123456789').get('active')).toBe(true);
         expect(devices.get('555').get('active')).toBe(true);
@@ -528,13 +556,14 @@ describe("The OMEMO module", function() {
     it("updates device bundles based on PEP messages",
             mock.initConverse([], {}, async function (_converse) {
 
+        await mock.waitForRoster(_converse, 'current');
+
         await mock.waitUntilDiscoConfirmed(
             _converse, _converse.bare_jid,
             [{'category': 'pubsub', 'type': 'pep'}],
             ['http://jabber.org/protocol/pubsub#publish-options']
         );
 
-        await mock.waitForRoster(_converse, 'current');
         const contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid));
         expect(Strophe.serialize(iq_stanza)).toBe(
@@ -544,7 +573,7 @@ describe("The OMEMO module", function() {
                 `</pubsub>`+
             `</iq>`);
 
-        let stanza = $iq({
+        _converse.connection._dataRecv(mock.createRequest($iq({
             'from': contact_jid,
             'id': iq_stanza.getAttribute('id'),
             'to': _converse.bare_jid,
@@ -553,16 +582,17 @@ describe("The OMEMO module", function() {
             .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': '555'});
-        _converse.connection._dataRecv(mock.createRequest(stanza));
+                        .c('device', {'id': '555'})
+        ));
+
         await await u.waitUntil(() => _converse.omemo_store);
         expect(_converse.devicelists.length).toBe(1);
-        let devicelist = _converse.devicelists.get(_converse.bare_jid);
-        expect(devicelist.devices.length).toBe(2);
-        expect(devicelist.devices.at(0).get('id')).toBe('555');
-        expect(devicelist.devices.at(1).get('id')).toBe('123456789');
+        const own_device_list = _converse.devicelists.get(_converse.bare_jid);
+        expect(own_device_list.devices.length).toBe(2);
+        expect(own_device_list.devices.at(0).get('id')).toBe('555');
+        expect(own_device_list.devices.at(1).get('id')).toBe('123456789');
         iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse));
-        stanza = $iq({
+        let stanza = $iq({
             'from': _converse.bare_jid,
             'id': iq_stanza.getAttribute('id'),
             'to': _converse.bare_jid,
@@ -576,13 +606,14 @@ describe("The OMEMO module", function() {
             'type': 'result'});
         _converse.connection._dataRecv(mock.createRequest(stanza));
         await _converse.api.waitUntil('OMEMOInitialized');
-        stanza = $msg({
+
+        _converse.connection._dataRecv(mock.createRequest($msg({
             'from': contact_jid,
             'to': _converse.bare_jid,
             'type': 'headline',
             'id': 'update_01',
         }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
-            .c('items', {'node': 'eu.siacs.conversations.axolotl.bundles:555'})
+            .c('items', {'node': 'eu.siacs.conversations.axolotl.bundles:1234'})
                 .c('item')
                     .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
                         .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t('1111').up()
@@ -591,13 +622,31 @@ describe("The OMEMO module", function() {
                         .c('prekeys')
                             .c('preKeyPublic', {'preKeyId': '1001'}).up()
                             .c('preKeyPublic', {'preKeyId': '1002'}).up()
-                            .c('preKeyPublic', {'preKeyId': '1003'});
-        _converse.connection._dataRecv(mock.createRequest(stanza));
-
-        expect(_converse.devicelists.length).toBe(2);
-        devicelist = _converse.devicelists.get(contact_jid);
-        expect(devicelist.devices.length).toBe(1);
-        let device = devicelist.devices.at(0);
+                            .c('preKeyPublic', {'preKeyId': '1003'})
+        ));
+
+        // Since we haven't yet fetched any devices for this user, the
+        // devicelist model for them isn't yet initialized.
+        // It will be created and then automatically the devices will
+        // be requested from the server via IQ stanza.
+        const iq_devicelist_get = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+        _converse.connection._dataRecv(mock.createRequest($iq({
+                'from': contact_jid,
+                'id': iq_devicelist_get.getAttribute('id'),
+                'to': _converse.connection.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': '1234'})
+        ));
+
+        await u.waitUntil(() => _converse.devicelists.length === 2);
+        const list = _converse.devicelists.get(contact_jid);
+        await list.initialized;
+        await u.waitUntil(() => list.devices.length);
+        let device = list.devices.at(0);
         expect(device.get('bundle').identity_key).toBe('3333');
         expect(device.get('bundle').signed_prekey.public_key).toBe('1111');
         expect(device.get('bundle').signed_prekey.id).toBe(4223);
@@ -613,7 +662,7 @@ describe("The OMEMO module", function() {
             'type': 'headline',
             'id': 'update_02',
         }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
-            .c('items', {'node': 'eu.siacs.conversations.axolotl.bundles:555'})
+            .c('items', {'node': 'eu.siacs.conversations.axolotl.bundles:1234'})
                 .c('item')
                     .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
                         .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t('5555').up()
@@ -626,10 +675,10 @@ describe("The OMEMO module", function() {
         _converse.connection._dataRecv(mock.createRequest(stanza));
 
         expect(_converse.devicelists.length).toBe(2);
-        devicelist = _converse.devicelists.get(contact_jid);
-        expect(devicelist.devices.length).toBe(1);
-        device = devicelist.devices.at(0);
-        expect(device.get('bundle').identity_key).toBe('7777');
+        expect(list.devices.length).toBe(1);
+        device = list.devices.at(0);
+
+        await u.waitUntil(() => device.get('bundle').identity_key === '7777');
         expect(device.get('bundle').signed_prekey.public_key).toBe('5555');
         expect(device.get('bundle').signed_prekey.id).toBe(4223);
         expect(device.get('bundle').signed_prekey.signature).toBe('6666');
@@ -638,13 +687,13 @@ describe("The OMEMO module", function() {
         expect(device.get('bundle').prekeys[1].id).toBe(2002);
         expect(device.get('bundle').prekeys[2].id).toBe(2003);
 
-        stanza = $msg({
+        _converse.connection._dataRecv(mock.createRequest($msg({
             'from': _converse.bare_jid,
             'to': _converse.bare_jid,
             'type': 'headline',
             'id': 'update_03',
         }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
-            .c('items', {'node': 'eu.siacs.conversations.axolotl.bundles:123456789'})
+            .c('items', {'node': 'eu.siacs.conversations.axolotl.bundles:555'})
                 .c('item')
                     .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
                         .c('signedPreKeyPublic', {'signedPreKeyId': '9999'}).t('8888').up()
@@ -653,16 +702,15 @@ describe("The OMEMO module", function() {
                         .c('prekeys')
                             .c('preKeyPublic', {'preKeyId': '3001'}).up()
                             .c('preKeyPublic', {'preKeyId': '3002'}).up()
-                            .c('preKeyPublic', {'preKeyId': '3003'});
-        _converse.connection._dataRecv(mock.createRequest(stanza));
+                            .c('preKeyPublic', {'preKeyId': '3003'})
+        ));
 
         expect(_converse.devicelists.length).toBe(2);
-        devicelist = _converse.devicelists.get(_converse.bare_jid);
-        expect(devicelist.devices.length).toBe(2);
-        expect(devicelist.devices.at(0).get('id')).toBe('555');
-        expect(devicelist.devices.at(1).get('id')).toBe('123456789');
-        device = devicelist.devices.at(1);
-        expect(device.get('bundle').identity_key).toBe('1111');
+        expect(own_device_list.devices.length).toBe(2);
+        expect(own_device_list.devices.at(0).get('id')).toBe('555');
+        expect(own_device_list.devices.at(1).get('id')).toBe('123456789');
+        device = own_device_list.devices.at(0);
+        await u.waitUntil(() => device.get('bundle')?.identity_key === '1111');
         expect(device.get('bundle').signed_prekey.public_key).toBe('8888');
         expect(device.get('bundle').signed_prekey.id).toBe(9999);
         expect(device.get('bundle').signed_prekey.signature).toBe('3333');
@@ -836,6 +884,7 @@ describe("The OMEMO module", function() {
         _converse.connection._dataRecv(mock.createRequest(stanza));
         await _converse.api.waitUntil('OMEMOInitialized', 1000);
         await mock.openChatBoxFor(_converse, contact_jid);
+
         iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
         expect(Strophe.serialize(iq_stanza)).toBe(
             `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+
@@ -844,7 +893,7 @@ describe("The OMEMO module", function() {
                 `</pubsub>`+
             `</iq>`);
 
-        stanza = $iq({
+        _converse.connection._dataRecv(mock.createRequest($iq({
             'from': contact_jid,
             'id': iq_stanza.getAttribute('id'),
             'to': _converse.bare_jid,
@@ -856,8 +905,9 @@ describe("The OMEMO module", function() {
                         .c('device', {'id': '368866411b877c30064a5f62b917cffe'}).up()
                         .c('device', {'id': '3300659945416e274474e469a1f0154c'}).up()
                         .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up()
-                        .c('device', {'id': 'ae890ac52d0df67ed7cfdf51b644e901'});
-        _converse.connection._dataRecv(mock.createRequest(stanza));
+                        .c('device', {'id': 'ae890ac52d0df67ed7cfdf51b644e901'})
+        ));
+
         devicelist = _converse.devicelists.get(contact_jid);
         await u.waitUntil(() => devicelist.devices.length);
         expect(_converse.devicelists.length).toBe(2);
@@ -908,13 +958,14 @@ describe("The OMEMO module", function() {
     it("shows OMEMO device fingerprints in the user details modal",
             mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
 
+        await mock.waitForRoster(_converse, 'current', 1);
+
         await mock.waitUntilDiscoConfirmed(
             _converse, _converse.bare_jid,
             [{'category': 'pubsub', 'type': 'pep'}],
             ['http://jabber.org/protocol/pubsub#publish-options']
         );
 
-        await mock.waitForRoster(_converse, 'current', 1);
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         await mock.openChatBoxFor(_converse, contact_jid)
         // We simply emit, to avoid doing all the setup work
@@ -925,12 +976,14 @@ describe("The OMEMO module", function() {
         show_modal_button.click();
         const modal = _converse.api.modal.get('user-details-modal');
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
+
         let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
         expect(Strophe.serialize(iq_stanza)).toBe(
             `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="mercutio@montague.lit" type="get" xmlns="jabber:client">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub"><items node="eu.siacs.conversations.axolotl.devicelist"/></pubsub>`+
             `</iq>`);
-        let stanza = $iq({
+
+        _converse.connection._dataRecv(mock.createRequest($iq({
             'from': contact_jid,
             'id': iq_stanza.getAttribute('id'),
             'to': _converse.bare_jid,
@@ -939,9 +992,11 @@ describe("The OMEMO module", function() {
             .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': '555'});
-        _converse.connection._dataRecv(mock.createRequest(stanza));
+                        .c('device', {'id': '555'})
+        ));
+
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
+
         iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
         expect(Strophe.serialize(iq_stanza)).toBe(
             `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="mercutio@montague.lit" type="get" xmlns="jabber:client">`+
@@ -949,7 +1004,8 @@ describe("The OMEMO module", function() {
                     `<items node="eu.siacs.conversations.axolotl.bundles:555"/>`+
                 `</pubsub>`+
             `</iq>`);
-        stanza = $iq({
+
+        _converse.connection._dataRecv(mock.createRequest($iq({
             'from': contact_jid,
             'id': iq_stanza.getAttribute('id'),
             'to': _converse.bare_jid,
@@ -965,14 +1021,14 @@ describe("The OMEMO module", function() {
                         .c('prekeys')
                             .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
                             .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
-                            .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
-        _converse.connection._dataRecv(mock.createRequest(stanza));
+                            .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'))
+        ));
 
         await u.waitUntil(() => modal.el.querySelectorAll('.fingerprints .fingerprint').length);
         expect(modal.el.querySelectorAll('.fingerprints .fingerprint').length).toBe(1);
         const el = modal.el.querySelector('.fingerprints .fingerprint');
         expect(el.textContent.trim()).toBe(
-            u.formatFingerprint(u.arrayBufferToHex(u.base64ToArrayBuffer('BQmHEOHjsYm3w5M8VqxAtqJmLCi7CaxxsdZz6G0YpuMI')))
+            omemo.formatFingerprint(u.arrayBufferToHex(u.base64ToArrayBuffer('BQmHEOHjsYm3w5M8VqxAtqJmLCi7CaxxsdZz6G0YpuMI')))
         );
         expect(modal.el.querySelectorAll('input[type="radio"]').length).toBe(2);
 
@@ -988,7 +1044,8 @@ describe("The OMEMO module", function() {
         // Test that the device can be set to untrusted
         untrusted_radio.click();
         trusted_radio = document.querySelector('input[type="radio"][name="555"][value="1"]');
-        expect(trusted_radio.hasAttribute('checked')).toBe(false);
+
+        await u.waitUntil(() => !trusted_radio.hasAttribute('checked'));
         expect(devicelist.devices.get('555').get('trusted')).toBe(-1);
 
         untrusted_radio = document.querySelector('input[type="radio"][name="555"][value="-1"]');

+ 33 - 29
src/plugins/omemo/utils.js

@@ -26,6 +26,14 @@ import {
 
 const { $msg, Strophe, URI, sizzle, u } = converse.env;
 
+export function formatFingerprint (fp) {
+    fp = fp.replace(/^05/, '');
+    for (let i=1; i<8; i++) {
+        const idx = i*8+i-1;
+        fp = fp.slice(0, idx) + ' ' + fp.slice(idx);
+    }
+    return fp;
+}
 
 async function encryptMessage (plaintext) {
     // The client MUST use fresh, randomly generated key/IV pairs
@@ -70,12 +78,6 @@ async function decryptMessage (obj) {
     return arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
 }
 
-export const omemo = {
-    decryptMessage,
-    encryptMessage
-}
-
-
 export async function encryptFile (file) {
     const iv = crypto.getRandomValues(new Uint8Array(12));
     const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256, }, true, ['encrypt', 'decrypt']);
@@ -300,9 +302,7 @@ function getJIDForDecryption (attrs) {
 
 async function handleDecryptedWhisperMessage (attrs, key_and_tag) {
     const from_jid = getJIDForDecryption(attrs);
-    const devicelist = _converse.devicelists.getDeviceList(from_jid);
-    await devicelist._devices_promise;
-
+    const devicelist = await _converse.devicelists.getDeviceList(from_jid);
     const encrypted = attrs.encrypted;
     let device = devicelist.get(encrypted.device_id);
     if (!device) {
@@ -511,7 +511,7 @@ export async function getSession (device) {
     }
 }
 
-function updateBundleFromStanza (stanza) {
+async function updateBundleFromStanza (stanza) {
     const items_el = sizzle(`items`, stanza).pop();
     if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
         return;
@@ -519,12 +519,12 @@ function updateBundleFromStanza (stanza) {
     const device_id = items_el.getAttribute('node').split(':')[1];
     const jid = stanza.getAttribute('from');
     const bundle_el = sizzle(`item > bundle`, items_el).pop();
-    const devicelist = _converse.devicelists.getDeviceList(jid);
+    const devicelist = await _converse.devicelists.getDeviceList(jid);
     const device = devicelist.devices.get(device_id) || devicelist.devices.create({ 'id': device_id, jid });
     device.save({ 'bundle': parseBundle(bundle_el) });
 }
 
-function updateDevicesFromStanza (stanza) {
+async function updateDevicesFromStanza (stanza) {
     const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop();
     if (!items_el) {
         return;
@@ -532,7 +532,7 @@ function updateDevicesFromStanza (stanza) {
     const device_selector = `item list[xmlns="${Strophe.NS.OMEMO}"] device`;
     const device_ids = sizzle(device_selector, items_el).map(d => d.getAttribute('id'));
     const jid = stanza.getAttribute('from');
-    const devicelist = _converse.devicelists.getDeviceList(jid);
+    const devicelist = await _converse.devicelists.getDeviceList(jid);
     const devices = devicelist.devices;
     const removed_ids = difference(devices.pluck('id'), device_ids);
 
@@ -560,11 +560,12 @@ function updateDevicesFromStanza (stanza) {
 export function registerPEPPushHandler () {
     // Add a handler for devices pushed from other connected clients
     _converse.connection.addHandler(
-        message => {
+        async message => {
             try {
                 if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
-                    updateDevicesFromStanza(message);
-                    updateBundleFromStanza(message);
+                    await api.waitUntil('OMEMOInitialized');
+                    await updateDevicesFromStanza(message);
+                    await updateBundleFromStanza(message);
                 }
             } catch (e) {
                 log.error(e.message);
@@ -586,11 +587,11 @@ export function restoreOMEMOSession () {
     return _converse.omemo_store.fetchSession();
 }
 
-function fetchDeviceLists () {
+async function fetchDeviceLists () {
     _converse.devicelists = new _converse.DeviceLists();
     const id = `converse.devicelists-${_converse.bare_jid}`;
     initStorage(_converse.devicelists, id);
-    return new Promise(resolve => {
+    await new Promise(resolve => {
         _converse.devicelists.fetch({
             'success': resolve,
             'error': (m, e) => {
@@ -599,16 +600,13 @@ function fetchDeviceLists () {
             }
         })
     });
-}
-
-async function fetchOwnDevices () {
-    let own_devicelist = _converse.devicelists.get(_converse.bare_jid);
-    if (own_devicelist) {
-        own_devicelist.fetchDevices();
-    } else {
-        own_devicelist = await _converse.devicelists.create({ 'jid': _converse.bare_jid }, { 'promise': true });
+    const promises = _converse.devicelists.map(l => l.initialized);
+    if (!_converse.devicelists.get(_converse.bare_jid)) {
+        // Create own device list if we none was restored
+        const own_list = await _converse.devicelists.create({ 'jid': _converse.bare_jid }, { 'promise': true });
+        return Promise.all([...promises, own_list.initialized]);
     }
-    return own_devicelist._devices_promise;
+    return Promise.all(promises);
 }
 
 export async function initOMEMO () {
@@ -618,7 +616,6 @@ export async function initOMEMO () {
     }
     try {
         await fetchDeviceLists();
-        await fetchOwnDevices();
         await restoreOMEMOSession();
         await _converse.omemo_store.publishBundle();
     } catch (e) {
@@ -629,7 +626,8 @@ export async function initOMEMO () {
     /**
      * Triggered once OMEMO support has been initialized
      * @event _converse#OMEMOInitialized
-     * @example _converse.api.listen.on('OMEMOInitialized', () => { ... }); */
+     * @example _converse.api.listen.on('OMEMOInitialized', () => { ... });
+     */
     api.trigger('OMEMOInitialized');
 }
 
@@ -817,3 +815,9 @@ export function createOMEMOMessageStanza (chatbox, message, devices) {
             });
     });
 }
+
+export const omemo = {
+    decryptMessage,
+    encryptMessage,
+    formatFingerprint
+}

+ 4 - 4
src/plugins/profile/templates/profile_modal.js

@@ -1,14 +1,14 @@
 import "shared/components/image-picker.js";
 import spinner from "templates/spinner.js";
 import { __ } from 'i18n';
-import { _converse, converse } from  "@converse/headless/core";
+import { _converse } from  "@converse/headless/core";
 import { html } from "lit";
 import { modal_header_close_button } from "plugins/modal/templates/buttons.js";
+import { formatFingerprint } from 'plugins/omemo/utils.js';
 
-const u = converse.env.utils;
 
 const fingerprint = (o) => html`
-    <span class="fingerprint">${u.formatFingerprint(o.view.current_device.get('bundle').fingerprint)}</span>`;
+    <span class="fingerprint">${formatFingerprint(o.view.current_device.get('bundle').fingerprint)}</span>`;
 
 
 const device_with_fingerprint = (o) => {
@@ -18,7 +18,7 @@ const device_with_fingerprint = (o) => {
             <label>
             <input type="checkbox" value="${o.device.get('id')}"
                 aria-label="${i18n_fingerprint_checkbox_label}"/>
-            <span class="fingerprint">${u.formatFingerprint(o.device.get('bundle').fingerprint)}</span>
+            <span class="fingerprint">${formatFingerprint(o.device.get('bundle').fingerprint)}</span>
             </label>
         </li>
     `;

+ 2 - 2
src/shared/styles/buttons.scss

@@ -24,8 +24,8 @@
     }
 
     .btn-primary {
-        background-color: var(--primary-color);
-        border-color: transparent;
+        background-color: var(--primary-color) !important;
+        border-color: transparent !important;
         &:focus, &:hover, &:active {
             background-color: var(--primary-color-dark) !important;
             border-color: transparent !important;