Parcourir la source

Split omemo plugin into more files

JC Brand il y a 4 ans
Parent
commit
13e19eb7f8

+ 50 - 0
src/plugins/omemo/api.js

@@ -0,0 +1,50 @@
+import { _converse } from '@converse/headless/core';
+import { generateFingerprint } from './utils.js';
+
+export default {
+    /**
+     * The "omemo" namespace groups methods relevant to OMEMO
+     * encryption.
+     *
+     * @namespace _converse.api.omemo
+     * @memberOf _converse.api
+     */
+    'omemo': {
+        /**
+         * The "bundle" namespace groups methods relevant to the user's
+         * OMEMO bundle.
+         *
+         * @namespace _converse.api.omemo.bundle
+         * @memberOf _converse.api.omemo
+         */
+        'bundle': {
+            /**
+             * Lets you generate a new OMEMO device bundle
+             *
+             * @method _converse.api.omemo.bundle.generate
+             * @returns {promise} Promise which resolves once we have a result from the server.
+             */
+            'generate': async () => {
+                // Remove current device
+                const devicelist = _converse.devicelists.get(_converse.bare_jid);
+                const device_id = _converse.omemo_store.get('device_id');
+                if (device_id) {
+                    const device = devicelist.devices.get(device_id);
+                    _converse.omemo_store.unset(device_id);
+                    if (device) {
+                        await new Promise(done => device.destroy({ 'success': done, 'error': done }));
+                    }
+                    devicelist.devices.trigger('remove');
+                }
+                // Generate new device bundle and publish
+                // https://xmpp.org/extensions/attic/xep-0384-0.3.0.html#usecases-announcing
+                await _converse.omemo_store.generateBundle();
+                await devicelist.publishDevices();
+                const device = devicelist.devices.get(_converse.omemo_store.get('device_id'));
+                const fp = generateFingerprint(device);
+                await _converse.omemo_store.publishBundle();
+                return fp;
+            }
+        }
+    }
+}

+ 3 - 0
src/plugins/omemo/consts.js

@@ -0,0 +1,3 @@
+export const UNDECIDED = 0;
+export const TRUSTED = 1;
+export const UNTRUSTED = -1;

+ 69 - 0
src/plugins/omemo/device.js

