Browse Source

Fix decrypting of OMEMO-encrypted files

Update OMEMO tests to test receiving an encrypted file.
Refactor the OMEMO tests to extract out more common code into helper functions.
JC Brand 4 months ago
parent
commit
6fb387516a

+ 1 - 0
src/headless/types/utils/index.d.ts

@@ -31,6 +31,7 @@ declare const _default: {
     isVideoURL(url: string | URL): boolean;
     isImageURL(url: string | URL): boolean;
     isEncryptedFileURL(url: string | URL): boolean;
+    withinString(string: string, callback: Function, options?: import("./types.js").ProcessStringOptions): string;
     getMediaURLsMetadata(text: string, offset?: number): {
         media_urls?: import("./types.js").MediaURLMetadata[];
     };

+ 13 - 0
src/headless/types/utils/url.d.ts

@@ -45,6 +45,19 @@ export function isImageURL(url: string | URL): boolean;
  * @param {string|URL} url
  */
 export function isEncryptedFileURL(url: string | URL): boolean;
+/**
+ * Processes a string to find and manipulate substrings based on a callback function.
+ * This function searches for patterns defined by the provided start and end regular expressions,
+ * and applies the callback to each matched substring, allowing for modifications
+ * @copyright Copyright (c) 2011 Rodney Rehm
+ *
+ * @param {string} string - The input string to be processed.
+ * @param {function} callback - A function that takes the matched substring and its start and end indices,
+ *                              and returns a modified substring or undefined to skip modification.
+ * @param {import("./types").ProcessStringOptions} [options]
+ * @returns {string} The modified string after processing all matches.
+ */
+export function withinString(string: string, callback: Function, options?: import("./types").ProcessStringOptions): string;
 /**
  * @param {string} text
  * @param {number} offset

+ 1 - 1
src/headless/utils/url.js

@@ -118,7 +118,7 @@ export function isEncryptedFileURL(url) {
  * @param {import("./types").ProcessStringOptions} [options]
  * @returns {string} The modified string after processing all matches.
  */
-function withinString(string, callback, options) {
+export function withinString(string, callback, options) {
     options = options || {};
     const _start = options.start || URL_REGEXES.start;
     const _end = options.end || URL_REGEXES.end;

+ 3 - 0
src/plugins/chatview/styles/chatbox.scss

@@ -123,6 +123,9 @@
                 margin: 0;
                 padding: 0.25em;
             }
+            .error {
+                color: var(--error-color);
+            }
         }
         .new-msgs-indicator {
             position: relative;

+ 157 - 191
src/plugins/omemo/tests/corrections.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { Strophe, $iq, $msg, $pres, u, omemo } = converse.env;
+const { Strophe, sizzle, stx, u, omemo } = converse.env;
 
 describe("An OMEMO encrypted message", function() {
 
@@ -12,18 +12,7 @@ describe("An OMEMO encrypted message", function() {
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         await 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.api.connection.get().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.api.connection.get()._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid, ['555']));
         await u.waitUntil(() => _converse.state.omemo_store);
         const devicelist = _converse.state.devicelists.get({'jid': contact_jid});
         await u.waitUntil(() => devicelist.devices.length === 1);
@@ -39,45 +28,28 @@ describe("An OMEMO encrypted message", function() {
             preventDefault: function preventDefault () {},
             key: "Enter",
         });
-        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.api.connection.get()._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'));
 
-        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => mock.bundleFetched(_converse, {
+            jid: contact_jid,
+            device_id: '555',
+            identity_key: '3333',
+            signed_prekey_id: "4223",
+            signed_prekey_public: "1111",
+            signed_prekey_sig: "2222",
+            prekeys: ['1001', '1002', '1003'],
+        }));
+        await u.waitUntil(() =>
+            mock.bundleFetched(_converse, {
+                jid: _converse.bare_jid,
+                device_id: "482886413b977930064a5888b92134fe",
+                identity_key: '300000',
+                signed_prekey_id: "4224",
+                signed_prekey_public: "100000",
+                signed_prekey_sig: "200000",
+                prekeys: ["1991", "1992", "1993"],
+            })
+        );
+
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.querySelector('.chat-msg__text').textContent)
@@ -107,26 +79,26 @@ describe("An OMEMO encrypted message", function() {
         const msg = _converse.api.connection.get().sent_stanzas.pop();
         const fallback_text = '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(msg))
-        .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
-                `to="mercutio@montague.lit" type="chat" `+
-                `xmlns="jabber:client">`+
-                    `<body>${fallback_text}</body>`+
-                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                    `<request xmlns="urn:xmpp:receipts"/>`+
-                    `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
-                    `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                    `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
-                        `<header sid="123456789">`+
-                            `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
-                            `<key rid="555">YzFwaDNSNzNYNw==</key>`+
-                            `<iv>${msg.querySelector('header iv').textContent}</iv>`+
-                        `</header>`+
-                        `<payload>${msg.querySelector('payload').textContent}</payload>`+
-                    `</encrypted>`+
-                    `<store xmlns="urn:xmpp:hints"/>`+
-                    `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
-            `</message>`);
+        expect(msg).toEqualStanza(stx`
+            <message from="romeo@montague.lit/orchard" id="${msg.getAttribute('id')}"
+                to="mercutio@montague.lit" type="chat"
+                xmlns="jabber:client">
+                    <body>${fallback_text}</body>
+                    <active xmlns="http://jabber.org/protocol/chatstates"/>
+                    <request xmlns="urn:xmpp:receipts"/>
+                    <replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>
+                    <origin-id id="${msg.querySelector('origin-id').getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>
+                    <encrypted xmlns="eu.siacs.conversations.axolotl">
+                        <header sid="123456789">
+                            <key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>
+                            <key rid="555">YzFwaDNSNzNYNw==</key>
+                            <iv>${msg.querySelector('header iv').textContent}</iv>
+                        </header>
+                        <payload>${msg.querySelector('payload').textContent}</payload>
+                    </encrypted>
+                    <store xmlns="urn:xmpp:hints"/>
+                    <encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>
+            </message>`);
 
         let older_versions = first_msg.get('older_versions');
         let keys = Object.keys(older_versions);
@@ -159,19 +131,22 @@ describe("An OMEMO encrypted message", function() {
 
         const first_rcvd_msg_id = u.getUniqueId();
         let obj = await omemo.encryptMessage('This is an encrypted message from the contact')
-        _converse.api.connection.get()._dataRecv(mock.createRequest($msg({
-                'from': contact_jid,
-                'to': _converse.api.connection.get().jid,
-                'type': 'chat',
-                'id': first_rcvd_msg_id
-            }).c('body').t(fallback_text).up()
-                .c('origin-id', {'id': first_rcvd_msg_id, 'xmlns': 'urn:xmpp:sid:0'}).up()
-                .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
-                    .c('header', {'sid':  '555'})
-                        .c('key', {'rid':  _converse.state.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up()
-                        .c('iv').t(obj.iv)
-                        .up().up()
-                    .c('payload').t(obj.payload)));
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <message from="${contact_jid}"
+                    to="${_converse.api.connection.get().jid}"
+                    type="chat"
+                    id="${first_rcvd_msg_id}"
+                    xmlns="jabber:client">
+                <body>${fallback_text}</body>
+                <origin-id id="${first_rcvd_msg_id}" xmlns="urn:xmpp:sid:0"/>
+                <encrypted xmlns="${Strophe.NS.OMEMO}">
+                    <header sid="555">
+                        <key rid="${_converse.state.omemo_store.get('device_id')}">${u.arrayBufferToBase64(obj.key_and_tag)}</key>
+                        <iv>${obj.iv}</iv>
+                    </header>
+                    <payload>${obj.payload}</payload>
+                </encrypted>
+            </message>`));
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.model.messages.length).toBe(2);
         expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim())
