Selaa lähdekoodia

Make sure XEP-0363 urls are also OMEMO encrypted

by re-using `ChatBox.prototype.sendMessage`.

updates #1182
JC Brand 4 vuotta sitten
vanhempi
commit
01efb02f9e

+ 1 - 0
karma.conf.js

@@ -85,6 +85,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/muc-views/tests/unfurls.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/xss.js", type: 'module' },
       { pattern: "src/plugins/notifications/tests/notification.js", type: 'module' },
+      { pattern: "src/plugins/omemo/tests/media-sharing.js", type: 'module' },
       { pattern: "src/plugins/omemo/tests/omemo.js", type: 'module' },
       { pattern: "src/plugins/register/tests/register.js", type: 'module' },
       { pattern: "src/plugins/rootview/tests/root.js", type: 'module' },

+ 65 - 0
spec/mock.js

@@ -672,3 +672,68 @@ const initConverse = async (settings) => {
     window.converse_disable_effects = true;
     return _converse;
 }
+
+
+mock.deviceListFetched = async function deviceListFetched (_converse, jid) {
+    const selector = `iq[to="${jid}"] items[node="eu.siacs.conversations.axolotl.devicelist"]`;
+    const stanza = await u.waitUntil(
+        () => Array.from(_converse.connection.IQ_stanzas).filter(iq => iq.querySelector(selector)).pop()
+    );
+    await u.waitUntil(() => _converse.devicelists.get(jid));
+    return stanza;
+}
+
+mock.ownDeviceHasBeenPublished = function ownDeviceHasBeenPublished (_converse) {
+    return Array.from(_converse.connection.IQ_stanzas).filter(
+        iq => iq.querySelector('iq[from="'+_converse.bare_jid+'"] publish[node="eu.siacs.conversations.axolotl.devicelist"]')
+    ).pop();
+}
+
+mock.bundleHasBeenPublished = function bundleHasBeenPublished (_converse) {
+    const selector = 'publish[node="eu.siacs.conversations.axolotl.bundles:123456789"]';
+    return Array.from(_converse.connection.IQ_stanzas).filter(iq => iq.querySelector(selector)).pop();
+}
+
+mock.bundleFetched = function bundleFetched (_converse, jid, device_id) {
+    return Array.from(_converse.connection.IQ_stanzas).filter(
+        iq => iq.querySelector(`iq[to="${jid}"] items[node="eu.siacs.conversations.axolotl.bundles:${device_id}"]`)
+    ).pop();
+}
+
+mock.initializedOMEMO = async function initializedOMEMO (_converse) {
+    await mock.waitUntilDiscoConfirmed(
+        _converse, _converse.bare_jid,
+        [{'category': 'pubsub', 'type': 'pep'}],
+        ['http://jabber.org/protocol/pubsub#publish-options']
+    );
+    let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid));
+    let stanza = $iq({
+        'from': _converse.bare_jid,
+        'id': iq_stanza.getAttribute('id'),
+        'to': _converse.bare_jid,
+        'type': 'result',
+    }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+        .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+            .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+                .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+                    .c('device', {'id': '482886413b977930064a5888b92134fe'});
+    _converse.connection._dataRecv(mock.createRequest(stanza));
+    iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse))
+
+    stanza = $iq({
+        'from': _converse.bare_jid,
+        'id': iq_stanza.getAttribute('id'),
+        'to': _converse.bare_jid,
+        'type': 'result'});
+    _converse.connection._dataRecv(mock.createRequest(stanza));
+
+    iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse))
+
+    stanza = $iq({
+        'from': _converse.bare_jid,
+        'id': iq_stanza.getAttribute('id'),
+        'to': _converse.bare_jid,
+        'type': 'result'});
+    _converse.connection._dataRecv(mock.createRequest(stanza));
+    await _converse.api.waitUntil('OMEMOInitialized');
+}

+ 19 - 13
src/headless/plugins/chat/model.js

@@ -241,9 +241,16 @@ const ChatBox = ModelWithContact.extend({
         }
     },
 