@@ -0,0 +1,69 @@
+import log from '@converse/headless/log';
+import { IQError } from './errors.js';
+import { Model } from '@converse/skeletor/src/model.js';
+import { UNDECIDED } from './consts.js';
+import { _converse, api, converse } from '@converse/headless/core';
+import { parseBundle } from './utils.js';
+
+const { Strophe, sizzle, u, $iq } = converse.env;
+
+
+/**
+ * @class
+ * @namespace _converse.Device
+ * @memberOf _converse
+ */
+const Device = Model.extend({
+    defaults: {
+        'trusted': UNDECIDED,
+        'active': true
+    },
+
+    getRandomPreKey () {
+        // XXX: assumes that the bundle has already been fetched
+        const bundle = this.get('bundle');
+        return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
+    },
+
+    async fetchBundleFromServer () {
+        const stanza = $iq({
+            'type': 'get',
+            'from': _converse.bare_jid,
+            'to': this.get('jid')
+        })
+            .c('pubsub', { 'xmlns': Strophe.NS.PUBSUB })
+            .c('items', { 'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}` });
+
+        let iq;
+        try {
+            iq = await api.sendIQ(stanza);
+        } catch (iq) {
+            log.error(`Could not fetch bundle for device ${this.get('id')} from ${this.get('jid')}`);
+            log.error(iq);
+            return null;
+        }
+        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();
+        const bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop();
+        const bundle = parseBundle(bundle_el);
+        this.save('bundle', bundle);
+        return bundle;
+    },
+
+    /**
+     * Fetch and save the bundle information associated with
+     * this device, if the information is not cached already.
+     * @method _converse.Device#getBundle
+     */
+    getBundle () {
+        if (this.get('bundle')) {
+            return Promise.resolve(this.get('bundle'), this);
+        } else {
+            return this.fetchBundleFromServer();
+        }
+    }
+});
+
+export default Device;

+ 130 - 0
src/plugins/omemo/devicelist.js

@@ -0,0 +1,130 @@
+import log from '@converse/headless/log';
+import { Model } from '@converse/skeletor/src/model.js';
+import { _converse, api, converse } from '@converse/headless/core';
+import { restoreOMEMOSession } from './utils.js';
+
+const { Strophe, $build, $iq, sizzle } = converse.env;
+
+/**
+ * @class
+ * @namespace _converse.DeviceList
+ * @memberOf _converse
+ */
+const DeviceList = Model.extend({
+    idAttribute: 'jid',
+
+    initialize () {
+        this.devices = new _converse.Devices();
+        const id = `converse.devicelist-${_converse.bare_jid}-${this.get('jid')}`;
+        this.devices.browserStorage = _converse.createStore(id);
+        this.fetchDevices();
+    },
+
+    async onDevicesFound (collection) {
+        if (collection.length === 0) {
+            let ids;
+            try {
+                ids = await this.fetchDevicesFromServer();
+            } catch (e) {
+                if (e === null) {
+                    log.error(`Timeout error while fetching devices for ${this.get('jid')}`);
+                } else {
+                    log.error(`Could not fetch devices for ${this.get('jid')}`);
+                    log.error(e);
+                }
+                this.destroy();
+            }
+            if (this.get('jid') === _converse.bare_jid) {
+                await this.publishCurrentDevice(ids);
+            }
+        }
+    },
+
+    fetchDevices () {
+        if (this._devices_promise === undefined) {
+            this._devices_promise = new Promise(resolve => {
+                this.devices.fetch({
+                    'success': c => resolve(this.onDevicesFound(c)),
+                    'error': (m, e) => {
+                        log.error(e);
+                        resolve();
+                    }
+                });
+            });
+        }
+        return this._devices_promise;
+    },
+
+    async getOwnDeviceId () {
+        let device_id = _converse.omemo_store.get('device_id');
+        if (!this.devices.findWhere({ 'id': device_id })) {
+            // Generate a new bundle if we cannot find our device
+            await _converse.omemo_store.generateBundle();
+            device_id = _converse.omemo_store.get('device_id');
+        }
+        return device_id;
+    },
+
+    async publishCurrentDevice (device_ids) {
+        if (this.get('jid') !== _converse.bare_jid) {
+            return; // We only publish for ourselves.
+        }
+        await restoreOMEMOSession();
+
+        if (!_converse.omemo_store) {
+            // Happens during tests. The connection gets torn down
+            // before publishCurrentDevice has time to finish.
+            log.warn('publishCurrentDevice: omemo_store is not defined, likely a timing issue');
+            return;
+        }
+        if (!device_ids.includes(await this.getOwnDeviceId())) {
+            return this.publishDevices();
+        }
+    },
+
+    async fetchDevicesFromServer () {
+        const stanza = $iq({
+            'type': 'get',
+            'from': _converse.bare_jid,
+            'to': this.get('jid')
+        })
+            .c('pubsub', { 'xmlns': Strophe.NS.PUBSUB })
+            .c('items', { 'node': Strophe.NS.OMEMO_DEVICELIST });
+
+        let iq;
+        try {
+            iq = await api.sendIQ(stanza);
+        } catch (e) {
+            log.error(e);
+            return [];
+        }
+        const selector = `list[xmlns="${Strophe.NS.OMEMO}"] device`;
+        const device_ids = sizzle(selector, iq).map(d => d.getAttribute('id'));
+        await Promise.all(
+            device_ids.map(id => this.devices.create({ id, 'jid': this.get('jid') }, { 'promise': true }))
+        );
+        return device_ids;
+    },
+
+    /**
+     * Send an IQ stanza to the current user's "devices" PEP node to
+     * ensure that all devices are published for potential chat partners to see.
+     * See: https://xmpp.org/extensions/xep-0384.html#usecases-announcing
+     */
+    publishDevices () {
+        const item = $build('item', { 'id': 'current' }).c('list', { 'xmlns': Strophe.NS.OMEMO });
+        this.devices.filter(d => d.get('active')).forEach(d => item.c('device', { 'id': d.get('id') }).up());
+        const options = { 'pubsub#access_model': 'open' };
+        return api.pubsub.publish(null, Strophe.NS.OMEMO_DEVICELIST, item, options, false);
+    },
+
+    removeOwnDevices (device_ids) {
+        if (this.get('jid') !== _converse.bare_jid) {
+            throw new Error("Cannot remove devices from someone else's device list");
+        }
+        device_ids.forEach(device_id => this.devices.get(device_id).destroy());
+        return this.publishDevices();
+    }
+});
+
+export default DeviceList;

+ 24 - 0
src/plugins/omemo/devicelists.js

@@ -0,0 +1,24 @@
+import DeviceList from './devicelist.js';
+import { Collection } from '@converse/skeletor/src/collection';
+
+/**
+ * @class
+ * @namespace _converse.DeviceLists
+ * @memberOf _converse
+ */
+const DeviceLists = Collection.extend({
+    model: DeviceList,
+
+    /**
+     * 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 });
+    }
+});
+
+export default DeviceLists;

+ 4 - 0
src/plugins/omemo/devices.js

@@ -0,0 +1,4 @@
+import Device from './device.js';
+import { Collection } from '@converse/skeletor/src/collection';
+
+export default Collection.extend({ model: Device });

+ 7 - 0
src/plugins/omemo/errors.js

@@ -0,0 +1,7 @@
+export class IQError extends Error {
+    constructor (message, iq) {
+        super(message, iq);
+        this.name = 'IQError';
+        this.iq = iq;
+    }
+}

+ 25 - 839
src/plugins/omemo/index.js

@@ -3,103 +3,40 @@
  * @copyright The Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-/* global libsignal */
-
-import '../modals/user-details.js';
-import './profile/index.js';
-import concat from 'lodash-es/concat';
-import debounce from 'lodash-es/debounce';
-import difference from 'lodash-es/difference';
-import invokeMap from 'lodash-es/invokeMap';
+import 'modals/user-details.js';
+import 'plugins/profile/index.js';
+import ChatBox from './overrides/chatbox.js';
+import ConverseMixins from './mixins/converse.js';
+import Device from './device.js';
+import DeviceList from './devicelist.js';
+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 omit from 'lodash-es/omit';
-import range from 'lodash-es/range';
-import { Collection } from '@converse/skeletor/src/collection';
-import { Model } from '@converse/skeletor/src/model.js';
-import { __ } from '../i18n';
+import omemo_api from './api.js';
+import { OMEMOEnabledChatBox } from './mixins/chatbox.js';
 import { _converse, api, converse } from '@converse/headless/core';
 import {
-    addKeysToMessageStanza,
-    generateDeviceID,
-    generateFingerprint,
-    getDevicesForContact,
     getOMEMOToolbarButton,
-    getSession,
-    getSessionCipher,
     initOMEMO,
+    omemo,
     onChatBoxesInitialized,
     onChatInitialized,
     parseEncryptedMessage,
     registerPEPPushHandler,
-    restoreOMEMOSession,
 } from './utils.js';
 
-const { Strophe, sizzle, $build, $iq, $msg, u } = converse.env;
+const { Strophe } = converse.env;
+
+converse.env.omemo = omemo;
 
 Strophe.addNamespace('OMEMO_DEVICELIST', Strophe.NS.OMEMO + '.devicelist');
 Strophe.addNamespace('OMEMO_VERIFICATION', Strophe.NS.OMEMO + '.verification');
 Strophe.addNamespace('OMEMO_WHITELISTED', Strophe.NS.OMEMO + '.whitelisted');
 Strophe.addNamespace('OMEMO_BUNDLES', Strophe.NS.OMEMO + '.bundles');
 
-const UNDECIDED = 0;
-const TRUSTED = 1; // eslint-disable-line no-unused-vars
-const UNTRUSTED = -1;
-
-class IQError extends Error {
-    constructor (message, iq) {
-        super(message, iq);
-        this.name = 'IQError';
-        this.iq = iq;
-    }
-}
-
-/**
- * Mixin object that contains OMEMO-related methods for
- * {@link _converse.ChatBox} or {@link _converse.ChatRoom} objects.
- *
- * @typedef {Object} OMEMOEnabledChatBox
- */
-const OMEMOEnabledChatBox = {
-    encryptKey (plaintext, device) {
-        return getSessionCipher(device.get('jid'), device.get('id'))
-            .encrypt(plaintext)
-            .then(payload => ({ 'payload': payload, 'device': device }));
-    },
-
-    handleMessageSendError (e) {
-        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);
-            }
-            api.alert('error', __('Error'), err_msgs);
-            log.error(e);
-        } else if (e.user_facing) {
-            api.alert('error', __('Error'), [e.message]);
-            log.error(e);
-        } else {
-            throw e;
-        }
-    }
-};
 
 converse.plugins.add('converse-omemo', {
     enabled (_converse) {
@@ -113,725 +50,23 @@ converse.plugins.add('converse-omemo', {
 
     dependencies: ['converse-chatview', 'converse-pubsub', 'converse-profile'],
 
-    overrides: {
-        ProfileModal: {
-            events: {
-                'change input.select-all': 'selectAll',
-                'click .generate-bundle': 'generateOMEMODeviceBundle',
-                'submit .fingerprint-removal': 'removeSelectedFingerprints'
-            },
-
-            initialize () {
-                this.debouncedRender = debounce(this.render, 50);
-                this.devicelist = _converse.devicelists.get(_converse.bare_jid);
-                this.listenTo(this.devicelist.devices, 'change:bundle', this.debouncedRender);
-                this.listenTo(this.devicelist.devices, 'reset', this.debouncedRender);
-                this.listenTo(this.devicelist.devices, 'reset', this.debouncedRender);
-                this.listenTo(this.devicelist.devices, 'remove', this.debouncedRender);
-                this.listenTo(this.devicelist.devices, 'add', this.debouncedRender);
-                return this.__super__.initialize.apply(this, arguments);
-            },
-
-            beforeRender () {
-                const device_id = _converse.omemo_store.get('device_id');
-
-                if (device_id) {
-                    this.current_device = this.devicelist.devices.get(device_id);
-                }
-                this.other_devices = this.devicelist.devices.filter(d => d.get('id') !== device_id);
-                if (this.__super__.beforeRender) {
-                    return this.__super__.beforeRender.apply(this, arguments);
-                }
-            },
-
-            selectAll (ev) {
-                let sibling = u.ancestor(ev.target, 'li');
-                while (sibling) {
-                    sibling.querySelector('input[type="checkbox"]').checked = ev.target.checked;
-                    sibling = sibling.nextElementSibling;
-                }
-            },
-
-            removeSelectedFingerprints (ev) {
-                ev.preventDefault();
-                ev.stopPropagation();
-                ev.target.querySelector('.select-all').checked = false;
-                const device_ids = sizzle('.fingerprint-removal-item input[type="checkbox"]:checked', ev.target).map(
-                    c => c.value
-                );
-                this.devicelist
-                    .removeOwnDevices(device_ids)
-                    .then(this.modal.hide)
-                    .catch(err => {
-                        log.error(err);
-                        _converse.api.alert(Strophe.LogLevel.ERROR, __('Error'), [
-                            __('Sorry, an error occurred while trying to remove the devices.')
-                        ]);
-                    });
-            },
-
-            generateOMEMODeviceBundle (ev) {
-                ev.preventDefault();
-                if (
-                    confirm(
-                        __(
-                            'Are you sure you want to generate new OMEMO keys? ' +
-                                'This will remove your old keys and all previously encrypted messages will no longer be decryptable on this device.'
-                        )
-                    )
-                ) {
-                    api.omemo.bundle.generate();
-                }
-            }
-        },
-
-        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));
-            }
-        },
-
-        ChatBox: {
-            async sendMessage (text, spoiler_hint) {
-                if (this.get('omemo_active') && text) {
-                    const attrs = this.getOutgoingMessageAttributes(text, spoiler_hint);
-                    attrs['is_encrypted'] = true;
-                    attrs['plaintext'] = attrs.message;
-                    let message, stanza;
-                    try {
-                        const devices = await _converse.getBundlesAndBuildSessions(this);
-                        message = await this.createMessage(attrs);
-                        stanza = await _converse.createOMEMOMessageStanza(this, message, devices);
-                    } catch (e) {
-                        this.handleMessageSendError(e);
-                        return null;
-                    }
-                    _converse.api.send(stanza);
-                    return message;
-                } else {
-                    return this.__super__.sendMessage.apply(this, arguments);
-                }
-            }
-        }
-    },
+    overrides: { ProfileModal, UserDetailsModal, ChatBox },
 
     initialize () {
-        /* The initialize function gets called as soon as the plugin is
-         * loaded by Converse.js's plugin machinery.
-         */
-
         api.settings.extend({ 'omemo_default': false });
         api.promises.add(['OMEMOInitialized']);
 
         _converse.NUM_PREKEYS = 100; // Set here so that tests can override
 
         Object.assign(_converse.ChatBox.prototype, OMEMOEnabledChatBox);
+        Object.assign(_converse, ConverseMixins);
+        Object.assign(_converse.api, omemo_api);
 
-        _converse.generateFingerprints = async function (jid) {
-            const devices = await getDevicesForContact(jid);
-            return Promise.all(devices.map(d => generateFingerprint(d)));
-        };
-
-        _converse.getDeviceForContact = function (jid, device_id) {
-            return getDevicesForContact(jid).then(devices => devices.get(device_id));
-        };
-
-        _converse.contactHasOMEMOSupport = async function (jid) {
-            /* Checks whether the contact advertises any OMEMO-compatible devices. */
-            const devices = await getDevicesForContact(jid);
-            return devices.length > 0;
-        };
-
-        _converse.getBundlesAndBuildSessions = async function (chatbox) {
-            const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.');
-            let devices;
-            if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
-                const collections = await Promise.all(chatbox.occupants.map(o => getDevicesForContact(o.get('jid'))));
-                devices = collections.reduce((a, b) => concat(a, b.models), []);
-            } else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
-                const their_devices = await getDevicesForContact(chatbox.get('jid'));
-                if (their_devices.length === 0) {
-                    const err = new Error(no_devices_err);
-                    err.user_facing = true;
-                    throw err;
-                }
-                const own_devices = _converse.devicelists.get(_converse.bare_jid).devices;
-                devices = [...own_devices.models, ...their_devices.models];
-            }
-            // Filter out our own device
-            const id = _converse.omemo_store.get('device_id');
-            devices = devices.filter(d => d.get('id') !== id);
-            // Fetch bundles if necessary
-            await Promise.all(devices.map(d => d.getBundle()));
-
-            const sessions = devices.filter(d => d).map(d => getSession(d));
-            await Promise.all(sessions);
-            if (sessions.includes(null)) {
-                // We couldn't build a session for certain devices.
-                devices = devices.filter(d => sessions[devices.indexOf(d)]);
-                if (devices.length === 0) {
-                    const err = new Error(no_devices_err);
-                    err.user_facing = true;
-                    throw err;
-                }
-            }
-            return devices;
-        };
-
-        _converse.createOMEMOMessageStanza = function (chatbox, message, devices) {
-            const body = __(
-                'This is an OMEMO encrypted message which your client doesn’t seem to support. ' +
-                    'Find more information on https://conversations.im/omemo'
-            );
-
-            if (!message.get('message')) {
-                throw new Error('No message body to encrypt!');
-            }
-            const stanza = $msg({
-                'from': _converse.connection.jid,
-                'to': chatbox.get('jid'),
-                'type': chatbox.get('message_type'),
-                'id': message.get('msgid')
-            })
-                .c('body')
-                .t(body)
-                .up();
-
-            if (message.get('type') === 'chat') {
-                stanza.c('request', { 'xmlns': Strophe.NS.RECEIPTS }).up();
-            }
-            // An encrypted header is added to the message for
-            // each device that is supposed to receive it.
-            // These headers simply contain the key that the
-            // payload message is encrypted with,
-            // and they are separately encrypted using the
-            // session corresponding to the counterpart device.
-            stanza
-                .c('encrypted', { 'xmlns': Strophe.NS.OMEMO })
-                .c('header', { 'sid': _converse.omemo_store.get('device_id') });
-
-            return omemo.encryptMessage(message.get('message')).then(obj => {
-                // The 16 bytes key and the GCM authentication tag (The tag
-                // SHOULD have at least 128 bit) are concatenated and for each
-                // intended recipient device, i.e. both own devices as well as
-                // devices associated with the contact, the result of this
-                // concatenation is encrypted using the corresponding
-                // long-standing SignalProtocol session.
-                const promises = devices
-                    .filter(device => device.get('trusted') != UNTRUSTED && device.get('active'))
-                    .map(device => chatbox.encryptKey(obj.key_and_tag, device));
-
-                return Promise.all(promises)
-                    .then(dicts => addKeysToMessageStanza(stanza, dicts, obj.iv))
-                    .then(stanza => {
-                        stanza
-                            .c('payload')
-                            .t(obj.payload)
-                            .up()
-                            .up();
-                        stanza.c('store', { 'xmlns': Strophe.NS.HINTS });
-                        return stanza;
-                    });
-            });
-        };
-
-        _converse.OMEMOStore = Model.extend({
-            Direction: {
-                SENDING: 1,
-                RECEIVING: 2
-            },
-
-            getIdentityKeyPair () {
-                const keypair = this.get('identity_keypair');
-                return Promise.resolve({
-                    'privKey': u.base64ToArrayBuffer(keypair.privKey),
-                    'pubKey': u.base64ToArrayBuffer(keypair.pubKey)
-                });
-            },
-
-            getLocalRegistrationId () {
-                return Promise.resolve(parseInt(this.get('device_id'), 10));
-            },
-
-            isTrustedIdentity (identifier, identity_key, direction) {
-                // eslint-disable-line no-unused-vars
-                if (identifier === null || identifier === undefined) {
-                    throw new Error("Can't check identity key for invalid key");
-                }
-                if (!(identity_key instanceof ArrayBuffer)) {
-                    throw new Error('Expected identity_key to be an ArrayBuffer');
-                }
-                const trusted = this.get('identity_key' + identifier);
-                if (trusted === undefined) {
-                    return Promise.resolve(true);
-                }
-                return Promise.resolve(u.arrayBufferToBase64(identity_key) === trusted);
-            },
-
-            loadIdentityKey (identifier) {
-                if (identifier === null || identifier === undefined) {
-                    throw new Error("Can't load identity_key for invalid identifier");
-                }
-                return Promise.resolve(u.base64ToArrayBuffer(this.get('identity_key' + identifier)));
-            },
-
-            saveIdentity (identifier, identity_key) {
-                if (identifier === null || identifier === undefined) {
-                    throw new Error("Can't save identity_key for invalid identifier");
-                }
-                const address = new libsignal.SignalProtocolAddress.fromString(identifier);
-                const existing = this.get('identity_key' + address.getName());
-                const b64_idkey = u.arrayBufferToBase64(identity_key);
-                this.save('identity_key' + address.getName(), b64_idkey);
-
-                if (existing && b64_idkey !== existing) {
-                    return Promise.resolve(true);
-                } else {
-                    return Promise.resolve(false);
-                }
-            },
-
-            getPreKeys () {
-                return this.get('prekeys') || {};
-            },
-
-            loadPreKey (key_id) {
-                const res = this.getPreKeys()[key_id];
-                if (res) {
-                    return Promise.resolve({
-                        'privKey': u.base64ToArrayBuffer(res.privKey),
-                        'pubKey': u.base64ToArrayBuffer(res.pubKey)
-                    });
-                }
-                return Promise.resolve();
-            },
-
-            storePreKey (key_id, key_pair) {
-                const prekey = {};
-                prekey[key_id] = {
-                    'pubKey': u.arrayBufferToBase64(key_pair.pubKey),
-                    'privKey': u.arrayBufferToBase64(key_pair.privKey)
-                };
-                this.save('prekeys', Object.assign(this.getPreKeys(), prekey));
-                return Promise.resolve();
-            },
-
-            removePreKey (key_id) {
-                this.save('prekeys', omit(this.getPreKeys(), key_id));
-                return Promise.resolve();
-            },
-
-            loadSignedPreKey (keyId) {
-                // eslint-disable-line no-unused-vars
-                const res = this.get('signed_prekey');
-                if (res) {
-                    return Promise.resolve({
-                        'privKey': u.base64ToArrayBuffer(res.privKey),
-                        'pubKey': u.base64ToArrayBuffer(res.pubKey)
-                    });
-                }
-                return Promise.resolve();
-            },
-
-            storeSignedPreKey (spk) {
-                if (typeof spk !== 'object') {
-                    // XXX: We've changed the signature of this method from the
-                    // example given in InMemorySignalProtocolStore.
-                    // Should be fine because the libsignal code doesn't
-                    // actually call this method.
-                    throw new Error('storeSignedPreKey: expected an object');
-                }
-                this.save('signed_prekey', {
-                    'id': spk.keyId,
-                    'privKey': u.arrayBufferToBase64(spk.keyPair.privKey),
-                    'pubKey': u.arrayBufferToBase64(spk.keyPair.pubKey),
-                    // XXX: The InMemorySignalProtocolStore does not pass
-                    // in or store the signature, but we need it when we
-                    // publish out bundle and this method isn't called from
-                    // within libsignal code, so we modify it to also store
-                    // the signature.
-                    'signature': u.arrayBufferToBase64(spk.signature)
-                });
-                return Promise.resolve();
-            },
-
-            removeSignedPreKey (key_id) {
-                if (this.get('signed_prekey')['id'] === key_id) {
-                    this.unset('signed_prekey');
-                    this.save();
-                }
-                return Promise.resolve();
-            },
-
-            loadSession (identifier) {
-                return Promise.resolve(this.get('session' + identifier));
-            },
-
-            storeSession (identifier, record) {
-                return Promise.resolve(this.save('session' + identifier, record));
-            },
-
-            removeSession (identifier) {
-                return Promise.resolve(this.unset('session' + identifier));
-            },
-
-            removeAllSessions (identifier) {
-                const keys = Object.keys(this.attributes).filter(key =>
-                    key.startsWith('session' + identifier) ? key : false
-                );
-                const attrs = {};
-                keys.forEach(key => {
-                    attrs[key] = undefined;
-                });
-                this.save(attrs);
-                return Promise.resolve();
-            },
-
-            publishBundle () {
-                const signed_prekey = this.get('signed_prekey');
-                const node = `${Strophe.NS.OMEMO_BUNDLES}:${this.get('device_id')}`;
-                const item = $build('item')
-                    .c('bundle', { 'xmlns': Strophe.NS.OMEMO })
-                    .c('signedPreKeyPublic', { 'signedPreKeyId': signed_prekey.id })
-                    .t(signed_prekey.pubKey)
-                    .up()
-                    .c('signedPreKeySignature')
-                    .t(signed_prekey.signature)
-                    .up()
-                    .c('identityKey')
-                    .t(this.get('identity_keypair').pubKey)
-                    .up()
-                    .c('prekeys');
-
-                Object.values(this.get('prekeys')).forEach((prekey, id) =>
-                    item
-                        .c('preKeyPublic', { 'preKeyId': id })
-                        .t(prekey.pubKey)
-                        .up()
-                );
-                const options = { 'pubsub#access_model': 'open' };
-                return api.pubsub.publish(null, node, item, options, false);
-            },
-
-            async generateMissingPreKeys () {
-                const missing_keys = difference(
-                    invokeMap(range(0, _converse.NUM_PREKEYS), Number.prototype.toString),
-                    Object.keys(this.getPreKeys())
-                );
-                if (missing_keys.length < 1) {
-                    log.warn('No missing prekeys to generate for our own device');
-                    return Promise.resolve();
-                }
-                const keys = await Promise.all(
-                    missing_keys.map(id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10)))
-                );
-                keys.forEach(k => this.storePreKey(k.keyId, k.keyPair));
-                const marshalled_keys = Object.keys(this.getPreKeys()).map(k => ({
-                    'id': k.keyId,
-                    'key': u.arrayBufferToBase64(k.pubKey)
-                }));
-                const devicelist = _converse.devicelists.get(_converse.bare_jid);
-                const device = devicelist.devices.get(this.get('device_id'));
-                const bundle = await device.getBundle();
-                device.save('bundle', Object.assign(bundle, { 'prekeys': marshalled_keys }));
-            },
-
-            /**
-             * Generate a the data used by the X3DH key agreement protocol
-             * that can be used to build a session with a device.
-             */
-            async generateBundle () {
-                // The first thing that needs to happen if a client wants to
-                // start using OMEMO is they need to generate an IdentityKey
-                // and a Device ID. The IdentityKey is a Curve25519 [6]
-                // public/private Key pair. The Device ID is a randomly
-                // generated integer between 1 and 2^31 - 1.
-                const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
-                const bundle = {};
-                const identity_key = u.arrayBufferToBase64(identity_keypair.pubKey);
-                const device_id = generateDeviceID();
-
-                bundle['identity_key'] = identity_key;
-                bundle['device_id'] = device_id;
-                this.save({
-                    'device_id': device_id,
-                    'identity_keypair': {
-                        'privKey': u.arrayBufferToBase64(identity_keypair.privKey),
-                        'pubKey': identity_key
-                    },
-                    'identity_key': identity_key
-                });
-                const signed_prekey = await libsignal.KeyHelper.generateSignedPreKey(identity_keypair, 0);
-
-                _converse.omemo_store.storeSignedPreKey(signed_prekey);
-                bundle['signed_prekey'] = {
-                    'id': signed_prekey.keyId,
-                    'public_key': u.arrayBufferToBase64(signed_prekey.keyPair.privKey),
-                    'signature': u.arrayBufferToBase64(signed_prekey.signature)
-                };
-                const keys = await Promise.all(
-                    range(0, _converse.NUM_PREKEYS).map(id => libsignal.KeyHelper.generatePreKey(id))
-                );
-                keys.forEach(k => _converse.omemo_store.storePreKey(k.keyId, k.keyPair));
-                const devicelist = _converse.devicelists.get(_converse.bare_jid);
-                const device = await devicelist.devices.create(
-                    { 'id': bundle.device_id, 'jid': _converse.bare_jid },
-                    { 'promise': true }
-                );
-                const marshalled_keys = keys.map(k => ({
-                    'id': k.keyId,
-                    'key': u.arrayBufferToBase64(k.keyPair.pubKey)
-                }));
-                bundle['prekeys'] = marshalled_keys;
-                device.save('bundle', bundle);
-            },
-
-            fetchSession () {
-                if (this._setup_promise === undefined) {
-                    this._setup_promise = new Promise((resolve, reject) => {
-                        this.fetch({
-                            'success': () => {
-                                if (!_converse.omemo_store.get('device_id')) {
-                                    this.generateBundle()
-                                        .then(resolve)
-                                        .catch(reject);
-                                } else {
-                                    resolve();
-                                }
-                            },
-                            'error': (model, resp) => {
-                                log.warn("Could not fetch OMEMO session from cache, we'll generate a new one.");
-                                log.warn(resp);
-                                this.generateBundle()
-                                    .then(resolve)
-                                    .catch(reject);
-                            }
-                        });
-                    });
-                }
-                return this._setup_promise;
-            }
-        });
-
-        /**
-         * @class
-         * @namespace _converse.Device
-         * @memberOf _converse
-         */
-        _converse.Device = Model.extend({
-            defaults: {
-                'trusted': UNDECIDED,
-                'active': true
-            },
-
-            getRandomPreKey () {
-                // XXX: assumes that the bundle has already been fetched
-                const bundle = this.get('bundle');
-                return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
-            },
-
-            async fetchBundleFromServer () {
-                const stanza = $iq({
-                    'type': 'get',
-                    'from': _converse.bare_jid,
-                    'to': this.get('jid')
-                })
-                    .c('pubsub', { 'xmlns': Strophe.NS.PUBSUB })
-                    .c('items', { 'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}` });
-
-                let iq;
-                try {
-                    iq = await api.sendIQ(stanza);
-                } catch (iq) {
-                    log.error(`Could not fetch bundle for device ${this.get('id')} from ${this.get('jid')}`);
-                    log.error(iq);
-                    return null;
-                }
-                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();
-                const bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop();
-                const bundle = parseBundle(bundle_el);
-                this.save('bundle', bundle);
-                return bundle;
-            },
-
-            /**
-             * Fetch and save the bundle information associated with
-             * this device, if the information is not cached already.
-             * @method _converse.Device#getBundle
-             */
-            getBundle () {
-                if (this.get('bundle')) {
-                    return Promise.resolve(this.get('bundle'), this);
-                } else {
-                    return this.fetchBundleFromServer();
-                }
-            }
-        });
-
-        _converse.Devices = Collection.extend({
-            model: _converse.Device
-        });
-
-        /**
-         * @class
-         * @namespace _converse.DeviceList
-         * @memberOf _converse
-         */
-        _converse.DeviceList = Model.extend({
-            idAttribute: 'jid',
-
-            initialize () {
-                this.devices = new _converse.Devices();
-                const id = `converse.devicelist-${_converse.bare_jid}-${this.get('jid')}`;
-                this.devices.browserStorage = _converse.createStore(id);
-                this.fetchDevices();
-            },
-
-            async onDevicesFound (collection) {
-                if (collection.length === 0) {
-                    let ids;
-                    try {
-                        ids = await this.fetchDevicesFromServer();
-                    } catch (e) {
-                        if (e === null) {
-                            log.error(`Timeout error while fetching devices for ${this.get('jid')}`);
-                        } else {
-                            log.error(`Could not fetch devices for ${this.get('jid')}`);
-                            log.error(e);
-                        }
-                        this.destroy();
-                    }
-                    if (this.get('jid') === _converse.bare_jid) {
-                        await this.publishCurrentDevice(ids);
-                    }
-                }
-            },
-
-            fetchDevices () {
-                if (this._devices_promise === undefined) {
-                    this._devices_promise = new Promise(resolve => {
-                        this.devices.fetch({
-                            'success': c => resolve(this.onDevicesFound(c)),
-                            'error': (m, e) => {
-                                log.error(e);
-                                resolve();
-                            }
-                        });
-                    });
-                }
-                return this._devices_promise;
-            },
-
-            async getOwnDeviceId () {
-                let device_id = _converse.omemo_store.get('device_id');
-                if (!this.devices.findWhere({ 'id': device_id })) {
-                    // Generate a new bundle if we cannot find our device
-                    await _converse.omemo_store.generateBundle();
-                    device_id = _converse.omemo_store.get('device_id');
-                }
-                return device_id;
-            },
-
-            async publishCurrentDevice (device_ids) {
-                if (this.get('jid') !== _converse.bare_jid) {
-                    return; // We only publish for ourselves.
-                }
-                await restoreOMEMOSession();
-
-                if (!_converse.omemo_store) {
-                    // Happens during tests. The connection gets torn down
-                    // before publishCurrentDevice has time to finish.
-                    log.warn('publishCurrentDevice: omemo_store is not defined, likely a timing issue');
-                    return;
-                }
-                if (!device_ids.includes(await this.getOwnDeviceId())) {
-                    return this.publishDevices();
-                }
-            },
-
-            async fetchDevicesFromServer () {
-                const stanza = $iq({
-                    'type': 'get',
-                    'from': _converse.bare_jid,
-                    'to': this.get('jid')
-                })
-                    .c('pubsub', { 'xmlns': Strophe.NS.PUBSUB })
-                    .c('items', { 'node': Strophe.NS.OMEMO_DEVICELIST });
-
-                let iq;
-                try {
-                    iq = await api.sendIQ(stanza);
-                } catch (e) {
-                    log.error(e);
-                    return [];
-                }
-                const selector = `list[xmlns="${Strophe.NS.OMEMO}"] device`;
-                const device_ids = sizzle(selector, iq).map(d => d.getAttribute('id'));
-                await Promise.all(
-                    device_ids.map(id => this.devices.create({ id, 'jid': this.get('jid') }, { 'promise': true }))
-                );
-                return device_ids;
-            },
-
-            /**
-             * Send an IQ stanza to the current user's "devices" PEP node to
-             * ensure that all devices are published for potential chat partners to see.
-             * See: https://xmpp.org/extensions/xep-0384.html#usecases-announcing
-             */
-            publishDevices () {
-                const item = $build('item', { 'id': 'current' }).c('list', { 'xmlns': Strophe.NS.OMEMO });
-                this.devices.filter(d => d.get('active')).forEach(d => item.c('device', { 'id': d.get('id') }).up());
-                const options = { 'pubsub#access_model': 'open' };
-                return api.pubsub.publish(null, Strophe.NS.OMEMO_DEVICELIST, item, options, false);
-            },
-
-            removeOwnDevices (device_ids) {
-                if (this.get('jid') !== _converse.bare_jid) {
-                    throw new Error("Cannot remove devices from someone else's device list");
-                }
-                device_ids.forEach(device_id => this.devices.get(device_id).destroy());
-                return this.publishDevices();
-            }
-        });
-
-        /**
-         * @class
-         * @namespace _converse.DeviceLists
-         * @memberOf _converse
-         */
-        _converse.DeviceLists = Collection.extend({
-            model: _converse.DeviceList,
-            /**
-             * 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 });
-            }
-        });
+        _converse.OMEMOStore = OMEMOStore;
+        _converse.Device = Device;
+        _converse.Devices = Devices;
+        _converse.DeviceList = DeviceList;
+        _converse.DeviceLists = DeviceLists;
 
         /******************** Event Handlers ********************/
         api.waitUntil('chatBoxesInitialized').then(onChatBoxesInitialized);
@@ -865,54 +100,5 @@ converse.plugins.add('converse-omemo', {
                 delete _converse.devicelists;
             }
         });
-
-        /************************ API ************************/
-        Object.assign(_converse.api, {
-            /**
-             * The "omemo" namespace groups methods relevant to OMEMO
-             * encryption.
-             *
-             * @namespace _converse.api.omemo
-             * @memberOf _converse.api
-             */
-            'omemo': {
-                /**
-                 * The "bundle" namespace groups methods relevant to the user's
-                 * OMEMO bundle.
-                 *
-                 * @namespace _converse.api.omemo.bundle
-                 * @memberOf _converse.api.omemo
-                 */
-                'bundle': {
-                    /**
-                     * Lets you generate a new OMEMO device bundle
-                     *
-                     * @method _converse.api.omemo.bundle.generate
-                     * @returns {promise} Promise which resolves once we have a result from the server.
-                     */
-                    'generate': async () => {
-                        // Remove current device
-                        const devicelist = _converse.devicelists.get(_converse.bare_jid);
-                        const device_id = _converse.omemo_store.get('device_id');
-                        if (device_id) {
-                            const device = devicelist.devices.get(device_id);
-                            _converse.omemo_store.unset(device_id);
-                            if (device) {
-                                await new Promise(done => device.destroy({ 'success': done, 'error': done }));
-                            }
-                            devicelist.devices.trigger('remove');
-                        }
-                        // Generate new device bundle and publish
-                        // https://xmpp.org/extensions/attic/xep-0384-0.3.0.html#usecases-announcing
-                        await _converse.omemo_store.generateBundle();
-                        await devicelist.publishDevices();
-                        const device = devicelist.devices.get(_converse.omemo_store.get('device_id'));
-                        const fp = generateFingerprint(device);
-                        await _converse.omemo_store.publishBundle();
-                        return fp;
-                    }
-                }
-            }
-        });
     }
 });

