2
0
Эх сурвалжийг харах

Create headless OMEMO plugin

- Move the models, store and api to the headless plugin
- Move PEP Push handler to headless plugin
- Include source maps for headless build
JC Brand 1 сар өмнө
parent
commit
81d17cbe01
78 өөрчлөгдсөн 2121 нэмэгдсэн , 1869 устгасан
  1. 2 1
      karma.conf.js
  2. 1 0
      rspack/rspack.headless.js
  3. 4 2
      src/headless/index.js
  4. 6 0
      src/headless/plugins/omemo/README.md
  5. 17 17
      src/headless/plugins/omemo/api.js
  6. 0 0
      src/headless/plugins/omemo/constants.js
  7. 9 5
      src/headless/plugins/omemo/device.js
  8. 8 2
      src/headless/plugins/omemo/devicelist.js
  9. 1 1
      src/headless/plugins/omemo/devicelists.js
  10. 1 1
      src/headless/plugins/omemo/devices.js
  11. 15 0
      src/headless/plugins/omemo/index.js
  12. 234 0
      src/headless/plugins/omemo/parsers.js
  13. 104 0
      src/headless/plugins/omemo/plugin.js
  14. 134 53
      src/headless/plugins/omemo/store.js
  15. 22 8
      src/headless/plugins/omemo/types.ts
  16. 588 0
      src/headless/plugins/omemo/utils.js
  17. 1 1
      src/headless/shared/_converse.js
  18. 1 0
      src/headless/shared/constants.js
  19. 23 0
      src/headless/shared/errors.js
  20. 60 64
      src/headless/shared/message.js
  21. 2 10
      src/headless/shared/model-with-messages.js
  22. 47 39
      src/headless/shared/types.ts
  23. 4 2
      src/headless/types/index.d.ts
  24. 1 2
      src/headless/types/plugins/chat/message.d.ts
  25. 19 19
      src/headless/types/plugins/chat/model.d.ts
  26. 1 2
      src/headless/types/plugins/muc/message.d.ts
  27. 24 24
      src/headless/types/plugins/muc/muc.d.ts
  28. 18 18
      src/headless/types/plugins/muc/occupant.d.ts
  29. 0 0
      src/headless/types/plugins/omemo/api.d.ts
  30. 1 1
      src/headless/types/plugins/omemo/constants.d.ts
  31. 1 1
      src/headless/types/plugins/omemo/device.d.ts
  32. 1 1
      src/headless/types/plugins/omemo/devicelist.d.ts
  33. 1 1
      src/headless/types/plugins/omemo/devicelists.d.ts
  34. 1 1
      src/headless/types/plugins/omemo/devices.d.ts
  35. 6 0
      src/headless/types/plugins/omemo/index.d.ts
  36. 20 0
      src/headless/types/plugins/omemo/parsers.d.ts
  37. 2 0
      src/headless/types/plugins/omemo/plugin.d.ts
  38. 107 0
      src/headless/types/plugins/omemo/store.d.ts
  39. 43 0
      src/headless/types/plugins/omemo/types.d.ts
  40. 74 0
      src/headless/types/plugins/omemo/utils.d.ts
  41. 14 9
      src/headless/types/shared/_converse.d.ts
  42. 1 1
      src/headless/types/shared/actions.d.ts
  43. 1 50
      src/headless/types/shared/api/index.d.ts
  44. 18 18
      src/headless/types/shared/chatbox.d.ts
  45. 15 0
      src/headless/types/shared/errors.d.ts
  46. 2 6
      src/headless/types/shared/message.d.ts
  47. 18 18
      src/headless/types/shared/model-with-messages.d.ts
  48. 20 13
      src/headless/types/shared/types.d.ts
  49. 2 2
      src/headless/types/utils/index.d.ts
  50. 2 1
      src/headless/utils/index.js
  51. 1 1
      src/index.js
  52. 4 1
      src/plugins/omemo-views/fingerprints.js
  53. 46 0
      src/plugins/omemo-views/index.js
  54. 0 0
      src/plugins/omemo-views/profile.js
  55. 0 0
      src/plugins/omemo-views/styles/omemo.scss
  56. 1 1
      src/plugins/omemo-views/templates/fingerprints.js
  57. 2 2
      src/plugins/omemo-views/templates/profile.js
  58. 4 4
      src/plugins/omemo-views/tests/corrections.js
  59. 1 1
      src/plugins/omemo-views/tests/media-sharing.js
  60. 1 1
      src/plugins/omemo-views/tests/muc.js
  61. 10 10
      src/plugins/omemo-views/tests/omemo.js
  62. 312 0
      src/plugins/omemo-views/utils.js
  63. 0 127
      src/plugins/omemo/index.js
  64. 0 1077
      src/plugins/omemo/utils.js
  65. 1 1
      src/shared/constants.js
  66. 0 23
      src/shared/errors.js
  67. 4 1
      src/types/plugins/omemo-views/fingerprints.d.ts
  68. 2 0
      src/types/plugins/omemo-views/index.d.ts
  69. 0 0
      src/types/plugins/omemo-views/profile.d.ts
  70. 0 0
      src/types/plugins/omemo-views/templates/fingerprints.d.ts
  71. 0 0
      src/types/plugins/omemo-views/templates/profile.d.ts
  72. 0 13
      src/types/plugins/omemo-views/types.d.ts
  73. 34 0
      src/types/plugins/omemo-views/utils.d.ts
  74. 0 4
      src/types/plugins/omemo/index.d.ts
  75. 0 59
      src/types/plugins/omemo/store.d.ts
  76. 0 140
      src/types/plugins/omemo/utils.d.ts
  77. 0 8
      src/types/shared/errors.d.ts
  78. 1 1
      src/types/shared/qrcode/utils.d.ts

+ 2 - 1
karma.conf.js