@@ -179,20 +154,23 @@ describe("An OMEMO encrypted message", function() {
 
         const msg_id = u.getUniqueId();
         obj = await omemo.encryptMessage('This is an edited encrypted message from the contact')
-        _converse.api.connection.get()._dataRecv(mock.createRequest($msg({
-                'from': contact_jid,
-                'to': _converse.api.connection.get().jid,
-                'type': 'chat',
-                'id': msg_id
-            }).c('body').t(fallback_text).up()
-                .c('replace', {'id': first_rcvd_msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).up()
-                .c('origin-id', {'id': msg_id, 'xmlns': 'urn:xmpp:sid:0'}).up()
-                .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
-                    .c('header', {'sid':  '555'})
-                        .c('key', {'rid':  _converse.state.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up()
-                        .c('iv').t(obj.iv)
-                        .up().up()
-                    .c('payload').t(obj.payload)));
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <message from="${contact_jid}"
+                     to="${_converse.api.connection.get().jid}"
+                     type="chat"
+                     id="${msg_id}"
+                     xmlns="jabber:client">
+                <body>${fallback_text}</body>
+                <replace id="${first_rcvd_msg_id}" xmlns="urn:xmpp:message-correct:0"/>
+                <origin-id id="${msg_id}" xmlns="urn:xmpp:sid:0"/>
+                <encrypted xmlns="${Strophe.NS.OMEMO}">
+                    <header sid="555">
+                        <key rid="${_converse.state.omemo_store.get('device_id')}">${u.arrayBufferToBase64(obj.key_and_tag)}</key>
+                        <iv>${obj.iv}</iv>
+                    </header>
+                    <payload>${obj.payload}</payload>
+                </encrypted>
+            </message>`));
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.model.messages.length).toBe(2);
         expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim())
@@ -244,38 +222,40 @@ describe("An OMEMO encrypted MUC message", function() {
 
         // newguy enters the room
         const contact_jid = 'newguy@montague.lit';
-        let stanza = $pres({
-                'to': 'romeo@montague.lit/orchard',
-                'from': 'lounge@montague.lit/newguy'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'newguy@montague.lit/_converse.js-290929789',
-                'role': 'participant'
-            }).tree();
+        let stanza = stx`
+            <presence to='romeo@montague.lit/orchard' from='lounge@montague.lit/newguy' xmlns="jabber:client">
+                <x xmlns='${Strophe.NS.MUC_USER}'>
+                    <item affiliation='none' jid='newguy@montague.lit/_converse.js-290929789' role='participant'/>
+                </x>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         // Wait for Converse to fetch newguy's device list
         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">`+
-                    `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
-                `</pubsub>`+
-            `</iq>`);
+        expect(iq_stanza).toEqualStanza(stx`
+            <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">
+                    <items node="eu.siacs.conversations.axolotl.devicelist"/>
+                </pubsub>
+            </iq>`);
 
         // The server returns his device list
-        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.devicelist"})
-                .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
-                    .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
-                        .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up()
+        stanza = stx`
+            <iq from='${contact_jid}'
+                    id='${iq_stanza.getAttribute('id')}'
+                    to='${_converse.bare_jid}'
+                    type='result'
+                    xmlns='jabber:client'>
+                <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+                    <items node='eu.siacs.conversations.axolotl.devicelist'>
+                        <item xmlns='http://jabber.org/protocol/pubsub'> <!-- TODO: must have an id attribute -->
+                            <list xmlns='eu.siacs.conversations.axolotl'>
+                                <device id='4e30f35051b7b8b42abe083742187228'/>
+                            </list>
+                        </item>
+                    </items>
+                </pubsub>
+            </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => _converse.state.omemo_store);
         expect(_converse.state.devicelists.length).toBe(2);
@@ -296,49 +276,29 @@ describe("An OMEMO encrypted MUC message", function() {
             key: "Enter",
         });
 
-        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'), 1000);
-        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:4e30f35051b7b8b42abe083742187228"})
-                .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.api.connection.get()._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => mock.bundleFetched(_converse, {
+            jid: contact_jid,
+            device_id: '4e30f35051b7b8b42abe083742187228',
+            identity_key: '3333',
+            signed_prekey_id: "4223",
+            signed_prekey_public: "1111",
+            signed_prekey_sig: "2222",
+            prekeys: ['1001', '1002', '1003'],
+        }));
+        await u.waitUntil(() =>
+            mock.bundleFetched(_converse, {
+                jid: _converse.bare_jid,
+                device_id: "482886413b977930064a5888b92134fe",
+                identity_key: '300000',
+                signed_prekey_id: "4224",
+                signed_prekey_public: "100000",
+                signed_prekey_sig: "200000",
+                prekeys: ["1991", "1992", "1993"],
+            })
+        );
 
-        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'), 1000);
-        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.api.connection.get(), 'send').and.callThrough();
-        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
-        await u.waitUntil(() => _converse.api.connection.get().send.calls.count(), 1000);
-        const sent_stanza = _converse.api.connection.get().send.calls.all()[0].args[0];
+        const sent_stanzas = _converse.api.connection.get().sent_stanzas;
+        const sent_stanza = await u.waitUntil(() => sent_stanzas.filter((s) => sizzle('body', s).length).pop(), 1000);
 
         expect(sent_stanza).toEqualStanza(stx`
             <message from="${own_jid}"
@@ -417,18 +377,21 @@ describe("An OMEMO encrypted MUC message", function() {
         const first_received_id = _converse.api.connection.get().getUniqueId()
         const first_received_message = 'This is an encrypted message from the contact';
         const first_obj = await omemo.encryptMessage(first_received_message)
-        _converse.api.connection.get()._dataRecv(mock.createRequest($msg({
-                'from': `${muc_jid}/newguy`,
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'id': first_received_id
-            }).c('body').t(fallback_text).up()
-                .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
-                    .c('header', {'sid':  '555'})
-                        .c('key', {'rid':  _converse.state.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(first_obj.key_and_tag)).up()
-                        .c('iv').t(first_obj.iv)
-                        .up().up()
-                    .c('payload').t(first_obj.payload)));
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <message from="${muc_jid}/newguy"
+                     to="${_converse.api.connection.get().jid}"
+                     type="groupchat"
+                     id="${first_received_id}"
+                     xmlns="jabber:client">
+                <body>${fallback_text}</body>
+                <encrypted xmlns="${Strophe.NS.OMEMO}">
+                    <header sid="555">
+                        <key rid="${_converse.state.omemo_store.get('device_id')}">${u.arrayBufferToBase64(first_obj.key_and_tag)}</key>
+                        <iv>${first_obj.iv}</iv>
+                    </header>
+                    <payload>${first_obj.payload}</payload>
+                </encrypted>
+            </message>`));
 
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.model.messages.length).toBe(2);
@@ -439,19 +402,22 @@ describe("An OMEMO encrypted MUC message", function() {
 
         const second_received_message = 'This is an edited encrypted message from the contact';
         const second_obj = await omemo.encryptMessage(second_received_message)
-        _converse.api.connection.get()._dataRecv(mock.createRequest($msg({
-                'from': `${muc_jid}/newguy`,
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'id': _converse.api.connection.get().getUniqueId()
-            }).c('body').t(fallback_text).up()
-                .c('replace',  {'id':first_received_id, 'xmlns': 'urn:xmpp:message-correct:0'})
-                .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
-                    .c('header', {'sid':  '555'})
-                        .c('key', {'rid':  _converse.state.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(second_obj.key_and_tag)).up()
-                        .c('iv').t(second_obj.iv)
-                        .up().up()
-                    .c('payload').t(second_obj.payload)));
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <message from="${muc_jid}/newguy"
+                     to="${_converse.api.connection.get().jid}"
+                     type="groupchat"
+                     id="${_converse.api.connection.get().getUniqueId()}"
+                     xmlns="jabber:client">
+                <body>${fallback_text}</body>
+                <replace id="${first_received_id}" xmlns="urn:xmpp:message-correct:0"/>
+                <encrypted xmlns="${Strophe.NS.OMEMO}">
+                    <header sid="555">
+                        <key rid="${_converse.state.omemo_store.get('device_id')}">${u.arrayBufferToBase64(second_obj.key_and_tag)}</key>
+                        <iv>${second_obj.iv}</iv>
+                    </header>
+                    <payload>${second_obj.payload}</payload>
+                </encrypted>
+            </message>`));
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         expect(view.model.messages.length).toBe(2);

+ 77 - 3
src/plugins/omemo/tests/media-sharing.js

@@ -1,10 +1,84 @@
 /*global mock, converse */
-const { $iq, Strophe, u, stx } = converse.env;
+const { omemo, Strophe, stx, u, sizzle } = converse.env;
 
 describe("The OMEMO module", function() {
 
     beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
 
+    it("shows an error when it can't download a received encrypted file",
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'current', 1);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.initializedOMEMO(_converse);
+        await mock.openChatBoxFor(_converse, contact_jid);
+        await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid, ['555']));
+
+        await u.waitUntil(() => _converse.state.omemo_store);
+        const devicelist = _converse.state.devicelists.get({'jid': contact_jid});
+        await u.waitUntil(() => devicelist.devices.length === 1);
+
+        const view = _converse.chatboxviews.get(contact_jid);
+        view.model.set('omemo_active', true);
+
+        const textarea = view.querySelector('.chat-textarea');
+        textarea.value = 'This message will be encrypted';
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            key: "Enter",
+        });
+
+        await u.waitUntil(() => mock.bundleFetched(_converse, {
+            jid: contact_jid,
+            device_id: '555',
+            identity_key: '3333',
+            signed_prekey_id: "4223",
+            signed_prekey_public: "1111",
+            signed_prekey_sig: "2222",
+            prekeys: ['1001', '1002', '1003'],
+        }));
+        await u.waitUntil(() =>
+            mock.bundleFetched(_converse, {
+                jid: _converse.bare_jid,
+                device_id: "482886413b977930064a5888b92134fe",
+                identity_key: '300000',
+                signed_prekey_id: "4224",
+                signed_prekey_public: "100000",
+                signed_prekey_sig: "200000",
+                prekeys: ["1991", "1992", "1993"],
+            })
+        );
+        const sent_stanzas = _converse.api.connection.get().sent_stanzas;
+        await u.waitUntil(() => sent_stanzas.filter((s) => sizzle('body', s).length).pop(), 1000);
+
+        // Test reception of an encrypted file
+        let obj = await omemo.encryptMessage('aesgcm://upload.example.org/b9e3eaaa-2eae-4900-ae41/k9mKam2JT.jpg#6b5ba0f96eae')
+
+        // XXX: Normally the key will be encrypted via libsignal.
+        // However, we're mocking libsignal in the tests, so we include it as plaintext in the message.
+        let stanza = stx`<message from="${contact_jid}"
+                        to="${_converse.api.connection.get().jid}"
+                        type="chat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
+                    <body>This is a fallback message</body>
+                    <encrypted xmlns="${Strophe.NS.OMEMO}">
+                        <header sid="555">
+                            <key rid="${_converse.state.omemo_store.get('device_id')}">${u.arrayBufferToBase64(obj.key_and_tag)}</key>
+                            <iv>${obj.iv}</iv>
+                        </header>
+                        <payload>${obj.payload}</payload>
+                    </encrypted>
+                </message>`;
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        expect(view.model.messages.length).toBe(2);
+        const error = await u.waitUntil(() => view.querySelector('.error'));
+        expect(error.textContent).toBe('Error: could not decrypt a received encrypted file, because it could not be downloaded');
+    }));
+
     it("implements XEP-0454 to encrypt uploaded files",
         mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
 
@@ -84,7 +158,7 @@ describe("The OMEMO module", function() {
         let sent_stanza;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
-        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
+        iq_stanza = await u.waitUntil(() => mock.bundleIQRequestSent(_converse, contact_jid, '555'));
         stanza = stx`
             <iq from="${contact_jid}"
                 id="${iq_stanza.getAttribute('id')}"
@@ -109,7 +183,7 @@ describe("The OMEMO module", function() {
             </pubsub>
             </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
-        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
+        iq_stanza = await u.waitUntil(() => mock.bundleIQRequestSent(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
         stanza = stx`
             <iq from="${_converse.bare_jid}"
                 id="${iq_stanza.getAttribute('id')}"

+ 66 - 101
src/plugins/omemo/tests/muc.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { $iq, $msg, $pres, Strophe, omemo } = converse.env;
+const { $iq, $msg, $pres, Strophe, sizzle, stx, omemo } = converse.env;
 const u = converse.env.utils;
 
 describe("The OMEMO module", function() {
@@ -37,38 +37,36 @@ describe("The OMEMO module", function() {
 
         // newguy enters the room
         const contact_jid = 'newguy@montague.lit';
-        let stanza = $pres({
-                'to': 'romeo@montague.lit/orchard',
-                'from': 'lounge@montague.lit/newguy'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'newguy@montague.lit/_converse.js-290929789',
-                'role': 'participant'
-            }).tree();
+        let stanza = stx`
+            <presence to='romeo@montague.lit/orchard' from='lounge@montague.lit/newguy' xmlns="jabber:client">
+                <x xmlns='${Strophe.NS.MUC_USER}'>
+                    <item affiliation='none' jid='newguy@montague.lit/_converse.js-290929789' role='participant'/>
+                </x>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         // Wait for Converse to fetch newguy's device list
         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">`+
-                    `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
-                `</pubsub>`+
-            `</iq>`);
+        expect(iq_stanza).toEqualStanza(stx`
+            <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">
+                    <items node="eu.siacs.conversations.axolotl.devicelist"/>
+                </pubsub>
+            </iq>`);
 
         // The server returns his device list
-        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.devicelist"})
-                .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
-                    .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
-                        .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up()
+        stanza = stx`
+            <iq from="${contact_jid}" id="${iq_stanza.getAttribute('id')}" to="${_converse.bare_jid}" type="result" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <items node="eu.siacs.conversations.axolotl.devicelist">
+                        <item xmlns="http://jabber.org/protocol/pubsub"> <!-- TODO: must have an id attribute -->
+                            <list xmlns="eu.siacs.conversations.axolotl">
+                                <device id="4e30f35051b7b8b42abe083742187228"/>
+                            </list>
+                        </item>
+                    </items>
+                </pubsub>
+            </iq>`
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => _converse.state.omemo_store);
         expect(_converse.state.devicelists.length).toBe(2);
@@ -91,49 +89,29 @@ describe("The OMEMO module", function() {
             preventDefault: function preventDefault () {},
             key: "Enter",
         });
-        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'), 1000);
-        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:4e30f35051b7b8b42abe083742187228"})
-                .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.api.connection.get()._dataRecv(mock.createRequest(stanza));
-
-        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'), 1000);
-        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'));
+        await u.waitUntil(() => mock.bundleFetched(_converse, {
+            jid: contact_jid,
+            device_id: '4e30f35051b7b8b42abe083742187228',
+            identity_key: '3333',
+            signed_prekey_id: "4223",
+            signed_prekey_public: "1111",
+            signed_prekey_sig: "2222",
+            prekeys: ['1001', '1002', '1003'],
+        }));
+        await u.waitUntil(() =>
+            mock.bundleFetched(_converse, {
+                jid: _converse.bare_jid,
+                device_id: "482886413b977930064a5888b92134fe",
+                identity_key: '300000',
+                signed_prekey_id: "4224",
+                signed_prekey_public: "100000",
+                signed_prekey_sig: "200000",
+                prekeys: ["1991", "1992", "1993"],
+            })
+        );
 
-        spyOn(_converse.api.connection.get(), 'send');
-        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
-        await u.waitUntil(() => _converse.api.connection.get().send.calls.count(), 1000);
-        const sent_stanza = _converse.api.connection.get().send.calls.all()[0].args[0];
+        const sent_stanzas = _converse.api.connection.get().sent_stanzas;
+        let sent_stanza = await u.waitUntil(() => sent_stanzas.filter((s) => sizzle('body', s).length).pop(), 1000);
 
         expect(sent_stanza).toEqualStanza(stx`
             <message from="${own_jid}"
@@ -160,18 +138,21 @@ describe("The OMEMO module", function() {
         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 it as plaintext in the message.
-        stanza = $msg({
-                'from': `${muc_jid}/newguy`,
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'id': _converse.api.connection.get().getUniqueId()
-            }).c('body').t('This is a fallback message').up()
-                .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
-                    .c('header', {'sid':  '555'})
-                        .c('key', {'rid':  _converse.state.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up()
-                        .c('iv').t(obj.iv)
-                        .up().up()
-                    .c('payload').t(obj.payload);
+        stanza = stx`
+            <message from="${muc_jid}/newguy"
+                     to="${_converse.api.connection.get().jid}"
+                     type="groupchat"
+                     id="${_converse.api.connection.get().getUniqueId()}"
+                     xmlns="jabber:client">
+                <body>This is a fallback message</body>
+                <encrypted xmlns="${Strophe.NS.OMEMO}">
+                    <header sid="555">
+                        <key rid="${_converse.state.omemo_store.get('device_id')}">${u.arrayBufferToBase64(obj.key_and_tag)}</key>
+                        <iv>${obj.iv}</iv>
+                    </header>
+                    <payload>${obj.payload}</payload>
+                </encrypted>
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.model.messages.length).toBe(2);
@@ -229,24 +210,7 @@ describe("The OMEMO module", function() {
             preventDefault: function preventDefault () {},
             key: "Enter",
         });
-        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">`+
-                    `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
-                `</pubsub>`+
-            `</iq>`);
-
-        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.devicelist"})
-                .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
-                    .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
-                        .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up()
+        await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid, ['4e30f35051b7b8b42abe083742187228']));
 
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => _converse.state.omemo_store);
@@ -257,7 +221,7 @@ describe("The OMEMO module", function() {
         expect(devicelist.devices.length).toBe(1);
         expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228');
 
-        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
+        let iq_stanza = await u.waitUntil(() => mock.bundleIQRequestSent(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
         stanza = $iq({
             'from': _converse.bare_jid,
             'id': iq_stanza.getAttribute('id'),
@@ -275,7 +239,8 @@ 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(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'));
+
+        iq_stanza = await u.waitUntil(() => mock.bundleIQRequestSent(_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">

+ 37 - 146
src/plugins/omemo/tests/omemo.js

@@ -1,5 +1,5 @@
 /*global mock, converse */
-const { $iq, $msg, omemo, Strophe, stx, u } = converse.env;
+const { $iq, $msg, omemo, Strophe, sizzle, stx, u } = converse.env;
 
 describe("The OMEMO module", function() {
 
@@ -18,28 +18,11 @@ describe("The OMEMO module", function() {
     it("enables encrypted messages to be sent and received",
             mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
 
-        let sent_stanza;
         await mock.waitForRoster(_converse, 'current', 1);
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         await mock.initializedOMEMO(_converse);
         await mock.openChatBoxFor(_converse, contact_jid);
-        let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
-        let stanza = stx`<iq from="${contact_jid}"
-                            xmlns="jabber:server"
-                            id="${iq_stanza.getAttribute('id')}"
-                            to="${_converse.api.connection.get().jid}"
-                            type="result">
-            <pubsub xmlns="http://jabber.org/protocol/pubsub">
-                <items node="eu.siacs.conversations.axolotl.devicelist">
-                    <item xmlns="http://jabber.org/protocol/pubsub">
-                        <list xmlns="eu.siacs.conversations.axolotl">
-                            <device id="555"/>
-                        </list>
-                    </item>
-                </items>
-            </pubsub>
-        </iq>`;
-        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid, ['555']));
         await u.waitUntil(() => _converse.state.omemo_store);
         const devicelist = _converse.state.devicelists.get({'jid': contact_jid});
         await u.waitUntil(() => devicelist.devices.length === 1);
@@ -55,57 +38,29 @@ describe("The OMEMO module", function() {
             preventDefault: function preventDefault () {},
             key: "Enter",
         });
-        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
-        stanza = stx`<iq from="${contact_jid}"
-                id="${iq_stanza.getAttribute('id')}"
-                to="${_converse.bare_jid}"
-                xmlns="jabber:server"
-                type="result">
-            <pubsub xmlns="http://jabber.org/protocol/pubsub">
-                <items node="eu.siacs.conversations.axolotl.bundles:555">
-                    <item>
-                        <bundle xmlns="eu.siacs.conversations.axolotl">
-                            <signedPreKeyPublic signedPreKeyId="4223">${btoa('1111')}</signedPreKeyPublic>
-                            <signedPreKeySignature>${btoa('2222')}</signedPreKeySignature>
-                            <identityKey>${btoa('3333')}</identityKey>
-                            <prekeys>
-                                <preKeyPublic preKeyId="1">${btoa('1001')}</preKeyPublic>
-                                <preKeyPublic preKeyId="2">${btoa('1002')}</preKeyPublic>
-                                <preKeyPublic preKeyId="3">${btoa('1003')}</preKeyPublic>
-                            </prekeys>
-                        </bundle>
-                    </item>
-                </items>
-            </pubsub>
-        </iq>`;
-        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
-        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
-        stanza = stx`<iq from="${_converse.bare_jid}"
-                xmlns="jabber:server"
-                id="${iq_stanza.getAttribute('id')}"
-                to="${_converse.bare_jid}"
-                type="result">
-            <pubsub xmlns="http://jabber.org/protocol/pubsub">
-                <items node="eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe">
-                    <item>
-                        <bundle xmlns="eu.siacs.conversations.axolotl">
-                            <signedPreKeyPublic signedPreKeyId="4223">${btoa('100000')}</signedPreKeyPublic>
-                            <signedPreKeySignature>${btoa('200000')}</signedPreKeySignature>
-                            <identityKey>${btoa('300000')}</identityKey>
-                            <prekeys>
-                                <preKeyPublic preKeyId="1">${btoa('1991')}</preKeyPublic>
-                                <preKeyPublic preKeyId="2">${btoa('1992')}</preKeyPublic>
-                                <preKeyPublic preKeyId="3">${btoa('1993')}</preKeyPublic>
-                            </prekeys>
-                        </bundle>
-                    </item>
-                </items>
-            </pubsub>
-        </iq>`;
 
-        spyOn(_converse.api.connection.get(), 'send').and.callFake(stanza => { sent_stanza = stanza });
-        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
-        await u.waitUntil(() => sent_stanza);
+        await u.waitUntil(() => mock.bundleFetched(_converse, {
+            jid: contact_jid,
+            device_id: '555',
+            identity_key: '3333',
+            signed_prekey_id: "4223",
+            signed_prekey_public: "1111",
+            signed_prekey_sig: "2222",
+            prekeys: ['1001', '1002', '1003'],
+        }));
+        await u.waitUntil(() =>
+            mock.bundleFetched(_converse, {
+                jid: _converse.bare_jid,
+                device_id: "482886413b977930064a5888b92134fe",
+                identity_key: '300000',
+                signed_prekey_id: "4224",
+                signed_prekey_public: "100000",
+                signed_prekey_sig: "200000",
+                prekeys: ["1991", "1992", "1993"],
+            })
+        );
+        const sent_stanzas = _converse.api.connection.get().sent_stanzas;
+        const sent_stanza = await u.waitUntil(() => sent_stanzas.filter((s) => sizzle('body', s).length).pop(), 1000);
         expect(sent_stanza).toEqualStanza(
             stx`<message from="romeo@montague.lit/orchard"
                         id="${sent_stanza.getAttribute("id")}"
@@ -132,7 +87,7 @@ describe("The OMEMO module", function() {
         let 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 it as plaintext in the message.
-        stanza = stx`<message from="${contact_jid}"
+        let stanza = stx`<message from="${contact_jid}"
                         to="${_converse.api.connection.get().jid}"
                         type="chat"
                         id="${_converse.api.connection.get().getUniqueId()}"
@@ -182,19 +137,7 @@ describe("The OMEMO module", function() {
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         await mock.initializedOMEMO(_converse);
         await mock.openChatBoxFor(_converse, contact_jid);
-        const iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
-        let stanza = $iq({
-                'from': contact_jid,
-                'id': iq_stanza.getAttribute('id'),
-                'to': _converse.api.connection.get().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.api.connection.get()._dataRecv(mock.createRequest(stanza));
-
+        await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid, ["555"]));
         await u.waitUntil(() => _converse.state.omemo_store);
 
         const view = _converse.chatboxviews.get(contact_jid);
@@ -204,7 +147,7 @@ describe("The OMEMO module", function() {
         const msg_txt = 'This is an encrypted message from the contact';
         const obj = await omemo.encryptMessage(msg_txt)
         const id = _converse.api.connection.get().getUniqueId();
-        stanza = $msg({
+        let stanza = $msg({
                 'from': contact_jid,
                 'to': _converse.api.connection.get().jid,
                 'type': 'chat',
@@ -367,7 +310,7 @@ describe("The OMEMO module", function() {
             preventDefault: function preventDefault () {},
             key: "Enter",
         });
-        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '988349631'));
+        iq_stanza = await u.waitUntil(() => mock.bundleIQRequestSent(_converse, _converse.bare_jid, '988349631'));
         expect(iq_stanza).toEqualStanza(
             stx`<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">
@@ -420,22 +363,7 @@ describe("The OMEMO module", function() {
         });
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
-        let iq_stanza = await mock.deviceListFetched(_converse, contact_jid);
-        stanza = stx`<iq from="${contact_jid}"
-                id="${iq_stanza.getAttribute('id')}"
-                to="${_converse.api.connection.get().jid}"
-                xmlns="jabber:server"
-                type="result">
-            <pubsub xmlns="http://jabber.org/protocol/pubsub">
-                <items node="eu.siacs.conversations.axolotl.devicelist">
-                    <item xmlns="http://jabber.org/protocol/pubsub">
-                        <list xmlns="eu.siacs.conversations.axolotl">
-                            <device id="555"/>
-                        </list>
-                    </item>
-                </items>
-            </pubsub>
-        </iq>`;
+        await mock.deviceListFetched(_converse, contact_jid, ["555"]);
 
         // XXX: the bundle gets published twice, we want to make sure
         // that we wait for the 2nd, so we clear all the already sent
@@ -443,7 +371,7 @@ describe("The OMEMO module", function() {
         _converse.api.connection.get().IQ_stanzas = [];
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => _converse.state.omemo_store);
-        iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse), 1000);
+        let iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse), 1000);
         expect(iq_stanza).toEqualStanza(
             stx`<iq to="${_converse.bare_jid}" from="${_converse.bare_jid}" id="${iq_stanza.getAttribute("id")}" type="set" xmlns="jabber:client">
                 <pubsub xmlns="http://jabber.org/protocol/pubsub">
@@ -581,19 +509,7 @@ describe("The OMEMO module", function() {
         //
         // This is perhaps a bit wasteful since we're already (AFIAK) getting the info we need
         // from the PEP headline message, but the code is simpler this way.
-        const iq_devicelist_get = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
-        _converse.api.connection.get()._dataRecv(mock.createRequest($iq({
-                'from': contact_jid,
-                'id': iq_devicelist_get.getAttribute('id'),
-                'to': _converse.api.connection.get().jid,
-                'type': 'result',
-            }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
-                .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
-                    .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
-                        .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
-                            .c('device', {'id': '1234'}).up()
-                            .c('device', {'id': '4223'})
-        ));
+        await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid, ["1234", "4223"]));
 
         await u.waitUntil(() => _converse.state.devicelists.length === 2);
 
@@ -794,18 +710,7 @@ describe("The OMEMO module", function() {
         // devicelist model for them isn't yet initialized.
         // It will be created and then automatically the devices will
         // be requested from the server via IQ stanza.
-        const iq_devicelist_get = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
-        _converse.api.connection.get()._dataRecv(mock.createRequest($iq({
-                'from': contact_jid,
-                'id': iq_devicelist_get.getAttribute('id'),
-                'to': _converse.api.connection.get().jid,
-                'type': 'result',
-            }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
-                .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
-                    .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
-                        .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
-                            .c('device', {'id': '1234'})
-        ));
+        await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid, ["1234"]));
 
         await u.waitUntil(() => _converse.state.devicelists.length === 2);
         const list = _converse.state.devicelists.get(contact_jid);
@@ -898,26 +803,12 @@ 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(() => mock.deviceListFetched(_converse, _converse.bare_jid));
-        let stanza = stx`<iq from="${contact_jid}"
-                xmlns="jabber:server"
-                id="${iq_stanza.getAttribute('id')}"
-                to="${_converse.bare_jid}" type="result">
-            <pubsub xmlns="http://jabber.org/protocol/pubsub">
-                <items node="eu.siacs.conversations.axolotl.devicelist">
-                    <item xmlns="http://jabber.org/protocol/pubsub">
-                        <list xmlns="eu.siacs.conversations.axolotl">
-                            <device id="482886413b977930064a5888b92134fe"/>
-                        </list>
-                    </item>
-                </items>
-            </pubsub>
-        </iq>`;
-        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid, ['482886413b977930064a5888b92134fe']));
+
         expect(_converse.state.devicelists.length).toBe(1);
         await mock.openChatBoxFor(_converse, contact_jid);
-        iq_stanza = await mock.ownDeviceHasBeenPublished(_converse);
-        stanza = stx`<iq from="${_converse.bare_jid}"
+        let iq_stanza = await mock.ownDeviceHasBeenPublished(_converse);
+        let stanza = stx`<iq from="${_converse.bare_jid}"
                         xmlns="jabber:server"
                         id="${iq_stanza.getAttribute('id')}"
                         to="${_converse.bare_jid}" type="result"/>`;
@@ -1195,7 +1086,7 @@ describe("The OMEMO module", function() {
 
         await u.waitUntil(() => u.isVisible(modal), 1000);
 
-        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
+        iq_stanza = await u.waitUntil(() => mock.bundleIQRequestSent(_converse, contact_jid, '555'));
         expect(iq_stanza).toEqualStanza(stx`
             <iq from="romeo@montague.lit"
                     id="${iq_stanza.getAttribute("id")}"

+ 46 - 32
src/plugins/omemo/utils.js

@@ -17,6 +17,8 @@ import { KEY_ALGO, UNTRUSTED, TAG_LENGTH } from "./consts.js";
 import { MIMETYPES_MAP } from "utils/file.js";
 import { IQError, UserFacingError } from "shared/errors.js";
 import DeviceLists from "./devicelists.js";
+import {getFileName} from "utils/html.js";
+import {Texture} from "shared/texture/texture.js";
 
 const { Strophe, sizzle, stx } = converse.env;
 const { CHATROOMS_TYPE, PRIVATE_CHAT_TYPE } = constants;
@@ -26,7 +28,6 @@ const {
     arrayBufferToHex,
     arrayBufferToString,
     base64ToArrayBuffer,
-    getURL,
     hexToArrayBuffer,
     initStorage,
     isAudioURL,
@@ -217,6 +218,7 @@ async function decryptFile(iv, key, cipher) {
 
 /**
  * @param {string} url
+ * @returns {Promise<ArrayBuffer|null>}
  */
 async function downloadFile(url) {
     let response;
@@ -233,44 +235,54 @@ async function downloadFile(url) {
     }
 }
 
-async function getAndDecryptFile(uri) {
-    const protocol = window.location.hostname === "localhost" && uri.domain() === "localhost" ? "http" : "https";
-    const http_url = uri.toString().replace(/^aesgcm/, protocol);
+/**
+ * @param {string} url_text
+ * @returns {Promise<string|Error|null>}
+ */
+async function getAndDecryptFile(url_text) {
+    const url = new URL(url_text);
+    const protocol = window.location.hostname === "localhost" && url.hostname === "localhost" ? "http" : "https";
+    const http_url = url.toString().replace(/^aesgcm/, protocol);
     const cipher = await downloadFile(http_url);
     if (cipher === null) {
-        log.error(`Could not decrypt a received encrypted file ${uri.toString()} since it could not be downloaded`);
+        log.error(`Could not decrypt a received encrypted file ${url.toString()} since it could not be downloaded`);
         return new Error(__("Error: could not decrypt a received encrypted file, because it could not be downloaded"));
     }
 
-    const hash = uri.hash().slice(1);
+    const hash = url.hash.slice(1);
     const key = hash.substring(hash.length - 64);
     const iv = hash.replace(key, "");
     let content;
     try {
         content = await decryptFile(iv, key, cipher);
     } catch (e) {
-        log.error(`Could not decrypt file ${uri.toString()}`);
+        log.error(`Could not decrypt file ${url.toString()}`);
         log.error(e);
         return null;
     }
-    const [filename, extension] = uri.filename().split(".");
+    const [filename, extension] = url.pathname.split("/").pop().split(".");
     const mimetype = MIMETYPES_MAP[extension];
     try {
         const file = new File([content], filename, { "type": mimetype });
         return URL.createObjectURL(file);
     } catch (e) {
-        log.error(`Could not decrypt file ${uri.toString()}`);
+        log.error(`Could not decrypt file ${url.toString()}`);
         log.error(e);
         return null;
     }
 }
 
-function getTemplateForObjectURL(uri, obj_url, richtext) {
+/**
+ * @param {string} file_url
+ * @param {string|Error} obj_url
+ * @param {Texture} richtext
+ * @returns {import("lit").TemplateResult}
+ */
+function getTemplateForObjectURL(file_url, obj_url, richtext) {
     if (isError(obj_url)) {
-        return html`<p class="error">${obj_url.message}</p>`;
+        return html`<p class="error">${/** @type {Error} */(obj_url).message}</p>`;
     }
 
-    const file_url = uri.toString();
     if (isImageURL(file_url)) {
         return tplImage({
             src: obj_url,
@@ -278,15 +290,14 @@ function getTemplateForObjectURL(uri, obj_url, richtext) {
             onLoad: richtext.onImgLoad,
         });
     } else if (isAudioURL(file_url)) {
-        return tplAudio(obj_url);
+        return tplAudio(/** @type {string} */(obj_url));
     } else if (isVideoURL(file_url)) {
-        return tplVideo(obj_url);
+        return tplVideo(/** @type {string} */(obj_url));
     } else {
-        return tplFile(obj_url, uri.filename());
+        return tplFile(obj_url, getFileName(file_url));
     }
 }
 
-
 /**
  * @param {string} text
  * @param {number} offset
@@ -294,24 +305,27 @@ function getTemplateForObjectURL(uri, obj_url, richtext) {
  */
 function addEncryptedFiles(text, offset, richtext) {
     const objs = [];
-    const regex = /\b(aesgcm:\/\/[^\s\r\n]+)/gi;
-    const trailing_punctuation = /[`!()\[\]{};:'".,<>?«»“”„‘’]+$/;
-    const balanced_parens = /(\([^\)]*\)|\[[^\]]*\]|\{[^}]*\}|<[^>]*>)/g;
-
-    let match;
-    while ((match = regex.exec(text)) !== null) {
-        const url = match[0].replace(trailing_punctuation, "");
-        const start = match.index;
-        const end = start + url.length;
-        // Check for balanced parentheses
-        if (balanced_parens.test(url)) {
-            objs.push({ url, start, end });
-        }
+    try {
+        const parse_options = { start: /\b(aesgcm:\/\/)/gi };
+        u.withinString(
+            text,
+            /**
+             * @param {string} url
+             * @param {number} start
+             * @param {number} end
+             */
+            (url, start, end) => {
+                objs.push({ url, start, end });
+                return url;
+            },
+            parse_options
+        );
+    } catch (error) {
+        log.debug(error);
+        return;
     }
-
     objs.forEach((o) => {
-        const uri = getURL(o.url);
-        const promise = getAndDecryptFile(uri).then((obj_url) => getTemplateForObjectURL(uri, obj_url, richtext));
+        const promise = getAndDecryptFile(o.url).then((obj_url) => getTemplateForObjectURL(o.url, obj_url, richtext));
 
         const template = html`${until(promise, "")}`;
         richtext.addTemplateResult(o.start + offset, o.end + offset, template);

+ 62 - 20
src/shared/tests/mock.js

@@ -798,13 +798,31 @@ async function _initConverse (settings) {
 }
 
 
-async function deviceListFetched (_converse, jid) {
+async function deviceListFetched (_converse, jid, device_ids) {
     const selector = `iq[to="${jid}"] items[node="eu.siacs.conversations.axolotl.devicelist"]`;
-    const stanza = await u.waitUntil(
+    const iq_stanza = await u.waitUntil(
         () => Array.from(_converse.api.connection.get().IQ_stanzas).filter(iq => iq.querySelector(selector)).pop()
     );
     await u.waitUntil(() => _converse.state.devicelists.get(jid));
-    return stanza;
+    if (Array.isArray(device_ids)) {
+        const stanza = stx`<iq from="${jid}"
+                            xmlns="jabber:server"
+                            id="${iq_stanza.getAttribute('id')}"
+                            to="${_converse.api.connection.get().jid}"
+                            type="result">
+            <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                <items node="eu.siacs.conversations.axolotl.devicelist">
+                    <item xmlns="http://jabber.org/protocol/pubsub">
+                        <list xmlns="eu.siacs.conversations.axolotl">
+                            ${device_ids.map((id) => stx`<device id="${id}"/>`)}
+                        </list>
+                    </item>
+                </items>
+            </pubsub>
+        </iq>`;
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+    }
+    return iq_stanza;
 }
 
 function ownDeviceHasBeenPublished (_converse) {
@@ -818,35 +836,58 @@ function bundleHasBeenPublished (_converse) {
     return Array.from(_converse.api.connection.get().IQ_stanzas).filter(iq => iq.querySelector(selector)).pop();
 }
 
-function bundleFetched (_converse, jid, device_id) {
+function bundleIQRequestSent(_converse, jid, device_id) {
     return Array.from(_converse.api.connection.get().IQ_stanzas).filter(
         iq => iq.querySelector(`iq[to="${jid}"] items[node="eu.siacs.conversations.axolotl.bundles:${device_id}"]`)
     ).pop();
 }
 
+async function bundleFetched(
+    _converse,
+    {
+        jid,
+        device_id,
+        identity_key,
+        signed_prekey_id,
+        signed_prekey_public,
+        signed_prekey_sig,
+        prekeys,
+    }
+) {
+    const iq_stanza = await u.waitUntil(() => bundleIQRequestSent(_converse, jid, device_id));
+    const stanza = stx`<iq from="${jid}"
+            id="${iq_stanza.getAttribute("id")}"
+            to="${_converse.bare_jid}"
+            xmlns="jabber:server"
+            type="result">
+        <pubsub xmlns="http://jabber.org/protocol/pubsub">
+            <items node="eu.siacs.conversations.axolotl.bundles:${device_id}">
+                <item>
+                    <bundle xmlns="eu.siacs.conversations.axolotl">
+                        <signedPreKeyPublic signedPreKeyId="${signed_prekey_id}">${btoa(signed_prekey_public)}</signedPreKeyPublic>
+                        <signedPreKeySignature>${btoa(signed_prekey_sig)}</signedPreKeySignature>
+                        <identityKey>${btoa(identity_key)}</identityKey>
+                        <prekeys>
+                            ${prekeys.map((k, i) => stx`<preKeyPublic preKeyId="${i}">${btoa(k)}</preKeyPublic>`)}
+                        </prekeys>
+                    </bundle>
+                </item>
+            </items>
+        </pubsub>
+    </iq>`;
+    _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+}
+
 async function initializedOMEMO(
     _converse,
     identities = [{ 'category': 'pubsub', 'type': 'pep' }],
     features = ['http://jabber.org/protocol/pubsub#publish-options']
 ) {
     await waitUntilDiscoConfirmed(_converse, _converse.bare_jid, identities, features);
-    let iq_stanza = await 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.api.connection.get()._dataRecv(createRequest(stanza));
-
-    iq_stanza = await u.waitUntil(() => ownDeviceHasBeenPublished(_converse));
+    await deviceListFetched(_converse, _converse.bare_jid, ['482886413b977930064a5888b92134fe']);
+    let iq_stanza = await u.waitUntil(() => ownDeviceHasBeenPublished(_converse));
 
-    stanza = $iq({
+    let stanza = $iq({
         'from': _converse.bare_jid,
         'id': iq_stanza.getAttribute('id'),
         'to': _converse.bare_jid,
@@ -867,6 +908,7 @@ async function initializedOMEMO(
 }
 
 Object.assign(mock, {
+    bundleIQRequestSent,
     bundleFetched,
     bundleHasBeenPublished,
     chatroom_names,

+ 2 - 0
src/types/utils/index.d.ts

@@ -15,6 +15,7 @@ declare const _default: {
     isVideoURL(url: string | URL): boolean;
     isImageURL(url: string | URL): boolean;
     isEncryptedFileURL(url: string | URL): boolean;
+    withinString(string: string, callback: Function, options?: import("headless/types/utils/types.js").ProcessStringOptions): string;
     getMediaURLsMetadata(text: string, offset?: number): {
         media_urls?: import("headless/types/utils/types.js").MediaURLMetadata[];
     };
@@ -121,6 +122,7 @@ declare const _default: {
         isVideoURL(url: string | URL): boolean;
         isImageURL(url: string | URL): boolean;
         isEncryptedFileURL(url: string | URL): boolean;
+        withinString(string: string, callback: Function, options?: import("headless/types/utils/types.js").ProcessStringOptions): string;
         getMediaURLsMetadata(text: string, offset?: number): {
             media_urls?: import("headless/types/utils/types.js").MediaURLMetadata[];
         };