+ 55 - 0
src/plugins/omemo/mixins/chatbox.js

@@ -0,0 +1,55 @@
+import log from '@converse/headless/log';
+import { __ } from 'i18n';
+import { api, converse } from '@converse/headless/core';
+import { getSessionCipher } from '../utils.js';
+
+const { Strophe, sizzle } = converse.env;
+
+/**
+ * Mixin object that contains OMEMO-related methods for
+ * {@link _converse.ChatBox} or {@link _converse.ChatRoom} objects.
+ *
+ * @typedef {Object} OMEMOEnabledChatBox
+ */
+export const OMEMOEnabledChatBox = {
+    encryptKey (plaintext, device) {
+        return getSessionCipher(device.get('jid'), device.get('id'))
+            .encrypt(plaintext)
+            .then(payload => ({ 'payload': payload, 'device': device }));
+    },
+
+    handleMessageSendError (e) {
+        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);
+            }
+            api.alert('error', __('Error'), err_msgs);
+            log.error(e);
+        } else if (e.user_facing) {
+            api.alert('error', __('Error'), [e.message]);
+            log.error(e);
+        } else {
+            throw e;
+        }
+    }
+};
+

+ 119 - 0
src/plugins/omemo/mixins/converse.js

@@ -0,0 +1,119 @@
+import concat from 'lodash-es/concat';
+import { UNTRUSTED } from '../consts.js';
+import { __ } from 'i18n';
+import { _converse, converse } from '@converse/headless/core';
+import {
+    addKeysToMessageStanza,
+    generateFingerprint,
+    getDevicesForContact,
+    getSession,
+    omemo,
+} from '../utils.js';
+
+const { Strophe, $msg } = converse.env;
+
+const ConverseMixins = {
+
+    generateFingerprints: async function (jid) {
+        const devices = await getDevicesForContact(jid);
+        return Promise.all(devices.map(d => generateFingerprint(d)));
+    },
+
+    getDeviceForContact: function (jid, device_id) {
+        return getDevicesForContact(jid).then(devices => devices.get(device_id));
+    },
+
+    contactHasOMEMOSupport: async function (jid) {
+        /* Checks whether the contact advertises any OMEMO-compatible devices. */
+        const devices = await getDevicesForContact(jid);
+        return devices.length > 0;
+    },
+
+    getBundlesAndBuildSessions: async function (chatbox) {
+        const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.');
+        let devices;
+        if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
+            const collections = await Promise.all(chatbox.occupants.map(o => getDevicesForContact(o.get('jid'))));
+            devices = collections.reduce((a, b) => concat(a, b.models), []);
+        } else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
+            const their_devices = await getDevicesForContact(chatbox.get('jid'));
+            if (their_devices.length === 0) {
+                const err = new Error(no_devices_err);
+                err.user_facing = true;
+                throw err;
+            }
+            const own_devices = _converse.devicelists.get(_converse.bare_jid).devices;
+            devices = [...own_devices.models, ...their_devices.models];
+        }
+        // Filter out our own device
+        const id = _converse.omemo_store.get('device_id');
+        devices = devices.filter(d => d.get('id') !== id);
+        // Fetch bundles if necessary
+        await Promise.all(devices.map(d => d.getBundle()));
+
+        const sessions = devices.filter(d => d).map(d => getSession(d));
+        await Promise.all(sessions);
+        if (sessions.includes(null)) {
+            // We couldn't build a session for certain devices.
+            devices = devices.filter(d => sessions[devices.indexOf(d)]);
+            if (devices.length === 0) {
+                const err = new Error(no_devices_err);
+                err.user_facing = true;
+                throw err;
+            }
+        }
+        return devices;
+    },
+
+    createOMEMOMessageStanza: function (chatbox, message, devices) {
+        const body = __(
+            'This is an OMEMO encrypted message which your client doesn’t seem to support. ' +
+                'Find more information on https://conversations.im/omemo'
+        );
+
+        if (!message.get('message')) {
+            throw new Error('No message body to encrypt!');
+        }
+        const stanza = $msg({
+            'from': _converse.connection.jid,
+            'to': chatbox.get('jid'),
+            'type': chatbox.get('message_type'),
+            'id': message.get('msgid')
+        }).c('body').t(body).up();
+
+        if (message.get('type') === 'chat') {
+            stanza.c('request', { 'xmlns': Strophe.NS.RECEIPTS }).up();
+        }
+        // An encrypted header is added to the message for
+        // each device that is supposed to receive it.
+        // These headers simply contain the key that the
+        // payload message is encrypted with,
+        // and they are separately encrypted using the
+        // session corresponding to the counterpart device.
+        stanza
+            .c('encrypted', { 'xmlns': Strophe.NS.OMEMO })
+            .c('header', { 'sid': _converse.omemo_store.get('device_id') });
+
+        return omemo.encryptMessage(message.get('message')).then(obj => {
+            // The 16 bytes key and the GCM authentication tag (The tag
+            // SHOULD have at least 128 bit) are concatenated and for each
+            // intended recipient device, i.e. both own devices as well as
+            // devices associated with the contact, the result of this
+            // concatenation is encrypted using the corresponding
+            // long-standing SignalProtocol session.
+            const promises = devices
+                .filter(device => device.get('trusted') != UNTRUSTED && device.get('active'))
+                .map(device => chatbox.encryptKey(obj.key_and_tag, device));
+
+            return Promise.all(promises)
+                .then(dicts => addKeysToMessageStanza(stanza, dicts, obj.iv))
+                .then(stanza => {
+                    stanza.c('payload').t(obj.payload).up().up();
+                    stanza.c('store', { 'xmlns': Strophe.NS.HINTS });
+                    return stanza;
+                });
+        });
+    }
+}
+
+export default ConverseMixins;