@@ -10,6 +10,7 @@ module.exports = function(config) {
     files: [
       { pattern: 'dist/*.js.map', included: false },
       { pattern: 'dist/*.css.map', included: false },
+      { pattern: 'src/headless/dist/*.js.map', included: false },
       {
         pattern: "dist/emoji.json",
         watched: false,
@@ -57,7 +58,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/minimize/tests/*.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/*.js", type: 'module' },
       { pattern: "src/plugins/notifications/tests/*.js", type: 'module' },
-      { pattern: "src/plugins/omemo/tests/*.js", type: 'module' },
+      { pattern: "src/plugins/omemo-views/tests/*.js", type: 'module' },
       { pattern: "src/plugins/profile/tests/*.js", type: 'module' },
       { pattern: "src/plugins/push/tests/*.js", type: 'module' },
       { pattern: "src/plugins/register/tests/*.js", type: 'module' },

+ 1 - 0
rspack/rspack.headless.js

@@ -18,6 +18,7 @@ const sharedConfig = {
     },
     plugins,
     mode: 'production',
+    devtool: 'source-map',
     module: {
         rules: [
             {

+ 4 - 2
src/headless/index.js

@@ -1,6 +1,6 @@
 import dayjs from 'dayjs';
 import advancedFormat from 'dayjs/plugin/advancedFormat';
-import log from "@converse/log";
+import log from '@converse/log';
 
 dayjs.extend(advancedFormat);
 
@@ -30,6 +30,7 @@ import './plugins/chatboxes/index.js';
 import './plugins/disco/index.js'; // XEP-0030 Service discovery
 import './plugins/adhoc/index.js'; // XEP-0050 Ad Hoc Commands
 import './plugins/headlines/index.js'; // Support for headline messages
+export { Device, Devices, DeviceList, DeviceLists } from './plugins/omemo/index.js'; // Support for headline messages
 
 // XEP-0313 Message Archive Management
 export { MAMPlaceholderMessage } from './plugins/mam/index.js';
@@ -49,8 +50,9 @@ export { VCard, VCards } from './plugins/vcard/index.js'; // XEP-0054 VCard-temp
 // ---------------------------
 // END: Removable components
 
+import * as omemo_constants from './plugins/omemo/constants.js';
 import * as muc_constants from './plugins/muc/constants.js';
-const constants = Object.assign({}, shared_constants, muc_constants);
+const constants = Object.assign({}, shared_constants, muc_constants, omemo_constants);
 
 Object.assign(_converse.constants, constants);
 

+ 6 - 0
src/headless/plugins/omemo/README.md

@@ -0,0 +1,6 @@
+# converse-omemo
+
+This plugin implements XEP-0384 OMEMO end-to-end encryption (version 0.3.0).
+
+Only the "headless" (i.e. non-UI) parts are implemented here.
+The UI parts are in the `converse-omemo-views` plugin.

+ 17 - 17
src/plugins/omemo/api.js → src/headless/plugins/omemo/api.js

@@ -1,6 +1,8 @@
-import { _converse, api, u } from "@converse/headless";
-import { generateFingerprint } from "./utils.js";
-import OMEMOStore from "./store.js";
+import { initStorage } from '../../utils/storage.js';
+import _converse from '../../shared/_converse.js';
+import api from '../../shared/api/index.js';
+import OMEMOStore from './store.js';
+import { generateFingerprint, getDeviceList } from './utils.js';
 
 export default {
     /**
@@ -15,18 +17,19 @@ export default {
          * Returns the device ID of the current device.
          */
         async getDeviceID() {
-            await api.waitUntil("OMEMOInitialized");
-            return _converse.state.omemo_store.get("device_id");
+            await api.waitUntil('OMEMOInitialized');
+            return _converse.state.omemo_store.get('device_id');
         },
 
         session: {
             async restore() {
                 const { state } = _converse;
                 if (state.omemo_store === undefined) {
-                    const bare_jid = _converse.session.get("bare_jid");
+                    const { state } = _converse;
+                    const bare_jid = _converse.session.get('bare_jid');
                     const id = `converse.omemosession-${bare_jid}`;
                     state.omemo_store = new OMEMOStore({ id });
-                    u.initStorage(state.omemo_store, id);
+                    initStorage(state.omemo_store, id);
                 }
                 await state.omemo_store.fetchSession();
             },
@@ -48,10 +51,7 @@ export default {
              *      should be created if it cannot be found.
              */
             async get(jid, create = false) {
-                const { devicelists } = _converse.state;
-                const list = devicelists.get(jid) || (create ? devicelists.create({ jid }) : null);
-                await list?.initialized;
-                return list;
+                return await getDeviceList(jid, create);
             },
         },
 
@@ -68,26 +68,26 @@ export default {
              * @returns {promise} Promise which resolves once we have a result from the server.
              */
             async generate() {
-                await api.waitUntil("OMEMOInitialized");
+                await api.waitUntil('OMEMOInitialized');
                 // Remove current device
-                const bare_jid = _converse.session.get("bare_jid");
+                const bare_jid = _converse.session.get('bare_jid');
                 const devicelist = await api.omemo.devicelists.get(bare_jid);
 
                 const { omemo_store } = _converse.state;
-                const device_id = omemo_store.get("device_id");
+                const device_id = omemo_store.get('device_id');
                 if (device_id) {
                     const device = devicelist.devices.get(device_id);
                     omemo_store.unset(device_id);
                     if (device) {
-                        await new Promise((done) => device.destroy({ "success": done, "error": done }));
+                        await new Promise((done) => device.destroy({ 'success': done, 'error': done }));
                     }
-                    devicelist.devices.trigger("remove");
+                    devicelist.devices.trigger('remove');
                 }
                 // Generate new device bundle and publish
                 // https://xmpp.org/extensions/attic/xep-0384-0.3.0.html#usecases-announcing
                 await omemo_store.generateBundle();
                 await devicelist.publishDevices();
-                const device = devicelist.devices.get(omemo_store.get("device_id"));
+                const device = devicelist.devices.get(omemo_store.get('device_id'));
                 const fp = generateFingerprint(device);
                 await omemo_store.publishBundle();
                 return fp;

+ 0 - 0
src/plugins/omemo/consts.js → src/headless/plugins/omemo/constants.js


+ 9 - 5
src/plugins/omemo/device.js → src/headless/plugins/omemo/device.js

@@ -1,9 +1,13 @@
-import { _converse, api, converse, log, u, Model } from "@converse/headless";
-import { IQError } from "shared/errors.js";
-import { UNDECIDED } from "./consts.js";
-import { parseBundle } from "./utils.js";
+import { Model } from '@converse/skeletor';
+import log from '@converse/log';
+import _converse from '../../shared/_converse.js';
+import api from '../../shared/api/index.js';
+import converse from '../../shared/api/public.js';
+import { IQError } from "../../shared/errors.js";
+import { UNDECIDED } from "./constants.js";
+import { parseBundle } from "./parsers.js";
 
-const { Strophe, sizzle, stx } = converse.env;
+const { Strophe, sizzle, stx, u } = converse.env;
 
 class Device extends Model {
     defaults() {

+ 8 - 2
src/plugins/omemo/devicelist.js → src/headless/plugins/omemo/devicelist.js

@@ -1,7 +1,13 @@
 import { getOpenPromise } from "@converse/openpromise";
-import { _converse, api, converse, errors, log, parsers, u, Model } from "@converse/headless";
+import { Model } from '@converse/skeletor';
+import log from '@converse/log';
+import * as errors from '../../shared/errors.js';
+import { parsers } from "../../shared/index.js";
+import _converse from '../../shared/_converse.js';
+import api from '../../shared/api/index.js';
+import converse from '../../shared/api/public.js';
 
-const { Strophe, stx, sizzle } = converse.env;
+const { Strophe, stx, sizzle, u } = converse.env;
 
 class DeviceList extends Model {
     get idAttribute() {

+ 1 - 1
src/plugins/omemo/devicelists.js → src/headless/plugins/omemo/devicelists.js

@@ -1,4 +1,4 @@
-import { Collection } from "@converse/headless";
+import { Collection } from "@converse/skeletor";
 import DeviceList from "./devicelist.js";
 
 class DeviceLists extends Collection {

+ 1 - 1
src/plugins/omemo/devices.js → src/headless/plugins/omemo/devices.js

@@ -1,4 +1,4 @@
-import { Collection } from "@converse/headless";
+import { Collection } from "@converse/skeletor";
 import Device from "./device.js";
 
 class Devices extends Collection {

+ 15 - 0
src/headless/plugins/omemo/index.js

@@ -0,0 +1,15 @@
+import converse from '../../shared/api/public.js';
+import Device from './device.js';
+import Devices from './devices.js';
+import DeviceList from './devicelist.js';
+import DeviceLists from './devicelists.js';
+import './plugin.js';
+
+const { Strophe } = converse.env;
+
+Strophe.addNamespace('OMEMO_DEVICELIST', Strophe.NS.OMEMO + '.devicelist');
+Strophe.addNamespace('OMEMO_VERIFICATION', Strophe.NS.OMEMO + '.verification');
+Strophe.addNamespace('OMEMO_WHITELISTED', Strophe.NS.OMEMO + '.whitelisted');
+Strophe.addNamespace('OMEMO_BUNDLES', Strophe.NS.OMEMO + '.bundles');
+
+export { Device, Devices, DeviceList, DeviceLists  };

+ 234 - 0
src/headless/plugins/omemo/parsers.js

@@ -0,0 +1,234 @@
+/**
+ * @typedef {import('../..//shared/types').MessageAttributes} MessageAttributes
+ * @typedef {import('../../plugins/muc/types').MUCMessageAttributes} MUCMessageAttributes
+ */
+import sizzle from 'sizzle';
+import log from '@converse/log';
+import api from '../../shared/api/index.js';
+import _converse from '../../shared/_converse.js';
+import converse from '../../shared/api/public.js';
+import u from '../../utils/index.js';
+import { decryptMessage, getSessionCipher } from './utils.js';
+
+const { Strophe } = converse.env;
+
+function getDecryptionErrorAttributes(e) {
+    const { __ } = _converse;
+    return {
+        'error_text':
+            __('Sorry, could not decrypt a received OMEMO message due to an error.') + ` ${e.name} ${e.message}`,
+        'error_condition': e.name,
+        'error_message': e.message,
+        'error_type': 'Decryption',
+        'is_ephemeral': true,
+        'is_error': true,
+        'type': 'error',
+    };
+}
+
+/**
+ * We use the bare, real (i.e. non-MUC) JID as encrypted session identifier.
+ * @param {MUCMessageAttributes|MessageAttributes} attrs
+ */
+function getJIDForDecryption(attrs) {
+    const { __ } = _converse;
+    let from_jid;
+    if (attrs.sender === 'me') {
+        from_jid = _converse.session.get('bare_jid');
+    } else if (attrs.contact_jid) {
+        from_jid = attrs.contact_jid;
+    } else if ('from_real_jid' in attrs) {
+        from_jid = attrs.from_real_jid;
+    } else {
+        from_jid = attrs.from;
+    }
+
+    if (!from_jid) {
+        Object.assign(attrs, {
+            error_text: __(
+                'Sorry, could not decrypt a received OMEMO ' +
+                    "message because we don't have the XMPP address for that user."
+            ),
+            error_type: 'Decryption',
+            is_ephemeral: true,
+            is_error: true,
+            type: 'error',
+        });
+        throw new Error('Could not find JID to decrypt OMEMO message for');
+    }
+    return from_jid;
+}
+
+/**
+ * @param {MUCMessageAttributes|MessageAttributes} attrs
+ * @param {ArrayBuffer} key_and_tag
+ */
+async function handleDecryptedWhisperMessage(attrs, key_and_tag) {
+    const from_jid = getJIDForDecryption(attrs);
+    const devicelist = await api.omemo.devicelists.get(from_jid, true);
+    const encrypted = attrs.encrypted;
+    let device = devicelist.devices.get(encrypted.device_id);
+    if (!device) {
+        device = await devicelist.devices.create({ 'id': encrypted.device_id, 'jid': from_jid }, { 'promise': true });
+    }
+    if (encrypted.payload) {
+        const key = key_and_tag.slice(0, 16);
+        const tag = key_and_tag.slice(16);
+        const result = await decryptMessage({
+            ...encrypted,
+            payload: encrypted.payload,
+            ...{ key, tag }
+        });
+        device.save('active', true);
+        return result;
+    }
+}
+
+/**
+ * @param {MUCMessageAttributes|MessageAttributes} attrs
+ */
+async function decryptWhisperMessage(attrs) {
+    const from_jid = getJIDForDecryption(attrs);
+    const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
+    const key = u.base64ToArrayBuffer(attrs.encrypted.key);
+    try {
+        const key_and_tag = await session_cipher.decryptWhisperMessage(key, 'binary');
+        const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
+        return Object.assign(attrs, { 'plaintext': plaintext });
+    } catch (e) {
+        log.error(`${e.name} ${e.message}`);
+        return Object.assign(attrs, getDecryptionErrorAttributes(e));
+    }
+}
+
+/**
+ * @param {MUCMessageAttributes|MessageAttributes} attrs
+ */
+async function decryptPrekeyWhisperMessage(attrs) {
+    const from_jid = getJIDForDecryption(attrs);
+    const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
+    const key = u.base64ToArrayBuffer(attrs.encrypted.key);
+    let key_and_tag;
+    try {
+        key_and_tag = await session_cipher.decryptPreKeyWhisperMessage(key, 'binary');
+    } catch (e) {
+        // TODO from the XEP:
+        // There are various reasons why decryption of an
+        // OMEMOKeyExchange or an OMEMOAuthenticatedMessage
+        // could fail. One reason is if the message was
+        // received twice and already decrypted once, in this
+        // case the client MUST ignore the decryption failure
+        // and not show any warnings/errors. In all other cases
+        // of decryption failure, clients SHOULD respond by
+        // forcibly doing a new key exchange and sending a new
+        // OMEMOKeyExchange with a potentially empty SCE
+        // payload. By building a new session with the original
+        // sender this way, the invalid session of the original
+        // sender will get overwritten with this newly created,
+        // valid session.
+        log.error(`${e.name} ${e.message}`);
+        return Object.assign(attrs, getDecryptionErrorAttributes(e));
+    }
+    // TODO from the XEP:
+    // When a client receives the first message for a given
+    // ratchet key with a counter of 53 or higher, it MUST send
+    // a heartbeat message. Heartbeat messages are normal OMEMO
+    // encrypted messages where the SCE payload does not include
+    // any elements. These heartbeat messages cause the ratchet
+    // to forward, thus consequent messages will have the
+    // counter restarted from 0.
+    try {
+        const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
+        const { omemo_store } = _converse.state;
+        await omemo_store.generateMissingPreKeys();
+        await omemo_store.publishBundle();
+        if (plaintext) {
+            return Object.assign(attrs, { 'plaintext': plaintext });
+        } else {
+            return Object.assign(attrs, { 'is_only_key': true });
+        }
+    } catch (e) {
+        log.error(`${e.name} ${e.message}`);
+        return Object.assign(attrs, getDecryptionErrorAttributes(e));
+    }
+}
+
+/**
+ * Hook handler for {@link parseMessage} and {@link parseMUCMessage}, which
+ * parses the passed in `message` stanza for OMEMO attributes and then sets
+ * them on the attrs object.
+ * @param {Element} stanza - The message stanza
+ * @param {MUCMessageAttributes|MessageAttributes} attrs
+ * @returns {Promise<MUCMessageAttributes| MessageAttributes|
+        import('./types').MUCMessageAttrsWithEncryption|import('./types').MessageAttrsWithEncryption>}
+ */
+export async function parseEncryptedMessage(stanza, attrs) {
+    if (
+        api.settings.get('clear_cache_on_logout') ||
+        !attrs.is_encrypted ||
+        attrs.encryption_namespace !== Strophe.NS.OMEMO
+    ) {
+        return attrs;
+    }
+    const encrypted_el = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop();
+    const header = encrypted_el.querySelector('header');
+    attrs.encrypted = { 'device_id': header.getAttribute('sid') };
+
+    const device_id = await api.omemo?.getDeviceID();
+    const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted_el).pop();
+    if (key) {
+        Object.assign(attrs.encrypted, {
+            iv: header.querySelector('iv').textContent,
+            key: key.textContent,
+            payload: encrypted_el.querySelector('payload')?.textContent || null,
+            prekey: ['true', '1'].includes(key.getAttribute('prekey')),
+        });
+    } else {
+        return Object.assign(attrs, {
+            error_condition: 'not-encrypted-for-this-device',
+            error_type: 'Decryption',
+            is_ephemeral: true,
+            is_error: true,
+            type: 'error',
+        });
+    }
+    // https://xmpp.org/extensions/xep-0384.html#usecases-receiving
+    if (attrs.encrypted.prekey === true) {
+        return decryptPrekeyWhisperMessage(attrs);
+    } else {
+        return decryptWhisperMessage(attrs);
+    }
+}
+
+/**
+ * Given an XML element representing a user's OMEMO bundle, parse it
+ * and return a map.
+ * @param {Element} bundle_el
+ * @returns {import('./types').Bundle}
+ */
+export function parseBundle(bundle_el) {
+    const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic');
+    const signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature');
+    const prekeys = sizzle(`prekeys > preKeyPublic`, bundle_el).map(
+        /** @param {Element} el */ (el) => ({
+            id: parseInt(el.getAttribute('preKeyId'), 10),
+            key: el.textContent,
+        })
+    );
+    return {
+        identity_key: bundle_el.querySelector('identityKey').textContent.trim(),
+        signed_prekey: {
+            id: parseInt(signed_prekey_public_el.getAttribute('signedPreKeyId'), 10),
+            public_key: signed_prekey_public_el.textContent,
+            signature: signed_prekey_signature_el.textContent,
+        },
+        prekeys,
+    };
+}
+
+Object.assign(u, {
+    omemo: {
+        ...u.omemo,
+        parseBundle,
+    },
+});

+ 104 - 0
src/headless/plugins/omemo/plugin.js

@@ -0,0 +1,104 @@
+import converse from '../../shared/api/public.js';
+import _converse from '../../shared/_converse.js';
+import api from '../../shared/api/index.js';
+import Device from './device.js';
+import DeviceList from './devicelist.js';
+import DeviceLists from './devicelists.js';
+import Devices from './devices.js';
+import OMEMOStore from './store.js';
+import omemo_api from './api.js';
+import { parseEncryptedMessage } from './parsers.js';
+import {
+    createOMEMOMessageStanza,
+    encryptFile,
+    getOutgoingMessageAttributes,
+    handleMessageSendError,
+    initOMEMO,
+    onChatInitialized,
+    registerPEPPushHandler,
+    setEncryptedFileURL,
+} from './utils.js';
+
+const { u, Strophe } = converse.env;
+
+converse.plugins.add('converse-omemo', {
+    dependencies: ['converse-pubsub', 'converse-profile'],
+
+    /**
+     * @param {import('../../shared/_converse.js').ConversePrivateGlobal} _converse
+     */
+    enabled(_converse) {
+        /**
+         * @typedef {Window & globalThis & {libsignal: any} } WindowWithLibsignal
+         */
+        return (
+            /** @type WindowWithLibsignal */ (window).libsignal &&
+            _converse.state.config.get('trusted') &&
+            !_converse.api.settings.get('clear_cache_on_logout') &&
+            !_converse.api.settings.get('blacklisted_plugins').includes('converse-omemo')
+        );
+    },
+
+    initialize() {
+        api.settings.extend({ omemo_default: false });
+        api.promises.add(['OMEMOInitialized']);
+
+        const exports = {
+            Device,
+            Devices,
+            DeviceList,
+            DeviceLists,
+            OMEMOStore,
+        };
+
+        Object.assign(_converse.api, omemo_api);
+        Object.assign(_converse, exports); // DEPRECATED
+        Object.assign(_converse.exports, exports);
+
+        api.listen.on(
+            'createMessageStanza',
+            /**
+             * @param {import('../../shared/chatbox.js').default} chat
+             * @param {import('../../shared/types').MessageAndStanza} data
+             */
+            async (chat, data) => {
+                try {
+                    data = await createOMEMOMessageStanza(chat, data);
+                } catch (e) {
+                    handleMessageSendError(e, chat);
+                }
+                return data;
+            }
+        );
+
+        api.listen.on('connected', registerPEPPushHandler);
+
+        api.listen.on('chatRoomInitialized', onChatInitialized);
+        api.listen.on('chatBoxInitialized', onChatInitialized);
+        api.listen.on('getOutgoingMessageAttributes', getOutgoingMessageAttributes);
+
+        api.listen.on('statusInitialized', initOMEMO);
+        api.listen.on('addClientFeatures', () => api.disco.own.features.add(`${Strophe.NS.OMEMO_DEVICELIST}+notify`));
+
+        api.listen.on('parseMessage', parseEncryptedMessage);
+        api.listen.on('parseMUCMessage', parseEncryptedMessage);
+
+        api.listen.on('afterFileUploaded', setEncryptedFileURL);
+        api.listen.on(
+            'beforeFileUpload',
+            /**
+             * @param {import('../../shared/chatbox.js').default} chat
+             * @param {File} file
+             */
+            (chat, file) => (chat.get('omemo_active') ? encryptFile(file) : file)
+        );
+
+        api.listen.on('clearSession', () => {
+            delete _converse.state.omemo_store;
+            if (u.shouldClearCache(_converse) && _converse.state.devicelists) {
+                _converse.state.devicelists.clearStore();
+                delete _converse.state.devicelists;
+            }
+        });
+    },
+});

+ 134 - 53
src/plugins/omemo/store.js → src/headless/plugins/omemo/store.js

@@ -1,12 +1,16 @@
-/**
- * @typedef {module:plugins-omemo-index.WindowWithLibsignal} WindowWithLibsignal
- */
-import { generateDeviceID } from "./utils.js";
-import { _converse, api, converse, log, Model } from "@converse/headless";
+import { Model } from '@converse/skeletor';
+import log from '@converse/log';
+import _converse from '../../shared/_converse.js';
+import converse from '../../shared/api/public.js';
+import api from '../../shared/api/index.js';
+import { getDeviceList } from './utils.js';
 
 const { Strophe, stx, u } = converse.env;
 
 class OMEMOStore extends Model {
+    /**
+     * @typedef {Window & globalThis & {libsignal: any} } WindowWithLibsignal
+     */
     get Direction() {
         return {
             SENDING: 1,
@@ -14,48 +18,63 @@ class OMEMOStore extends Model {
         };
     }
 
+    /**
+     * @returns {Promise<import('./types').KeyPair>}
+     */
     getIdentityKeyPair() {
-        const keypair = this.get("identity_keypair");
+        const keypair = this.get('identity_keypair');
         return Promise.resolve({
-            "privKey": u.base64ToArrayBuffer(keypair.privKey),
-            "pubKey": u.base64ToArrayBuffer(keypair.pubKey),
+            'privKey': u.base64ToArrayBuffer(keypair.privKey),
+            'pubKey': u.base64ToArrayBuffer(keypair.pubKey),
         });
     }
 
     getLocalRegistrationId() {
-        return Promise.resolve(parseInt(this.get("device_id"), 10));
+        return Promise.resolve(parseInt(this.get('device_id'), 10));
     }
 
+    /**
+     * @param {string} identifier
+     * @param {ArrayBuffer} identity_key
+     * @param {unknown} _direction
+     */
     isTrustedIdentity(identifier, identity_key, _direction) {
         if (identifier === null || identifier === undefined) {
             throw new Error("Can't check identity key for invalid key");
         }
         if (!(identity_key instanceof ArrayBuffer)) {
-            throw new Error("Expected identity_key to be an ArrayBuffer");
+            throw new Error('Expected identity_key to be an ArrayBuffer');
         }
-        const trusted = this.get("identity_key" + identifier);
+        const trusted = this.get('identity_key' + identifier);
         if (trusted === undefined) {
             return Promise.resolve(true);
         }
         return Promise.resolve(u.arrayBufferToBase64(identity_key) === trusted);
     }
 
+    /**
+     * @param {string} identifier
+     */
     loadIdentityKey(identifier) {
         if (identifier === null || identifier === undefined) {
             throw new Error("Can't load identity_key for invalid identifier");
         }
-        return Promise.resolve(u.base64ToArrayBuffer(this.get("identity_key" + identifier)));
+        return Promise.resolve(u.base64ToArrayBuffer(this.get('identity_key' + identifier)));
     }
 
+    /**
+     * @param {string} identifier
+     * @param {string} identity_key
+     */
     saveIdentity(identifier, identity_key) {
         if (identifier === null || identifier === undefined) {
             throw new Error("Can't save identity_key for invalid identifier");
         }
         const { libsignal } = /** @type WindowWithLibsignal */ (window);
         const address = new libsignal.SignalProtocolAddress.fromString(identifier);
-        const existing = this.get("identity_key" + address.getName());
+        const existing = this.get('identity_key' + address.getName());
         const b64_idkey = u.arrayBufferToBase64(identity_key);
-        this.save("identity_key" + address.getName(), b64_idkey);
+        this.save('identity_key' + address.getName(), b64_idkey);
 
         if (existing && b64_idkey !== existing) {
             return Promise.resolve(true);
@@ -65,93 +84,126 @@ class OMEMOStore extends Model {
     }
 
     getPreKeys() {
-        return this.get("prekeys") || {};
+        return this.get('prekeys') || {};
     }
 
+    /**
+     * @param {string} key_id
+     */
     loadPreKey(key_id) {
         const res = this.getPreKeys()[key_id];
         if (res) {
             return Promise.resolve({
-                "privKey": u.base64ToArrayBuffer(res.privKey),
-                "pubKey": u.base64ToArrayBuffer(res.pubKey),
+                'privKey': u.base64ToArrayBuffer(res.privKey),
+                'pubKey': u.base64ToArrayBuffer(res.pubKey),
             });
         }
         return Promise.resolve();
     }
 
+    /**
+     * @param {string} key_id
+     * @param {import('./types').KeyPair} key_pair
+     */
     storePreKey(key_id, key_pair) {
         const prekey = {};
         prekey[key_id] = {
-            "pubKey": u.arrayBufferToBase64(key_pair.pubKey),
-            "privKey": u.arrayBufferToBase64(key_pair.privKey),
+            'pubKey': u.arrayBufferToBase64(key_pair.pubKey),
+            'privKey': u.arrayBufferToBase64(key_pair.privKey),
         };
-        this.save("prekeys", Object.assign(this.getPreKeys(), prekey));
+        this.save('prekeys', Object.assign(this.getPreKeys(), prekey));
         return Promise.resolve();
     }
 
+    /**
+     * @param {string} key_id
+     */
     removePreKey(key_id) {
         const prekeys = { ...this.getPreKeys() };
         delete prekeys[key_id];
-        this.save("prekeys", prekeys);
+        this.save('prekeys', prekeys);
         return Promise.resolve();
     }
 
-    loadSignedPreKey(_keyId) {
-        const res = this.get("signed_prekey");
+    /**
+     * @param {string} _key_id
+     * @returns {Promise<import('./types').KeyPair|void>}
+     */
+    loadSignedPreKey(_key_id) {
+        const res = this.get('signed_prekey');
         if (res) {
             return Promise.resolve({
-                "privKey": u.base64ToArrayBuffer(res.privKey),
-                "pubKey": u.base64ToArrayBuffer(res.pubKey),
+                'privKey': u.base64ToArrayBuffer(res.privKey),
+                'pubKey': u.base64ToArrayBuffer(res.pubKey),
             });
         }
         return Promise.resolve();
     }
 
+    /**
+     * @param {import('./types').SignedPreKey} spk
+     */
     storeSignedPreKey(spk) {
-        if (typeof spk !== "object") {
+        if (typeof spk !== 'object') {
             // XXX: We've changed the signature of this method from the
             // example given in InMemorySignalProtocolStore.
             // Should be fine because the libsignal code doesn't
             // actually call this method.
-            throw new Error("storeSignedPreKey: expected an object");
+            throw new Error('storeSignedPreKey: expected an object');
         }
-        this.save("signed_prekey", {
-            "id": spk.keyId,
-            "privKey": u.arrayBufferToBase64(spk.keyPair.privKey),
-            "pubKey": u.arrayBufferToBase64(spk.keyPair.pubKey),
+        this.save('signed_prekey', {
+            'id': spk.keyId,
+            'privKey': u.arrayBufferToBase64(spk.keyPair.privKey),
+            'pubKey': u.arrayBufferToBase64(spk.keyPair.pubKey),
             // XXX: The InMemorySignalProtocolStore does not pass
             // in or store the signature, but we need it when we
             // publish our bundle and this method isn't called from
             // within libsignal code, so we modify it to also store
             // the signature.
-            "signature": u.arrayBufferToBase64(spk.signature),
+            'signature': u.arrayBufferToBase64(spk.signature),
         });
         return Promise.resolve();
     }
 
+    /**
+     * @param {string} key_id
+     */
     removeSignedPreKey(key_id) {
-        if (this.get("signed_prekey")["id"] === key_id) {
-            this.unset("signed_prekey");
+        if (this.get('signed_prekey')['id'] === key_id) {
+            this.unset('signed_prekey');
             this.save();
         }
         return Promise.resolve();
     }
 
+    /**
+     * @param {string} identifier
+     */
     loadSession(identifier) {
-        return Promise.resolve(this.get("session" + identifier));
+        return Promise.resolve(this.get('session' + identifier));
     }
 
+    /**
+     * @param {string} identifier
+     * @param {object} record
+     */
     storeSession(identifier, record) {
-        return Promise.resolve(this.save("session" + identifier, record));
+        return Promise.resolve(this.save('session' + identifier, record));
     }
 
+    /**
+     * @param {string} identifier
+     */
     removeSession(identifier) {
-        return Promise.resolve(this.unset("session" + identifier));
+        return Promise.resolve(this.unset('session' + identifier));
     }
 
+    /**
+     * @param {string} identifier
+     */
     removeAllSessions(identifier) {
         const keys = Object.keys(this.attributes).filter((key) =>
-            key.startsWith("session" + identifier) ? key : false
+            key.startsWith('session' + identifier) ? key : false
         );
         const attrs = {};
         keys.forEach((key) => {
@@ -162,21 +214,21 @@ class OMEMOStore extends Model {
     }
 
     publishBundle() {
-        const signed_prekey = this.get("signed_prekey");
-        const node = `${Strophe.NS.OMEMO_BUNDLES}:${this.get("device_id")}`;
+        const signed_prekey = this.get('signed_prekey');
+        const node = `${Strophe.NS.OMEMO_BUNDLES}:${this.get('device_id')}`;
         const item = stx`
             <item>
                 <bundle xmlns="${Strophe.NS.OMEMO}">
                     <signedPreKeyPublic signedPreKeyId="${signed_prekey.id}">${signed_prekey.pubKey}</signedPreKeyPublic>
                     <signedPreKeySignature>${signed_prekey.signature}</signedPreKeySignature>
-                    <identityKey>${this.get("identity_keypair").pubKey}</identityKey>
-                    <prekeys>${Object.values(this.get("prekeys")).map(
+                    <identityKey>${this.get('identity_keypair').pubKey}</identityKey>
+                    <prekeys>${Object.values(this.get('prekeys')).map(
                         (prekey, id) => stx`<preKeyPublic preKeyId="${id}">${prekey.pubKey}</preKeyPublic>`
                     )}
                     </prekeys>
                 </bundle>
             </item>`;
-        const options = { access_model: "open" };
+        const options = { access_model: 'open' };
         return api.pubsub.publish(null, node, item, options, false);
     }
 
@@ -190,7 +242,7 @@ class OMEMOStore extends Model {
         );
 
         if (missing_keys.length < 1) {
-            log.debug("No missing prekeys to generate for our own device");
+            log.debug('No missing prekeys to generate for our own device');
             return Promise.resolve();
         }
 
@@ -203,11 +255,11 @@ class OMEMOStore extends Model {
             key: prekeys[id].pubKey,
         }));
 
-        const bare_jid = _converse.session.get("bare_jid");
-        const devicelist = await api.omemo.devicelists.get(bare_jid);
-        const device = devicelist.devices.get(this.get("device_id"));
+        const bare_jid = _converse.session.get('bare_jid');
+        const devicelist = await getDeviceList(bare_jid);
+        const device = devicelist.devices.get(this.get('device_id'));
         const bundle = await device.getBundle();
-        device.save("bundle", Object.assign(bundle, { "prekeys": marshalled_keys }));
+        device.save('bundle', Object.assign(bundle, { 'prekeys': marshalled_keys }));
     }
 
     /**
@@ -271,16 +323,16 @@ class OMEMOStore extends Model {
         const prekeys = await this.generatePreKeys();
 
         const bundle = { identity_key, device_id, prekeys };
-        bundle["signed_prekey"] = {
+        bundle['signed_prekey'] = {
             id: signed_prekey.keyId,
             public_key: u.arrayBufferToBase64(signed_prekey.keyPair.pubKey),
             signature: u.arrayBufferToBase64(signed_prekey.signature),
         };
 
-        const bare_jid = _converse.session.get("bare_jid");
+        const bare_jid = _converse.session.get('bare_jid');
         const devicelist = await api.omemo.devicelists.get(bare_jid);
-        const device = await devicelist.devices.create({ id: bundle.device_id, "jid": bare_jid }, { promise: true });
-        device.save("bundle", bundle);
+        const device = await devicelist.devices.create({ id: bundle.device_id, 'jid': bare_jid }, { promise: true });
+        device.save('bundle', bundle);
     }
 
     fetchSession() {
@@ -288,12 +340,16 @@ class OMEMOStore extends Model {
             this._setup_promise = new Promise((resolve, reject) => {
                 this.fetch({
                     success: () => {
-                        if (!this.get("device_id")) {
+                        if (!this.get('device_id')) {
                             this.generateBundle().then(resolve).catch(reject);
                         } else {
                             resolve();
                         }
                     },
+                    /**
+                     * @param {unknown} _model
+                     * @param {unknown} resp
+                     */
                     error: (_model, resp) => {
                         log.warn(`Could restore OMEMO session, we'll generate a new one: ${resp}`);
                         this.generateBundle().then(resolve).catch(reject);
@@ -305,4 +361,29 @@ class OMEMOStore extends Model {
     }
 }
 
+export async function generateDeviceID() {
+    /**
+     * @typedef {module:plugins-omemo-index.WindowWithLibsignal} WindowWithLibsignal
+     */
+    const { libsignal } = /** @type WindowWithLibsignal */ (window);
+
+    /* Generates a device ID, making sure that it's unique */
+    const bare_jid = _converse.session.get('bare_jid');
+    const devicelist = await getDeviceList(bare_jid, true);
+    const existing_ids = devicelist.devices.pluck('id');
+    let device_id = libsignal.KeyHelper.generateRegistrationId();
+
+    // Before publishing a freshly generated device id for the first time,
+    // a device MUST check whether that device id already exists, and if so, generate a new one.
+    let i = 0;
+    while (existing_ids.includes(device_id)) {
+        device_id = libsignal.KeyHelper.generateRegistrationId();
+        i++;
+        if (i === 10) {
+            throw new Error('Unable to generate a unique device ID');
+        }
+    }
+    return device_id.toString();
+}
+
 export default OMEMOStore;

+ 22 - 8
src/plugins/omemo/types.ts → src/headless/plugins/omemo/types.ts

@@ -1,4 +1,5 @@
-import { MUCMessageAttributes, MessageAttributes } from "./utils";
+import { MUCMessageAttributes } from '../../plugins/muc/types';
+import { MessageAttributes } from '../../shared/types';
 
 export type PreKey = {
     id: number;
@@ -15,6 +16,25 @@ export type Bundle = {
     prekeys: PreKey[];
 };
 
+export type KeyPair = {
+    pubKey: string;
+    privKey: string;
+};
+
+export type SignedPreKey = {
+    keyId: string;
+    keyPair: KeyPair;
+    signature: ArrayBuffer;
+};
+
+export type EncryptedMessage = {
+    key: ArrayBuffer;
+    tag: ArrayBuffer;
+    key_and_tag?: ArrayBuffer;
+    payload: string;
+    iv?: string;
+};
+
 export type EncryptedMessageAttributes = {
     iv: string;
     key: string;
@@ -25,10 +45,4 @@ export type EncryptedMessageAttributes = {
 export type MUCMessageAttrsWithEncryption = MUCMessageAttributes & EncryptedMessageAttributes;
 export type MessageAttrsWithEncryption = MessageAttributes & EncryptedMessageAttributes;
 
-export type EncryptedMessage = {
-    key: ArrayBuffer;
-    tag: ArrayBuffer;
-    key_and_tag: ArrayBuffer;
-    payload: string;
-    iv: string;
-};
+export type WindowWithLibsignal = Window & typeof globalThis & { libsignal: any };

+ 588 - 0
src/headless/plugins/omemo/utils.js

@@ -0,0 +1,588 @@
+import sizzle from 'sizzle';
+import log from '@converse/log';
+import converse from '../../shared/api/public.js';
+import _converse from '../../shared/_converse.js';
+import { constants, errors } from '../../shared/index.js';
+import { initStorage } from '../../utils/storage.js';
+import api from '../../shared/api/index.js';
+import MUC from '../../plugins/muc/muc.js';
+import { KEY_ALGO, TAG_LENGTH, UNTRUSTED } from './constants.js';
+import DeviceLists from './devicelists.js';
+
+const { u, Strophe, stx } = converse.env;
+const { arrayBufferToHex, base64ToArrayBuffer } = u;
+
+/**
+ * @param {Element} stanza
+ */
+async function updateDevicesFromStanza(stanza) {
+    const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop();
+    if (!items_el) return;
+
+    const device_selector = `item list[xmlns="${Strophe.NS.OMEMO}"] device`;
+    const device_ids = sizzle(device_selector, items_el).map((d) => d.getAttribute('id'));
+    const jid = stanza.getAttribute('from');
+    const devicelist = await api.omemo.devicelists.get(jid, true);
+    const devices = devicelist.devices;
+    const removed_ids = devices.pluck('id').filter(/** @param {string} id */ (id) => !device_ids.includes(id));
+
+    const bare_jid = _converse.session.get('bare_jid');
+
+    removed_ids.forEach(
+        /** @param {string} id */ (id) => {
+            if (jid === bare_jid && id === _converse.state.omemo_store.get('device_id')) {
+                return; // We don't set the current device as inactive
+            }
+            devices.get(id).save('active', false);
+        }
+    );
+    device_ids.forEach(
+        /** @param {string} device_id */ (device_id) => {
+            const device = devices.get(device_id);
+            if (device) {
+                device.save('active', true);
+            } else {
+                devices.create({ id: device_id, jid });
+            }
+        }
+    );
+    if (u.isSameBareJID(bare_jid, jid)) {
+        // Make sure our own device is on the list
+        // (i.e. if it was removed, add it again).
+        devicelist.publishCurrentDevice(device_ids);
+    }
+}
+
+/**
+ * @param {Element} stanza
+ */
+async function updateBundleFromStanza(stanza) {
+    const items_el = sizzle(`items`, stanza).pop();
+    if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
+        return;
+    }
+    const device_id = items_el.getAttribute('node').split(':')[1];
+    const jid = stanza.getAttribute('from');
+    const bundle_el = sizzle(`item > bundle`, items_el).pop();
+    const devicelist = await api.omemo.devicelists.get(jid, true);
+    const device = devicelist.devices.get(device_id) || devicelist.devices.create({ 'id': device_id, jid });
+    const bundle = u.omemo.parseBundle(bundle_el);
+    device.save({ bundle });
+}
+
+/**
+ * @param {Element} message
+ */
+async function handlePEPPush(message) {
+    try {
+        if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
+            await api.waitUntil('OMEMOInitialized');
+            await updateDevicesFromStanza(message);
+            await updateBundleFromStanza(message);
+        }
+    } catch (e) {
+        log.error(e);
+    }
+}
+
+/**
+ * Register a pubsub handler for devices pushed from other connected clients
+ */
+export function registerPEPPushHandler() {
+    api.connection.get().addHandler(
+        /** @param {Element} message */
+        (message) => {
+            handlePEPPush(message);
+            return true;
+        },
+        null,
+        'message',
+        'headline'
+    );
+}
+
+async function fetchDeviceLists() {
+    const bare_jid = _converse.session.get('bare_jid');
+
+    _converse.state.devicelists = new DeviceLists();
+    const id = `converse.devicelists-${bare_jid}`;
+    initStorage(_converse.state.devicelists, id);
+    await new Promise((resolve) => {
+        _converse.state.devicelists.fetch({
+            success: resolve,
+            /**
+             * @param {unknown} _m
+             * @param {unknown} e
+             */
+            error: (_m, e) => {
+                log.error(e);
+                resolve();
+            },
+        });
+    });
+    // Call API method to wait for our own device list to be fetched from the
+    // server or to be created. If we have no pre-existing OMEMO session, this
+    // will cause a new device and bundle to be generated and published.
+    await api.omemo.devicelists.get(bare_jid, true);
+}
+
+/**
+ * @param {boolean} reconnecting
+ */
+export async function initOMEMO(reconnecting) {
+    if (reconnecting) {
+        return;
+    }
+    if (!_converse.state.config.get('trusted') || api.settings.get('clear_cache_on_logout')) {
+        log.warn('Not initializing OMEMO, since this browser is not trusted or clear_cache_on_logout is set to true');
+        return;
+    }
+    try {
+        await fetchDeviceLists();
+        await api.omemo.session.restore();
+        await _converse.state.omemo_store.publishBundle();
+    } catch (e) {
+        log.error('Could not initialize OMEMO support');
+        log.error(e);
+        return;
+    }
+    /**
+     * Triggered once OMEMO support has been initialized
+     * @event _converse#OMEMOInitialized
+     * @example _converse.api.listen.on('OMEMOInitialized', () => { ... });
+     */
+    api.trigger('OMEMOInitialized');
+}
+
+/**
+ * @param {String} jid - The Jabber ID for which the device list will be returned.
+ * @param {boolean} [create=false] - Set to `true` if the device list
+ *      should be created if it cannot be found.
+ */
+export async function getDeviceList(jid, create = false) {
+    const { devicelists } = _converse.state;
+    const list = devicelists.get(jid) || (create ? devicelists.create({ jid }) : null);
+    await list?.initialized;
+    return list;
+}
+
+/**
+ * @param {import('./device.js').default} device
+ */
+export async function generateFingerprint(device) {
+    if (device.get('bundle')?.fingerprint) {
+        return;
+    }
+    const bundle = await device.getBundle();
+    bundle['fingerprint'] = arrayBufferToHex(base64ToArrayBuffer(bundle['identity_key']));
+    device.save('bundle', bundle);
+    device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference
+}
+
+/**
+ * @param {Error|errors.IQError|errors.UserFacingError} e
+ * @param {import('../../shared/chatbox.js').default} chat
+ */
+export function handleMessageSendError(e, chat) {
+    const { __ } = _converse;
+    if (e instanceof errors.IQError) {
+        chat.save('omemo_supported', false);
+
+        const err_msgs = [];
+        if (sizzle(`presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, e.iq).length) {
+            err_msgs.push(
+                __(
+                    "Sorry, we're unable to send an encrypted message because %1$s " +
+                        'requires you to be subscribed to their presence in order to see their OMEMO information',
+                    e.iq.getAttribute('from')
+                )
+            );
+        } else if (sizzle(`remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq).length) {
+            err_msgs.push(
+                __(
+                    "Sorry, we're unable to send an encrypted message because the remote server for %1$s could not be found",
+                    e.iq.getAttribute('from')
+                )
+            );
+        } else {
+            err_msgs.push(__('Unable to send an encrypted message due to an unexpected error.'));
+            err_msgs.push(e.iq.outerHTML);
+        }
+        api.alert('error', __('Error'), err_msgs);
+    } else if (e instanceof errors.UserFacingError) {
+        api.alert('error', __('Error'), [e.message]);
+    }
+    throw e;
+}
+
+/**
+ * @param {string} jid
+ * @returns {Promise<import('./devices.js').default>}
+ */
+export async function getDevicesForContact(jid) {
+    await api.waitUntil('OMEMOInitialized');
+    const devicelist = await api.omemo.devicelists.get(jid, true);
+    await devicelist.fetchDevices();
+    return devicelist.devices;
+}
+
+/**
+ * @param {string} jid
+ * @param {number} id
+ */
+export function getSessionCipher(jid, id) {
+    const { libsignal } = /** @type import('./types').WindowWithLibsignal */ (window);
+    const address = new libsignal.SignalProtocolAddress(jid, id);
+    return new libsignal.SessionCipher(_converse.state.omemo_store, address);
+}
+
+/**
+ * @param {ArrayBuffer} key_and_tag
+ * @param {import('./device.js').default} device
+ */
+function encryptKey(key_and_tag, device) {
+    return getSessionCipher(device.get('jid'), device.get('id'))
+        .encrypt(key_and_tag)
+        .then(/** @param {ArrayBuffer} payload */ (payload) => ({ payload, device }));
+}
+
+/**
+ * @param {import('./device.js').default} device
+ */
+async function buildSession(device) {
+    const { libsignal } = /** @type import('./types').WindowWithLibsignal */ (window);
+    const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
+    const sessionBuilder = new libsignal.SessionBuilder(_converse.state.omemo_store, address);
+    const prekey = device.getRandomPreKey();
+    const bundle = await device.getBundle();
+    return sessionBuilder.processPreKey({
+        registrationId: parseInt(device.get('id'), 10),
+        identityKey: base64ToArrayBuffer(bundle.identity_key),
+        signedPreKey: {
+            keyId: bundle.signed_prekey.id, // <Number>
+            publicKey: base64ToArrayBuffer(bundle.signed_prekey.public_key),
+            signature: base64ToArrayBuffer(bundle.signed_prekey.signature),
+        },
+        preKey: {
+            keyId: prekey.id, // <Number>
+            publicKey: base64ToArrayBuffer(prekey.key),
+        },
+    });
+}
+
+/**
+ * @param {import('./device.js').default} device
+ */
+export async function getSession(device) {
+    if (!device.get('bundle')) {
+        log.error(`Could not build an OMEMO session for device ${device.get('id')} because we don't have its bundle`);
+        return null;
+    }
+    const { libsignal } = /** @type import('./types').WindowWithLibsignal */ (window);
+    const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
+    const session = await _converse.state.omemo_store.loadSession(address.toString());
+    if (session) {
+        return session;
+    } else {
+        try {
+            return await buildSession(device);
+        } catch (e) {
+            log.error(`Could not build an OMEMO session for device ${device.get('id')}`);
+            log.error(e);
+            return null;
+        }
+    }
+}
+
+/**
+ * @param {import('../../shared/chatbox.js').default} chatbox
+ * @returns {Promise<import('./device.js').default[]>}
+ */
+async function getBundlesAndBuildSessions(chatbox) {
+    /**
+     * @typedef {import('./device.js').default} Device
+     */
+    const { __ } = _converse;
+    const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.');
+    let devices;
+    if (chatbox instanceof MUC) {
+        const collections = await Promise.all(
+            chatbox.occupants.map(
+                /** @param {import('../../plugins/muc/occupant').default} o */
+                (o) => getDevicesForContact(o.get('jid'))
+            )
+        );
+        devices = collections.reduce((a, b) => a.concat(b.models), []);
+    } else if (chatbox.get('type') === constants.PRIVATE_CHAT_TYPE) {
+        const their_devices = await getDevicesForContact(chatbox.get('jid'));
+        if (their_devices.length === 0) {
+            throw new errors.UserFacingError(no_devices_err);
+        }
+        const bare_jid = _converse.session.get('bare_jid');
+        const own_list = await api.omemo.devicelists.get(bare_jid);
+        const own_devices = own_list.devices;
+        devices = [...own_devices.models, ...their_devices.models];
+    }
+    // Filter out our own device
+    const id = _converse.state.omemo_store.get('device_id');
+    devices = devices.filter(/** @param {Device} d */ (d) => d.get('id') !== id);
+
+    // Fetch bundles if necessary
+    await Promise.all(devices.map(/** @param {Device} d */ (d) => d.getBundle()));
+
+    const sessions = devices
+        .filter(/** @param {Device} d */ (d) => d)
+        .map(/** @param {Device} d */ (d) => getSession(d));
+    await Promise.all(sessions);
+
+    if (sessions.includes(null)) {
+        // We couldn't build a session for certain devices.
+        devices = devices.filter(/** @param {Device} d */ (d) => sessions[devices.indexOf(d)]);
+        if (devices.length === 0) {
+            throw new errors.UserFacingError(no_devices_err);
+        }
+    }
+    return devices;
+}
+
+/**
+ * @param {string} plaintext
+ * @returns {Promise<import('./types').EncryptedMessage>}
+ */
+async function encryptMessage(plaintext) {
+    // The client MUST use fresh, randomly generated key/IV pairs
+    // with AES-128 in Galois/Counter Mode (GCM).
+
+    // For GCM a 12 byte IV is strongly suggested as other IV lengths
+    // will require additional calculations. In principle any IV size
+    // can be used as long as the IV doesn't ever repeat. NIST however
+    // suggests that only an IV size of 12 bytes needs to be supported
+    // by implementations.
+    //
+    // https://crypto.stackexchange.com/questions/26783/ciphertext-and-tag-size-and-iv-transmission-with-aes-in-gcm-mode
+    const iv = crypto.getRandomValues(new window.Uint8Array(12));
+    const key = await crypto.subtle.generateKey(KEY_ALGO, true, ['encrypt', 'decrypt']);
+    const algo = /** @type {AesGcmParams} */ {
+        iv,
+        name: 'AES-GCM',
+        tagLength: TAG_LENGTH,
+    };
+    const encrypted = await crypto.subtle.encrypt(algo, key, u.stringToArrayBuffer(plaintext));
+    const length = encrypted.byteLength - ((128 + 7) >> 3);
+    const ciphertext = encrypted.slice(0, length);
+    const tag = encrypted.slice(length);
+    const exported_key = await crypto.subtle.exportKey('raw', key);
+    return {
+        tag,
+        key: exported_key,
+        key_and_tag: u.appendArrayBuffer(exported_key, tag),
+        payload: u.arrayBufferToBase64(ciphertext),
+        iv: u.arrayBufferToBase64(iv),
+    };
+}
+
+/**
+ * @param {import('./types').EncryptedMessage} obj
+ * @returns {Promise<string>}
+ */
+export async function decryptMessage(obj) {
+    const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt', 'decrypt']);
+    const cipher = u.appendArrayBuffer(u.base64ToArrayBuffer(obj.payload), obj.tag);
+    const algo = /** @type {AesGcmParams} */ {
+        name: 'AES-GCM',
+        iv: u.base64ToArrayBuffer(obj.iv),
+        tagLength: TAG_LENGTH,
+    };
+    return u.arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
+}
+
+/**
+ * @param {import('../../shared/chatbox.js').default} chat
+ * @param {import('../../shared/types').MessageAndStanza} data
+ * @return {Promise<import('../../shared/types').MessageAndStanza>}
+ */
+export async function createOMEMOMessageStanza(chat, data) {
+    let { stanza } = data;
+    const { message } = data;
+    if (!message.get('is_encrypted')) {
+        return data;
+    }
+    if (!message.get('body')) {
+        throw new Error('No message body to encrypt!');
+    }
+    const devices = await getBundlesAndBuildSessions(chat);
+    const { key_and_tag, iv, payload } = await encryptMessage(message.get('plaintext'));
+
+    // 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 dicts = await Promise.all(
+        devices
+            .filter((device) => device.get('trusted') != UNTRUSTED && device.get('active'))
+            .map((device) => encryptKey(key_and_tag, device))
+    );
+
+    // 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
+        .cnode(
+            stx`
+            <encrypted xmlns="${Strophe.NS.OMEMO}">
+                <header sid="${_converse.state.omemo_store.get('device_id')}">
+                    ${dicts.map(({ payload, device }) => {
+                        const prekey = 3 == parseInt(payload.type, 10);
+                        if (prekey) {
+                            return stx`<key rid="${device.get('id')}" prekey="true">${btoa(payload.body)}</key>`;
+                        }
+                        return stx`<key rid="${device.get('id')}">${btoa(payload.body)}</key>`;
+                    })}
+                    <iv>${iv}</iv>
+                </header>
+                <payload>${payload}</payload>
+            </encrypted>`
+        )
+        .root();
+
+    stanza.cnode(stx`<store xmlns="${Strophe.NS.HINTS}"/>`).root();
+    stanza.cnode(stx`<encryption xmlns="${Strophe.NS.EME}" namespace="${Strophe.NS.OMEMO}"/>`).root();
+    return { message, stanza };
+}
+
+/**
+ * @param {import('../../shared/chatbox.js').default} chat
+ * @param {import('../../shared/types').MessageAttributes} attrs
+ * @return {import('../../shared/types').MessageAttributes}
+ */
+export function getOutgoingMessageAttributes(chat, attrs) {
+    const { __ } = _converse;
+    if (chat.get('omemo_active') && attrs.body) {
+        return {
+            ...attrs,
+            is_encrypted: true,
+            plaintext: attrs.body,
+            body: __(
+                'This is an OMEMO encrypted message which your client doesn’t seem to support. ' +
+                    'Find more information on https://conversations.im/omemo'
+            ),
+        };
+    }
+    return attrs;
+}
+
+/**
+ * @param {string} jid
+ */
+export async function contactHasOMEMOSupport(jid) {
+    /* Checks whether the contact advertises any OMEMO-compatible devices. */
+    const devices = await u.omemo.getDevicesForContact(jid);
+    return devices.length > 0;
+}
+
+/**
+ * @param {import('../../shared/chatbox.js').default} chatbox
+ */
+async function checkOMEMOSupported(chatbox) {
+    let supported;
+    if (chatbox.get('type') === constants.CHATROOMS_TYPE) {
+        await api.waitUntil('OMEMOInitialized');
+        const { features } = /** @type {MUC} */ (chatbox);
+        supported = features.get('nonanonymous') && features.get('membersonly');
+    } else if (chatbox.get('type') === constants.PRIVATE_CHAT_TYPE) {
+        supported = await contactHasOMEMOSupport(chatbox.get('jid'));
+    }
+    chatbox.set('omemo_supported', !!supported);
+    if (supported && api.settings.get('omemo_default')) {
+        chatbox.set('omemo_active', true);
+    }
+}
+
+/**
+ * @param {MUC} chatroom
+ * @param {import('../../plugins/muc/occupant').default} occupant
+ */
+async function onOccupantAdded(chatroom, occupant) {
+    if (occupant.isSelf() || !chatroom.features.get('nonanonymous') || !chatroom.features.get('membersonly')) {
+        return;
+    }
+    const { __ } = _converse;
+    if (chatroom.get('omemo_active')) {
+        const supported = await contactHasOMEMOSupport(occupant.get('jid'));
+        if (!supported) {
+            chatroom.createMessage({
+                'message': __(
+                    "%1$s doesn't appear to have a client that supports OMEMO. " +
+                        'Encrypted chat will no longer be possible in this grouchat.',
+                    occupant.get('nick')
+                ),
+                'type': 'error',
+            });
+            chatroom.save({ 'omemo_active': false, 'omemo_supported': false });
+        }
+    }
+}
+
+/**
+ * @param {import('../../shared/chatbox.js').default} chatbox
+ */
+export function onChatInitialized(chatbox) {
+    checkOMEMOSupported(chatbox);
+    if (chatbox.get('type') === constants.CHATROOMS_TYPE) {
+        /** @type {MUC} */ (chatbox).occupants.on(
+            'add',
+            /** @param {import('../../plugins/muc/occupant').default} o */ (o) =>
+                onOccupantAdded(/** @type {MUC} */ (chatbox), o)
+        );
+        /** @type {MUC} */ (chatbox).features.on('change', () => checkOMEMOSupported(chatbox));
+    }
+}
+
+/**
+ * @param {import('../../shared/message').default} message
+ * @param {import('../../shared/types').FileUploadMessageAttributes} attrs
+ */
+export function setEncryptedFileURL(message, attrs) {
+    if (message.file.xep454_ivkey) {
+        const url = attrs.oob_url.replace(/^https?:/, 'aesgcm:') + '#' + message.file.xep454_ivkey;
+        return {
+            ...attrs,
+            ...{
+                oob_url: null, // Since only the body gets encrypted, we don't set the oob_url
+                message: url,
+                body: url,
+            },
+        };
+    }
+    return attrs;
+}
+
+/**
+ * @param {File} file
+ * @returns {Promise<File>}
+ */
+export async function encryptFile(file) {
+    const iv = crypto.getRandomValues(new Uint8Array(12));
+    const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
+    const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, await file.arrayBuffer());
+    const exported_key = await window.crypto.subtle.exportKey('raw', key);
+    const encrypted_file = new File([encrypted], file.name, { type: file.type, lastModified: file.lastModified });
+
+    Object.assign(encrypted_file, { xep454_ivkey: arrayBufferToHex(iv) + arrayBufferToHex(exported_key) });
+    return encrypted_file;
+}
+
+Object.assign(u, {
+    omemo: {
+        ...u.omemo,
+        decryptMessage,
+        encryptMessage,
+        generateFingerprint,
+        getDevicesForContact,
+    },
+});

+ 1 - 1
src/headless/shared/_converse.js

@@ -59,7 +59,7 @@ const DEPRECATED_ATTRS = {
  * @global
  * @namespace _converse
  */
-class ConversePrivateGlobal extends EventEmitter(Object) {
+export class ConversePrivateGlobal extends EventEmitter(Object) {
 
     constructor () {
         super();

+ 1 - 0
src/headless/shared/constants.js

@@ -143,6 +143,7 @@ export const CORE_PLUGINS = [
     'converse-smacks',
     'converse-status',
     'converse-vcard',
+    'converse-omemo',
 ];
 
 export const CHAT_STATES = ['active', 'composing', 'gone', 'inactive', 'paused'];

+ 23 - 0
src/headless/shared/errors.js

@@ -1,5 +1,28 @@
 export class MethodNotImplementedError extends Error {}
 
+export class UserFacingError extends Error {
+    /**
+     * @param {string} message
+     */
+    constructor (message) {
+        super(message);
+        this.name = 'UserFacingError';
+        this.user_facing = true;
+    }
+}
+
+export class IQError extends Error {
+    /**
+     * @param {string} message
+     * @param {Element} iq
+     */
+    constructor (message, iq) {
+        super(message);
+        this.name = 'IQError';
+        this.iq = iq;
+    }
+}
+
 /**
  * Custom error for indicating timeouts
  * @namespace converse.env

+ 60 - 64
src/headless/shared/message.js

@@ -1,20 +1,16 @@
-import dayjs from "dayjs";
-import sizzle from "sizzle";
-import { Strophe, $iq } from "strophe.js";
-import { Model } from "@converse/skeletor";
-import log from "@converse/log";
-import _converse from "../shared/_converse.js";
-import api from "../shared/api/index.js";
-import { SUCCESS, FAILURE } from "../shared/constants.js";
-import ColorAwareModel from "../shared/color.js";
-import ModelWithContact from "../shared/model-with-contact.js";
-import ModelWithVCard from "../shared/model-with-vcard";
-import { getUniqueId } from "../utils/index.js";
+import dayjs from 'dayjs';
+import sizzle from 'sizzle';
+import { Strophe, $iq } from 'strophe.js';
+import { Model } from '@converse/skeletor';
+import log from '@converse/log';
+import _converse from '../shared/_converse.js';
+import api from '../shared/api/index.js';
+import { SUCCESS, FAILURE } from '../shared/constants.js';
+import ColorAwareModel from '../shared/color.js';
+import ModelWithContact from '../shared/model-with-contact.js';
+import ModelWithVCard from '../shared/model-with-vcard';
+import { getUniqueId } from '../utils/index.js';
 
-/**
- * @template {import('./types').ModelExtender} T
- * @param {T} BaseModel
- */
 class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model))) {
     defaults() {
         return {
@@ -57,7 +53,7 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
             // fails for some reason.
             // TODO: This is likely fixable by setting `wait` when
             // creating messages. See the wait-for-messages branch.
-            this.validationError = "Empty message";
+            this.validationError = 'Empty message';
             this.safeDestroy();
             return false;
         }
@@ -81,7 +77,7 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
         }
         const is_ephemeral = this.isEphemeral();
         if (is_ephemeral) {
-            const timeout = typeof is_ephemeral === "number" ? is_ephemeral : 10000;
+            const timeout = typeof is_ephemeral === 'number' ? is_ephemeral : 10000;
             this.ephemeral_timer = setTimeout(() => this.safeDestroy(), timeout);
         }
     }
@@ -92,7 +88,7 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
      * @returns {boolean}
      */
     isEphemeral() {
-        return this.get("is_ephemeral");
+        return this.get('is_ephemeral');
     }
 
     /**
@@ -104,14 +100,14 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
         if (!text) {
             return false;
         }
-        return text.startsWith("/me ");
+        return text.startsWith('/me ');
     }
 
     /**
      * @returns {boolean}
      */
     isRetracted() {
-        return this.get("retracted") || this.get("moderated") === "retracted";
+        return this.get('retracted') || this.get('moderated') === 'retracted';
     }
 
     /**
@@ -133,18 +129,18 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
         if (prev_model === null) {
             return false;
         }
-        const date = dayjs(this.get("time"));
+        const date = dayjs(this.get('time'));
         return (
-            this.get("from") === prev_model.get("from") &&
+            this.get('from') === prev_model.get('from') &&
             !this.isRetracted() &&
             !prev_model.isRetracted() &&
             !this.isMeCommand() &&
             !prev_model.isMeCommand() &&
-            !!this.get("is_encrypted") === !!prev_model.get("is_encrypted") &&
-            this.get("type") === prev_model.get("type") &&
-            this.get("type") !== "info" &&
-            date.isBefore(dayjs(prev_model.get("time")).add(10, "minutes")) &&
-            (this.get("type") === "groupchat" ? this.get("occupant_id") === prev_model.get("occupant_id") : true)
+            !!this.get('is_encrypted') === !!prev_model.get('is_encrypted') &&
+            this.get('type') === prev_model.get('type') &&
+            this.get('type') !== 'info' &&
+            date.isBefore(dayjs(prev_model.get('time')).add(10, 'minutes')) &&
+            (this.get('type') === 'groupchat' ? this.get('occupant_id') === prev_model.get('occupant_id') : true)
         );
     }
 
@@ -153,19 +149,19 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
      * @returns { Boolean }
      */
     mayBeRetracted() {
-        const is_own_message = this.get("sender") === "me";
-        const not_canceled = this.get("error_type") !== "cancel";
-        return is_own_message && not_canceled && ["all", "own"].includes(api.settings.get("allow_message_retraction"));
+        const is_own_message = this.get('sender') === 'me';
+        const not_canceled = this.get('error_type') !== 'cancel';
+        return is_own_message && not_canceled && ['all', 'own'].includes(api.settings.get('allow_message_retraction'));
     }
 
     getMessageText() {
-        if (this.get("is_encrypted")) {
+        if (this.get('is_encrypted')) {
             const { __ } = _converse;
-            return this.get("plaintext") || this.get("body") || __("Undecryptable OMEMO message");
-        } else if (["groupchat", "chat", "normal"].includes(this.get("type"))) {
-            return this.get("body");
+            return this.get('plaintext') || this.get('body') || __('Undecryptable OMEMO message');
+        } else if (['groupchat', 'chat', 'normal'].includes(this.get('type'))) {
+            return this.get('body');
         } else {
-            return this.get("message");
+            return this.get('message');
         }
     }
 
@@ -174,17 +170,17 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
      * https://xmpp.org/extensions/xep-0363.html#request
      */
     sendSlotRequestStanza() {
-        if (!this.file) return Promise.reject(new Error("file is undefined"));
+        if (!this.file) return Promise.reject(new Error('file is undefined'));
 
         const iq = $iq({
-            "from": _converse.session.get("jid"),
-            "to": this.get("slot_request_url"),
-            "type": "get",
-        }).c("request", {
-            "xmlns": Strophe.NS.HTTPUPLOAD,
-            "filename": this.file.name,
-            "size": this.file.size,
-            "content-type": this.file.type,
+            'from': _converse.session.get('jid'),
+            'to': this.get('slot_request_url'),
+            'type': 'get',
+        }).c('request', {
+            'xmlns': Strophe.NS.HTTPUPLOAD,
+            'filename': this.file.name,
+            'size': this.file.size,
+            'content-type': this.file.type,
         });
         return api.sendIQ(iq);
     }
@@ -199,8 +195,8 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
         // to be manually set via document.cookie, so we're leaving it out here.
         return {
             headers: headers
-                .map((h) => ({ "name": h.getAttribute("name"), "value": h.textContent }))
-                .filter((h) => ["Authorization", "Expires"].includes(h.name)),
+                .map((h) => ({ 'name': h.getAttribute('name'), 'value': h.textContent }))
+                .filter((h) => ['Authorization', 'Expires'].includes(h.name)),
         };
     }
 
@@ -213,22 +209,22 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
             log.error(e);
             return this.save({
                 is_ephemeral: true,
-                message: __("Sorry, could not determine upload URL."),
-                type: "error",
+                message: __('Sorry, could not determine upload URL.'),
+                type: 'error',
             });
         }
         const slot = sizzle(`slot[xmlns="${Strophe.NS.HTTPUPLOAD}"]`, stanza).pop();
         if (slot) {
             this.upload_metadata = this.getUploadRequestMetadata(stanza);
             this.save({
-                get: slot.querySelector("get").getAttribute("url"),
-                put: slot.querySelector("put").getAttribute("url"),
+                get: slot.querySelector('get').getAttribute('url'),
+                put: slot.querySelector('put').getAttribute('url'),
             });
         } else {
             return this.save({
                 is_ephemeral: true,
-                message: __("Sorry, could not determine file upload URL."),
-                type: "error",
+                message: __('Sorry, could not determine file upload URL.'),
+                type: 'error',
             });
         }
     }
@@ -238,12 +234,12 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
 
         xhr.onreadystatechange = async (event) => {
             if (xhr.readyState === XMLHttpRequest.DONE) {
-                log.info("Status: " + xhr.status);
+                log.info('Status: ' + xhr.status);
                 if (xhr.status === 200 || xhr.status === 201) {
                     let attrs = {
-                        body: this.get("get"),
-                        message: this.get("get"),
-                        oob_url: this.get("get"),
+                        body: this.get('get'),
+                        message: this.get('get'),
+                        oob_url: this.get('get'),
                         upload: SUCCESS,
                     };
                     /**
@@ -251,7 +247,7 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
                      * saved on the message once a file has been uploaded.
                      * @event _converse#afterFileUploaded
                      */
-                    attrs = await api.hook("afterFileUploaded", this, attrs);
+                    attrs = await api.hook('afterFileUploaded', this, attrs);
                     this.save(attrs);
                 } else {
                     log.error(event);
@@ -261,10 +257,10 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
         };
 
         xhr.upload.addEventListener(
-            "progress",
+            'progress',
             (evt) => {
                 if (evt.lengthComputable) {
-                    this.set("progress", evt.loaded / evt.total);
+                    this.set('progress', evt.loaded / evt.total);
                 }
             },
             false
@@ -279,17 +275,17 @@ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model)
                     xhr.responseText
                 );
             } else {
-                message = __("Sorry, could not succesfully upload your file.");
+                message = __('Sorry, could not succesfully upload your file.');
             }
             this.save({
                 is_ephemeral: true,
                 message,
-                type: "error",
+                type: 'error',
                 upload: FAILURE,
             });
         };
-        xhr.open("PUT", this.get("put"), true);
-        xhr.setRequestHeader("Content-type", this.file.type);
+        xhr.open('PUT', this.get('put'), true);
+        xhr.setRequestHeader('Content-type', this.file.type);
         this.upload_metadata.headers?.forEach((h) => xhr.setRequestHeader(h.name, h.value));
         xhr.send(this.file);
     }

+ 2 - 10
src/headless/shared/model-with-messages.js

@@ -34,7 +34,6 @@ export default function ModelWithMessages(BaseModel) {
      * @typedef {import('../plugins/muc/parsers').MUCMessageAttributes} MUCMessageAttributes
      * @typedef {import('../shared/types').MessageAttributes} MessageAttributes
      * @typedef {import('./message').default} BaseMessage
-     * @typedef {import('strophe.js').Builder} Builder
      */
 
     return class ModelWithMessages extends BaseModel {
@@ -963,15 +962,8 @@ export default function ModelWithMessages(BaseModel) {
             /**
              * *Hook* which allows plugins to update an outgoing message stanza
              * @event _converse#createMessageStanza
-             * @param {ChatBox|MUC} chat - The chat from
-             *      which this message stanza is being sent.
-             * @param {Object} data - BaseMessage data
-             * @param {BaseMessage} data.message
-             *      The message object from which the stanza is created and which gets persisted to storage.
-             * @param {Builder} data.stanza
-             *      The stanza that will be sent out, as a Strophe.Builder object.
-             *      You can use the Strophe.Builder functions to extend the stanza.
-             *      See http://strophe.im/strophejs/doc/1.4.3/files/strophe-umd-js.html#Strophe.Builder.Functions
+             * @param {ChatBox|MUC} chat - The chat from which this message stanza is being sent.
+             * @param {import('./types').MessageAndStanza} data - BaseMessage data
              */
             const data = await api.hook('createMessageStanza', this, { message, stanza });
             return data.stanza;

+ 47 - 39
src/headless/shared/types.ts

@@ -1,9 +1,16 @@
-import { Collection, Model } from "@converse/skeletor";
-import { getOpenPromise } from "@converse/openpromise";
+import { Builder } from 'strophe.js';
+import { Collection, Model } from '@converse/skeletor';
+import { getOpenPromise } from '@converse/openpromise';
+import BaseMessage from './message.js';
+
+export type MessageAndStanza = {
+    message: BaseMessage; // The message object from which the stanza is created and which gets persisted to storage.
+    stanza: Builder; // The stanza that will be sent out, as a Strophe.Builder object.
+};
 
 export type ReplaceableOpenPromise = ReturnType<typeof getOpenPromise> & {
     replace?: boolean;
-}
+};
 
 export type ModelAttributes = Record<string, any>;
 
@@ -32,6 +39,7 @@ type EncryptionPayloadAttrs = {
     key?: string;
     prekey?: boolean;
     device_id: string;
+    payload?: string;
 };
 
 export type RetractionAttrs = {
@@ -70,19 +78,19 @@ export type XFormCaptchaURI = {
     data: string;
 };
 
-type XFormListTypes = "list-single" | "list-multi";
-type XFormJIDTypes = "jid-single" | "jid-multi";
-type XFormTextTypes = "text-multi" | "text-private" | "text-single";
-type XFormDateTypes = "date" | "datetime";
+type XFormListTypes = 'list-single' | 'list-multi';
+type XFormJIDTypes = 'jid-single' | 'jid-multi';
+type XFormTextTypes = 'text-multi' | 'text-private' | 'text-single';
+type XFormDateTypes = 'date' | 'datetime';
 type XFormFieldTypes =
     | XFormListTypes
     | XFormJIDTypes
     | XFormTextTypes
     | XFormDateTypes
-    | "fixed"
-    | "boolean"
-    | "url"
-    | "hidden";
+    | 'fixed'
+    | 'boolean'
+    | 'url'
+    | 'hidden';
 
 export type XFormField = {
     var: string;
@@ -97,7 +105,7 @@ export type XFormField = {
     readonly: boolean;
 };
 
-export type XFormResponseType = "result" | "form";
+export type XFormResponseType = 'result' | 'form';
 
 export type XForm = {
     type: XFormResponseType;
@@ -121,30 +129,30 @@ export type ErrorExtra = Record<string, string>;
 
 // https://datatracker.ietf.org/doc/html/rfc6120#section-8.3
 export type ErrorName =
-    | "bad-request"
-    | "conflict"
-    | "feature-not-implemented"
-    | "forbidden"
-    | "gone"
-    | "internal-server-error"
-    | "item-not-found"
-    | "jid-malformed"
-    | "not-acceptable"
-    | "not-allowed"
-    | "not-authorized"
-    | "payment-required"
-    | "recipient-unavailable"
-    | "redirect"
-    | "registration-required"
-    | "remote-server-not-found"
-    | "remote-server-timeout"
-    | "resource-constraint"
-    | "service-unavailable"
-    | "subscription-required"
-    | "undefined-condition"
-    | "unexpected-request";
-
-export type ErrorType = "auth" | "cancel" | "continue" | "modify" | "wait";
+    | 'bad-request'
+    | 'conflict'
+    | 'feature-not-implemented'
+    | 'forbidden'
+    | 'gone'
+    | 'internal-server-error'
+    | 'item-not-found'
+    | 'jid-malformed'
+    | 'not-acceptable'
+    | 'not-allowed'
+    | 'not-authorized'
+    | 'payment-required'
+    | 'recipient-unavailable'
+    | 'redirect'
+    | 'registration-required'
+    | 'remote-server-not-found'
+    | 'remote-server-timeout'
+    | 'resource-constraint'
+    | 'service-unavailable'
+    | 'subscription-required'
+    | 'undefined-condition'
+    | 'unexpected-request';
+
+export type ErrorType = 'auth' | 'cancel' | 'continue' | 'modify' | 'wait';
 
 // Represents a XEP-0372 reference
 export type Reference = {
@@ -200,7 +208,7 @@ export type MessageAttributes = EncryptionAttrs &
         replace_id: string; // The `id` attribute of a XEP-0308 <replace> element
         retracted: string; // An ISO8601 string recording the time that the message was retracted
         retracted_id: string; // The `id` attribute of a XEP-424 <retracted> element
-        sender: "me" | "them"; // Whether the message was sent by the current user or someone else
+        sender: 'me' | 'them'; // Whether the message was sent by the current user or someone else
         spoiler_hint: string; //  The XEP-0382 spoiler hint
         stanza_id: string; // The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple.
         subject: string; // The <subject> element value
@@ -217,5 +225,5 @@ export type FileUploadMessageAttributes = {
     upload: 'success' | 'failure';
 };
 
-export type MessageMarkerType = "displayed" | "received" | "acknowledged";
-export type ChatStateType = "active" | "composing" | "paused" | "inactive" | "gone";
+export type MessageMarkerType = 'displayed' | 'received' | 'acknowledged';
+export type ChatStateType = 'active' | 'composing' | 'paused' | 'inactive' | 'gone';

+ 4 - 2
src/headless/types/index.d.ts

@@ -8,18 +8,20 @@ import { api } from './shared/index.js';
 import converse from './shared/api/public.js';
 import { _converse } from './shared/index.js';
 import { i18n } from './shared/index.js';
-import log from "@converse/log";
+import log from '@converse/log';
 import u from './utils/index.js';
-export const constants: typeof shared_constants & typeof muc_constants;
+export const constants: typeof shared_constants & typeof muc_constants & typeof omemo_constants;
 import { parsers } from './shared/index.js';
 import * as errors from './shared/errors.js';
 import { constants as shared_constants } from './shared/index.js';
 import * as muc_constants from './plugins/muc/constants.js';
+import * as omemo_constants from './plugins/omemo/constants.js';
 export { BaseMessage, ModelWithMessages, api, converse, _converse, i18n, log, u, parsers, errors };
 export { Collection, EventEmitter, Model } from "@converse/skeletor";
 export { Builder, Stanza } from "strophe.js";
 export { Bookmark, Bookmarks } from "./plugins/bookmarks/index.js";
 export { ChatBox, Message, Messages } from "./plugins/chat/index.js";
+export { Device, Devices, DeviceList, DeviceLists } from "./plugins/omemo/index.js";
 export { MUCMessage, MUCMessages, MUC, MUCOccupant, MUCOccupants } from "./plugins/muc/index.js";
 export { RosterContact, RosterContacts, RosterFilter, Presence, Presences } from "./plugins/roster/index.js";
 export { VCard, VCards } from "./plugins/vcard/index.js";

+ 1 - 2
src/headless/types/plugins/chat/message.d.ts

@@ -6,8 +6,7 @@ export default Message;
  * @memberOf _converse
  * @example const msg = new Message({'message': 'hello world!'});
  */
-declare class Message extends BaseMessage<any> {
-    constructor(models?: import("@converse/skeletor").Model[], options?: object);
+declare class Message extends BaseMessage {
     initialize(): Promise<void>;
     initialized: Promise<any> & {
         isResolved: boolean;

+ 19 - 19
src/headless/types/plugins/chat/model.d.ts

@@ -87,33 +87,33 @@ declare const ChatBox_base: {
         fetchMessages(): any;
         afterMessagesFetched(): void;
         onMessage(_attrs_or_error: import("../../shared/types").MessageAttributes | Error): Promise<void>;
-        getUpdatedMessageAttributes(message: import("../../shared/message").default<any>, attrs: import("../../shared/types").MessageAttributes): object;
-        updateMessage(message: import("../../shared/message").default<any>, attrs: import("../../shared/types").MessageAttributes): void;
-        handleCorrection(attrs: import("../../shared/types").MessageAttributes | import("../muc/types.js").MUCMessageAttributes): Promise<import("../../shared/message").default<any> | void>;
+        getUpdatedMessageAttributes(message: import("../../shared/message").default, attrs: import("../../shared/types").MessageAttributes): object;
+        updateMessage(message: import("../../shared/message").default, attrs: import("../../shared/types").MessageAttributes): void;
+        handleCorrection(attrs: import("../../shared/types").MessageAttributes | import("../muc/types.js").MUCMessageAttributes): Promise<import("../../shared/message").default | void>;
         queueMessage(attrs: import("../../shared/types").MessageAttributes): any;
         msg_chain: any;
         getOutgoingMessageAttributes(_attrs?: import("../../shared/types").MessageAttributes): Promise<import("../../shared/types").MessageAttributes>;
-        sendMessage(attrs?: any): Promise<import("../../shared/message").default<any>>;
-        retractOwnMessage(message: import("../../shared/message").default<any>): void;
+        sendMessage(attrs?: any): Promise<import("../../shared/message").default>;
+        retractOwnMessage(message: import("../../shared/message").default): void;
         sendFiles(files: File[]): Promise<void>;
         setEditable(attrs: any, send_time: string): void;
         setChatState(state: string, options?: object): any;
         chat_state_timeout: NodeJS.Timeout;
-        onMessageAdded(message: import("../../shared/message").default<any>): void;
-        onMessageUploadChanged(message: import("../../shared/message").default<any>): Promise<void>;
-        onMessageCorrecting(message: import("../../shared/message").default<any>): void;
+        onMessageAdded(message: import("../../shared/message").default): void;
+        onMessageUploadChanged(message: import("../../shared/message").default): Promise<void>;
+        onMessageCorrecting(message: import("../../shared/message").default): void;
         onScrolledChanged(): void;
         pruneHistoryWhenScrolledDown(): void;
         shouldShowErrorMessage(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;
         clearMessages(): Promise<void>;
         editEarlierMessage(): void;
         editLaterMessage(): any;
-        isChatMessage(_message: import("../../shared/message").default<any>): boolean;
-        getOldestMessage(): import("../../shared/message").default<any>;
-        getMostRecentMessage(): import("../../shared/message").default<any>;
+        isChatMessage(_message: import("../../shared/message").default): boolean;
+        getOldestMessage(): import("../../shared/message").default;
+        getMostRecentMessage(): import("../../shared/message").default;
         getMessageReferencedByError(attrs: object): any;
-        findDanglingRetraction(attrs: object): import("../../shared/message").default<any> | null;
-        getDuplicateMessage(attrs: object): import("../../shared/message").default<any>;
+        findDanglingRetraction(attrs: object): import("../../shared/message").default | null;
+        getDuplicateMessage(attrs: object): import("../../shared/message").default;
         getOriginIdQueryAttrs(attrs: object): {
             origin_id: any;
             from: any;
@@ -123,15 +123,15 @@ declare const ChatBox_base: {
             from: any;
             msgid: any;
         };
-        sendMarkerForMessage(msg: import("../../shared/message").default<any>, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
-        handleUnreadMessage(message: import("../../shared/message").default<any>): void;
-        getErrorAttributesForMessage(message: import("../../shared/message").default<any>, attrs: import("../../shared/types").MessageAttributes): Promise<any>;
+        sendMarkerForMessage(msg: import("../../shared/message").default, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
+        handleUnreadMessage(message: import("../../shared/message").default): void;
+        getErrorAttributesForMessage(message: import("../../shared/message").default, attrs: import("../../shared/types").MessageAttributes): Promise<any>;
         handleErrorMessageStanza(stanza: Element): Promise<void>;
-        incrementUnreadMsgsCounter(message: import("../../shared/message").default<any>): void;
+        incrementUnreadMsgsCounter(message: import("../../shared/message").default): void;
         clearUnreadMsgCounter(): void;
         handleRetraction(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;
         handleReceipt(attrs: import("../../shared/types").MessageAttributes): boolean;
-        createMessageStanza(message: import("../../shared/message").default<any>): Promise<any>;
+        createMessageStanza(message: import("../../shared/message").default): Promise<any>;
         pruneHistory(): void;
         debouncedPruneHistory: import("lodash").DebouncedFunc<() => void>;
         isScrolledUp(): any;
@@ -407,7 +407,7 @@ declare class ChatBox extends ChatBox_base {
     /**
      * @param {import('../../shared/message').default} message
      */
-    isChatMessage(message: import("../../shared/message").default<any>): boolean;
+    isChatMessage(message: import("../../shared/message").default): boolean;
 }
 import ChatBoxBase from '../../shared/chatbox.js';
 //# sourceMappingURL=model.d.ts.map

+ 1 - 2
src/headless/types/plugins/muc/message.d.ts

@@ -1,6 +1,5 @@
 export default MUCMessage;
-declare class MUCMessage extends BaseMessage<any> {
-    constructor(models?: import("@converse/skeletor").Model[], options?: object);
+declare class MUCMessage extends BaseMessage {
     get occupants(): any;
     getDisplayName(): any;
     /**

+ 24 - 24
src/headless/types/plugins/muc/muc.d.ts

@@ -87,33 +87,33 @@ declare const MUC_base: {
         fetchMessages(): any;
         afterMessagesFetched(): void;
         onMessage(_attrs_or_error: import("../../shared/types").MessageAttributes | Error): Promise<void>;
-        getUpdatedMessageAttributes(message: import("../../shared/message.js").default<any>, attrs: import("../../shared/types").MessageAttributes): object;
-        updateMessage(message: import("../../shared/message.js").default<any>, attrs: import("../../shared/types").MessageAttributes): void;
-        handleCorrection(attrs: import("../../shared/types").MessageAttributes | import("./types").MUCMessageAttributes): Promise<import("../../shared/message.js").default<any> | void>;
+        getUpdatedMessageAttributes(message: import("../../shared/message.js").default, attrs: import("../../shared/types").MessageAttributes): object;
+        updateMessage(message: import("../../shared/message.js").default, attrs: import("../../shared/types").MessageAttributes): void;
+        handleCorrection(attrs: import("../../shared/types").MessageAttributes | import("./types").MUCMessageAttributes): Promise<import("../../shared/message.js").default | void>;
         queueMessage(attrs: import("../../shared/types").MessageAttributes): any;
         msg_chain: any;
         getOutgoingMessageAttributes(_attrs?: import("../../shared/types").MessageAttributes): Promise<import("../../shared/types").MessageAttributes>;
-        sendMessage(attrs?: any): Promise<import("../../shared/message.js").default<any>>;
-        retractOwnMessage(message: import("../../shared/message.js").default<any>): void;
+        sendMessage(attrs?: any): Promise<import("../../shared/message.js").default>;
+        retractOwnMessage(message: import("../../shared/message.js").default): void;
         sendFiles(files: File[]): Promise<void>;
         setEditable(attrs: any, send_time: string): void;
         setChatState(state: string, options?: object): any;
         chat_state_timeout: NodeJS.Timeout;
-        onMessageAdded(message: import("../../shared/message.js").default<any>): void;
-        onMessageUploadChanged(message: import("../../shared/message.js").default<any>): Promise<void>;
-        onMessageCorrecting(message: import("../../shared/message.js").default<any>): void;
+        onMessageAdded(message: import("../../shared/message.js").default): void;
+        onMessageUploadChanged(message: import("../../shared/message.js").default): Promise<void>;
+        onMessageCorrecting(message: import("../../shared/message.js").default): void;
         onScrolledChanged(): void;
         pruneHistoryWhenScrolledDown(): void;
         shouldShowErrorMessage(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;
         clearMessages(): Promise<void>;
         editEarlierMessage(): void;
         editLaterMessage(): any;
-        isChatMessage(_message: import("../../shared/message.js").default<any>): boolean;
-        getOldestMessage(): import("../../shared/message.js").default<any>;
-        getMostRecentMessage(): import("../../shared/message.js").default<any>;
+        isChatMessage(_message: import("../../shared/message.js").default): boolean;
+        getOldestMessage(): import("../../shared/message.js").default;
+        getMostRecentMessage(): import("../../shared/message.js").default;
         getMessageReferencedByError(attrs: object): any;
-        findDanglingRetraction(attrs: object): import("../../shared/message.js").default<any> | null;
-        getDuplicateMessage(attrs: object): import("../../shared/message.js").default<any>;
+        findDanglingRetraction(attrs: object): import("../../shared/message.js").default | null;
+        getDuplicateMessage(attrs: object): import("../../shared/message.js").default;
         getOriginIdQueryAttrs(attrs: object): {
             origin_id: any;
             from: any;
@@ -123,15 +123,15 @@ declare const MUC_base: {
             from: any;
             msgid: any;
         };
-        sendMarkerForMessage(msg: import("../../shared/message.js").default<any>, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
-        handleUnreadMessage(message: import("../../shared/message.js").default<any>): void;
-        getErrorAttributesForMessage(message: import("../../shared/message.js").default<any>, attrs: import("../../shared/types").MessageAttributes): Promise<any>;
+        sendMarkerForMessage(msg: import("../../shared/message.js").default, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
+        handleUnreadMessage(message: import("../../shared/message.js").default): void;
+        getErrorAttributesForMessage(message: import("../../shared/message.js").default, attrs: import("../../shared/types").MessageAttributes): Promise<any>;
         handleErrorMessageStanza(stanza: Element): Promise<void>;
-        incrementUnreadMsgsCounter(message: import("../../shared/message.js").default<any>): void;
+        incrementUnreadMsgsCounter(message: import("../../shared/message.js").default): void;
         clearUnreadMsgCounter(): void;
         handleRetraction(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;
         handleReceipt(attrs: import("../../shared/types").MessageAttributes): boolean;
-        createMessageStanza(message: import("../../shared/message.js").default<any>): Promise<any>;
+        createMessageStanza(message: import("../../shared/message.js").default): Promise<any>;
         pruneHistory(): void;
         debouncedPruneHistory: import("lodash").DebouncedFunc<() => void>;
         isScrolledUp(): any;
@@ -348,7 +348,7 @@ declare class MUC extends MUC_base {
      * @param {boolean} [force=false] - Whether a marker should be sent for the
      *  message, even if it didn't include a `markable` element.
      */
-    sendMarkerForMessage(msg: import("../../shared/message.js").default<any>, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
+    sendMarkerForMessage(msg: import("../../shared/message.js").default, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
     /**
      * Finds the last eligible message and then sends a XEP-0333 chat marker for it.
      * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
@@ -444,7 +444,7 @@ declare class MUC extends MUC_base {
      * Retract one of your messages in this groupchat
      * @param {BaseMessage} message - The message which we're retracting.
      */
-    retractOwnMessage(message: import("../../shared/message.js").default<any>): Promise<void>;
+    retractOwnMessage(message: import("../../shared/message.js").default): Promise<void>;
     /**
      * Retract someone else's message in this groupchat.
      * @param {MUCMessage} message - The message which we're retracting.
@@ -489,7 +489,7 @@ declare class MUC extends MUC_base {
     /**
      * @param {import('../../shared/message').default} message
      */
-    isChatMessage(message: import("../../shared/message.js").default<any>): boolean;
+    isChatMessage(message: import("../../shared/message").default): boolean;
     /**
      * Return an array of unique nicknames based on all occupants and messages in this MUC.
      * @returns {String[]}
@@ -843,7 +843,7 @@ declare class MUC extends MUC_base {
      *  message, as returned by {@link parseMUCMessage}
      * @returns {MUCMessage|BaseMessage}
      */
-    getDuplicateMessage(attrs: object): import("./message.js").default | import("../../shared/message.js").default<any>;
+    getDuplicateMessage(attrs: object): import("./message.js").default | import("../../shared/message.js").default;
     /**
      * Handler for all MUC messages sent to this groupchat. This method
      * shouldn't be called directly, instead {@link MUC#queueMessage}
@@ -932,11 +932,11 @@ declare class MUC extends MUC_base {
      * was mentioned in a message.
      * @param {BaseMessage} message - The text message
      */
-    isUserMentioned(message: import("../../shared/message.js").default<any>): any;
+    isUserMentioned(message: import("../../shared/message.js").default): any;
     /**
      * @param {BaseMessage} message - The text message
      */
-    incrementUnreadMsgsCounter(message: import("../../shared/message.js").default<any>): void;
+    incrementUnreadMsgsCounter(message: import("../../shared/message.js").default): void;
     clearUnreadMsgCounter(): Promise<void>;
 }
 import { Model } from '@converse/skeletor';

+ 18 - 18
src/headless/types/plugins/muc/occupant.d.ts

@@ -87,33 +87,33 @@ declare const MUCOccupant_base: {
         fetchMessages(): any;
         afterMessagesFetched(): void;
         onMessage(_attrs_or_error: import("../../shared/types").MessageAttributes | Error): Promise<void>;
-        getUpdatedMessageAttributes(message: import("../../shared/message").default<any>, attrs: import("../../shared/types").MessageAttributes): object;
-        updateMessage(message: import("../../shared/message").default<any>, attrs: import("../../shared/types").MessageAttributes): void;
-        handleCorrection(attrs: import("../../shared/types").MessageAttributes | import("./types.js").MUCMessageAttributes): Promise<import("../../shared/message").default<any> | void>;
+        getUpdatedMessageAttributes(message: import("../../shared/message").default, attrs: import("../../shared/types").MessageAttributes): object;
+        updateMessage(message: import("../../shared/message").default, attrs: import("../../shared/types").MessageAttributes): void;
+        handleCorrection(attrs: import("../../shared/types").MessageAttributes | import("./types.js").MUCMessageAttributes): Promise<import("../../shared/message").default | void>;
         queueMessage(attrs: import("../../shared/types").MessageAttributes): any;
         msg_chain: any;
         getOutgoingMessageAttributes(_attrs?: import("../../shared/types").MessageAttributes): Promise<import("../../shared/types").MessageAttributes>;
-        sendMessage(attrs?: any): Promise<import("../../shared/message").default<any>>;
-        retractOwnMessage(message: import("../../shared/message").default<any>): void;
+        sendMessage(attrs?: any): Promise<import("../../shared/message").default>;
+        retractOwnMessage(message: import("../../shared/message").default): void;
         sendFiles(files: File[]): Promise<void>;
         setEditable(attrs: any, send_time: string): void;
         setChatState(state: string, options?: object): any;
         chat_state_timeout: NodeJS.Timeout;
-        onMessageAdded(message: import("../../shared/message").default<any>): void;
-        onMessageUploadChanged(message: import("../../shared/message").default<any>): Promise<void>;
-        onMessageCorrecting(message: import("../../shared/message").default<any>): void;
+        onMessageAdded(message: import("../../shared/message").default): void;
+        onMessageUploadChanged(message: import("../../shared/message").default): Promise<void>;
+        onMessageCorrecting(message: import("../../shared/message").default): void;
         onScrolledChanged(): void;
         pruneHistoryWhenScrolledDown(): void;
         shouldShowErrorMessage(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;
         clearMessages(): Promise<void>;
         editEarlierMessage(): void;
         editLaterMessage(): any;
-        isChatMessage(_message: import("../../shared/message").default<any>): boolean;
-        getOldestMessage(): import("../../shared/message").default<any>;
-        getMostRecentMessage(): import("../../shared/message").default<any>;
+        isChatMessage(_message: import("../../shared/message").default): boolean;
+        getOldestMessage(): import("../../shared/message").default;
+        getMostRecentMessage(): import("../../shared/message").default;
         getMessageReferencedByError(attrs: object): any;
-        findDanglingRetraction(attrs: object): import("../../shared/message").default<any> | null;
-        getDuplicateMessage(attrs: object): import("../../shared/message").default<any>;
+        findDanglingRetraction(attrs: object): import("../../shared/message").default | null;
+        getDuplicateMessage(attrs: object): import("../../shared/message").default;
         getOriginIdQueryAttrs(attrs: object): {
             origin_id: any;
             from: any;
@@ -123,15 +123,15 @@ declare const MUCOccupant_base: {
             from: any;
             msgid: any;
         };
-        sendMarkerForMessage(msg: import("../../shared/message").default<any>, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
-        handleUnreadMessage(message: import("../../shared/message").default<any>): void;
-        getErrorAttributesForMessage(message: import("../../shared/message").default<any>, attrs: import("../../shared/types").MessageAttributes): Promise<any>;
+        sendMarkerForMessage(msg: import("../../shared/message").default, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
+        handleUnreadMessage(message: import("../../shared/message").default): void;
+        getErrorAttributesForMessage(message: import("../../shared/message").default, attrs: import("../../shared/types").MessageAttributes): Promise<any>;
         handleErrorMessageStanza(stanza: Element): Promise<void>;
-        incrementUnreadMsgsCounter(message: import("../../shared/message").default<any>): void;
+        incrementUnreadMsgsCounter(message: import("../../shared/message").default): void;
         clearUnreadMsgCounter(): void;
         handleRetraction(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;
         handleReceipt(attrs: import("../../shared/types").MessageAttributes): boolean;
-        createMessageStanza(message: import("../../shared/message").default<any>): Promise<any>;
+        createMessageStanza(message: import("../../shared/message").default): Promise<any>;
         pruneHistory(): void;
         debouncedPruneHistory: import("lodash").DebouncedFunc<() => void>;
         isScrolledUp(): any;

+ 0 - 0
src/types/plugins/omemo/api.d.ts → src/headless/types/plugins/omemo/api.d.ts


+ 1 - 1
src/types/plugins/omemo/consts.d.ts → src/headless/types/plugins/omemo/constants.d.ts

@@ -6,4 +6,4 @@ export namespace KEY_ALGO {
     let name: string;
     let length: number;
 }
-//# sourceMappingURL=consts.d.ts.map
+//# sourceMappingURL=constants.d.ts.map

+ 1 - 1
src/types/plugins/omemo/device.d.ts → src/headless/types/plugins/omemo/device.d.ts

@@ -23,5 +23,5 @@ declare class Device extends Model {
      */
     getBundle(): Promise<import("./types").Bundle>;
 }
-import { Model } from "@converse/headless";
+import { Model } from '@converse/skeletor';
 //# sourceMappingURL=device.d.ts.map

+ 1 - 1
src/types/plugins/omemo/devicelist.d.ts → src/headless/types/plugins/omemo/devicelist.d.ts

@@ -39,5 +39,5 @@ declare class DeviceList extends Model {
      */
     removeOwnDevices(device_ids: string[]): Promise<any>;
 }
-import { Model } from "@converse/headless";
+import { Model } from '@converse/skeletor';
 //# sourceMappingURL=devicelist.d.ts.map

+ 1 - 1
src/types/plugins/omemo/devicelists.d.ts → src/headless/types/plugins/omemo/devicelists.d.ts

@@ -3,6 +3,6 @@ declare class DeviceLists extends Collection {
     constructor();
     model: typeof DeviceList;
 }
-import { Collection } from "@converse/headless";
+import { Collection } from "@converse/skeletor";
 import DeviceList from "./devicelist.js";
 //# sourceMappingURL=devicelists.d.ts.map

+ 1 - 1
src/types/plugins/omemo/devices.d.ts → src/headless/types/plugins/omemo/devices.d.ts

@@ -3,6 +3,6 @@ declare class Devices extends Collection {
     constructor();
     model: typeof Device;
 }
-import { Collection } from "@converse/headless";
+import { Collection } from "@converse/skeletor";
 import Device from "./device.js";
 //# sourceMappingURL=devices.d.ts.map

+ 6 - 0
src/headless/types/plugins/omemo/index.d.ts

@@ -0,0 +1,6 @@
+import Device from './device.js';
+import Devices from './devices.js';
+import DeviceList from './devicelist.js';
+import DeviceLists from './devicelists.js';
+export { Device, Devices, DeviceList, DeviceLists };
+//# sourceMappingURL=index.d.ts.map

+ 20 - 0
src/headless/types/plugins/omemo/parsers.d.ts

@@ -0,0 +1,20 @@
+/**
+ * Hook handler for {@link parseMessage} and {@link parseMUCMessage}, which
+ * parses the passed in `message` stanza for OMEMO attributes and then sets
+ * them on the attrs object.
+ * @param {Element} stanza - The message stanza
+ * @param {MUCMessageAttributes|MessageAttributes} attrs
+ * @returns {Promise<MUCMessageAttributes| MessageAttributes|
+        import('./types').MUCMessageAttrsWithEncryption|import('./types').MessageAttrsWithEncryption>}
+ */
+export function parseEncryptedMessage(stanza: Element, attrs: MUCMessageAttributes | MessageAttributes): Promise<MUCMessageAttributes | MessageAttributes | import("./types").MUCMessageAttrsWithEncryption | import("./types").MessageAttrsWithEncryption>;
+/**
+ * Given an XML element representing a user's OMEMO bundle, parse it
+ * and return a map.
+ * @param {Element} bundle_el
+ * @returns {import('./types').Bundle}
+ */
+export function parseBundle(bundle_el: Element): import("./types").Bundle;
+export type MessageAttributes = import("../..//shared/types").MessageAttributes;
+export type MUCMessageAttributes = import("../../plugins/muc/types").MUCMessageAttributes;
+//# sourceMappingURL=parsers.d.ts.map

+ 2 - 0
src/headless/types/plugins/omemo/plugin.d.ts

@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=plugin.d.ts.map

+ 107 - 0
src/headless/types/plugins/omemo/store.d.ts

@@ -0,0 +1,107 @@
+export function generateDeviceID(): Promise<any>;
+export default OMEMOStore;
+declare class OMEMOStore extends Model {
+    /**
+     * @typedef {Window & globalThis & {libsignal: any} } WindowWithLibsignal
+     */
+    get Direction(): {
+        SENDING: number;
+        RECEIVING: number;
+    };
+    /**
+     * @returns {Promise<import('./types').KeyPair>}
+     */
+    getIdentityKeyPair(): Promise<import("./types").KeyPair>;
+    getLocalRegistrationId(): Promise<number>;
+    /**
+     * @param {string} identifier
+     * @param {ArrayBuffer} identity_key
+     * @param {unknown} _direction
+     */
+    isTrustedIdentity(identifier: string, identity_key: ArrayBuffer, _direction: unknown): Promise<boolean>;
+    /**
+     * @param {string} identifier
+     */
+    loadIdentityKey(identifier: string): Promise<any>;
+    /**
+     * @param {string} identifier
+     * @param {string} identity_key
+     */
+    saveIdentity(identifier: string, identity_key: string): Promise<boolean>;
+    getPreKeys(): any;
+    /**
+     * @param {string} key_id
+     */
+    loadPreKey(key_id: string): Promise<void> | Promise<{
+        privKey: any;
+        pubKey: any;
+    }>;
+    /**
+     * @param {string} key_id
+     * @param {import('./types').KeyPair} key_pair
+     */
+    storePreKey(key_id: string, key_pair: import("./types").KeyPair): Promise<void>;
+    /**
+     * @param {string} key_id
+     */
+    removePreKey(key_id: string): Promise<void>;
+    /**
+     * @param {string} _key_id
+     * @returns {Promise<import('./types').KeyPair|void>}
+     */
+    loadSignedPreKey(_key_id: string): Promise<import("./types").KeyPair | void>;
+    /**
+     * @param {import('./types').SignedPreKey} spk
+     */
+    storeSignedPreKey(spk: import("./types").SignedPreKey): Promise<void>;
+    /**
+     * @param {string} key_id
+     */
+    removeSignedPreKey(key_id: string): Promise<void>;
+    /**
+     * @param {string} identifier
+     */
+    loadSession(identifier: string): Promise<any>;
+    /**
+     * @param {string} identifier
+     * @param {object} record
+     */
+    storeSession(identifier: string, record: object): Promise<any>;
+    /**
+     * @param {string} identifier
+     */
+    removeSession(identifier: string): Promise<false | Awaited<this>>;
+    /**
+     * @param {string} identifier
+     */
+    removeAllSessions(identifier: string): Promise<void>;
+    publishBundle(): any;
+    generateMissingPreKeys(): Promise<void>;
+    /**
+     * Generates, stores and then returns pre-keys.
+     *
+     * Pre-keys are one half of a X3DH key exchange and are published as part
+     * of the device bundle.
+     *
+     * For a new contact or device to establish an encrypted session, it needs
+     * to use a pre-key, which it chooses randomly from the list of available
+     * ones.
+     */
+    generatePreKeys(): Promise<{
+        id: any;
+        key: any;
+    }[]>;
+    /**
+     * Generate the cryptographic data used by the X3DH key agreement protocol
+     * in order to build a session with other devices.
+     *
+     * By generating a bundle, and publishing it via PubSub, we allow other
+     * clients to download it and start asynchronous encrypted sessions with us,
+     * even if we're offline at that time.
+     */
+    generateBundle(): Promise<void>;
+    fetchSession(): Promise<any>;
+    _setup_promise: Promise<any>;
+}
+import { Model } from '@converse/skeletor';
+//# sourceMappingURL=store.d.ts.map

+ 43 - 0
src/headless/types/plugins/omemo/types.d.ts

@@ -0,0 +1,43 @@
+import { MUCMessageAttributes } from '../../plugins/muc/types';
+import { MessageAttributes } from '../../shared/types';
+export type PreKey = {
+    id: number;
+    key: string;
+};
+export type Bundle = {
+    identity_key: string;
+    signed_prekey: {
+        id: number;
+        public_key: string;
+        signature: string;
+    };
+    prekeys: PreKey[];
+};
+export type KeyPair = {
+    pubKey: string;
+    privKey: string;
+};
+export type SignedPreKey = {
+    keyId: string;
+    keyPair: KeyPair;
+    signature: ArrayBuffer;
+};
+export type EncryptedMessage = {
+    key: ArrayBuffer;
+    tag: ArrayBuffer;
+    key_and_tag?: ArrayBuffer;
+    payload: string;
+    iv?: string;
+};
+export type EncryptedMessageAttributes = {
+    iv: string;
+    key: string;
+    payload: string | null;
+    prekey: boolean;
+};
+export type MUCMessageAttrsWithEncryption = MUCMessageAttributes & EncryptedMessageAttributes;
+export type MessageAttrsWithEncryption = MessageAttributes & EncryptedMessageAttributes;
+export type WindowWithLibsignal = Window & typeof globalThis & {
+    libsignal: any;
+};
+//# sourceMappingURL=types.d.ts.map

+ 74 - 0
src/headless/types/plugins/omemo/utils.d.ts

@@ -0,0 +1,74 @@
+/**
+ * Register a pubsub handler for devices pushed from other connected clients
+ */
+export function registerPEPPushHandler(): void;
+/**
+ * @param {boolean} reconnecting
+ */
+export function initOMEMO(reconnecting: boolean): Promise<void>;
+/**
+ * @param {String} jid - The Jabber ID for which the device list will be returned.
+ * @param {boolean} [create=false] - Set to `true` if the device list
+ *      should be created if it cannot be found.
+ */
+export function getDeviceList(jid: string, create?: boolean): Promise<any>;
+/**
+ * @param {import('./device.js').default} device
+ */
+export function generateFingerprint(device: import("./device.js").default): Promise<void>;
+/**
+ * @param {Error|errors.IQError|errors.UserFacingError} e
+ * @param {import('../../shared/chatbox.js').default} chat
+ */
+export function handleMessageSendError(e: Error | errors.IQError | errors.UserFacingError, chat: import("../../shared/chatbox.js").default): void;
+/**
+ * @param {string} jid
+ * @returns {Promise<import('./devices.js').default>}
+ */
+export function getDevicesForContact(jid: string): Promise<import("./devices.js").default>;
+/**
+ * @param {string} jid
+ * @param {number} id
+ */
+export function getSessionCipher(jid: string, id: number): any;
+/**
+ * @param {import('./device.js').default} device
+ */
+export function getSession(device: import("./device.js").default): Promise<any>;
+/**
+ * @param {import('./types').EncryptedMessage} obj
+ * @returns {Promise<string>}
+ */
+export function decryptMessage(obj: import("./types").EncryptedMessage): Promise<string>;
+/**
+ * @param {import('../../shared/chatbox.js').default} chat
+ * @param {import('../../shared/types').MessageAndStanza} data
+ * @return {Promise<import('../../shared/types').MessageAndStanza>}
+ */
+export function createOMEMOMessageStanza(chat: import("../../shared/chatbox.js").default, data: import("../../shared/types").MessageAndStanza): Promise<import("../../shared/types").MessageAndStanza>;
+/**
+ * @param {import('../../shared/chatbox.js').default} chat
+ * @param {import('../../shared/types').MessageAttributes} attrs
+ * @return {import('../../shared/types').MessageAttributes}
+ */
+export function getOutgoingMessageAttributes(chat: import("../../shared/chatbox.js").default, attrs: import("../../shared/types").MessageAttributes): import("../../shared/types").MessageAttributes;
+/**
+ * @param {string} jid
+ */
+export function contactHasOMEMOSupport(jid: string): Promise<boolean>;
+/**
+ * @param {import('../../shared/chatbox.js').default} chatbox
+ */
+export function onChatInitialized(chatbox: import("../../shared/chatbox.js").default): void;
+/**
+ * @param {import('../../shared/message').default} message
+ * @param {import('../../shared/types').FileUploadMessageAttributes} attrs
+ */
+export function setEncryptedFileURL(message: import("../../shared/message").default, attrs: import("../../shared/types").FileUploadMessageAttributes): import("../../shared/types").FileUploadMessageAttributes;
+/**
+ * @param {File} file
+ * @returns {Promise<File>}
+ */
+export function encryptFile(file: File): Promise<File>;
+import { errors } from '../../shared/index.js';
+//# sourceMappingURL=utils.d.ts.map

+ 14 - 9
src/headless/types/shared/_converse.d.ts

@@ -1,10 +1,3 @@
-export default _converse;
-export type Storage = import("@converse/skeletor").Storage;
-export type Collection = import("@converse/skeletor").Collection;
-export type DiscoState = import("../plugins/disco/index").DiscoState;
-export type Profile = import("../plugins/status/profile").default;
-export type VCards = import("../plugins/vcard/vcard").default;
-declare const _converse: ConversePrivateGlobal;
 declare const ConversePrivateGlobal_base: (new (...args: any[]) => {
     on(name: string, callback: (event: any, model: import("@converse/skeletor/src/types/model.js").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any, context: any): any;
     _events: any;
@@ -13,7 +6,12 @@ declare const ConversePrivateGlobal_base: (new (...args: any[]) => {
     _listeningTo: {};
     _listenId: any;
     off(name: string, callback: (event: any, model: import("@converse/skeletor/src/types/model.js").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any, context?: any): any;
-    stopListening(obj?: any, name?: string, callback?: (event: any, model: import("@converse/skeletor/src/types/model.js").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any): any;
+    stopListening(obj?: any, name?: string, callback?: (event: any, model: import("@converse/skeletor/src/types/model.js").Model, collection: import("@converse/skeletor").Collection, options? /**
+     * Namespace for storing code that might be useful to 3rd party
+     * plugins. We want to make it possible for 3rd party plugins to have
+     * access to code (e.g. classes) from converse.js without having to add
+     * converse.js as a dependency.
+     */: Record<string, any>) => any): any;
     once(name: string, callback: (event: any, model: import("@converse/skeletor/src/types/model.js").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any, context: any): any;
     listenToOnce(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor/src/types/model.js").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any): any;
     trigger(name: string, ...args: any[]): any;
@@ -24,7 +22,7 @@ declare const ConversePrivateGlobal_base: (new (...args: any[]) => {
  * @global
  * @namespace _converse
  */
-declare class ConversePrivateGlobal extends ConversePrivateGlobal_base {
+export class ConversePrivateGlobal extends ConversePrivateGlobal_base {
     constructor();
     initialize(): void;
     VERSION_NAME: string;
@@ -106,5 +104,12 @@ declare class ConversePrivateGlobal extends ConversePrivateGlobal_base {
      */
     ___(str: string): string;
 }
+export default _converse;
+export type Storage = import("@converse/skeletor").Storage;
+export type Collection = import("@converse/skeletor").Collection;
+export type DiscoState = import("../plugins/disco/index").DiscoState;
+export type Profile = import("../plugins/status/profile").default;
+export type VCards = import("../plugins/vcard/vcard").default;
 import { Model } from '@converse/skeletor';
+declare const _converse: ConversePrivateGlobal;
 //# sourceMappingURL=_converse.d.ts.map

+ 1 - 1
src/headless/types/shared/actions.d.ts

@@ -32,5 +32,5 @@ export function sendChatState(jid: string, chat_state: import("./types").ChatSta
  * @param {import('../shared/message').default} message - The message which we're retracting.
  * @param {string} retraction_id - Unique ID for the retraction message
  */
-export function sendRetractionMessage(jid: string, message: import("../shared/message").default<any>, retraction_id: string): any;
+export function sendRetractionMessage(jid: string, message: import("../shared/message").default, retraction_id: string): any;
 //# sourceMappingURL=actions.d.ts.map

+ 1 - 50
src/headless/types/shared/api/index.d.ts

@@ -1,54 +1,5 @@
 export default api;
-export type _converse = {
-    initialize(): void;
-    VERSION_NAME: string;
-    strict_plugin_dependencies: boolean;
-    pluggable: any;
-    templates: {};
-    storage: {};
-    promises: {
-        initialized: Promise<any> & {
-            isResolved: boolean;
-            isPending: boolean;
-            isRejected: boolean;
-            resolve: (value: any) => void;
-            reject: (reason?: any) => void;
-        };
-    };
-    NUM_PREKEYS: number;
-    TIMEOUTS: {
-        PAUSED: number;
-        INACTIVE: number;
-    };
-    api: any;
-    labels: Record<string, string | Record<string, string>>;
-    exports: Record<string, any>;
-    constants: Record<string, any>;
-    env: import("./types.js").ConverseEnv;
-    state: any;
-    initSession(): void;
-    session: import("@converse/skeletor").Model;
-    __(...args: string[]): any;
-    ___(str: string): string;
-    on(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any, context: any): any;
-    _events: any;
-    _listeners: {};
-    listenTo(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any): any;
-    _listeningTo: {};
-    _listenId: any;
-    off(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any, context?: any): any;
-    stopListening(obj?: any, name?: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any): any;
-    once(name: string, callback: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any, context: any): any;
-    listenToOnce(obj: any, name: string, callback?: (event: any, model: import("@converse/skeletor").Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any): any;
-    trigger(name: string, ...args: any[]): any;
-    constructor: Function;
-    toString(): string;
-    toLocaleString(): string;
-    valueOf(): Object;
-    hasOwnProperty(v: PropertyKey): boolean;
-    isPrototypeOf(v: Object): boolean;
-    propertyIsEnumerable(v: PropertyKey): boolean;
-};
+export type _converse = import("../_converse.js").ConversePrivateGlobal;
 export type APIEndpoint = any;
 /**
  * :shared-api.APIEndpoint

+ 18 - 18
src/headless/types/shared/chatbox.d.ts

@@ -17,33 +17,33 @@ declare const ChatBoxBase_base: {
         fetchMessages(): any;
         afterMessagesFetched(): void;
         onMessage(_attrs_or_error: import("./types.js").MessageAttributes | Error): Promise<void>;
-        getUpdatedMessageAttributes(message: import("./message.js").default<any>, attrs: import("./types.js").MessageAttributes): object;
-        updateMessage(message: import("./message.js").default<any>, attrs: import("./types.js").MessageAttributes): void;
-        handleCorrection(attrs: import("./types.js").MessageAttributes | import("../plugins/muc/types.js").MUCMessageAttributes): Promise<import("./message.js").default<any> | void>;
+        getUpdatedMessageAttributes(message: import("./message.js").default, attrs: import("./types.js").MessageAttributes): object;
+        updateMessage(message: import("./message.js").default, attrs: import("./types.js").MessageAttributes): void;
+        handleCorrection(attrs: import("./types.js").MessageAttributes | import("../plugins/muc/types.js").MUCMessageAttributes): Promise<import("./message.js").default | void>;
         queueMessage(attrs: import("./types.js").MessageAttributes): any;
         msg_chain: any;
         getOutgoingMessageAttributes(_attrs?: import("./types.js").MessageAttributes): Promise<import("./types.js").MessageAttributes>;
-        sendMessage(attrs?: any): Promise<import("./message.js").default<any>>;
-        retractOwnMessage(message: import("./message.js").default<any>): void;
+        sendMessage(attrs?: any): Promise<import("./message.js").default>;
+        retractOwnMessage(message: import("./message.js").default): void;
         sendFiles(files: File[]): Promise<void>;
         setEditable(attrs: any, send_time: string): void;
         setChatState(state: string, options?: object): any;
         chat_state_timeout: NodeJS.Timeout;
-        onMessageAdded(message: import("./message.js").default<any>): void;
-        onMessageUploadChanged(message: import("./message.js").default<any>): Promise<void>;
-        onMessageCorrecting(message: import("./message.js").default<any>): void;
+        onMessageAdded(message: import("./message.js").default): void;
+        onMessageUploadChanged(message: import("./message.js").default): Promise<void>;
+        onMessageCorrecting(message: import("./message.js").default): void;
         onScrolledChanged(): void;
         pruneHistoryWhenScrolledDown(): void;
         shouldShowErrorMessage(attrs: import("./types.js").MessageAttributes): Promise<boolean>;
         clearMessages(): Promise<void>;
         editEarlierMessage(): void;
         editLaterMessage(): any;
-        isChatMessage(_message: import("./message.js").default<any>): boolean;
-        getOldestMessage(): import("./message.js").default<any>;
-        getMostRecentMessage(): import("./message.js").default<any>;
+        isChatMessage(_message: import("./message.js").default): boolean;
+        getOldestMessage(): import("./message.js").default;
+        getMostRecentMessage(): import("./message.js").default;
         getMessageReferencedByError(attrs: object): any;
-        findDanglingRetraction(attrs: object): import("./message.js").default<any> | null;
-        getDuplicateMessage(attrs: object): import("./message.js").default<any>;
+        findDanglingRetraction(attrs: object): import("./message.js").default | null;
+        getDuplicateMessage(attrs: object): import("./message.js").default;
         getOriginIdQueryAttrs(attrs: object): {
             origin_id: any;
             from: any;
@@ -53,15 +53,15 @@ declare const ChatBoxBase_base: {
             from: any;
             msgid: any;
         };
-        sendMarkerForMessage(msg: import("./message.js").default<any>, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
-        handleUnreadMessage(message: import("./message.js").default<any>): void;
-        getErrorAttributesForMessage(message: import("./message.js").default<any>, attrs: import("./types.js").MessageAttributes): Promise<any>;
+        sendMarkerForMessage(msg: import("./message.js").default, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
+        handleUnreadMessage(message: import("./message.js").default): void;
+        getErrorAttributesForMessage(message: import("./message.js").default, attrs: import("./types.js").MessageAttributes): Promise<any>;
         handleErrorMessageStanza(stanza: Element): Promise<void>;
-        incrementUnreadMsgsCounter(message: import("./message.js").default<any>): void;
+        incrementUnreadMsgsCounter(message: import("./message.js").default): void;
         clearUnreadMsgCounter(): void;
         handleRetraction(attrs: import("./types.js").MessageAttributes): Promise<boolean>;
         handleReceipt(attrs: import("./types.js").MessageAttributes): boolean;
-        createMessageStanza(message: import("./message.js").default<any>): Promise<any>;
+        createMessageStanza(message: import("./message.js").default): Promise<any>;
         pruneHistory(): void;
         debouncedPruneHistory: import("lodash").DebouncedFunc<() => void>;
         isScrolledUp(): any;

+ 15 - 0
src/headless/types/shared/errors.d.ts

@@ -1,5 +1,20 @@
 export class MethodNotImplementedError extends Error {
 }
+export class UserFacingError extends Error {
+    /**
+     * @param {string} message
+     */
+    constructor(message: string);
+    user_facing: boolean;
+}
+export class IQError extends Error {
+    /**
+     * @param {string} message
+     * @param {Element} iq
+     */
+    constructor(message: string, iq: Element);
+    iq: Element;
+}
 /**
  * Custom error for indicating timeouts
  * @namespace converse.env

+ 2 - 6
src/headless/types/shared/message.d.ts

@@ -213,11 +213,7 @@ declare const BaseMessage_base: {
         propertyIsEnumerable(v: PropertyKey): boolean;
     };
 } & typeof Model;
-/**
- * @template {import('./types').ModelExtender} T
- * @param {T} BaseModel
- */
-declare class BaseMessage<T extends import("./types").ModelExtender> extends BaseMessage_base {
+declare class BaseMessage extends BaseMessage_base {
     /**
      * @param {Model[]} [models]
      * @param {object} [options]
@@ -293,5 +289,5 @@ declare class BaseMessage<T extends import("./types").ModelExtender> extends Bas
     };
     uploadFile(): void;
 }
-import { Model } from "@converse/skeletor";
+import { Model } from '@converse/skeletor';
 //# sourceMappingURL=message.d.ts.map

+ 18 - 18
src/headless/types/shared/model-with-messages.d.ts

@@ -46,12 +46,12 @@ export default function ModelWithMessages<T extends import("./types").ModelExten
          * @param {MessageAttributes} attrs
          * @returns {object}
          */
-        getUpdatedMessageAttributes(message: import("./message").default<any>, attrs: import("./types").MessageAttributes): object;
+        getUpdatedMessageAttributes(message: import("./message").default, attrs: import("./types").MessageAttributes): object;
         /**
          * @param {BaseMessage} message
          * @param {MessageAttributes} attrs
          */
-        updateMessage(message: import("./message").default<any>, attrs: import("./types").MessageAttributes): void;
+        updateMessage(message: import("./message").default, attrs: import("./types").MessageAttributes): void;
         /**
          * Determines whether the given attributes of an incoming message
          * represent a XEP-0308 correction and, if so, handles it appropriately.
@@ -60,7 +60,7 @@ export default function ModelWithMessages<T extends import("./types").ModelExten
          * @returns {Promise<BaseMessage|void>} Returns the corrected
          *  message or `undefined` if not applicable.
          */
-        handleCorrection(attrs: import("./types").MessageAttributes | import("../plugins/muc/types.js").MUCMessageAttributes): Promise<import("./message").default<any> | void>;
+        handleCorrection(attrs: import("./types").MessageAttributes | import("../plugins/muc/types.js").MUCMessageAttributes): Promise<import("./message").default | void>;
         /**
          * Queue an incoming `chat` message stanza for processing.
          * @param {MessageAttributes} attrs - A promise which resolves to the message attributes
@@ -80,12 +80,12 @@ export default function ModelWithMessages<T extends import("./types").ModelExten
          *  const chat = api.chats.get('buddy1@example.org');
          *  chat.sendMessage({'body': 'hello world'});
          */
-        sendMessage(attrs?: any): Promise<import("./message").default<any>>;
+        sendMessage(attrs?: any): Promise<import("./message").default>;
         /**
          * Retract one of your messages in this chat
          * @param {BaseMessage} message - The message which we're retracting.
          */
-        retractOwnMessage(message: import("./message").default<any>): void;
+        retractOwnMessage(message: import("./message").default): void;
         /**
          * @param {File[]} files'
          */
@@ -114,15 +114,15 @@ export default function ModelWithMessages<T extends import("./types").ModelExten
         /**
          * @param {BaseMessage} message
          */
-        onMessageAdded(message: import("./message").default<any>): void;
+        onMessageAdded(message: import("./message").default): void;
         /**
          * @param {BaseMessage} message
          */
-        onMessageUploadChanged(message: import("./message").default<any>): Promise<void>;
+        onMessageUploadChanged(message: import("./message").default): Promise<void>;
         /**
          * @param {BaseMessage} message
          */
-        onMessageCorrecting(message: import("./message").default<any>): void;
+        onMessageCorrecting(message: import("./message").default): void;
         onScrolledChanged(): void;
         pruneHistoryWhenScrolledDown(): void;
         /**
@@ -139,11 +139,11 @@ export default function ModelWithMessages<T extends import("./types").ModelExten
          * @param {BaseMessage} _message
          * @returns {boolean}
          */
-        isChatMessage(_message: import("./message").default<any>): boolean;
+        isChatMessage(_message: import("./message").default): boolean;
         /** @returns {BaseMessage} */
-        getOldestMessage(): import("./message").default<any>;
+        getOldestMessage(): import("./message").default;
         /** @returns {BaseMessage} */
-        getMostRecentMessage(): import("./message").default<any>;
+        getMostRecentMessage(): import("./message").default;
         /**
          * Given an error `<message>` stanza's attributes, find the saved message model which is
          * referenced by that error.
@@ -159,7 +159,7 @@ export default function ModelWithMessages<T extends import("./types").ModelExten
          *  message, as returned by {@link parseMessage}
          * @returns {BaseMessage|null}
          */
-        findDanglingRetraction(attrs: object): import("./message").default<any> | null;
+        findDanglingRetraction(attrs: object): import("./message").default | null;
         /**
          * Returns an already cached message (if it exists) based on the
          * passed in attributes map.
@@ -167,7 +167,7 @@ export default function ModelWithMessages<T extends import("./types").ModelExten
          *  message, as returned by {@link parseMessage}
          * @returns {BaseMessage}
          */
-        getDuplicateMessage(attrs: object): import("./message").default<any>;
+        getDuplicateMessage(attrs: object): import("./message").default;
         /**
          * @param {object} attrs - Attributes representing a received
          */
@@ -193,18 +193,18 @@ export default function ModelWithMessages<T extends import("./types").ModelExten
          * @param {boolean} [force=false] - Whether a marker should be sent for the
          *  message, even if it didn't include a `markable` element.
          */
-        sendMarkerForMessage(msg: import("./message").default<any>, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
+        sendMarkerForMessage(msg: import("./message").default, type?: ("received" | "displayed" | "acknowledged"), force?: boolean): Promise<void>;
         /**
          * Given a newly received {@link BaseMessage} instance,
          * update the unread counter if necessary.
          * @param {BaseMessage} message
          */
-        handleUnreadMessage(message: import("./message").default<any>): void;
+        handleUnreadMessage(message: import("./message").default): void;
         /**
          * @param {BaseMessage} message
          * @param {MessageAttributes} attrs
          */
-        getErrorAttributesForMessage(message: import("./message").default<any>, attrs: import("./types").MessageAttributes): Promise<any>;
+        getErrorAttributesForMessage(message: import("./message").default, attrs: import("./types").MessageAttributes): Promise<any>;
         /**
          * @param {Element} stanza
          */
@@ -212,7 +212,7 @@ export default function ModelWithMessages<T extends import("./types").ModelExten
         /**
          * @param {BaseMessage} message
          */
-        incrementUnreadMsgsCounter(message: import("./message").default<any>): void;
+        incrementUnreadMsgsCounter(message: import("./message").default): void;
         clearUnreadMsgCounter(): void;
         /**
          * Handles message retraction based on the passed in attributes.
@@ -231,7 +231,7 @@ export default function ModelWithMessages<T extends import("./types").ModelExten
          * @method ChatBox#createMessageStanza
          * @param {BaseMessage} message - The message object
          */
-        createMessageStanza(message: import("./message").default<any>): Promise<any>;
+        createMessageStanza(message: import("./message").default): Promise<any>;
         /**
          * Prunes the message history to ensure it does not exceed the maximum
          * number of messages specified in the settings.

+ 20 - 13
src/headless/types/shared/types.d.ts

@@ -1,5 +1,11 @@
-import { Collection, Model } from "@converse/skeletor";
-import { getOpenPromise } from "@converse/openpromise";
+import { Builder } from 'strophe.js';
+import { Collection, Model } from '@converse/skeletor';
+import { getOpenPromise } from '@converse/openpromise';
+import BaseMessage from './message.js';
+export type MessageAndStanza = {
+    message: BaseMessage;
+    stanza: Builder;
+};
 export type ReplaceableOpenPromise = ReturnType<typeof getOpenPromise> & {
     replace?: boolean;
 };
@@ -21,6 +27,7 @@ type EncryptionPayloadAttrs = {
     key?: string;
     prekey?: boolean;
     device_id: string;
+    payload?: string;
 };
 export type RetractionAttrs = {
     editable: boolean;
@@ -52,11 +59,11 @@ export type XFormCaptchaURI = {
     type: string;
     data: string;
 };
-type XFormListTypes = "list-single" | "list-multi";
-type XFormJIDTypes = "jid-single" | "jid-multi";
-type XFormTextTypes = "text-multi" | "text-private" | "text-single";
-type XFormDateTypes = "date" | "datetime";
-type XFormFieldTypes = XFormListTypes | XFormJIDTypes | XFormTextTypes | XFormDateTypes | "fixed" | "boolean" | "url" | "hidden";
+type XFormListTypes = 'list-single' | 'list-multi';
+type XFormJIDTypes = 'jid-single' | 'jid-multi';
+type XFormTextTypes = 'text-multi' | 'text-private' | 'text-single';
+type XFormDateTypes = 'date' | 'datetime';
+type XFormFieldTypes = XFormListTypes | XFormJIDTypes | XFormTextTypes | XFormDateTypes | 'fixed' | 'boolean' | 'url' | 'hidden';
 export type XFormField = {
     var: string;
     label: string;
@@ -69,7 +76,7 @@ export type XFormField = {
     uri?: XFormCaptchaURI;
     readonly: boolean;
 };
-export type XFormResponseType = "result" | "form";
+export type XFormResponseType = 'result' | 'form';
 export type XForm = {
     type: XFormResponseType;
     title?: string;
@@ -86,8 +93,8 @@ export type XEP372Reference = {
     uri: string;
 };
 export type ErrorExtra = Record<string, string>;
-export type ErrorName = "bad-request" | "conflict" | "feature-not-implemented" | "forbidden" | "gone" | "internal-server-error" | "item-not-found" | "jid-malformed" | "not-acceptable" | "not-allowed" | "not-authorized" | "payment-required" | "recipient-unavailable" | "redirect" | "registration-required" | "remote-server-not-found" | "remote-server-timeout" | "resource-constraint" | "service-unavailable" | "subscription-required" | "undefined-condition" | "unexpected-request";
-export type ErrorType = "auth" | "cancel" | "continue" | "modify" | "wait";
+export type ErrorName = 'bad-request' | 'conflict' | 'feature-not-implemented' | 'forbidden' | 'gone' | 'internal-server-error' | 'item-not-found' | 'jid-malformed' | 'not-acceptable' | 'not-allowed' | 'not-authorized' | 'payment-required' | 'recipient-unavailable' | 'redirect' | 'registration-required' | 'remote-server-not-found' | 'remote-server-timeout' | 'resource-constraint' | 'service-unavailable' | 'subscription-required' | 'undefined-condition' | 'unexpected-request';
+export type ErrorType = 'auth' | 'cancel' | 'continue' | 'modify' | 'wait';
 export type Reference = {
     begin: number;
     end: number;
@@ -141,7 +148,7 @@ export type MessageAttributes = EncryptionAttrs & MessageErrorAttributes & {
     replace_id: string;
     retracted: string;
     retracted_id: string;
-    sender: "me" | "them";
+    sender: 'me' | 'them';
     spoiler_hint: string;
     stanza_id: string;
     subject: string;
@@ -156,7 +163,7 @@ export type FileUploadMessageAttributes = {
     oob_url: string;
     upload: 'success' | 'failure';
 };
-export type MessageMarkerType = "displayed" | "received" | "acknowledged";
-export type ChatStateType = "active" | "composing" | "paused" | "inactive" | "gone";
+export type MessageMarkerType = 'displayed' | 'received' | 'acknowledged';
+export type ChatStateType = 'active' | 'composing' | 'paused' | 'inactive' | 'gone';
 export {};
 //# sourceMappingURL=types.d.ts.map

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

@@ -8,7 +8,7 @@ export function isEmptyMessage(attrs: any): boolean;
  * inserted before the mentioned nicknames.
  * @param {import('../shared/message').default} message
  */
-export function prefixMentions(message: import("../shared/message").default<any>): any;
+export function prefixMentions(message: import("../shared/message").default): any;
 export function getRandomInt(max: any): number;
 /**
  * @param {string} [suffix]
@@ -112,7 +112,7 @@ export type CommonUtils = Record<string, Function>;
 /**
  * The utils object
  */
-export type PluginUtils = Record<"muc" | "mam", CommonUtils>;
+export type PluginUtils = Record<"muc" | "mam" | "omemo" | "roster", CommonUtils>;
 /**
  * Call the callback once all the events have been triggered
  * @param { Array } events: An array of objects, with keys `object` and

+ 2 - 1
src/headless/utils/index.js

@@ -23,7 +23,7 @@ import * as url from './url.js';
 
 /**
  * @typedef {Record<string, Function>} CommonUtils
- * @typedef {Record<'muc'|'mam', CommonUtils>} PluginUtils
+ * @typedef {Record<'muc'|'mam'|'omemo'|'roster', CommonUtils>} PluginUtils
  *
  * The utils object
  * @namespace u
@@ -33,6 +33,7 @@ const u = {
     muc: null,
     mam: null,
     roster: null,
+    omemo: null,
 };
 
 /**

+ 1 - 1
src/index.js

@@ -33,7 +33,7 @@ import "./plugins/minimize/index.js";       // Allows chat boxes to be minimized
 import "./plugins/muc-views/index.js";      // Views related to MUC
 import "./plugins/notifications/index.js";
 import "./plugins/profile/index.js";
-import "./plugins/omemo/index.js";
+import "./plugins/omemo-views/index.js";
 import "./plugins/push/index.js";           // XEP-0357 Push Notifications
 import "./plugins/register/index.js";       // XEP-0077 In-band registration
 import "./plugins/roomslist/index.js";      // Show currently open chat rooms

+ 4 - 1
src/plugins/omemo/fingerprints.js → src/plugins/omemo-views/fingerprints.js

@@ -28,8 +28,11 @@ export class Fingerprints extends CustomElement {
         return this.devicelist ? tplFingerprints(this) : "";
     }
 
+    /**
+        * @param {Event} ev
+        */
     toggleDeviceTrust(ev) {
-        const radio = ev.target;
+        const radio = /** @type {HTMLInputElement} */(ev.target);
         const device = this.devicelist.devices.get(radio.getAttribute("name"));
         device.save("trusted", parseInt(radio.value, 10));
     }

+ 46 - 0
src/plugins/omemo-views/index.js

@@ -0,0 +1,46 @@
+/**
+ * @copyright The Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ * @module plugins-omemo-index
+ */
+import { _converse, api, converse, log } from '@converse/headless';
+import './fingerprints.js';
+import './profile.js';
+import 'shared/modals/user-details.js';
+import {
+    generateFingerprints,
+    getOMEMOToolbarButton,
+    handleEncryptedFiles,
+    onChatComponentInitialized,
+} from './utils.js';
+
+import './styles/omemo.scss';
+
+converse.plugins.add('converse-omemo-views', {
+    /**
+     * @param {import('@converse/headless/types/shared/_converse.d.js').ConversePrivateGlobal} _converse
+     */
+    enabled(_converse) {
+        const plugins = _converse.pluggable.plugins;
+        return plugins['converse-bosh']?.enabled();
+    },
+
+    dependencies: ['converse-chatview', 'converse-omemo'],
+
+    initialize() {
+        api.listen.on('chatBoxViewInitialized', onChatComponentInitialized);
+        api.listen.on('chatRoomViewInitialized', onChatComponentInitialized);
+        api.listen.on('getToolbarButtons', getOMEMOToolbarButton);
+        api.listen.on('afterMessageBodyTransformed', handleEncryptedFiles);
+
+        api.listen.on('userDetailsModalInitialized', (contact) => {
+            const jid = contact.get('jid');
+            generateFingerprints(jid).catch((e) => log.error(e));
+        });
+
+        api.listen.on('profileModalInitialized', () => {
+            const bare_jid = _converse.session.get('bare_jid');
+            generateFingerprints(bare_jid).catch((e) => log.error(e));
+        });
+    },
+});

+ 0 - 0
src/plugins/omemo/profile.js → src/plugins/omemo-views/profile.js


+ 0 - 0
src/plugins/omemo/styles/omemo.scss → src/plugins/omemo-views/styles/omemo.scss


+ 1 - 1
src/plugins/omemo/templates/fingerprints.js → src/plugins/omemo-views/templates/fingerprints.js

@@ -7,7 +7,7 @@ const { u } = converse.env;
 
 /**
  * @param {import('../fingerprints').Fingerprints} el
- * @param {import('../device').default} device
+ * @param {import('@converse/headless').Device} device
  */
 const device_fingerprint = (el, device) => {
     const i18n_trusted = __("Trusted");

+ 2 - 2
src/plugins/omemo/templates/profile.js → src/plugins/omemo-views/templates/profile.js

@@ -1,7 +1,7 @@
-import spinner from "templates/spinner.js";
-import { formatFingerprint, formatFingerprintForQRCode } from "plugins/omemo/utils.js";
 import { html } from "lit";
 import { __ } from "i18n";
+import spinner from "templates/spinner.js";
+import { formatFingerprint, formatFingerprintForQRCode } from "../utils.js";
 import "shared/qrcode/component.js";
 
 /**

+ 4 - 4
src/plugins/omemo/tests/corrections.js → src/plugins/omemo-views/tests/corrections.js

@@ -130,7 +130,7 @@ describe("An OMEMO encrypted message", function() {
         expect(older_versions[keys[1]]).toBe('But soft, what light through yonder door breaks?');
 
         const first_rcvd_msg_id = u.getUniqueId();
-        let obj = await omemo.encryptMessage('This is an encrypted message from the contact')
+        let obj = await u.omemo.encryptMessage('This is an encrypted message from the contact')
         _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
             <message from="${contact_jid}"
                     to="${_converse.api.connection.get().jid}"
@@ -153,7 +153,7 @@ describe("An OMEMO encrypted message", function() {
             .toBe('This is an encrypted message from the contact');
 
         const msg_id = u.getUniqueId();
-        obj = await omemo.encryptMessage('This is an edited encrypted message from the contact')
+        obj = await u.omemo.encryptMessage('This is an edited encrypted message from the contact')
         _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
             <message from="${contact_jid}"
                      to="${_converse.api.connection.get().jid}"
@@ -376,7 +376,7 @@ describe("An OMEMO encrypted MUC message", function() {
         // Test reception of an encrypted message
         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)
+        const first_obj = await u.omemo.encryptMessage(first_received_message)
         _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
             <message from="${muc_jid}/newguy"
                      to="${_converse.api.connection.get().jid}"
@@ -401,7 +401,7 @@ describe("An OMEMO encrypted MUC message", function() {
         expect(_converse.state.devicelists.at(1).get('jid')).toBe(contact_jid);
 
         const second_received_message = 'This is an edited encrypted message from the contact';
-        const second_obj = await omemo.encryptMessage(second_received_message)
+        const second_obj = await u.omemo.encryptMessage(second_received_message)
         _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
             <message from="${muc_jid}/newguy"
                      to="${_converse.api.connection.get().jid}"

+ 1 - 1
src/plugins/omemo/tests/media-sharing.js → src/plugins/omemo-views/tests/media-sharing.js

@@ -54,7 +54,7 @@ describe("The OMEMO module", function() {
         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')
+        let obj = await u.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.

+ 1 - 1
src/plugins/omemo/tests/muc.js → src/plugins/omemo-views/tests/muc.js

@@ -135,7 +135,7 @@ describe("The OMEMO module", function() {
             </message>`);
 
         // Test reception of an encrypted message
-        const obj = await omemo.encryptMessage('This is an encrypted message from the contact')
+        const obj = await u.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`

+ 10 - 10
src/plugins/omemo/tests/omemo.js → src/plugins/omemo-views/tests/omemo.js

@@ -1,5 +1,5 @@
 /*global mock, converse */
-const { $iq, $msg, omemo, Strophe, sizzle, stx, u } = converse.env;
+const { $iq, $msg, Strophe, sizzle, stx, u } = converse.env;
 
 describe("The OMEMO module", function() {
 
@@ -10,8 +10,8 @@ describe("The OMEMO module", function() {
 
         const message = 'This message will be encrypted'
         await mock.waitForRoster(_converse, 'current', 1);
-        const payload = await omemo.encryptMessage(message);
-        const result = await omemo.decryptMessage(payload);
+        const payload = await u.omemo.encryptMessage(message);
+        const result = await u.omemo.decryptMessage(payload);
         expect(result).toBe(message);
     }));
 
@@ -84,7 +84,7 @@ describe("The OMEMO module", function() {
             </message>`);
 
         // Test reception of an encrypted message
-        let obj = await omemo.encryptMessage('This is an encrypted message from the contact')
+        let obj = await u.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.
         let stanza = stx`<message from="${contact_jid}"
@@ -108,7 +108,7 @@ describe("The OMEMO module", function() {
             .toBe('This is an encrypted message from the contact');
 
         // #1193 Check for a received message without <body> tag
-        obj = await omemo.encryptMessage('Another received encrypted message without fallback')
+        obj = await u.omemo.encryptMessage('Another received encrypted message without fallback')
         stanza = stx`<message from="${contact_jid}"
                         to="${_converse.api.connection.get().jid}"
                         type="chat"
@@ -145,7 +145,7 @@ describe("The OMEMO module", function() {
 
         // Test reception of an encrypted message
         const msg_txt = 'This is an encrypted message from the contact';
-        const obj = await omemo.encryptMessage(msg_txt)
+        const obj = await u.omemo.encryptMessage(msg_txt)
         const id = _converse.api.connection.get().getUniqueId();
         let stanza = $msg({
                 'from': contact_jid,
@@ -235,7 +235,7 @@ describe("The OMEMO module", function() {
         view.model.set('omemo_active', true);
 
         // Test reception of an encrypted carbon message
-        const obj = await omemo.encryptMessage('This is an encrypted carbon message from another device of mine')
+        const obj = await u.omemo.encryptMessage('This is an encrypted carbon message from another device of mine')
         const carbon = stx`
             <message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="romeo@montague.lit" type="chat">
                 <sent xmlns="urn:xmpp:carbons:2">
@@ -331,7 +331,7 @@ describe("The OMEMO module", function() {
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
 
         await u.waitUntil(() => mock.initializedOMEMO(_converse));
-        const obj = await omemo.encryptMessage('This is an encrypted message from the contact');
+        const obj = await u.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.
@@ -715,7 +715,7 @@ describe("The OMEMO module", function() {
         await u.waitUntil(() => _converse.state.devicelists.length === 2);
         const list = _converse.state.devicelists.get(contact_jid);
         await list.initialized;
-        await u.waitUntil(() => list.devices.length);
+        await u.waitUntil(() => list.devices.at(0).get('bundle'));
         let device = list.devices.at(0);
         expect(device.get('bundle').identity_key).toBe('3333');
         expect(device.get('bundle').signed_prekey.public_key).toBe('1111');
@@ -1129,7 +1129,7 @@ describe("The OMEMO module", function() {
         expect(modal.querySelectorAll('.fingerprints .fingerprint').length).toBe(1);
         const el = modal.querySelector('.fingerprints .fingerprint');
         expect(el.textContent.trim()).toBe(
-            omemo.formatFingerprint(u.arrayBufferToHex(u.base64ToArrayBuffer('BQmHEOHjsYm3w5M8VqxAtqJmLCi7CaxxsdZz6G0YpuMI')))
+            u.omemo.formatFingerprint(u.arrayBufferToHex(u.base64ToArrayBuffer('BQmHEOHjsYm3w5M8VqxAtqJmLCi7CaxxsdZz6G0YpuMI')))
         );
         expect(modal.querySelectorAll('input[type="radio"]').length).toBe(2);
 

+ 312 - 0
src/plugins/omemo-views/utils.js

@@ -0,0 +1,312 @@
+/**
+ * @typedef {module:plugins-omemo-index.WindowWithLibsignal} WindowWithLibsignal
+ * @typedef {import('@converse/headless/shared/types').MessageAttributes} MessageAttributes
+ * @typedef {import('@converse/headless/plugins/muc/types').MUCMessageAttributes} MUCMessageAttributes
+ * @typedef {import('@converse/headless').ChatBox} ChatBox
+ * @typedef {import('@converse/headless/types/shared/message').default} BaseMessage
+ */
+import { html } from 'lit';
+import { __ } from 'i18n';
+import { until } from 'lit/directives/until.js';
+import { _converse, api, log, u, constants } from '@converse/headless';
+import tplAudio from 'shared/texture/templates/audio.js';
+import tplFile from 'templates/file.js';
+import tplImage from 'shared/texture/templates/image.js';
+import tplVideo from 'shared/texture/templates/video.js';
+import { MIMETYPES_MAP } from 'utils/file.js';
+import { getFileName } from 'utils/html.js';
+
+const { CHATROOMS_TYPE } = constants;
+const { hexToArrayBuffer, isAudioURL, isError, isImageURL, isVideoURL } = u;
+
+/**
+ * @param {string} fp
+ */
+export function formatFingerprint(fp) {
+    fp = fp.replace(/^05/, '');
+    for (let i = 1; i < 8; i++) {
+        const idx = i * 8 + i - 1;
+        fp = fp.slice(0, idx) + ' ' + fp.slice(idx);
+    }
+    return fp;
+}
+
+/**
+ * @param {string} fp
+ */
+export function formatFingerprintForQRCode(fp) {
+    const sid = _converse.state.omemo_store.get('device_id');
+    const jid = _converse.session.get('bare_jid');
+    fp = fp.replace(/^05/, '');
+    return `xmpp:${jid}?omemo-sid-${sid}=${fp}`;
+}
+
+/**
+ * @param {string} iv
+ * @param {string} key
+ * @param {ArrayBuffer} cipher
+ */
+async function decryptFile(iv, key, cipher) {
+    const key_obj = await crypto.subtle.importKey('raw', hexToArrayBuffer(key), 'AES-GCM', false, ['decrypt']);
+    const algo = /** @type {AesGcmParams} */ {
+        name: 'AES-GCM',
+        iv: hexToArrayBuffer(iv),
+    };
+    return crypto.subtle.decrypt(algo, key_obj, cipher);
+}
+
+/**
+ * @param {string} url
+ * @returns {Promise<ArrayBuffer|null>}
+ */
+async function downloadFile(url) {
+    let response;
+    try {
+        response = await fetch(url);
+    } catch (e) {
+        log.error(`${e.name}: Failed to download encrypted media: ${url}`);
+        log.error(e);
+        return null;
+    }
+
+    if (response.status >= 200 && response.status < 400) {
+        return response.arrayBuffer();
+    }
+}
+
+/**
+ * @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 ${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 = 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 ${url.toString()}`);
+        log.error(e);
+        return null;
+    }
+    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 ${url.toString()}`);
+        log.error(e);
+        return null;
+    }
+}
+
+/**
+ * @param {string} file_url
+ * @param {string|Error} obj_url
+ * @param {import('shared/texture/texture.js').Texture} richtext
+ * @returns {import("lit").TemplateResult}
+ */
+function getTemplateForObjectURL(file_url, obj_url, richtext) {
+    if (isError(obj_url)) {
+        return html`<p class="error">${/** @type {Error} */ (obj_url).message}</p>`;
+    }
+
+    if (isImageURL(file_url)) {
+        return tplImage({
+            src: obj_url,
+            onClick: richtext.onImgClick,
+            onLoad: richtext.onImgLoad,
+        });
+    } else if (isAudioURL(file_url)) {
+        return tplAudio(/** @type {string} */ (obj_url));
+    } else if (isVideoURL(file_url)) {
+        return tplVideo(/** @type {string} */ (obj_url));
+    } else {
+        return tplFile(obj_url, getFileName(file_url));
+    }
+}
+
+/**
+ * @param {string} text
+ * @param {number} offset
+ * @param {import('shared/texture/texture.js').Texture} richtext
+ */
+function addEncryptedFiles(text, offset, richtext) {
+    const objs = [];
+    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 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);
+    });
+}
+
+/**
+ * @param {import('shared/texture/texture.js').Texture} richtext
+ */
+export function handleEncryptedFiles(richtext) {
+    if (!_converse.state.config.get('trusted')) {
+        return;
+    }
+    richtext.addAnnotations(
+        /**
+         * @param {string} text
+         * @param {number} offset
+         */
+        (text, offset) => addEncryptedFiles(text, offset, richtext)
+    );
+}
+
+export function onChatComponentInitialized(el) {
+    el.listenTo(el.model.messages, 'add', (message) => {
+        if (message.get('is_encrypted') && !message.get('is_error')) {
+            el.model.save('omemo_supported', true);
+        }
+    });
+    el.listenTo(el.model, 'change:omemo_supported', () => {
+        if (!el.model.get('omemo_supported') && el.model.get('omemo_active')) {
+            el.model.set('omemo_active', false);
+        } else {
+            // Manually trigger an update, setting omemo_active to
+            // false above will automatically trigger one.
+            el.querySelector('converse-chat-toolbar')?.requestUpdate();
+        }
+    });
+    el.listenTo(el.model, 'change:omemo_active', () => {
+        el.querySelector('converse-chat-toolbar').requestUpdate();
+    });
+}
+
+/**
+ * @param {string} jid
+ */
+export async function generateFingerprints(jid) {
+    const devices = await u.omemo.getDevicesForContact(jid);
+    return Promise.all(devices.map((d) => u.omemo.generateFingerprint(d)));
+}
+
+/**
+ * @param {string} jid
+ * @param {string} device_id
+ * @returns {Promise<import('@converse/headless').Device[]>}
+ */
+export async function getDeviceForContact(jid, device_id) {
+    const devices = await u.omemo.getDevicesForContact(jid);
+    return devices.get(device_id);
+}
+
+/**
+ * @param {MouseEvent} ev
+ */
+function toggleOMEMO(ev) {
+    ev.stopPropagation();
+    ev.preventDefault();
+    const toolbar_el = u.ancestor(ev.target, 'converse-chat-toolbar');
+    if (!toolbar_el.model.get('omemo_supported')) {
+        let messages;
+        if (toolbar_el.model.get('type') === CHATROOMS_TYPE) {
+            messages = [
+                __(
+                    'Cannot use end-to-end encryption in this groupchat, ' +
+                        'either the groupchat has some anonymity or not all participants support OMEMO.'
+                ),
+            ];
+        } else {
+            messages = [
+                __(
+                    "Cannot use end-to-end encryption because %1$s uses a client that doesn't support OMEMO.",
+                    toolbar_el.model.contact.getDisplayName()
+                ),
+            ];
+        }
+        return api.alert('error', __('Error'), messages);
+    }
+    toolbar_el.model.save({ 'omemo_active': !toolbar_el.model.get('omemo_active') });
+}
+
+/**
+ * @param {import('shared/chat/toolbar').ChatToolbar} toolbar_el
+ * @param {Array<import('lit').TemplateResult>} buttons
+ */
+export function getOMEMOToolbarButton(toolbar_el, buttons) {
+    const model = toolbar_el.model;
+    const is_muc = model.get('type') === CHATROOMS_TYPE;
+    let title;
+    if (model.get('omemo_supported')) {
+        const i18n_plaintext = __('Messages are being sent in plaintext');
+        const i18n_encrypted = __('Messages are sent encrypted');
+        title = model.get('omemo_active') ? i18n_encrypted : i18n_plaintext;
+    } else if (is_muc) {
+        title = __(
+            'This groupchat needs to be members-only and non-anonymous in ' +
+                'order to support OMEMO encrypted messages'
+        );
+    } else {
+        title = __('OMEMO encryption is not supported');
+    }
+
+    let color;
+    if (model.get('omemo_supported')) {
+        if (model.get('omemo_active')) {
+            color = is_muc ? `var(--muc-color)` : `var(--chat-color)`;
+        } else {
+            color = `var(--error-color)`;
+        }
+    } else {
+        color = `var(--disabled-color)`;
+    }
+    buttons.push(html`
+        <button
+            type="button"
+            class="btn toggle-omemo"
+            title="${title}"
+            data-disabled=${!model.get('omemo_supported')}
+            @click=${toggleOMEMO}
+        >
+            <converse-icon
+                class="fa ${model.get('omemo_active') ? `fa-lock` : `fa-unlock`}"
+                path-prefix="${api.settings.get('assets_path')}"
+                size="1em"
+                color="${color}"
+            ></converse-icon>
+        </button>
+    `);
+    return buttons;
+}
+
+Object.assign(u, {
+    omemo: {
+        ...u.omemo,
+        formatFingerprint,
+    },
+});

+ 0 - 127
src/plugins/omemo/index.js

@@ -1,127 +0,0 @@
-/**
- * @copyright The Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- * @module plugins-omemo-index
- * @typedef {Window & globalThis & {libsignal: any} } WindowWithLibsignal
- */
-import { _converse, api, converse, log, u } from "@converse/headless";
-import "./fingerprints.js";
-import "./profile.js";
-import "shared/modals/user-details.js";
-import Device from "./device.js";
-import DeviceList from "./devicelist.js";
-import DeviceLists from "./devicelists.js";
-import Devices from "./devices.js";
-import OMEMOStore from "./store.js";
-import omemo_api from "./api.js";
-import {
-    createOMEMOMessageStanza,
-    encryptFile,
-    generateFingerprints,
-    getOMEMOToolbarButton,
-    getOutgoingMessageAttributes,
-    handleEncryptedFiles,
-    handleMessageSendError,
-    initOMEMO,
-    omemo,
-    onChatComponentInitialized,
-    onChatInitialized,
-    parseEncryptedMessage,
-    registerPEPPushHandler,
-    setEncryptedFileURL,
-} from "./utils.js";
-
-import "./styles/omemo.scss";
-
-const { Strophe } = converse.env;
-const { shouldClearCache } = u;
-
-converse.env.omemo = omemo;
-
-Strophe.addNamespace("OMEMO_DEVICELIST", Strophe.NS.OMEMO + ".devicelist");
-Strophe.addNamespace("OMEMO_VERIFICATION", Strophe.NS.OMEMO + ".verification");
-Strophe.addNamespace("OMEMO_WHITELISTED", Strophe.NS.OMEMO + ".whitelisted");
-Strophe.addNamespace("OMEMO_BUNDLES", Strophe.NS.OMEMO + ".bundles");
-
-converse.plugins.add("converse-omemo", {
-    enabled(_converse) {
-        return (
-            /** @type WindowWithLibsignal */ (window).libsignal &&
-            _converse.config.get("trusted") &&
-            !api.settings.get("clear_cache_on_logout") &&
-            !_converse.api.settings.get("blacklisted_plugins").includes("converse-omemo")
-        );
-    },
-
-    dependencies: ["converse-chatview", "converse-pubsub", "converse-profile"],
-
-    initialize() {
-        api.settings.extend({ omemo_default: false });
-        api.promises.add(["OMEMOInitialized"]);
-
-        Object.assign(_converse.api, omemo_api);
-
-        const exports = {
-            OMEMOStore,
-            Device,
-            Devices,
-            DeviceList,
-            DeviceLists,
-        };
-
-        Object.assign(_converse, exports); // DEPRECATED
-        Object.assign(_converse.exports, exports);
-
-        /******************** Event Handlers ********************/
-
-        api.listen.on('chatRoomInitialized', onChatInitialized);
-        api.listen.on('chatBoxInitialized', onChatInitialized);
-        api.listen.on("getOutgoingMessageAttributes", getOutgoingMessageAttributes);
-
-        api.listen.on("createMessageStanza", async (chat, data) => {
-            try {
-                data = await createOMEMOMessageStanza(chat, data);
-            } catch (e) {
-                handleMessageSendError(e, chat);
-            }
-            return data;
-        });
-
-        api.listen.on("afterFileUploaded", (msg, attrs) =>
-            msg.file.xep454_ivkey ? setEncryptedFileURL(msg, attrs) : attrs
-        );
-        api.listen.on("beforeFileUpload", (chat, file) => (chat.get("omemo_active") ? encryptFile(file) : file));
-
-        api.listen.on("parseMessage", parseEncryptedMessage);
-        api.listen.on("parseMUCMessage", parseEncryptedMessage);
-
-        api.listen.on("chatBoxViewInitialized", onChatComponentInitialized);
-        api.listen.on("chatRoomViewInitialized", onChatComponentInitialized);
-
-        api.listen.on("connected", registerPEPPushHandler);
-        api.listen.on("getToolbarButtons", getOMEMOToolbarButton);
-
-        api.listen.on("statusInitialized", initOMEMO);
-        api.listen.on("addClientFeatures", () => api.disco.own.features.add(`${Strophe.NS.OMEMO_DEVICELIST}+notify`));
-
-        api.listen.on("afterMessageBodyTransformed", handleEncryptedFiles);
-
-        api.listen.on("userDetailsModalInitialized", (contact) => {
-            const jid = contact.get("jid");
-            generateFingerprints(jid).catch((e) => log.error(e));
-        });
-
-        api.listen.on("profileModalInitialized", () => {
-            const bare_jid = _converse.session.get("bare_jid");
-            generateFingerprints(bare_jid).catch((e) => log.error(e));
-        });
-
-        api.listen.on("clearSession", () => {
-            delete _converse.state.omemo_store;
-            if (shouldClearCache(_converse) && _converse.state.devicelists) {
-                _converse.state.devicelists.clearStore();
-                delete _converse.state.devicelists;
-            }
-        });
-    },
-});

+ 0 - 1077
src/plugins/omemo/utils.js

@@ -1,1077 +0,0 @@
-/**
- * @typedef {module:plugins-omemo-index.WindowWithLibsignal} WindowWithLibsignal
- * @typedef {import('@converse/headless/shared/types').MessageAttributes} MessageAttributes
- * @typedef {import('@converse/headless/plugins/muc/types').MUCMessageAttributes} MUCMessageAttributes
- * @typedef {import('@converse/headless').ChatBox} ChatBox
- * @typedef {import('@converse/headless/types/shared/message').default} BaseMessage
- */
-import { html } from 'lit';
-import { __ } from 'i18n';
-import { until } from 'lit/directives/until.js';
-import { _converse, converse, api, log, u, constants, MUC } from '@converse/headless';
-import tplAudio from 'shared/texture/templates/audio.js';
-import tplFile from 'templates/file.js';
-import tplImage from 'shared/texture/templates/image.js';
-import tplVideo from 'shared/texture/templates/video.js';
-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';
-
-const { Strophe, sizzle, stx } = converse.env;
-const { CHATROOMS_TYPE, PRIVATE_CHAT_TYPE } = constants;
-const {
-    appendArrayBuffer,
-    arrayBufferToBase64,
-    arrayBufferToHex,
-    arrayBufferToString,
-    base64ToArrayBuffer,
-    hexToArrayBuffer,
-    initStorage,
-    isAudioURL,
-    isError,
-    isImageURL,
-    isVideoURL,
-    stringToArrayBuffer,
-} = u;
-
-/**
- * @param {string} fp
- */
-export function formatFingerprint(fp) {
-    fp = fp.replace(/^05/, '');
-    for (let i = 1; i < 8; i++) {
-        const idx = i * 8 + i - 1;
-        fp = fp.slice(0, idx) + ' ' + fp.slice(idx);
-    }
-    return fp;
-}
-
-/**
- * @param {string} fp
- */
-export function formatFingerprintForQRCode(fp) {
-    const sid = _converse.state.omemo_store.get('device_id');
-    const jid = _converse.session.get('bare_jid');
-    fp = fp.replace(/^05/, '');
-    return `xmpp:${jid}?omemo-sid-${sid}=${fp}`;
-}
-
-/**
- * @param {Error|IQError|UserFacingError} e
- * @param {ChatBox} chat
- */
-export function handleMessageSendError(e, chat) {
-    if (e instanceof IQError) {
-        chat.save('omemo_supported', false);
-
-        const err_msgs = [];
-        if (sizzle(`presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, e.iq).length) {
-            err_msgs.push(
-                __(
-                    "Sorry, we're unable to send an encrypted message because %1$s " +
-                        'requires you to be subscribed to their presence in order to see their OMEMO information',
-                    e.iq.getAttribute('from')
-                )
-            );
-        } else if (sizzle(`remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq).length) {
-            err_msgs.push(
-                __(
-                    "Sorry, we're unable to send an encrypted message because the remote server for %1$s could not be found",
-                    e.iq.getAttribute('from')
-                )
-            );
-        } else {
-            err_msgs.push(__('Unable to send an encrypted message due to an unexpected error.'));
-            err_msgs.push(e.iq.outerHTML);
-        }
-        api.alert('error', __('Error'), err_msgs);
-    } else if (e instanceof UserFacingError) {
-        api.alert('error', __('Error'), [e.message]);
-    }
-    throw e;
-}
-
-/**
- * @param {string} jid
- */
-export async function contactHasOMEMOSupport(jid) {
-    /* Checks whether the contact advertises any OMEMO-compatible devices. */
-    const devices = await getDevicesForContact(jid);
-    return devices.length > 0;
-}
-
-/**
- * @param {ChatBox|MUC} chat
- * @param {MessageAttributes} attrs
- * @return {MessageAttributes}
- */
-export function getOutgoingMessageAttributes(chat, attrs) {
-    if (chat.get('omemo_active') && attrs.body) {
-        return {
-            ...attrs,
-            is_encrypted: true,
-            plaintext: attrs.body,
-            body: __(
-                'This is an OMEMO encrypted message which your client doesn’t seem to support. ' +
-                    'Find more information on https://conversations.im/omemo'
-            ),
-        };
-    }
-    return attrs;
-}
-
-/**
- * @param {string} plaintext
- * @returns {Promise<import('./types').EncryptedMessage>}
- */
-async function encryptMessage(plaintext) {
-    // The client MUST use fresh, randomly generated key/IV pairs
-    // with AES-128 in Galois/Counter Mode (GCM).
-
-    // For GCM a 12 byte IV is strongly suggested as other IV lengths
-    // will require additional calculations. In principle any IV size
-    // can be used as long as the IV doesn't ever repeat. NIST however
-    // suggests that only an IV size of 12 bytes needs to be supported
-    // by implementations.
-    //
-    // https://crypto.stackexchange.com/questions/26783/ciphertext-and-tag-size-and-iv-transmission-with-aes-in-gcm-mode
-    const iv = crypto.getRandomValues(new window.Uint8Array(12));
-    const key = await crypto.subtle.generateKey(KEY_ALGO, true, ['encrypt', 'decrypt']);
-    const algo = /** @type {AesGcmParams} */ {
-        iv,
-        name: 'AES-GCM',
-        tagLength: TAG_LENGTH,
-    };
-    const encrypted = await crypto.subtle.encrypt(algo, key, stringToArrayBuffer(plaintext));
-    const length = encrypted.byteLength - ((128 + 7) >> 3);
-    const ciphertext = encrypted.slice(0, length);
-    const tag = encrypted.slice(length);
-    const exported_key = await crypto.subtle.exportKey('raw', key);
-    return {
-        tag,
-        key: exported_key,
-        key_and_tag: appendArrayBuffer(exported_key, tag),
-        payload: arrayBufferToBase64(ciphertext),
-        iv: arrayBufferToBase64(iv),
-    };
-}
-
-/**
- * @param {import('./types').EncryptedMessage} obj
- * @returns {Promise<string>}
- */
-async function decryptMessage(obj) {
-    const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt', 'decrypt']);
-    const cipher = appendArrayBuffer(base64ToArrayBuffer(obj.payload), obj.tag);
-    const algo = /** @type {AesGcmParams} */ {
-        name: 'AES-GCM',
-        iv: base64ToArrayBuffer(obj.iv),
-        tagLength: TAG_LENGTH,
-    };
-    return arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
-}
-
-/**
- * @param {File} file
- * @returns {Promise<File>}
- */
-export async function encryptFile(file) {
-    const iv = crypto.getRandomValues(new Uint8Array(12));
-    const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
-    const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, await file.arrayBuffer());
-    const exported_key = await window.crypto.subtle.exportKey('raw', key);
-    const encrypted_file = new File([encrypted], file.name, { type: file.type, lastModified: file.lastModified });
-
-    Object.assign(encrypted_file, { xep454_ivkey: arrayBufferToHex(iv) + arrayBufferToHex(exported_key) });
-    return encrypted_file;
-}
-
-/**
- * @param {import('@converse/headless/types/shared/message').default} message
- * @param {import('@converse/headless/shared/types').FileUploadMessageAttributes} attrs
- */
-export function setEncryptedFileURL(message, attrs) {
-    const url = attrs.oob_url.replace(/^https?:/, 'aesgcm:') + '#' + message.file.xep454_ivkey;
-    return Object.assign(attrs, {
-        oob_url: null, // Since only the body gets encrypted, we don't set the oob_url
-        message: url,
-        body: url,
-    });
-}
-
-/**
- * @param {string} iv
- * @param {string} key
- * @param {ArrayBuffer} cipher
- */
-async function decryptFile(iv, key, cipher) {
-    const key_obj = await crypto.subtle.importKey('raw', hexToArrayBuffer(key), 'AES-GCM', false, ['decrypt']);
-    const algo = /** @type {AesGcmParams} */ {
-        name: 'AES-GCM',
-        iv: hexToArrayBuffer(iv),
-    };
-    return crypto.subtle.decrypt(algo, key_obj, cipher);
-}
-
-/**
- * @param {string} url
- * @returns {Promise<ArrayBuffer|null>}
- */
-async function downloadFile(url) {
-    let response;
-    try {
-        response = await fetch(url);
-    } catch (e) {
-        log.error(`${e.name}: Failed to download encrypted media: ${url}`);
-        log.error(e);
-        return null;
-    }
-
-    if (response.status >= 200 && response.status < 400) {
-        return response.arrayBuffer();
-    }
-}
-
-/**
- * @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 ${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 = 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 ${url.toString()}`);
-        log.error(e);
-        return null;
-    }
-    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 ${url.toString()}`);
-        log.error(e);
-        return null;
-    }
-}
-
-/**
- * @param {string} file_url
- * @param {string|Error} obj_url
- * @param {import('shared/texture/texture.js').Texture} richtext
- * @returns {import("lit").TemplateResult}
- */
-function getTemplateForObjectURL(file_url, obj_url, richtext) {
-    if (isError(obj_url)) {
-        return html`<p class="error">${/** @type {Error} */ (obj_url).message}</p>`;
-    }
-
-    if (isImageURL(file_url)) {
-        return tplImage({
-            src: obj_url,
-            onClick: richtext.onImgClick,
-            onLoad: richtext.onImgLoad,
-        });
-    } else if (isAudioURL(file_url)) {
-        return tplAudio(/** @type {string} */ (obj_url));
-    } else if (isVideoURL(file_url)) {
-        return tplVideo(/** @type {string} */ (obj_url));
-    } else {
-        return tplFile(obj_url, getFileName(file_url));
-    }
-}
-
-/**
- * @param {string} text
- * @param {number} offset
- * @param {import('shared/texture/texture.js').Texture} richtext
- */
-function addEncryptedFiles(text, offset, richtext) {
-    const objs = [];
-    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 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);
-    });
-}
-
-/**
- * @param {import('shared/texture/texture.js').Texture} richtext
- */
-export function handleEncryptedFiles(richtext) {
-    if (!_converse.state.config.get('trusted')) {
-        return;
-    }
-    richtext.addAnnotations(
-        /**
-         * @param {string} text
-         * @param {number} offset
-         */
-        (text, offset) => addEncryptedFiles(text, offset, richtext)
-    );
-}
-
-/**
- * Hook handler for {@link parseMessage} and {@link parseMUCMessage}, which
- * parses the passed in `message` stanza for OMEMO attributes and then sets
- * them on the attrs object.
- * @param {Element} stanza - The message stanza
- * @param {MUCMessageAttributes|MessageAttributes} attrs
- * @returns {Promise<MUCMessageAttributes| MessageAttributes|
-        import('./types').MUCMessageAttrsWithEncryption|import('./types').MessageAttrsWithEncryption>}
- */
-export async function parseEncryptedMessage(stanza, attrs) {
-    if (
-        api.settings.get('clear_cache_on_logout') ||
-        !attrs.is_encrypted ||
-        attrs.encryption_namespace !== Strophe.NS.OMEMO
-    ) {
-        return attrs;
-    }
-    const encrypted_el = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop();
-    const header = encrypted_el.querySelector('header');
-    attrs.encrypted = { 'device_id': header.getAttribute('sid') };
-
-    const device_id = await api.omemo?.getDeviceID();
-    const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted_el).pop();
-    if (key) {
-        Object.assign(attrs.encrypted, {
-            iv: header.querySelector('iv').textContent,
-            key: key.textContent,
-            payload: encrypted_el.querySelector('payload')?.textContent || null,
-            prekey: ['true', '1'].includes(key.getAttribute('prekey')),
-        });
-    } else {
-        return Object.assign(attrs, {
-            error_condition: 'not-encrypted-for-this-device',
-            error_type: 'Decryption',
-            is_ephemeral: true,
-            is_error: true,
-            type: 'error',
-        });
-    }
-    // https://xmpp.org/extensions/xep-0384.html#usecases-receiving
-    if (attrs.encrypted.prekey === true) {
-        return decryptPrekeyWhisperMessage(attrs);
-    } else {
-        return decryptWhisperMessage(attrs);
-    }
-}
-
-export function onChatInitialized(chatbox) {
-    checkOMEMOSupported(chatbox);
-    if (chatbox.get('type') === CHATROOMS_TYPE) {
-        chatbox.occupants.on('add', (o) => onOccupantAdded(chatbox, o));
-        chatbox.features.on('change', () => checkOMEMOSupported(chatbox));
-    }
-}
-
-export function onChatComponentInitialized(el) {
-    el.listenTo(el.model.messages, 'add', (message) => {
-        if (message.get('is_encrypted') && !message.get('is_error')) {
-            el.model.save('omemo_supported', true);
-        }
-    });
-    el.listenTo(el.model, 'change:omemo_supported', () => {
-        if (!el.model.get('omemo_supported') && el.model.get('omemo_active')) {
-            el.model.set('omemo_active', false);
-        } else {
-            // Manually trigger an update, setting omemo_active to
-            // false above will automatically trigger one.
-            el.querySelector('converse-chat-toolbar')?.requestUpdate();
-        }
-    });
-    el.listenTo(el.model, 'change:omemo_active', () => {
-        el.querySelector('converse-chat-toolbar').requestUpdate();
-    });
-}
-
-/**
- * @param {string} jid
- * @param {number} id
- */
-export function getSessionCipher(jid, id) {
-    const { libsignal } = /** @type WindowWithLibsignal */ (window);
-    const address = new libsignal.SignalProtocolAddress(jid, id);
-    return new libsignal.SessionCipher(_converse.state.omemo_store, address);
-}
-
-/**
- * We use the bare, real (i.e. non-MUC) JID as encrypted session identifier.
- * @param {MUCMessageAttributes|MessageAttributes} attrs
- */
-function getJIDForDecryption(attrs) {
-    let from_jid;
-    if (attrs.sender === 'me') {
-        from_jid = _converse.session.get('bare_jid');
-    } else if (attrs.contact_jid) {
-        from_jid = attrs.contact_jid;
-    } else if ('from_real_jid' in attrs) {
-        from_jid = attrs.from_real_jid;
-    } else {
-        from_jid = attrs.from;
-    }
-
-    if (!from_jid) {
-        Object.assign(attrs, {
-            error_text: __(
-                'Sorry, could not decrypt a received OMEMO ' +
-                    "message because we don't have the XMPP address for that user."
-            ),
-            error_type: 'Decryption',
-            is_ephemeral: true,
-            is_error: true,
-            type: 'error',
-        });
-        throw new Error('Could not find JID to decrypt OMEMO message for');
-    }
-    return from_jid;
-}
-
-async function handleDecryptedWhisperMessage(attrs, key_and_tag) {
-    const from_jid = getJIDForDecryption(attrs);
-    const devicelist = await api.omemo.devicelists.get(from_jid, true);
-    const encrypted = attrs.encrypted;
-    let device = devicelist.devices.get(encrypted.device_id);
-    if (!device) {
-        device = await devicelist.devices.create({ 'id': encrypted.device_id, 'jid': from_jid }, { 'promise': true });
-    }
-    if (encrypted.payload) {
-        const key = key_and_tag.slice(0, 16);
-        const tag = key_and_tag.slice(16);
-        const result = await omemo.decryptMessage(Object.assign(encrypted, { key, tag }));
-        device.save('active', true);
-        return result;
-    }
-}
-
-function getDecryptionErrorAttributes(e) {
-    return {
-        'error_text':
-            __('Sorry, could not decrypt a received OMEMO message due to an error.') + ` ${e.name} ${e.message}`,
-        'error_condition': e.name,
-        'error_message': e.message,
-        'error_type': 'Decryption',
-        'is_ephemeral': true,
-        'is_error': true,
-        'type': 'error',
-    };
-}
-
-/**
- * @param {MUCMessageAttributes|MessageAttributes} attrs
- */
-async function decryptPrekeyWhisperMessage(attrs) {
-    const from_jid = getJIDForDecryption(attrs);
-    const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
-    const key = base64ToArrayBuffer(attrs.encrypted.key);
-    let key_and_tag;
-    try {
-        key_and_tag = await session_cipher.decryptPreKeyWhisperMessage(key, 'binary');
-    } catch (e) {
-        // TODO from the XEP:
-        // There are various reasons why decryption of an
-        // OMEMOKeyExchange or an OMEMOAuthenticatedMessage
-        // could fail. One reason is if the message was
-        // received twice and already decrypted once, in this
-        // case the client MUST ignore the decryption failure
-        // and not show any warnings/errors. In all other cases
-        // of decryption failure, clients SHOULD respond by
-        // forcibly doing a new key exchange and sending a new
-        // OMEMOKeyExchange with a potentially empty SCE
-        // payload. By building a new session with the original
-        // sender this way, the invalid session of the original
-        // sender will get overwritten with this newly created,
-        // valid session.
-        log.error(`${e.name} ${e.message}`);
-        return Object.assign(attrs, getDecryptionErrorAttributes(e));
-    }
-    // TODO from the XEP:
-    // When a client receives the first message for a given
-    // ratchet key with a counter of 53 or higher, it MUST send
-    // a heartbeat message. Heartbeat messages are normal OMEMO
-    // encrypted messages where the SCE payload does not include
-    // any elements. These heartbeat messages cause the ratchet
-    // to forward, thus consequent messages will have the
-    // counter restarted from 0.
-    try {
-        const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
-        const { omemo_store } = _converse.state;
-        await omemo_store.generateMissingPreKeys();
-        await omemo_store.publishBundle();
-        if (plaintext) {
-            return Object.assign(attrs, { 'plaintext': plaintext });
-        } else {
-            return Object.assign(attrs, { 'is_only_key': true });
-        }
-    } catch (e) {
-        log.error(`${e.name} ${e.message}`);
-        return Object.assign(attrs, getDecryptionErrorAttributes(e));
-    }
-}
-
-/**
- * @param {MUCMessageAttributes|MessageAttributes} attrs
- */
-async function decryptWhisperMessage(attrs) {
-    const from_jid = getJIDForDecryption(attrs);
-    const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
-    const key = base64ToArrayBuffer(attrs.encrypted.key);
-    try {
-        const key_and_tag = await session_cipher.decryptWhisperMessage(key, 'binary');
-        const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
-        return Object.assign(attrs, { 'plaintext': plaintext });
-    } catch (e) {
-        log.error(`${e.name} ${e.message}`);
-        return Object.assign(attrs, getDecryptionErrorAttributes(e));
-    }
-}
-
-/**
- * Given an XML element representing a user's OMEMO bundle, parse it
- * and return a map.
- * @param {Element} bundle_el
- * @returns {import('./types').Bundle}
- */
-export function parseBundle(bundle_el) {
-    const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic');
-    const signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature');
-    const prekeys = sizzle(`prekeys > preKeyPublic`, bundle_el).map(
-        /** @param {Element} el */ (el) => ({
-            id: parseInt(el.getAttribute('preKeyId'), 10),
-            key: el.textContent,
-        })
-    );
-    return {
-        identity_key: bundle_el.querySelector('identityKey').textContent.trim(),
-        signed_prekey: {
-            id: parseInt(signed_prekey_public_el.getAttribute('signedPreKeyId'), 10),
-            public_key: signed_prekey_public_el.textContent,
-            signature: signed_prekey_signature_el.textContent,
-        },
-        prekeys,
-    };
-}
-
-/**
- * @param {string} jid
- */
-export async function generateFingerprints(jid) {
-    const devices = await getDevicesForContact(jid);
-    return Promise.all(devices.map((d) => generateFingerprint(d)));
-}
-
-/**
- * @param {import('./device.js').default} device
- */
-export async function generateFingerprint(device) {
-    if (device.get('bundle')?.fingerprint) {
-        return;
-    }
-    const bundle = await device.getBundle();
-    bundle['fingerprint'] = arrayBufferToHex(base64ToArrayBuffer(bundle['identity_key']));
-    device.save('bundle', bundle);
-    device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference
-}
-
-/**
- * @param {string} jid
- * @returns {Promise<import('./devices.js').default>}
- */
-export async function getDevicesForContact(jid) {
-    await api.waitUntil('OMEMOInitialized');
-    const devicelist = await api.omemo.devicelists.get(jid, true);
-    await devicelist.fetchDevices();
-    return devicelist.devices;
-}
-
-/**
- * @param {string} jid
- * @param {string} device_id
- * @returns {Promise<import('./device.js').default[]>}
- */
-export async function getDeviceForContact(jid, device_id) {
-    const devices = await getDevicesForContact(jid);
-    return devices.get(device_id);
-}
-
-export async function generateDeviceID() {
-    const { libsignal } = /** @type WindowWithLibsignal */ (window);
-
-    /* Generates a device ID, making sure that it's unique */
-    const bare_jid = _converse.session.get('bare_jid');
-    const devicelist = await api.omemo.devicelists.get(bare_jid, true);
-    const existing_ids = devicelist.devices.pluck('id');
-    let device_id = libsignal.KeyHelper.generateRegistrationId();
-
-    // Before publishing a freshly generated device id for the first time,
-    // a device MUST check whether that device id already exists, and if so, generate a new one.
-    let i = 0;
-    while (existing_ids.includes(device_id)) {
-        device_id = libsignal.KeyHelper.generateRegistrationId();
-        i++;
-        if (i === 10) {
-            throw new Error('Unable to generate a unique device ID');
-        }
-    }
-    return device_id.toString();
-}
-
-/**
- * @param {import('./device.js').default} device
- */
-async function buildSession(device) {
-    const { libsignal } = /** @type WindowWithLibsignal */ (window);
-    const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
-    const sessionBuilder = new libsignal.SessionBuilder(_converse.state.omemo_store, address);
-    const prekey = device.getRandomPreKey();
-    const bundle = await device.getBundle();
-    return sessionBuilder.processPreKey({
-        registrationId: parseInt(device.get('id'), 10),
-        identityKey: base64ToArrayBuffer(bundle.identity_key),
-        signedPreKey: {
-            keyId: bundle.signed_prekey.id, // <Number>
-            publicKey: base64ToArrayBuffer(bundle.signed_prekey.public_key),
-            signature: base64ToArrayBuffer(bundle.signed_prekey.signature),
-        },
-        preKey: {
-            keyId: prekey.id, // <Number>
-            publicKey: base64ToArrayBuffer(prekey.key),
-        },
-    });
-}
-
-/**
- * @param {import('./device.js').default} device
- */
-export async function getSession(device) {
-    if (!device.get('bundle')) {
-        log.error(`Could not build an OMEMO session for device ${device.get('id')} because we don't have its bundle`);
-        return null;
-    }
-    const { libsignal } = /** @type WindowWithLibsignal */ (window);
-    const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
-    const session = await _converse.state.omemo_store.loadSession(address.toString());
-    if (session) {
-        return session;
-    } else {
-        try {
-            return await buildSession(device);
-        } catch (e) {
-            log.error(`Could not build an OMEMO session for device ${device.get('id')}`);
-            log.error(e);
-            return null;
-        }
-    }
-}
-
-/**
- * @param {Element} stanza
- */
-async function updateBundleFromStanza(stanza) {
-    const items_el = sizzle(`items`, stanza).pop();
-    if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
-        return;
-    }
-    const device_id = items_el.getAttribute('node').split(':')[1];
-    const jid = stanza.getAttribute('from');
-    const bundle_el = sizzle(`item > bundle`, items_el).pop();
-    const devicelist = await api.omemo.devicelists.get(jid, true);
-    const device = devicelist.devices.get(device_id) || devicelist.devices.create({ 'id': device_id, jid });
-    device.save({ 'bundle': parseBundle(bundle_el) });
-}
-
-/**
- * @param {Element} stanza
- */
-async function updateDevicesFromStanza(stanza) {
-    const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop();
-    if (!items_el) return;
-
-    const device_selector = `item list[xmlns="${Strophe.NS.OMEMO}"] device`;
-    const device_ids = sizzle(device_selector, items_el).map((d) => d.getAttribute('id'));
-    const jid = stanza.getAttribute('from');
-    const devicelist = await api.omemo.devicelists.get(jid, true);
-    const devices = devicelist.devices;
-    const removed_ids = devices.pluck('id').filter((id) => !device_ids.includes(id));
-
-    const bare_jid = _converse.session.get('bare_jid');
-
-    removed_ids.forEach(
-        /** @param {string} id */ (id) => {
-            if (jid === bare_jid && id === _converse.state.omemo_store.get('device_id')) {
-                return; // We don't set the current device as inactive
-            }
-            devices.get(id).save('active', false);
-        }
-    );
-    device_ids.forEach(
-        /** @param {string} device_id */ (device_id) => {
-            const device = devices.get(device_id);
-            if (device) {
-                device.save('active', true);
-            } else {
-                devices.create({ id: device_id, jid });
-            }
-        }
-    );
-    if (u.isSameBareJID(bare_jid, jid)) {
-        // Make sure our own device is on the list
-        // (i.e. if it was removed, add it again).
-        devicelist.publishCurrentDevice(device_ids);
-    }
-}
-
-/**
- * @param {Element} message
- */
-async function handlePEPPush(message) {
-    try {
-        if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
-            await api.waitUntil('OMEMOInitialized');
-            await updateDevicesFromStanza(message);
-            await updateBundleFromStanza(message);
-        }
-    } catch (e) {
-        log.error(e);
-    }
-}
-
-/**
- * Register a pubsub handler for devices pushed from other connected clients
- */
-export function registerPEPPushHandler() {
-    api.connection.get().addHandler(
-        /** @param {Element} message */
-        (message) => {
-            handlePEPPush(message);
-            return true;
-        },
-        null,
-        'message',
-        'headline'
-    );
-}
-
-export async function restoreOMEMOSession() {}
-
-async function fetchDeviceLists() {
-    const bare_jid = _converse.session.get('bare_jid');
-
-    _converse.state.devicelists = new DeviceLists();
-    const id = `converse.devicelists-${bare_jid}`;
-    initStorage(_converse.state.devicelists, id);
-    await new Promise((resolve) => {
-        _converse.state.devicelists.fetch({
-            success: resolve,
-            error: (_m, e) => {
-                log.error(e);
-                resolve();
-            },
-        });
-    });
-    // Call API method to wait for our own device list to be fetched from the
-    // server or to be created. If we have no pre-existing OMEMO session, this
-    // will cause a new device and bundle to be generated and published.
-    await api.omemo.devicelists.get(bare_jid, true);
-}
-
-/**
- * @param {boolean} reconnecting
- */
-export async function initOMEMO(reconnecting) {
-    if (reconnecting) {
-        return;
-    }
-    if (!_converse.state.config.get('trusted') || api.settings.get('clear_cache_on_logout')) {
-        log.warn('Not initializing OMEMO, since this browser is not trusted or clear_cache_on_logout is set to true');
-        return;
-    }
-    try {
-        await fetchDeviceLists();
-        await api.omemo.session.restore();
-        await _converse.state.omemo_store.publishBundle();
-    } catch (e) {
-        log.error('Could not initialize OMEMO support');
-        log.error(e);
-        return;
-    }
-    /**
-     * Triggered once OMEMO support has been initialized
-     * @event _converse#OMEMOInitialized
-     * @example _converse.api.listen.on('OMEMOInitialized', () => { ... });
-     */
-    api.trigger('OMEMOInitialized');
-}
-
-/**
- * @param {MUC} chatroom
- * @param {import('@converse/headless/types/plugins/muc/occupant').default} occupant
- */
-async function onOccupantAdded(chatroom, occupant) {
-    if (occupant.isSelf() || !chatroom.features.get('nonanonymous') || !chatroom.features.get('membersonly')) {
-        return;
-    }
-    if (chatroom.get('omemo_active')) {
-        const supported = await contactHasOMEMOSupport(occupant.get('jid'));
-        if (!supported) {
-            chatroom.createMessage({
-                'message': __(
-                    "%1$s doesn't appear to have a client that supports OMEMO. " +
-                        'Encrypted chat will no longer be possible in this grouchat.',
-                    occupant.get('nick')
-                ),
-                'type': 'error',
-            });
-            chatroom.save({ 'omemo_active': false, 'omemo_supported': false });
-        }
-    }
-}
-
-async function checkOMEMOSupported(chatbox) {
-    let supported;
-    if (chatbox.get('type') === CHATROOMS_TYPE) {
-        await api.waitUntil('OMEMOInitialized');
-        supported = chatbox.features.get('nonanonymous') && chatbox.features.get('membersonly');
-    } else if (chatbox.get('type') === PRIVATE_CHAT_TYPE) {
-        supported = await contactHasOMEMOSupport(chatbox.get('jid'));
-    }
-    chatbox.set('omemo_supported', !!supported);
-    if (supported && api.settings.get('omemo_default')) {
-        chatbox.set('omemo_active', true);
-    }
-}
-
-/**
- * @param {MouseEvent} ev
- */
-function toggleOMEMO(ev) {
-    ev.stopPropagation();
-    ev.preventDefault();
-    const toolbar_el = u.ancestor(ev.target, 'converse-chat-toolbar');
-    if (!toolbar_el.model.get('omemo_supported')) {
-        let messages;
-        if (toolbar_el.model.get('type') === CHATROOMS_TYPE) {
-            messages = [
-                __(
-                    'Cannot use end-to-end encryption in this groupchat, ' +
-                        'either the groupchat has some anonymity or not all participants support OMEMO.'
-                ),
-            ];
-        } else {
-            messages = [
-                __(
-                    "Cannot use end-to-end encryption because %1$s uses a client that doesn't support OMEMO.",
-                    toolbar_el.model.contact.getDisplayName()
-                ),
-            ];
-        }
-        return api.alert('error', __('Error'), messages);
-    }
-    toolbar_el.model.save({ 'omemo_active': !toolbar_el.model.get('omemo_active') });
-}
-
-/**
- * @param {import('shared/chat/toolbar').ChatToolbar} toolbar_el
- * @param {Array<import('lit').TemplateResult>} buttons
- */
-export function getOMEMOToolbarButton(toolbar_el, buttons) {
-    const model = toolbar_el.model;
-    const is_muc = model.get('type') === CHATROOMS_TYPE;
-    let title;
-    if (model.get('omemo_supported')) {
-        const i18n_plaintext = __('Messages are being sent in plaintext');
-        const i18n_encrypted = __('Messages are sent encrypted');
-        title = model.get('omemo_active') ? i18n_encrypted : i18n_plaintext;
-    } else if (is_muc) {
-        title = __(
-            'This groupchat needs to be members-only and non-anonymous in ' +
-                'order to support OMEMO encrypted messages'
-        );
-    } else {
-        title = __('OMEMO encryption is not supported');
-    }
-
-    let color;
-    if (model.get('omemo_supported')) {
-        if (model.get('omemo_active')) {
-            color = is_muc ? `var(--muc-color)` : `var(--chat-color)`;
-        } else {
-            color = `var(--error-color)`;
-        }
-    } else {
-        color = `var(--disabled-color)`;
-    }
-    buttons.push(html`
-        <button
-            type="button"
-            class="btn toggle-omemo"
-            title="${title}"
-            data-disabled=${!model.get('omemo_supported')}
-            @click=${toggleOMEMO}
-        >
-            <converse-icon
-                class="fa ${model.get('omemo_active') ? `fa-lock` : `fa-unlock`}"
-                path-prefix="${api.settings.get('assets_path')}"
-                size="1em"
-                color="${color}"
-            ></converse-icon>
-        </button>
-    `);
-    return buttons;
-}
-
-/**
- * @param {MUC|ChatBox} chatbox
- * @returns {Promise<import('./device.js').default[]>}
- */
-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 instanceof MUC) {
-        const collections = await Promise.all(
-            chatbox.occupants.map(
-                /** @param {import('@converse/headless/types/plugins/muc/occupant').default} o */
-                (o) => getDevicesForContact(o.get('jid'))
-            )
-        );
-        devices = collections.reduce((a, b) => a.concat(b.models), []);
-    } else if (chatbox.get('type') === PRIVATE_CHAT_TYPE) {
-        const their_devices = await getDevicesForContact(chatbox.get('jid'));
-        if (their_devices.length === 0) {
-            throw new UserFacingError(no_devices_err);
-        }
-        const bare_jid = _converse.session.get('bare_jid');
-        const own_list = await api.omemo.devicelists.get(bare_jid);
-        const own_devices = own_list.devices;
-        devices = [...own_devices.models, ...their_devices.models];
-    }
-    // Filter out our own device
-    const id = _converse.state.omemo_store.get('device_id');
-    devices = devices.filter(/** @param {import('./device.js').default} d */ (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) {
-            throw new UserFacingError(no_devices_err);
-        }
-    }
-    return devices;
-}
-
-/**
- * @param {ArrayBuffer} key_and_tag
- * @param {import('./device.js').default} device
- */
-function encryptKey(key_and_tag, device) {
-    return getSessionCipher(device.get('jid'), device.get('id'))
-        .encrypt(key_and_tag)
-        .then((payload) => ({ payload, device }));
-}
-
-/**
- * @param {MUC|ChatBox} chat
- * @param {{ message: BaseMessage, stanza: import('@converse/headless').Builder }} data
- * @return {Promise<{ message: BaseMessage, stanza: import('@converse/headless').Builder }>}
- */
-export async function createOMEMOMessageStanza(chat, data) {
-    let { stanza } = data;
-    const { message } = data;
-    if (!message.get('is_encrypted')) {
-        return data;
-    }
-    if (!message.get('body')) {
-        throw new Error('No message body to encrypt!');
-    }
-    const devices = await getBundlesAndBuildSessions(chat);
-    const { key_and_tag, iv, payload } = await omemo.encryptMessage(message.get('plaintext'));
-
-    // 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 dicts = await Promise.all(
-        devices
-            .filter((device) => device.get('trusted') != UNTRUSTED && device.get('active'))
-            .map((device) => encryptKey(key_and_tag, device))
-    );
-
-    // 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
-        .cnode(
-            stx`
-            <encrypted xmlns="${Strophe.NS.OMEMO}">
-                <header sid="${_converse.state.omemo_store.get('device_id')}">
-                    ${dicts.map(({ payload, device }) => {
-                        const prekey = 3 == parseInt(payload.type, 10);
-                        if (prekey) {
-                            return stx`<key rid="${device.get('id')}" prekey="true">${btoa(payload.body)}</key>`;
-                        }
-                        return stx`<key rid="${device.get('id')}">${btoa(payload.body)}</key>`;
-                    })}
-                    <iv>${iv}</iv>
-                </header>
-                <payload>${payload}</payload>
-            </encrypted>`
-        )
-        .root();
-
-    stanza.cnode(stx`<store xmlns="${Strophe.NS.HINTS}"/>`).root();
-    stanza.cnode(stx`<encryption xmlns="${Strophe.NS.EME}" namespace="${Strophe.NS.OMEMO}"/>`).root();
-    return { message, stanza };
-}
-
-export const omemo = {
-    decryptMessage,
-    encryptMessage,
-    formatFingerprint,
-};

+ 1 - 1
src/shared/constants.js

@@ -20,7 +20,7 @@ export const VIEW_PLUGINS = [
     'converse-modal',
     'converse-muc-views',
     'converse-notification',
-    'converse-omemo',
+    'converse-omemo-views',
     'converse-profile',
     'converse-push',
     'converse-register',

+ 0 - 23
src/shared/errors.js

@@ -1,23 +0,0 @@
-export class IQError extends Error {
-    /**
-     * @param {string} message
-     * @param {Element} iq
-     */
-    constructor (message, iq) {
-        super(message);
-        this.name = 'IQError';
-        this.iq = iq;
-    }
-}
-
-export class UserFacingError extends Error {
-
-    /**
-     * @param {string} message
-     */
-    constructor (message) {
-        super(message);
-        this.name = 'UserFacingError';
-        this.user_facing = true;
-    }
-}

+ 4 - 1
src/types/plugins/omemo/fingerprints.d.ts → src/types/plugins/omemo-views/fingerprints.d.ts

@@ -8,7 +8,10 @@ export class Fingerprints extends CustomElement {
     initialize(): Promise<void>;
     devicelist: any;
     render(): import("lit-html").TemplateResult<1> | "";
-    toggleDeviceTrust(ev: any): void;
+    /**
+        * @param {Event} ev
+        */
+    toggleDeviceTrust(ev: Event): void;
 }
 import { CustomElement } from "shared/components/element.js";
 //# sourceMappingURL=fingerprints.d.ts.map

+ 2 - 0
src/types/plugins/omemo-views/index.d.ts

@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=index.d.ts.map

+ 0 - 0
src/types/plugins/omemo/profile.d.ts → src/types/plugins/omemo-views/profile.d.ts


+ 0 - 0
src/types/plugins/omemo/templates/fingerprints.d.ts → src/types/plugins/omemo-views/templates/fingerprints.d.ts


+ 0 - 0
src/types/plugins/omemo/templates/profile.d.ts → src/types/plugins/omemo-views/templates/profile.d.ts


+ 0 - 13
src/types/plugins/omemo/types.d.ts → src/types/plugins/omemo-views/types.d.ts

@@ -1,17 +1,4 @@
 import { MUCMessageAttributes, MessageAttributes } from "./utils";
-export type PreKey = {
-    id: number;
-    key: string;
-};
-export type Bundle = {
-    identity_key: string;
-    signed_prekey: {
-        id: number;
-        public_key: string;
-        signature: string;
-    };
-    prekeys: PreKey[];
-};
 export type EncryptedMessageAttributes = {
     iv: string;
     key: string;

+ 34 - 0
src/types/plugins/omemo-views/utils.d.ts

@@ -0,0 +1,34 @@
+/**
+ * @param {string} fp
+ */
+export function formatFingerprint(fp: string): string;
+/**
+ * @param {string} fp
+ */
+export function formatFingerprintForQRCode(fp: string): string;
+/**
+ * @param {import('shared/texture/texture.js').Texture} richtext
+ */
+export function handleEncryptedFiles(richtext: import("shared/texture/texture.js").Texture): void;
+export function onChatComponentInitialized(el: any): void;
+/**
+ * @param {string} jid
+ */
+export function generateFingerprints(jid: string): Promise<any[]>;
+/**
+ * @param {string} jid
+ * @param {string} device_id
+ * @returns {Promise<import('@converse/headless').Device[]>}
+ */
+export function getDeviceForContact(jid: string, device_id: string): Promise<import("@converse/headless").Device[]>;
+/**
+ * @param {import('shared/chat/toolbar').ChatToolbar} toolbar_el
+ * @param {Array<import('lit').TemplateResult>} buttons
+ */
+export function getOMEMOToolbarButton(toolbar_el: import("shared/chat/toolbar").ChatToolbar, buttons: Array<import("lit").TemplateResult>): import("lit-html").TemplateResult<1 | 2 | 3>[];
+export type WindowWithLibsignal = any;
+export type MessageAttributes = import("@converse/headless/shared/types").MessageAttributes;
+export type MUCMessageAttributes = import("@converse/headless/plugins/muc/types").MUCMessageAttributes;
+export type ChatBox = import("@converse/headless").ChatBox;
+export type BaseMessage = import("@converse/headless/types/shared/message").default;
+//# sourceMappingURL=utils.d.ts.map

+ 0 - 4
src/types/plugins/omemo/index.d.ts

@@ -1,4 +0,0 @@
-export type WindowWithLibsignal = Window & typeof globalThis & {
-    libsignal: any;
-};
-//# sourceMappingURL=index.d.ts.map

+ 0 - 59
src/types/plugins/omemo/store.d.ts

@@ -1,59 +0,0 @@
-export default OMEMOStore;
-export type WindowWithLibsignal = any;
-declare class OMEMOStore extends Model {
-    get Direction(): {
-        SENDING: number;
-        RECEIVING: number;
-    };
-    getIdentityKeyPair(): Promise<{
-        privKey: any;
-        pubKey: any;
-    }>;
-    getLocalRegistrationId(): Promise<number>;
-    isTrustedIdentity(identifier: any, identity_key: any, _direction: any): Promise<boolean>;
-    loadIdentityKey(identifier: any): Promise<any>;
-    saveIdentity(identifier: any, identity_key: any): Promise<boolean>;
-    getPreKeys(): any;
-    loadPreKey(key_id: any): Promise<void> | Promise<{
-        privKey: any;
-        pubKey: any;
-    }>;
-    storePreKey(key_id: any, key_pair: any): Promise<void>;
-    removePreKey(key_id: any): Promise<void>;
-    loadSignedPreKey(_keyId: any): Promise<void> | Promise<{
-        privKey: any;
-        pubKey: any;
-    }>;
-    storeSignedPreKey(spk: any): Promise<void>;
-    removeSignedPreKey(key_id: any): Promise<void>;
-    loadSession(identifier: any): Promise<any>;
-    storeSession(identifier: any, record: any): Promise<any>;
-    removeSession(identifier: any): Promise<false | Awaited<this>>;
-    removeAllSessions(identifier: any): Promise<void>;
-    publishBundle(): any;
-    generateMissingPreKeys(): Promise<void>;
-    /**
-     * Generates, stores and then returns pre-keys.
-     *
-     * Pre-keys are one half of a X3DH key exchange and are published as part
-     * of the device bundle.
-     *
-     * For a new contact or device to establish an encrypted session, it needs
-     * to use a pre-key, which it chooses randomly from the list of available
-     * ones.
-     */
-    generatePreKeys(): Promise<any>;
-    /**
-     * Generate the cryptographic data used by the X3DH key agreement protocol
-     * in order to build a session with other devices.
-     *
-     * By generating a bundle, and publishing it via PubSub, we allow other
-     * clients to download it and start asynchronous encrypted sessions with us,
-     * even if we're offline at that time.
-     */
-    generateBundle(): Promise<void>;
-    fetchSession(): Promise<any>;
-    _setup_promise: Promise<any>;
-}
-import { Model } from "@converse/headless";
-//# sourceMappingURL=store.d.ts.map

+ 0 - 140
src/types/plugins/omemo/utils.d.ts

@@ -1,140 +0,0 @@
-/**
- * @param {string} fp
- */
-export function formatFingerprint(fp: string): string;
-/**
- * @param {string} fp
- */
-export function formatFingerprintForQRCode(fp: string): string;
-/**
- * @param {Error|IQError|UserFacingError} e
- * @param {ChatBox} chat
- */
-export function handleMessageSendError(e: Error | IQError | UserFacingError, chat: ChatBox): void;
-/**
- * @param {string} jid
- */
-export function contactHasOMEMOSupport(jid: string): Promise<boolean>;
-/**
- * @param {ChatBox|MUC} chat
- * @param {MessageAttributes} attrs
- * @return {MessageAttributes}
- */
-export function getOutgoingMessageAttributes(chat: ChatBox | MUC, attrs: MessageAttributes): MessageAttributes;
-/**
- * @param {File} file
- * @returns {Promise<File>}
- */
-export function encryptFile(file: File): Promise<File>;
-/**
- * @param {import('@converse/headless/types/shared/message').default} message
- * @param {import('@converse/headless/shared/types').FileUploadMessageAttributes} attrs
- */
-export function setEncryptedFileURL(message: import("@converse/headless").BaseMessage<any>, attrs: import("@converse/headless/shared/types").FileUploadMessageAttributes): import("@converse/headless/shared/types").FileUploadMessageAttributes & {
-    oob_url: any;
-    message: string;
-    body: string;
-};
-/**
- * @param {import('shared/texture/texture.js').Texture} richtext
- */
-export function handleEncryptedFiles(richtext: import("shared/texture/texture.js").Texture): void;
-/**
- * Hook handler for {@link parseMessage} and {@link parseMUCMessage}, which
- * parses the passed in `message` stanza for OMEMO attributes and then sets
- * them on the attrs object.
- * @param {Element} stanza - The message stanza
- * @param {MUCMessageAttributes|MessageAttributes} attrs
- * @returns {Promise<MUCMessageAttributes| MessageAttributes|
-        import('./types').MUCMessageAttrsWithEncryption|import('./types').MessageAttrsWithEncryption>}
- */
-export function parseEncryptedMessage(stanza: Element, attrs: MUCMessageAttributes | MessageAttributes): Promise<MUCMessageAttributes | MessageAttributes | import("./types").MUCMessageAttrsWithEncryption | import("./types").MessageAttrsWithEncryption>;
-export function onChatInitialized(chatbox: any): void;
-export function onChatComponentInitialized(el: any): void;
-/**
- * @param {string} jid
- * @param {number} id
- */
-export function getSessionCipher(jid: string, id: number): any;
-/**
- * Given an XML element representing a user's OMEMO bundle, parse it
- * and return a map.
- * @param {Element} bundle_el
- * @returns {import('./types').Bundle}
- */
-export function parseBundle(bundle_el: Element): import("./types").Bundle;
-/**
- * @param {string} jid
- */
-export function generateFingerprints(jid: string): Promise<any[]>;
-/**
- * @param {import('./device.js').default} device
- */
-export function generateFingerprint(device: import("./device.js").default): Promise<void>;
-/**
- * @param {string} jid
- * @returns {Promise<import('./devices.js').default>}
- */
-export function getDevicesForContact(jid: string): Promise<import("./devices.js").default>;
-/**
- * @param {string} jid
- * @param {string} device_id
- * @returns {Promise<import('./device.js').default[]>}
- */
-export function getDeviceForContact(jid: string, device_id: string): Promise<import("./device.js").default[]>;
-export function generateDeviceID(): Promise<any>;
-/**
- * @param {import('./device.js').default} device
- */
-export function getSession(device: import("./device.js").default): Promise<any>;
-/**
- * Register a pubsub handler for devices pushed from other connected clients
- */
-export function registerPEPPushHandler(): void;
-export function restoreOMEMOSession(): Promise<void>;
-/**
- * @param {boolean} reconnecting
- */
-export function initOMEMO(reconnecting: boolean): Promise<void>;
-/**
- * @param {import('shared/chat/toolbar').ChatToolbar} toolbar_el
- * @param {Array<import('lit').TemplateResult>} buttons
- */
-export function getOMEMOToolbarButton(toolbar_el: import("shared/chat/toolbar").ChatToolbar, buttons: Array<import("lit").TemplateResult>): import("lit-html").TemplateResult<1 | 2 | 3>[];
-/**
- * @param {MUC|ChatBox} chat
- * @param {{ message: BaseMessage, stanza: import('@converse/headless').Builder }} data
- * @return {Promise<{ message: BaseMessage, stanza: import('@converse/headless').Builder }>}
- */
-export function createOMEMOMessageStanza(chat: MUC | ChatBox, data: {
-    message: BaseMessage;
-    stanza: import("@converse/headless").Builder;
-}): Promise<{
-    message: BaseMessage;
-    stanza: import("@converse/headless").Builder;
-}>;
-export namespace omemo {
-    export { decryptMessage };
-    export { encryptMessage };
-    export { formatFingerprint };
-}
-export type WindowWithLibsignal = any;
-export type MessageAttributes = import("@converse/headless/shared/types").MessageAttributes;
-export type MUCMessageAttributes = import("@converse/headless/plugins/muc/types").MUCMessageAttributes;
-export type ChatBox = import("@converse/headless").ChatBox;
-export type BaseMessage = import("@converse/headless").BaseMessage<any>;
-import { IQError } from 'shared/errors.js';
-import { UserFacingError } from 'shared/errors.js';
-import { MUC } from '@converse/headless';
-/**
- * @param {import('./types').EncryptedMessage} obj
- * @returns {Promise<string>}
- */
-declare function decryptMessage(obj: import("./types").EncryptedMessage): Promise<string>;
-/**
- * @param {string} plaintext
- * @returns {Promise<import('./types').EncryptedMessage>}
- */
-declare function encryptMessage(plaintext: string): Promise<import("./types").EncryptedMessage>;
-export {};
-//# sourceMappingURL=utils.d.ts.map

+ 0 - 8
src/types/shared/errors.d.ts

@@ -1,11 +1,3 @@
-export class IQError extends Error {
-    /**
-     * @param {string} message
-     * @param {Element} iq
-     */
-    constructor(message: string, iq: Element);
-    iq: Element;
-}
 export class UserFacingError extends Error {
     /**
      * @param {string} message

+ 1 - 1
src/types/shared/qrcode/utils.d.ts

@@ -15,7 +15,7 @@ export namespace QRUtil {
     function getPatternPosition(typeNumber: any): number[];
     function getMask(maskPattern: any, i: any, j: any): boolean;
     function getErrorCorrectPolynomial(errorCorrectLength: any): QRPolynomial;
-    function getLengthInBits(mode: any, type: any): 10 | 8 | 9 | 11 | 12 | 16 | 14 | 13;
+    function getLengthInBits(mode: any, type: any): 16 | 8 | 10 | 9 | 11 | 12 | 14 | 13;
     function getLostPoint(qrCode: any): number;
 }
 import QRPolynomial from "./polynomial";