-    onMessageUploadChanged (message) {
+    async onMessageUploadChanged (message) {
         if (message.get('upload') === _converse.SUCCESS) {
-            api.send(this.createMessageStanza(message));
+            const attrs = {
+                'body': message.get('message'),
+                'spoiler_hint': message.get('spoiler_hint'),
+                'oob_url': message.get('oob_url')
+
+            }
+            await this.sendMessage(attrs);
+            message.destroy();
         }
     },
 
@@ -842,11 +849,12 @@ const ChatBox = ModelWithContact.extend({
         return stanza;
     },
 
-    getOutgoingMessageAttributes (text, spoiler_hint) {
-        const is_spoiler = this.get('composing_spoiler');
+    getOutgoingMessageAttributes (attrs) {
+        const is_spoiler = !!this.get('composing_spoiler');
         const origin_id = u.getUniqueId();
+        const text = attrs?.body;
         const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
-        return {
+        return Object.assign({}, attrs, {
             'from': _converse.bare_jid,
             'fullname': _converse.xmppstatus.get('fullname'),
             'id': origin_id,
@@ -856,13 +864,12 @@ const ChatBox = ModelWithContact.extend({
             'msgid': origin_id,
             'nickname': this.get('nickname'),
             'sender': 'me',
-            'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
             'time': (new Date()).toISOString(),
             'type': this.get('message_type'),
             body,
             is_spoiler,
             origin_id
-        }
+        });
     },
 
     /**
@@ -911,15 +918,14 @@ const ChatBox = ModelWithContact.extend({
      * @private
      * @method _converse.ChatBox#sendMessage
      * @memberOf _converse.ChatBox
-     * @param { String } text - The chat message text
-     * @param { String } spoiler_hint - An optional hint, if the message being sent is a spoiler
+     * @param { Object } [attrs] - A map of attributes to be saved on the message
      * @returns { _converse.Message }
      * @example
-     * const chat = api.chats.get('buddy1@example.com');
-     * chat.sendMessage('hello world');
+     * const chat = api.chats.get('buddy1@example.org');
+     * chat.sendMessage({'body': 'hello world'});
      */
-    async sendMessage (text, spoiler_hint) {
-        const attrs = this.getOutgoingMessageAttributes(text, spoiler_hint);
+    async sendMessage (attrs) {
+        attrs = this.getOutgoingMessageAttributes(attrs);
         let message = this.messages.findWhere('correcting')
         if (message) {
             const older_versions = message.get('older_versions') || {};

+ 7 - 5
src/headless/plugins/muc/muc.js

@@ -959,12 +959,15 @@ const ChatRoomMixin = {
         return [updated_message, updated_references];
     },
 
-    getOutgoingMessageAttributes (original_message, spoiler_hint) {
+    getOutgoingMessageAttributes (attrs) {
         const is_spoiler = this.get('composing_spoiler');
-        const [text, references] = this.parseTextForReferences(original_message);
+        let text = '', references;
+        if (attrs?.body) {
+            [text, references] = this.parseTextForReferences(attrs.body);
+        }
         const origin_id = u.getUniqueId();
         const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
-        return {
+        return Object.assign({}, attrs, {
             body,
             is_spoiler,
             origin_id,
@@ -977,9 +980,8 @@ const ChatRoomMixin = {
             'message': body,
             'nick': this.get('nick'),
             'sender': 'me',
-            'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
             'type': 'groupchat'
-        };
+        });
     },
 
     /**

+ 6 - 6
src/headless/plugins/muc/tests/pruning.js

@@ -14,20 +14,20 @@ describe("A Groupchat Message", function () {
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
         expect(model.ui.get('scrolled')).toBeFalsy();
 
-        model.sendMessage('1st message');
-        model.sendMessage('2nd message');
-        model.sendMessage('3rd message');
+        model.sendMessage({'body': '1st message'});
+        model.sendMessage({'body': '2nd message'});
+        model.sendMessage({'body': '3rd message'});
         await u.waitUntil(() => model.messages.length === 3);
         // Make sure pruneHistory fires
         await new Promise(resolve => setTimeout(resolve, 550));
 
-        model.sendMessage('4th message');
+        model.sendMessage({'body': '4th message'});
         await u.waitUntil(() => model.messages.length === 4);
         await u.waitUntil(() => model.messages.length === 3, 550);
 
         model.ui.set('scrolled', true);
-        model.sendMessage('5th message');
-        model.sendMessage('6th message');
+        model.sendMessage({'body': '5th message'});
+        model.sendMessage({'body': '6th message'});
         await u.waitUntil(() => model.messages.length === 5);
 
         // Wait long enough to be sure the debounced pruneHistory method didn't fire.

+ 1 - 1
src/plugins/chatview/message-form.js

@@ -195,7 +195,7 @@ export default class MessageForm extends ElementView {
         this.querySelector('converse-emoji-dropdown')?.hideMenu();
 
         const is_command = this.parseMessageForCommands(message_text);
-        const message = is_command ? null : await this.model.sendMessage(message_text, spoiler_hint);
+        const message = is_command ? null : await this.model.sendMessage({'body': message_text, spoiler_hint});
         if (is_command || message) {
             hint_el.value = '';
             textarea.value = '';

+ 4 - 4
src/plugins/chatview/tests/messages.js

@@ -1030,7 +1030,7 @@ describe("A Chat Message", function () {
                 await _converse.api.chats.open(sender_jid)
                 let msg_text = 'This message will not be sent, due to an error';
                 const view = _converse.chatboxviews.get(sender_jid);
-                const message = await view.model.sendMessage(msg_text);
+                const message = await view.model.sendMessage({'body': msg_text});
                 await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
                 let msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent;
                 expect(msg_txt).toEqual(msg_text);
@@ -1039,7 +1039,7 @@ describe("A Chat Message", function () {
                 // not be received, to test that errors appear
                 // after the relevant message.
                 msg_text = 'This message will be sent, and also receive an error';
-                const second_message = await view.model.sendMessage(msg_text);
+                const second_message = await view.model.sendMessage({'body': msg_text});
                 await u.waitUntil(() => sizzle('.chat-msg .chat-msg__text', view).length === 2, 1000);
                 msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent;
                 expect(msg_txt).toEqual(msg_text);
@@ -1098,7 +1098,7 @@ describe("A Chat Message", function () {
                 expect(view.querySelectorAll('.chat-msg__error').length).toEqual(2);
 
                 msg_text = 'This message will be sent, and also receive an error';
-                const third_message = await view.model.sendMessage(msg_text);
+                const third_message = await view.model.sendMessage({'body': msg_text});
                 await u.waitUntil(() => sizzle('converse-chat-message:last-child .chat-msg__text', view).pop()?.textContent === msg_text);
 
                 // A different error message will however render
@@ -1157,7 +1157,7 @@ describe("A Chat Message", function () {
                 _converse.connection._dataRecv(mock.createRequest(stanza));
                 const view = _converse.chatboxviews.get(contact_jid);
                 const msg_text = 'This message will show!';
-                await view.model.sendMessage(msg_text);
+                await view.model.sendMessage({'body': msg_text});
                 await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
                 expect(view.querySelectorAll('.chat-error').length).toEqual(0);
             }));

+ 2 - 2
src/plugins/muc-views/tests/component.js

@@ -53,7 +53,7 @@ describe("The <converse-muc> component", function () {
         await mock.returnMemberLists(_converse, muc_jid, [], all_affiliations);
         await model.messages.fetched;
 
-        model.sendMessage('hello from the lounge!');
+        model.sendMessage({'body': 'hello from the lounge!'});
 
         const span_el = document.createElement('span');
         span_el.classList.add('conversejs');
@@ -83,7 +83,7 @@ describe("The <converse-muc> component", function () {
         await mock.returnMemberLists(_converse, muc2_jid, [], all_affiliations);
         await model.messages.fetched;
 
-        model2.sendMessage('hello from the bar!');
+        model2.sendMessage({'body': 'hello from the bar!'});
         muc_el.setAttribute('jid', muc2_jid);
 
         await u.waitUntil(() => muc_el.querySelector('converse-chat-message-body').textContent.trim() === 'hello from the bar!');

+ 2 - 2
src/plugins/muc-views/tests/muc-messages.js

@@ -411,7 +411,7 @@ describe("A Groupchat Message", function () {
             .c('status').attrs({code:'210'}).nodeTree;
         _converse.connection._dataRecv(mock.createRequest(presence));
 
-        view.model.sendMessage('hello world');
+        view.model.sendMessage({'body': 'hello world'});
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 3);
 
         const occupant = await u.waitUntil(() => view.model.messages.filter(m => m.get('type') === 'groupchat')[2].occupant);
@@ -542,7 +542,7 @@ describe("A Groupchat Message", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
         const view = _converse.chatboxviews.get(muc_jid);
 
-        view.model.sendMessage('hello world');
+        view.model.sendMessage({'body': 'hello world'});
         await u.waitUntil(() => view.model.messages.length === 1);
         const msg = view.model.messages.at(0);
         expect(msg.get('stanza_id')).toBeUndefined();

+ 4 - 4
src/plugins/muc-views/tests/retractions.js

@@ -5,7 +5,7 @@ const u = converse.env.utils;
 
 
 async function sendAndThenRetractMessage (_converse, view) {
-    view.model.sendMessage('hello world');
+    view.model.sendMessage({'body': 'hello world'});
     await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
     const msg_obj = view.model.messages.last();
     const reflection_stanza = u.toStanza(`
@@ -313,7 +313,7 @@ describe("Message Retractions", function () {
             const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const view = await mock.openChatBoxFor(_converse, contact_jid);
 
-            view.model.sendMessage('hello world');
+            view.model.sendMessage({'body': 'hello world'});
             await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
 
             const message = view.model.messages.at(0);
@@ -709,7 +709,7 @@ describe("Message Retractions", function () {
             const occupant = view.model.getOwnOccupant();
             expect(occupant.get('role')).toBe('moderator');
 
-            view.model.sendMessage('Visit this site to get free bitcoin');
+            view.model.sendMessage({'body': 'Visit this site to get free bitcoin'});
             await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
             const stanza_id = 'retraction-id-1';
             const msg_obj = view.model.messages.at(0);
@@ -758,7 +758,7 @@ describe("Message Retractions", function () {
             const occupant = view.model.getOwnOccupant();
             expect(occupant.get('role')).toBe('moderator');
 
-            view.model.sendMessage('Visit this site to get free bitcoin');
+            view.model.sendMessage({'body': 'Visit this site to get free bitcoin'});
             await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
             const stanza_id = 'retraction-id-1';
             const msg_obj = view.model.messages.at(0);

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

@@ -1,3 +1,10 @@
 export const UNDECIDED = 0;
 export const TRUSTED = 1;
 export const UNTRUSTED = -1;
+
+export const TAG_LENGTH = 128;
+
+export const KEY_ALGO = {
+    'name': 'AES-GCM',
+    'length': 128
+};

+ 1 - 98
src/plugins/omemo/mixins/converse.js

@@ -1,16 +1,5 @@
-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';
+import { generateFingerprint, getDevicesForContact, } from '../utils.js';
 
-const { Strophe, $msg } = converse.env;
 
 const ConverseMixins = {
 
@@ -27,92 +16,6 @@ const ConverseMixins = {
         /* 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;
-                });
-        });
     }
 }
 

+ 8 - 6
src/plugins/omemo/overrides/chatbox.js

@@ -1,16 +1,18 @@
 import { _converse } from '@converse/headless/core';
+import { createOMEMOMessageStanza, getBundlesAndBuildSessions } from '../utils.js';
 
 const ChatBox = {
-    async sendMessage (text, spoiler_hint) {
-        if (this.get('omemo_active') && text) {
-            const attrs = this.getOutgoingMessageAttributes(text, spoiler_hint);
+    async sendMessage (attrs) {
+        if (this.get('omemo_active') && attrs?.body) {
+            const plaintext = attrs?.body;
+            attrs = this.getOutgoingMessageAttributes(attrs);
             attrs['is_encrypted'] = true;
-            attrs['plaintext'] = attrs.message;
+            attrs['plaintext'] = plaintext;
             let message, stanza;
             try {
-                const devices = await _converse.getBundlesAndBuildSessions(this);
+                const devices = await getBundlesAndBuildSessions(this);
                 message = await this.createMessage(attrs);
-                stanza = await _converse.createOMEMOMessageStanza(this, message, devices);
+                stanza = await createOMEMOMessageStanza(this, message, devices);
             } catch (e) {
                 this.handleMessageSendError(e);
                 return null;

+ 152 - 0
src/plugins/omemo/tests/media-sharing.js

@@ -0,0 +1,152 @@
+/*global mock, converse */
+
+const { $iq, Strophe, u } = converse.env;
+
+
+describe("The OMEMO module", function() {
+
+    it("implements XEP-0454 to encrypt uploaded files",
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+        const base_url = 'https://example.org/';
+        await mock.waitUntilDiscoConfirmed(
+            _converse, _converse.domain,
+            [{'category': 'server', 'type':'IM'}],
+            ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+        const send_backup = XMLHttpRequest.prototype.send;
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+
+        await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
+        await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
+        await mock.waitForRoster(_converse, 'current', 3);
+        const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+        await u.waitUntil(() => mock.initializedOMEMO(_converse));
+
+        await mock.openChatBoxFor(_converse, contact_jid);
+
+        let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+        let stanza = $iq({
+                'from': contact_jid,
+                'id': iq_stanza.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': '555'});
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => _converse.omemo_store);
+        const devicelist = _converse.devicelists.get({'jid': contact_jid});
+        await u.waitUntil(() => devicelist.devices.length === 1);
+
+        const view = _converse.chatboxviews.get(contact_jid);
+        const file = new File(['secret'], 'secret.txt', { type: 'text/plain' })
+        view.model.set('omemo_active', true);
+        view.model.sendFiles([file]);
+
+        await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
+        const iq = IQ_stanzas.pop();
+        const url = base_url+"/secret.txt";
+        stanza = u.toStanza(`
+            <iq from="upload.montague.tld"
+                id="${iq.getAttribute("id")}"
+                to="romeo@montague.lit/orchard"
+                type="result">
+            <slot xmlns="urn:xmpp:http:upload:0">
+                <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/secret.txt">
+                <header name="Authorization">Basic Base64String==</header>
+                <header name="Cookie">foo=bar; user=romeo</header>
+                </put>
+                <get url="${url}" />
+            </slot>
+            </iq>`);
+
+        spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () {
+            const message = view.model.messages.at(0);
+            message.set('progress', 1);
+            await u.waitUntil(() => view.querySelector('.chat-content progress')?.getAttribute('value') === '1')
+            message.save({
+                'upload': _converse.SUCCESS,
+                'oob_url': message.get('get'),
+                'message': message.get('get')
+            });
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+        });
+        let sent_stanza;
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
+        stanza = $iq({
+            'from': contact_jid,
+            'id': iq_stanza.getAttribute('id'),
+            'to': _converse.bare_jid,
+            'type': 'result',
+        }).c('pubsub', {
+            'xmlns': 'http://jabber.org/protocol/pubsub'
+            }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"})
+                .c('item')
+                    .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+                        .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
+                        .c('signedPreKeySignature').t(btoa('2222')).up()
+                        .c('identityKey').t(btoa('3333')).up()
+                        .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));
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
+        stanza = $iq({
+            'from': _converse.bare_jid,
+            'id': iq_stanza.getAttribute('id'),
+            'to': _converse.bare_jid,
+            'type': 'result',
+        }).c('pubsub', {
+            'xmlns': 'http://jabber.org/protocol/pubsub'
+            }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"})
+                .c('item')
+                    .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+                        .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up()
+                        .c('signedPreKeySignature').t(btoa('200000')).up()
+                        .c('identityKey').t(btoa('300000')).up()
+                        .c('prekeys')
+                            .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up()
+                            .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up()
+                            .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993'));
+
+        spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+
+        await u.waitUntil(() => sent_stanza);
+
+        const fallback = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo';
+        expect(Strophe.serialize(sent_stanza)).toBe(
+            `<message from="romeo@montague.lit/orchard" `+
+                `id="${sent_stanza.getAttribute("id")}" `+
+                `to="lady.montague@montague.lit" `+
+                `type="chat" `+
+                `xmlns="jabber:client">`+
+                    `<body>${fallback}</body>`+
+                    `<request xmlns="urn:xmpp:receipts"/>`+
+                    `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
+                    `<header sid="123456789">`+
+                        `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
+                        `<key rid="555">YzFwaDNSNzNYNw==</key>`+
+                        `<iv>${sent_stanza.querySelector('header iv').textContent}</iv>`+
+                    `</header>`+
+                `<payload>${sent_stanza.querySelector('payload').textContent}</payload>`+
+                `</encrypted>`+
+                `<store xmlns="urn:xmpp:hints"/>`+
+            `</message>`);
+
+        const link_el = await u.waitUntil(() => view.querySelector('.chat-msg__media'));
+        expect(link_el.textContent.trim()).toBe('Download file "secret.txt"', 1000);
+
+        const message = view.model.messages.at(0);
+        expect(message.get('is_encrypted')).toBe(true);
+
+        XMLHttpRequest.prototype.send = send_backup;
+    }));
+});

+ 41 - 106
src/plugins/omemo/tests/omemo.js

@@ -3,71 +3,6 @@
 const { $iq, $pres, $msg, omemo, Strophe } = converse.env;
 const u = converse.env.utils;
 
-async function deviceListFetched (_converse, jid) {
-    const selector = `iq[to="${jid}"] items[node="eu.siacs.conversations.axolotl.devicelist"]`;
-    const stanza = await u.waitUntil(
-        () => Array.from(_converse.connection.IQ_stanzas).filter(iq => iq.querySelector(selector)).pop()
-    );
-    await u.waitUntil(() => _converse.devicelists.get(jid));
-    return stanza;
-}
-
-function ownDeviceHasBeenPublished (_converse) {
-    return Array.from(_converse.connection.IQ_stanzas).filter(
-        iq => iq.querySelector('iq[from="'+_converse.bare_jid+'"] publish[node="eu.siacs.conversations.axolotl.devicelist"]')
-    ).pop();
-}
-
-function bundleHasBeenPublished (_converse) {
-    const selector = 'publish[node="eu.siacs.conversations.axolotl.bundles:123456789"]';
-    return Array.from(_converse.connection.IQ_stanzas).filter(iq => iq.querySelector(selector)).pop();
-}
-
-function bundleFetched (_converse, jid, device_id) {
-    return Array.from(_converse.connection.IQ_stanzas).filter(
-        iq => iq.querySelector(`iq[to="${jid}"] items[node="eu.siacs.conversations.axolotl.bundles:${device_id}"]`)
-    ).pop();
-}
-
-async function initializedOMEMO (_converse) {
-    await mock.waitUntilDiscoConfirmed(
-        _converse, _converse.bare_jid,
-        [{'category': 'pubsub', 'type': 'pep'}],
-        ['http://jabber.org/protocol/pubsub#publish-options']
-    );
-    let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, _converse.bare_jid));
-    let stanza = $iq({
-        'from': _converse.bare_jid,
-        'id': iq_stanza.getAttribute('id'),
-        'to': _converse.bare_jid,
-        'type': 'result',
-    }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
-        .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
-            .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
-                .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
-                    .c('device', {'id': '482886413b977930064a5888b92134fe'});
-    _converse.connection._dataRecv(mock.createRequest(stanza));
-    iq_stanza = await u.waitUntil(() => ownDeviceHasBeenPublished(_converse))
-
-    stanza = $iq({
-        'from': _converse.bare_jid,
-        'id': iq_stanza.getAttribute('id'),
-        'to': _converse.bare_jid,
-        'type': 'result'});
-    _converse.connection._dataRecv(mock.createRequest(stanza));
-
-    iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse))
-
-    stanza = $iq({
-        'from': _converse.bare_jid,
-        'id': iq_stanza.getAttribute('id'),
-        'to': _converse.bare_jid,
-        'type': 'result'});
-    _converse.connection._dataRecv(mock.createRequest(stanza));
-    await _converse.api.waitUntil('OMEMOInitialized');
-}
-
-
 describe("The OMEMO module", function() {
 
     it("adds methods for encrypting and decrypting messages via AES GCM",
@@ -86,9 +21,9 @@ 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(() => initializedOMEMO(_converse));
+        await u.waitUntil(() => mock.initializedOMEMO(_converse));
         await mock.openChatBoxFor(_converse, contact_jid);
-        let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid));
+        let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
         let stanza = $iq({
                 'from': contact_jid,
                 'id': iq_stanza.getAttribute('id'),
@@ -115,7 +50,7 @@ describe("The OMEMO module", function() {
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
         });
-        iq_stanza = await u.waitUntil(() => bundleFetched(_converse, contact_jid, '555'));
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
         stanza = $iq({
             'from': contact_jid,
             'id': iq_stanza.getAttribute('id'),
@@ -134,7 +69,7 @@ describe("The OMEMO module", function() {
                             .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
                             .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        iq_stanza = await u.waitUntil(() => bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
         stanza = $iq({
             'from': _converse.bare_jid,
             'id': iq_stanza.getAttribute('id'),
@@ -233,7 +168,7 @@ describe("The OMEMO module", function() {
         ];
         await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features);
         const view = _converse.chatboxviews.get('lounge@montague.lit');
-        await u.waitUntil(() => initializedOMEMO(_converse));
+        await u.waitUntil(() => mock.initializedOMEMO(_converse));
 
         const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
         const el = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo'));
@@ -255,7 +190,7 @@ describe("The OMEMO module", function() {
         _converse.connection._dataRecv(mock.createRequest(stanza));
 
         // Wait for Converse to fetch newguy's device list
-        let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid));
+        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="${contact_jid}" type="get" xmlns="jabber:client">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -278,7 +213,7 @@ describe("The OMEMO module", function() {
         await u.waitUntil(() => _converse.omemo_store);
         expect(_converse.devicelists.length).toBe(2);
 
-        await u.waitUntil(() => deviceListFetched(_converse, contact_jid));
+        await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
         const devicelist = _converse.devicelists.get(contact_jid);
         expect(devicelist.devices.length).toBe(1);
         expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228');
@@ -296,7 +231,7 @@ describe("The OMEMO module", function() {
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
         });
-        iq_stanza = await u.waitUntil(() => bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'), 1000);
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'), 1000);
         console.log("Bundle fetched 4e30f35051b7b8b42abe083742187228");
         stanza = $iq({
             'from': contact_jid,
@@ -317,7 +252,7 @@ describe("The OMEMO module", function() {
                             .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
         _converse.connection._dataRecv(mock.createRequest(stanza));
 
-        iq_stanza = await u.waitUntil(() => bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'), 1000);
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'), 1000);
         console.log("Bundle fetched 482886413b977930064a5888b92134fe");
         stanza = $iq({
             'from': _converse.bare_jid,
@@ -367,9 +302,9 @@ describe("The OMEMO module", function() {
         await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
         await mock.waitForRoster(_converse, 'current', 1);
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        await u.waitUntil(() => initializedOMEMO(_converse));
+        await u.waitUntil(() => mock.initializedOMEMO(_converse));
         await mock.openChatBoxFor(_converse, contact_jid);
-        let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid));
+        let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
         const my_devicelist = _converse.devicelists.get({'jid': _converse.bare_jid});
         expect(my_devicelist.devices.length).toBe(2);
 
@@ -429,7 +364,7 @@ describe("The OMEMO module", function() {
 
         // The message received is a prekey message, so missing prekeys are
         // generated and a new bundle published.
-        iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse));
+        iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse));
         const result_iq = $iq({
             'from': _converse.bare_jid,
             'id': iq_stanza.getAttribute('id'),
@@ -460,7 +395,7 @@ describe("The OMEMO module", function() {
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
         });
-        iq_stanza = await u.waitUntil(() => bundleFetched(_converse, _converse.bare_jid, '988349631'));
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '988349631'));
         expect(Strophe.serialize(iq_stanza)).toBe(
             `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${_converse.bare_jid}" type="get" xmlns="jabber:client">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -486,7 +421,7 @@ describe("The OMEMO module", function() {
         ];
         await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features);
         const view = _converse.chatboxviews.get('lounge@montague.lit');
-        await u.waitUntil(() => initializedOMEMO(_converse));
+        await u.waitUntil(() => mock.initializedOMEMO(_converse));
 
         const contact_jid = 'newguy@montague.lit';
         let stanza = $pres({
@@ -515,7 +450,7 @@ describe("The OMEMO module", function() {
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
         });
-        let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid));
+        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="${contact_jid}" type="get" xmlns="jabber:client">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -539,11 +474,11 @@ describe("The OMEMO module", function() {
         expect(_converse.devicelists.length).toBe(2);
 
         const devicelist = _converse.devicelists.get(contact_jid);
-        await u.waitUntil(() => deviceListFetched(_converse, contact_jid));
+        await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
         expect(devicelist.devices.length).toBe(1);
         expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228');
 
-        iq_stanza = await u.waitUntil(() => bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
         stanza = $iq({
             'from': _converse.bare_jid,
             'id': iq_stanza.getAttribute('id'),
@@ -561,7 +496,7 @@ describe("The OMEMO module", function() {
                             .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up()
                             .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up()
                             .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993'));
-        iq_stanza = await u.waitUntil(() => bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'));
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'));
 
         /* <iq xmlns="jabber:client" to="jc@opkode.com/converse.js-34183907" type="error" id="945c8ab3-b561-4d8a-92da-77c226bb1689:sendIQ" from="joris@konuro.net">
          *     <pubsub xmlns="http://jabber.org/protocol/pubsub">
@@ -603,7 +538,7 @@ describe("The OMEMO module", function() {
         await mock.waitForRoster(_converse, 'current', 1);
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
 
-        await u.waitUntil(() => initializedOMEMO(_converse));
+        await u.waitUntil(() => mock.initializedOMEMO(_converse));
         const obj = await omemo.encryptMessage('This is an encrypted message from the contact');
         // XXX: Normally the key will be encrypted via libsignal.
         // However, we're mocking libsignal in the tests, so we include
@@ -635,7 +570,7 @@ describe("The OMEMO module", function() {
         });
         _converse.connection._dataRecv(mock.createRequest(stanza));
 
-        let iq_stanza = await deviceListFetched(_converse, contact_jid);
+        let iq_stanza = await mock.deviceListFetched(_converse, contact_jid);
         stanza = $iq({
             'from': contact_jid,
             'id': iq_stanza.getAttribute('id'),
@@ -653,7 +588,7 @@ describe("The OMEMO module", function() {
         _converse.connection.IQ_stanzas = [];
         _converse.connection._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => _converse.omemo_store);
-        iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse), 1000);
+        iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse), 1000);
         expect(Strophe.serialize(iq_stanza)).toBe(
             `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" type="set" xmlns="jabber:client">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -703,7 +638,7 @@ describe("The OMEMO module", function() {
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
 
         // Wait until own devices are fetched
-        let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, _converse.bare_jid));
+        let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid));
         expect(Strophe.serialize(iq_stanza)).toBe(
             `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -729,14 +664,14 @@ describe("The OMEMO module", function() {
         expect(devicelist.devices.length).toBe(2);
         expect(devicelist.devices.at(0).get('id')).toBe('555');
         expect(devicelist.devices.at(1).get('id')).toBe('123456789');
-        iq_stanza = await u.waitUntil(() => ownDeviceHasBeenPublished(_converse));
+        iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse));
         stanza = $iq({
             'from': _converse.bare_jid,
             'id': iq_stanza.getAttribute('id'),
             'to': _converse.bare_jid,
             'type': 'result'});
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse));
+        iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse));
 
         stanza = $iq({
             'from': _converse.bare_jid,
@@ -822,7 +757,7 @@ describe("The OMEMO module", function() {
                         .c('device', {'id': '444'})
         _converse.connection._dataRecv(mock.createRequest(stanza));
 
-        iq_stanza = await u.waitUntil(() => ownDeviceHasBeenPublished(_converse));
+        iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse));
         // Check that our own device is added again, but that removed
         // devices are not added.
         expect(Strophe.serialize(iq_stanza)).toBe(
@@ -872,7 +807,7 @@ describe("The OMEMO module", function() {
 
         await mock.waitForRoster(_converse, 'current');
         const contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, _converse.bare_jid));
+        let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid));
         expect(Strophe.serialize(iq_stanza)).toBe(
             `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -897,14 +832,14 @@ describe("The OMEMO module", function() {
         expect(devicelist.devices.length).toBe(2);
         expect(devicelist.devices.at(0).get('id')).toBe('555');
         expect(devicelist.devices.at(1).get('id')).toBe('123456789');
-        iq_stanza = await u.waitUntil(() => ownDeviceHasBeenPublished(_converse));
+        iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse));
         stanza = $iq({
             'from': _converse.bare_jid,
             'id': iq_stanza.getAttribute('id'),
             'to': _converse.bare_jid,
             'type': 'result'});
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse));
+        iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse));
         stanza = $iq({
             'from': _converse.bare_jid,
             'id': iq_stanza.getAttribute('id'),
@@ -1021,7 +956,7 @@ describe("The OMEMO module", function() {
 
         await mock.waitForRoster(_converse, 'current', 1);
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, _converse.bare_jid));
+        let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid));
         let stanza = $iq({
             'from': contact_jid,
             'id': iq_stanza.getAttribute('id'),
@@ -1035,7 +970,7 @@ describe("The OMEMO module", function() {
         _converse.connection._dataRecv(mock.createRequest(stanza));
         expect(_converse.devicelists.length).toBe(1);
         await mock.openChatBoxFor(_converse, contact_jid);
-        iq_stanza = await ownDeviceHasBeenPublished(_converse);
+        iq_stanza = await mock.ownDeviceHasBeenPublished(_converse);
         stanza = $iq({
             'from': _converse.bare_jid,
             'id': iq_stanza.getAttribute('id'),
@@ -1043,7 +978,7 @@ describe("The OMEMO module", function() {
             'type': 'result'});
         _converse.connection._dataRecv(mock.createRequest(stanza));
 
-        iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse));
+        iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse));
         expect(Strophe.serialize(iq_stanza)).toBe(
             `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" type="set" xmlns="jabber:client">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -1095,7 +1030,7 @@ describe("The OMEMO module", function() {
         await mock.waitForRoster(_converse, 'current', 1);
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
 
-        let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, _converse.bare_jid));
+        let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid));
         expect(Strophe.serialize(iq_stanza)).toBe(
             `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -1121,7 +1056,7 @@ describe("The OMEMO module", function() {
         expect(devicelist.devices.at(0).get('id')).toBe('482886413b977930064a5888b92134fe');
         expect(devicelist.devices.at(1).get('id')).toBe('123456789');
         // Check that own device was published
-        iq_stanza = await u.waitUntil(() => ownDeviceHasBeenPublished(_converse));
+        iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse));
         expect(Strophe.serialize(iq_stanza)).toBe(
             `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute(`id`)}" type="set" xmlns="jabber:client">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -1153,7 +1088,7 @@ describe("The OMEMO module", function() {
             'type': 'result'});
         _converse.connection._dataRecv(mock.createRequest(stanza));
 
-        const iq_el = await u.waitUntil(() => bundleHasBeenPublished(_converse));
+        const iq_el = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse));
         expect(iq_el.getAttributeNames().sort().join()).toBe(["from", "type", "xmlns", "id"].sort().join());
         expect(iq_el.querySelector('prekeys').childNodes.length).toBe(100);
 
@@ -1172,7 +1107,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(() => deviceListFetched(_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">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -1264,7 +1199,7 @@ describe("The OMEMO module", function() {
         ];
         await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features);
         const view = _converse.chatboxviews.get('lounge@montague.lit');
-        await u.waitUntil(() => initializedOMEMO(_converse));
+        await u.waitUntil(() => mock.initializedOMEMO(_converse));
 
         const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
         let toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo'));
@@ -1298,7 +1233,7 @@ describe("The OMEMO module", function() {
             }).tree();
         _converse.connection._dataRecv(mock.createRequest(stanza));
 
-        let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid));
+        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="${contact_jid}" type="get" xmlns="jabber:client">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -1321,7 +1256,7 @@ describe("The OMEMO module", function() {
         await u.waitUntil(() => _converse.omemo_store);
         expect(_converse.devicelists.length).toBe(2);
 
-        await u.waitUntil(() => deviceListFetched(_converse, contact_jid));
+        await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
         const devicelist = _converse.devicelists.get(contact_jid);
         expect(devicelist.devices.length).toBe(2);
         expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228');
@@ -1381,7 +1316,7 @@ describe("The OMEMO module", function() {
                 'role': 'participant'
             }).tree();
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        iq_stanza = await u.waitUntil(() => deviceListFetched(_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">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
@@ -1432,7 +1367,7 @@ 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(() => deviceListFetched(_converse, contact_jid));
+        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>`+
@@ -1449,7 +1384,7 @@ describe("The OMEMO module", function() {
                         .c('device', {'id': '555'});
         _converse.connection._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
-        iq_stanza = await u.waitUntil(() => bundleFetched(_converse, contact_jid, '555'));
+        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">`+
                 `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+

+ 93 - 13
src/plugins/omemo/utils.js

@@ -6,13 +6,15 @@ import tpl_audio from 'templates/audio.js';
 import tpl_file from 'templates/file.js';
 import tpl_image from 'templates/image.js';
 import tpl_video from 'templates/video.js';
+import { MIMETYPES_MAP } from 'utils/file.js';
+import { KEY_ALGO, UNTRUSTED, TAG_LENGTH } from './consts.js';
 import { __ } from 'i18n';
 import { _converse, converse, api } from '@converse/headless/core';
 import { html } from 'lit';
 import { initStorage } from '@converse/headless/shared/utils.js';
 import { isAudioURL, isImageURL, isVideoURL, getURI } from 'utils/html.js';
+import concat from 'lodash-es/concat';
 import { until } from 'lit/directives/until.js';
-import { MIMETYPES_MAP } from 'utils/file.js';
 import {
     appendArrayBuffer,
     arrayBufferToBase64,
@@ -23,13 +25,7 @@ import {
     stringToArrayBuffer
 } from '@converse/headless/utils/arraybuffer.js';
 
-const { Strophe, sizzle, u } = converse.env;
-
-const TAG_LENGTH = 128;
-const KEY_ALGO = {
-    'name': 'AES-GCM',
-    'length': 128
-};
+const { $msg, Strophe, sizzle, u } = converse.env;
 
 
 async function encryptMessage (plaintext) {
@@ -368,11 +364,7 @@ export function addKeysToMessageStanza (stanza, dicts, iv) {
             }
             stanza.up();
             if (i == dicts.length - 1) {
-                stanza
-                    .c('iv')
-                    .t(iv)
-                    .up()
-                    .up();
+                stanza.c('iv').t(iv).up().up();
             }
         }
     }
@@ -687,3 +679,91 @@ export function getOMEMOToolbarButton (toolbar_el, buttons) {
     `);
     return buttons;
 }
+
+
+export async function getBundlesAndBuildSessions (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;
+}
+
+
+export function createOMEMOMessageStanza (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;
+            });
+    });
+}