+ 26 - 0
src/plugins/omemo/overrides/chatbox.js

@@ -0,0 +1,26 @@
+import { _converse } from '@converse/headless/core';
+
+const ChatBox = {
+    async sendMessage (text, spoiler_hint) {
+        if (this.get('omemo_active') && text) {
+            const attrs = this.getOutgoingMessageAttributes(text, spoiler_hint);
+            attrs['is_encrypted'] = true;
+            attrs['plaintext'] = attrs.message;
+            let message, stanza;
+            try {
+                const devices = await _converse.getBundlesAndBuildSessions(this);
+                message = await this.createMessage(attrs);
+                stanza = await _converse.createOMEMOMessageStanza(this, message, devices);
+            } catch (e) {
+                this.handleMessageSendError(e);
+                return null;
+            }
+            _converse.api.send(stanza);
+            return message;
+        } else {
+            return this.__super__.sendMessage.apply(this, arguments);
+        }
+    }
+}
+
+export default ChatBox;

+ 76 - 0
src/plugins/omemo/overrides/profile-modal.js

@@ -0,0 +1,76 @@
+import debounce from 'lodash-es/debounce';
+import log from '@converse/headless/log';
+import { __ } from 'i18n';
+import { _converse, api, converse } from '@converse/headless/core';
+
+const { Strophe, sizzle, u } = converse.env;
+
+
+const ProfileModal = {
+    events: {
+        'change input.select-all': 'selectAll',
+        'click .generate-bundle': 'generateOMEMODeviceBundle',
+        'submit .fingerprint-removal': 'removeSelectedFingerprints'
+    },
+
+    initialize () {
+        this.debouncedRender = debounce(this.render, 50);
+        this.devicelist = _converse.devicelists.get(_converse.bare_jid);
+        this.listenTo(this.devicelist.devices, 'change:bundle', this.debouncedRender);
+        this.listenTo(this.devicelist.devices, 'reset', this.debouncedRender);
+        this.listenTo(this.devicelist.devices, 'reset', this.debouncedRender);
+        this.listenTo(this.devicelist.devices, 'remove', this.debouncedRender);
+        this.listenTo(this.devicelist.devices, 'add', this.debouncedRender);
+        return this.__super__.initialize.apply(this, arguments);
+    },
+
+    beforeRender () {
+        const device_id = _converse.omemo_store.get('device_id');
+
+        if (device_id) {
+            this.current_device = this.devicelist.devices.get(device_id);
+        }
+        this.other_devices = this.devicelist.devices.filter(d => d.get('id') !== device_id);
+        if (this.__super__.beforeRender) {
+            return this.__super__.beforeRender.apply(this, arguments);
+        }
+    },
+
+    selectAll (ev) {
+        let sibling = u.ancestor(ev.target, 'li');
+        while (sibling) {
+            sibling.querySelector('input[type="checkbox"]').checked = ev.target.checked;
+            sibling = sibling.nextElementSibling;
+        }
+    },
+
+    removeSelectedFingerprints (ev) {
+        ev.preventDefault();
+        ev.stopPropagation();
+        ev.target.querySelector('.select-all').checked = false;
+        const device_ids = sizzle('.fingerprint-removal-item input[type="checkbox"]:checked', ev.target).map(
+            c => c.value
+        );
+        this.devicelist
+            .removeOwnDevices(device_ids)
+            .then(this.modal.hide)
+            .catch(err => {
+                log.error(err);
+                _converse.api.alert(Strophe.LogLevel.ERROR, __('Error'), [
+                    __('Sorry, an error occurred while trying to remove the devices.')
+                ]);
+            });
+    },
+
+    generateOMEMODeviceBundle (ev) {
+        ev.preventDefault();
+        if (confirm(__(
+            'Are you sure you want to generate new OMEMO keys? ' +
+            'This will remove your old keys and all previously encrypted messages will no longer be decryptable on this device.'
+        ))) {
+            api.omemo.bundle.generate();
+        }
+    }
+}
+
+export default ProfileModal;

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

@@ -0,0 +1,26 @@
+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;

+ 283 - 0
src/plugins/omemo/store.js

@@ -0,0 +1,283 @@
+/* global libsignal */
+import difference from 'lodash-es/difference';
+import invokeMap from 'lodash-es/invokeMap';
+import log from '@converse/headless/log';
+import range from 'lodash-es/range';
+import omit from 'lodash-es/omit';
+import { Model } from '@converse/skeletor/src/model.js';
+import { generateDeviceID } from './utils.js';
+import { _converse, api, converse } from '@converse/headless/core';
+
+const { Strophe, $build, u } = converse.env;
+
+
+const OMEMOStore = Model.extend({
+    Direction: {
+        SENDING: 1,
+        RECEIVING: 2
+    },
+
+    getIdentityKeyPair () {
+        const keypair = this.get('identity_keypair');
+        return Promise.resolve({
+            'privKey': u.base64ToArrayBuffer(keypair.privKey),
+            'pubKey': u.base64ToArrayBuffer(keypair.pubKey)
+        });
+    },
+
+    getLocalRegistrationId () {
+        return Promise.resolve(parseInt(this.get('device_id'), 10));
+    },
+
+    isTrustedIdentity (identifier, identity_key, direction) { // eslint-disable-line no-unused-vars
+        if (identifier === null || identifier === undefined) {
+            throw new Error("Can't check identity key for invalid key");
+        }
+        if (!(identity_key instanceof ArrayBuffer)) {
+            throw new Error('Expected identity_key to be an ArrayBuffer');
+        }
+        const trusted = this.get('identity_key' + identifier);
+        if (trusted === undefined) {
+            return Promise.resolve(true);
+        }
+        return Promise.resolve(u.arrayBufferToBase64(identity_key) === trusted);
+    },
+
+    loadIdentityKey (identifier) {
+        if (identifier === null || identifier === undefined) {
+            throw new Error("Can't load identity_key for invalid identifier");
+        }
+        return Promise.resolve(u.base64ToArrayBuffer(this.get('identity_key' + identifier)));
+    },
+
+    saveIdentity (identifier, identity_key) {
+        if (identifier === null || identifier === undefined) {
+            throw new Error("Can't save identity_key for invalid identifier");
+        }
+        const address = new libsignal.SignalProtocolAddress.fromString(identifier);
+        const existing = this.get('identity_key' + address.getName());
+        const b64_idkey = u.arrayBufferToBase64(identity_key);
+        this.save('identity_key' + address.getName(), b64_idkey);
+
+        if (existing && b64_idkey !== existing) {
+            return Promise.resolve(true);
+        } else {
+            return Promise.resolve(false);
+        }
+    },
+
+    getPreKeys () {
+        return this.get('prekeys') || {};
+    },
+
+    loadPreKey (key_id) {
+        const res = this.getPreKeys()[key_id];
+        if (res) {
+            return Promise.resolve({
+                'privKey': u.base64ToArrayBuffer(res.privKey),
+                'pubKey': u.base64ToArrayBuffer(res.pubKey)
+            });
+        }
+        return Promise.resolve();
+    },
+
+    storePreKey (key_id, key_pair) {
+        const prekey = {};
+        prekey[key_id] = {
+            'pubKey': u.arrayBufferToBase64(key_pair.pubKey),
+            'privKey': u.arrayBufferToBase64(key_pair.privKey)
+        };
+        this.save('prekeys', Object.assign(this.getPreKeys(), prekey));
+        return Promise.resolve();
+    },
+
+    removePreKey (key_id) {
+        this.save('prekeys', omit(this.getPreKeys(), key_id));
+        return Promise.resolve();
+    },
+
+    loadSignedPreKey (keyId) { // eslint-disable-line no-unused-vars
+        const res = this.get('signed_prekey');
+        if (res) {
+            return Promise.resolve({
+                'privKey': u.base64ToArrayBuffer(res.privKey),
+                'pubKey': u.base64ToArrayBuffer(res.pubKey)
+            });
+        }
+        return Promise.resolve();
+    },
+
+    storeSignedPreKey (spk) {
+        if (typeof spk !== 'object') {
+            // XXX: We've changed the signature of this method from the
+            // example given in InMemorySignalProtocolStore.
+            // Should be fine because the libsignal code doesn't
+            // actually call this method.
+            throw new Error('storeSignedPreKey: expected an object');
+        }
+        this.save('signed_prekey', {
+            'id': spk.keyId,
+            'privKey': u.arrayBufferToBase64(spk.keyPair.privKey),
+            'pubKey': u.arrayBufferToBase64(spk.keyPair.pubKey),
+            // XXX: The InMemorySignalProtocolStore does not pass
+            // in or store the signature, but we need it when we
+            // publish out bundle and this method isn't called from
+            // within libsignal code, so we modify it to also store
+            // the signature.
+            'signature': u.arrayBufferToBase64(spk.signature)
+        });
+        return Promise.resolve();
+    },
+
+    removeSignedPreKey (key_id) {
+        if (this.get('signed_prekey')['id'] === key_id) {
+            this.unset('signed_prekey');
+            this.save();
+        }
+        return Promise.resolve();
+    },
+
+    loadSession (identifier) {
+        return Promise.resolve(this.get('session' + identifier));
+    },
+
+    storeSession (identifier, record) {
+        return Promise.resolve(this.save('session' + identifier, record));
+    },
+
+    removeSession (identifier) {
+        return Promise.resolve(this.unset('session' + identifier));
+    },
+
+    removeAllSessions (identifier) {
+        const keys = Object.keys(this.attributes).filter(key =>
+            key.startsWith('session' + identifier) ? key : false
+        );
+        const attrs = {};
+        keys.forEach(key => {
+            attrs[key] = undefined;
+        });
+        this.save(attrs);
+        return Promise.resolve();
+    },
+
+    publishBundle () {
+        const signed_prekey = this.get('signed_prekey');
+        const node = `${Strophe.NS.OMEMO_BUNDLES}:${this.get('device_id')}`;
+        const item = $build('item')
+            .c('bundle', { 'xmlns': Strophe.NS.OMEMO })
+                .c('signedPreKeyPublic', { 'signedPreKeyId': signed_prekey.id })
+                    .t(signed_prekey.pubKey).up()
+                .c('signedPreKeySignature')
+                    .t(signed_prekey.signature).up()
+                .c('identityKey')
+                    .t(this.get('identity_keypair').pubKey).up()
+                .c('prekeys');
+
+        Object.values(this.get('prekeys')).forEach((prekey, id) =>
+            item
+                .c('preKeyPublic', { 'preKeyId': id })
+                .t(prekey.pubKey)
+                .up()
+        );
+        const options = { 'pubsub#access_model': 'open' };
+        return api.pubsub.publish(null, node, item, options, false);
+    },
+
+    async generateMissingPreKeys () {
+        const missing_keys = difference(
+            invokeMap(range(0, _converse.NUM_PREKEYS), Number.prototype.toString),
+            Object.keys(this.getPreKeys())
+        );
+        if (missing_keys.length < 1) {
+            log.warn('No missing prekeys to generate for our own device');
+            return Promise.resolve();
+        }
+        const keys = await Promise.all(
+            missing_keys.map(id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10)))
+        );
+        keys.forEach(k => this.storePreKey(k.keyId, k.keyPair));
+        const marshalled_keys = Object.keys(this.getPreKeys()).map(k => ({
+            'id': k.keyId,
+            'key': u.arrayBufferToBase64(k.pubKey)
+        }));
+        const devicelist = _converse.devicelists.get(_converse.bare_jid);
+        const device = devicelist.devices.get(this.get('device_id'));
+        const bundle = await device.getBundle();
+        device.save('bundle', Object.assign(bundle, { 'prekeys': marshalled_keys }));
+    },
+
+    /**
+     * Generate a the data used by the X3DH key agreement protocol
+     * that can be used to build a session with a device.
+     */
+    async generateBundle () {
+        // The first thing that needs to happen if a client wants to
+        // start using OMEMO is they need to generate an IdentityKey
+        // and a Device ID. The IdentityKey is a Curve25519 [6]
+        // public/private Key pair. The Device ID is a randomly
+        // generated integer between 1 and 2^31 - 1.
+        const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
+        const bundle = {};
+        const identity_key = u.arrayBufferToBase64(identity_keypair.pubKey);
+        const device_id = generateDeviceID();
+
+        bundle['identity_key'] = identity_key;
+        bundle['device_id'] = device_id;
+        this.save({
+            'device_id': device_id,
+            'identity_keypair': {
+                'privKey': u.arrayBufferToBase64(identity_keypair.privKey),
+                'pubKey': identity_key
+            },
+            'identity_key': identity_key
+        });
+        const signed_prekey = await libsignal.KeyHelper.generateSignedPreKey(identity_keypair, 0);
+
+        _converse.omemo_store.storeSignedPreKey(signed_prekey);
+        bundle['signed_prekey'] = {
+            'id': signed_prekey.keyId,
+            'public_key': u.arrayBufferToBase64(signed_prekey.keyPair.privKey),
+            'signature': u.arrayBufferToBase64(signed_prekey.signature)
+        };
+        const keys = await Promise.all(
+            range(0, _converse.NUM_PREKEYS).map(id => libsignal.KeyHelper.generatePreKey(id))
+        );
+        keys.forEach(k => _converse.omemo_store.storePreKey(k.keyId, k.keyPair));
+        const devicelist = _converse.devicelists.get(_converse.bare_jid);
+        const device = await devicelist.devices.create(
+            { 'id': bundle.device_id, 'jid': _converse.bare_jid },
+            { 'promise': true }
+        );
+        const marshalled_keys = keys.map(k => ({
+            'id': k.keyId,
+            'key': u.arrayBufferToBase64(k.keyPair.pubKey)
+        }));
+        bundle['prekeys'] = marshalled_keys;
+        device.save('bundle', bundle);
+    },
+
+    fetchSession () {
+        if (this._setup_promise === undefined) {
+            this._setup_promise = new Promise((resolve, reject) => {
+                this.fetch({
+                    'success': () => {
+                        if (!_converse.omemo_store.get('device_id')) {
+                            this.generateBundle().then(resolve).catch(reject);
+                        } else {
+                            resolve();
+                        }
+                    },
+                    'error': (model, resp) => {
+                        log.warn("Could not fetch OMEMO session from cache, we'll generate a new one.");
+                        log.warn(resp);
+                        this.generateBundle().then(resolve).catch(reject);
+                    }
+                });
+            });
+        }
+        return this._setup_promise;
+    }
+});
+
+export default OMEMOStore;

+ 8 - 7
src/plugins/omemo/utils.js

@@ -1,7 +1,7 @@
 /* global libsignal */
 import difference from 'lodash-es/difference';
 import log from '@converse/headless/log';
-import { __ } from '../i18n';
+import { __ } from 'i18n';
 import { _converse, converse, api } from '@converse/headless/core';
 import { html } from 'lit-html';
 
@@ -13,7 +13,7 @@ const KEY_ALGO = {
     'length': 128
 };
 
-const omemo = (converse.env.omemo = {
+export const omemo = {
     async encryptMessage (plaintext) {
         // The client MUST use fresh, randomly generated key/IV pairs
         // with AES-128 in Galois/Counter Mode (GCM).
@@ -57,7 +57,7 @@ const omemo = (converse.env.omemo = {
         };
         return u.arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
     }
-});
+}
 
 export function parseEncryptedMessage (stanza, attrs) {
     if (attrs.is_encrypted && attrs.encrypted.key) {
@@ -234,10 +234,11 @@ export function addKeysToMessageStanza (stanza, dicts, iv) {
     return Promise.resolve(stanza);
 }
 
-function parseBundle (bundle_el) {
-    /* Given an XML element representing a user's OMEMO bundle, parse it
-     * and return a map.
-     */
+/**
+ * Given an XML element representing a user's OMEMO bundle, parse it
+ * and return a map.
+ */
+export function parseBundle (bundle_el) {
     const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic');
     const signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature');
     const prekeys = sizzle(`prekeys > preKeyPublic`, bundle_el).map(el => ({