Browse Source

Refactor OMEMO.

- Add hooks to the stanza parsers so that plugins can do additional parsing.
- Change ChatBox instance methods to functions and use them for stanza parsing.
- Move encrypt and decrypt messages to `converse.env.omemo`

Apparently, when receving a 1:1 carbon message, a device was wrongly created
for the contact's device list, instead of our own.
JC Brand 4 years ago
parent
commit
7c43d0435c
6 changed files with 262 additions and 192 deletions
  1. 12 12
      package-lock.json
  2. 24 21
      spec/omemo.js
  3. 214 150
      src/converse-omemo.js
  4. 1 3
      src/headless/converse-chat.js
  5. 0 2
      src/headless/converse-muc.js
  6. 11 4
      src/headless/utils/stanza.js

+ 12 - 12
package-lock.json

@@ -7307,9 +7307,9 @@
 			},
 			},
 			"dependencies": {
 			"dependencies": {
 				"dot-prop": {
 				"dot-prop": {
-					"version": "5.2.0",
-					"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz",
-					"integrity": "sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==",
+					"version": "5.3.0",
+					"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
+					"integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==",
 					"dev": true,
 					"dev": true,
 					"requires": {
 					"requires": {
 						"is-obj": "^2.0.0"
 						"is-obj": "^2.0.0"
@@ -11651,9 +11651,9 @@
 			}
 			}
 		},
 		},
 		"git-url-parse": {
 		"git-url-parse": {
-			"version": "11.1.3",
-			"resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.1.3.tgz",
-			"integrity": "sha512-GPsfwticcu52WQ+eHp0IYkAyaOASgYdtsQDIt4rUp6GbiNt1P9ddrh3O0kQB0eD4UJZszVqNT3+9Zwcg40fywA==",
+			"version": "11.2.0",
+			"resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.2.0.tgz",
+			"integrity": "sha512-KPoHZg8v+plarZvto4ruIzzJLFQoRx+sUs5DQSr07By9IBKguVd+e6jwrFR6/TP6xrCJlNV1tPqLO1aREc7O2g==",
 			"dev": true,
 			"dev": true,
 			"requires": {
 			"requires": {
 				"git-up": "^4.0.0"
 				"git-up": "^4.0.0"
@@ -14712,9 +14712,9 @@
 			}
 			}
 		},
 		},
 		"node-fetch": {
 		"node-fetch": {
-			"version": "2.6.0",
-			"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
-			"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==",
+			"version": "2.6.1",
+			"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
+			"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==",
 			"dev": true
 			"dev": true
 		},
 		},
 		"node-fetch-npm": {
 		"node-fetch-npm": {
@@ -21278,9 +21278,9 @@
 			}
 			}
 		},
 		},
 		"rxjs": {
 		"rxjs": {
-			"version": "6.6.2",
-			"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.2.tgz",
-			"integrity": "sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg==",
+			"version": "6.6.3",
+			"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz",
+			"integrity": "sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==",
 			"dev": true,
 			"dev": true,
 			"requires": {
 			"requires": {
 				"tslib": "^1.9.0"
 				"tslib": "^1.9.0"

+ 24 - 21
spec/omemo.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 /*global mock, converse */
 
 
-const { $iq, $pres, $msg, _, Strophe } = converse.env;
+const { $iq, $pres, $msg, _, omemo, Strophe } = converse.env;
 const u = converse.env.utils;
 const u = converse.env.utils;
 
 
 async function deviceListFetched (_converse, jid) {
 async function deviceListFetched (_converse, jid) {
@@ -78,15 +78,12 @@ describe("The OMEMO module", function() {
 
 
         const message = 'This message will be encrypted'
         const message = 'This message will be encrypted'
         await mock.waitForRoster(_converse, 'current', 1);
         await mock.waitForRoster(_converse, 'current', 1);
-        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        const view = await mock.openChatBoxFor(_converse, contact_jid);
-        const payload = await view.model.encryptMessage(message);
-        const result = await view.model.decryptMessage(payload);
+        const payload = await omemo.encryptMessage(message);
+        const result = await omemo.decryptMessage(payload);
         expect(result).toBe(message);
         expect(result).toBe(message);
         done();
         done();
     }));
     }));
 
 
-
     it("enables encrypted messages to be sent and received",
     it("enables encrypted messages to be sent and received",
         mock.initConverse(
         mock.initConverse(
             ['rosterGroupsFetched', 'chatBoxesFetched'], {},
             ['rosterGroupsFetched', 'chatBoxesFetched'], {},
@@ -182,10 +179,9 @@ describe("The OMEMO module", function() {
             `</message>`);
             `</message>`);
 
 
         // Test reception of an encrypted message
         // Test reception of an encrypted message
-        let obj = await view.model.encryptMessage('This is an encrypted message from the contact')
+        let obj = await omemo.encryptMessage('This is an encrypted message from the contact')
         // XXX: Normally the key will be encrypted via libsignal.
         // 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.
+        // However, we're mocking libsignal in the tests, so we include it as plaintext in the message.
         stanza = $msg({
         stanza = $msg({
                 'from': contact_jid,
                 'from': contact_jid,
                 'to': _converse.connection.jid,
                 'to': _converse.connection.jid,
@@ -205,7 +201,7 @@ describe("The OMEMO module", function() {
             .toBe('This is an encrypted message from the contact');
             .toBe('This is an encrypted message from the contact');
 
 
         // #1193 Check for a received message without <body> tag
         // #1193 Check for a received message without <body> tag
-        obj = await view.model.encryptMessage('Another received encrypted message without fallback')
+        obj = await omemo.encryptMessage('Another received encrypted message without fallback')
         stanza = $msg({
         stanza = $msg({
                 'from': contact_jid,
                 'from': contact_jid,
                 'to': _converse.connection.jid,
                 'to': _converse.connection.jid,
@@ -383,6 +379,9 @@ describe("The OMEMO module", function() {
         await u.waitUntil(() => initializedOMEMO(_converse));
         await u.waitUntil(() => initializedOMEMO(_converse));
         await mock.openChatBoxFor(_converse, contact_jid);
         await mock.openChatBoxFor(_converse, contact_jid);
         let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid));
         let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid));
+        const my_devicelist = _converse.devicelists.get({'jid': _converse.bare_jid});
+        expect(my_devicelist.devices.length).toBe(2);
+
         const stanza = $iq({
         const stanza = $iq({
                 'from': contact_jid,
                 'from': contact_jid,
                 'id': iq_stanza.getAttribute('id'),
                 'id': iq_stanza.getAttribute('id'),
@@ -395,14 +394,15 @@ describe("The OMEMO module", function() {
                             .c('device', {'id': '555'});
                             .c('device', {'id': '555'});
         _converse.connection._dataRecv(mock.createRequest(stanza));
         _converse.connection._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => _converse.omemo_store);
         await u.waitUntil(() => _converse.omemo_store);
-        const devicelist = _converse.devicelists.get({'jid': contact_jid});
-        await u.waitUntil(() => devicelist.devices.length === 1);
+
+        const contact_devicelist = _converse.devicelists.get({'jid': contact_jid});
+        await u.waitUntil(() => contact_devicelist.devices.length === 1);
 
 
         const view = _converse.chatboxviews.get(contact_jid);
         const view = _converse.chatboxviews.get(contact_jid);
         view.model.set('omemo_active', true);
         view.model.set('omemo_active', true);
 
 
         // Test reception of an encrypted carbon message
         // Test reception of an encrypted carbon message
-        const obj = await view.model.encryptMessage('This is an encrypted carbon message from another device of mine')
+        const obj = await omemo.encryptMessage('This is an encrypted carbon message from another device of mine')
         const carbon = u.toStanza(`
         const carbon = u.toStanza(`
             <message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="romeo@montague.lit" type="chat">
             <message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="romeo@montague.lit" type="chat">
                 <sent xmlns="urn:xmpp:carbons:2">
                 <sent xmlns="urn:xmpp:carbons:2">
@@ -440,10 +440,14 @@ describe("The OMEMO module", function() {
         expect(view.el.querySelector('.chat-msg__text').textContent.trim())
         expect(view.el.querySelector('.chat-msg__text').textContent.trim())
             .toBe('This is an encrypted carbon message from another device of mine');
             .toBe('This is an encrypted carbon message from another device of mine');
 
 
-        expect(devicelist.devices.length).toBe(2);
-        expect(devicelist.devices.at(0).get('id')).toBe('555');
-        expect(devicelist.devices.at(1).get('id')).toBe('988349631');
-        expect(devicelist.devices.get('988349631').get('active')).toBe(true);
+        expect(contact_devicelist.devices.length).toBe(1);
+
+        // Check that the new device id has been added to my devices
+        expect(my_devicelist.devices.length).toBe(3);
+        expect(my_devicelist.devices.at(0).get('id')).toBe('482886413b977930064a5888b92134fe');
+        expect(my_devicelist.devices.at(1).get('id')).toBe('123456789');
+        expect(my_devicelist.devices.at(2).get('id')).toBe('988349631');
+        expect(my_devicelist.devices.get('988349631').get('active')).toBe(true);
 
 
         const textarea = view.el.querySelector('.chat-textarea');
         const textarea = view.el.querySelector('.chat-textarea');
         textarea.value = 'This is an encrypted message from this device';
         textarea.value = 'This is an encrypted message from this device';
@@ -601,7 +605,7 @@ describe("The OMEMO module", function() {
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
 
 
         await u.waitUntil(() => initializedOMEMO(_converse));
         await u.waitUntil(() => initializedOMEMO(_converse));
-        const obj = await _converse.ChatBox.prototype.encryptMessage('This is an encrypted message from the contact');
+        const obj = await omemo.encryptMessage('This is an encrypted message from the contact');
         // XXX: Normally the key will be encrypted via libsignal.
         // XXX: Normally the key will be encrypted via libsignal.
         // However, we're mocking libsignal in the tests, so we include
         // However, we're mocking libsignal in the tests, so we include
         // it as plaintext in the message.
         // it as plaintext in the message.
@@ -631,8 +635,8 @@ describe("The OMEMO module", function() {
             return generateMissingPreKeys.apply(_converse.omemo_store, arguments);
             return generateMissingPreKeys.apply(_converse.omemo_store, arguments);
         });
         });
         _converse.connection._dataRecv(mock.createRequest(stanza));
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        let iq_stanza = await u.waitUntil(() => _converse.chatboxviews.get(contact_jid));
-        iq_stanza = await deviceListFetched(_converse, contact_jid);
+
+        let iq_stanza = await deviceListFetched(_converse, contact_jid);
         stanza = $iq({
         stanza = $iq({
             'from': contact_jid,
             'from': contact_jid,
             'id': iq_stanza.getAttribute('id'),
             'id': iq_stanza.getAttribute('id'),
@@ -688,7 +692,6 @@ describe("The OMEMO module", function() {
         done();
         done();
     }));
     }));
 
 
-
     it("updates device lists based on PEP messages",
     it("updates device lists based on PEP messages",
         mock.initConverse(
         mock.initConverse(
             ['rosterGroupsFetched'], {'allow_non_roster_messaging': true},
             ['rosterGroupsFetched'], {'allow_non_roster_messaging': true},

+ 214 - 150
src/converse-omemo.js

@@ -40,6 +40,149 @@ class IQError extends Error {
     }
     }
 }
 }
 
 
+const omemo = converse.env.omemo = {
+
+    async 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)),
+                key = await crypto.subtle.generateKey(KEY_ALGO, true, ["encrypt", "decrypt"]),
+                algo = {
+                    'name': 'AES-GCM',
+                    'iv': iv,
+                    'tagLength': TAG_LENGTH
+                },
+                encrypted = await crypto.subtle.encrypt(algo, key, u.stringToArrayBuffer(plaintext)),
+                length = encrypted.byteLength - ((128 + 7) >> 3),
+                ciphertext = encrypted.slice(0, length),
+                tag = encrypted.slice(length),
+                exported_key = await crypto.subtle.exportKey("raw", key);
+
+        return {
+            'key': exported_key,
+            'tag': tag,
+            'key_and_tag': u.appendArrayBuffer(exported_key, tag),
+            'payload': u.arrayBufferToBase64(ciphertext),
+            'iv': u.arrayBufferToBase64(iv)
+        };
+    },
+
+    async 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 = {
+            'name': "AES-GCM",
+            'iv': u.base64ToArrayBuffer(obj.iv),
+            'tagLength': TAG_LENGTH
+        };
+        return u.arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
+    }
+};
+
+function getSessionCipher (jid, id) {
+    const address = new libsignal.SignalProtocolAddress(jid, id);
+    return new window.libsignal.SessionCipher(_converse.omemo_store, address);
+}
+
+async function handleDecryptedWhisperMessage (attrs, key_and_tag) {
+    const encrypted = attrs.encrypted;
+    const devicelist = _converse.devicelists.getDeviceList(attrs.from);
+    await devicelist._devices_promise;
+
+    let device = devicelist.get(encrypted.device_id);
+    if (!device) {
+        device = await devicelist.devices.create({'id': encrypted.device_id, 'jid': attrs.from}, {'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': key, 'tag': tag}));
+        device.save('active', true);
+        return result;
+    }
+}
+
+function getDecryptionErrorAttributes (e) {
+    if (api.settings.get("loglevel") === 'debug') {
+        return {
+            'error_text': __("Sorry, could not decrypt a received OMEMO message due to an error.") + ` ${e.name} ${e.message}`,
+            'error_type': 'Decryption',
+            'is_ephemeral': true,
+            'is_error': true,
+            'type': 'error',
+        }
+    } else {
+        return {};
+    }
+}
+
+async function decryptPrekeyWhisperMessage (attrs) {
+    const session_cipher = getSessionCipher(attrs.from, 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);
+        await _converse.omemo_store.generateMissingPreKeys();
+        await _converse.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));
+    }
+}
+
+async function decryptWhisperMessage (attrs) {
+    const session_cipher = getSessionCipher(attrs.from, 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));
+    }
+}
 
 
 function addKeysToMessageStanza (stanza, dicts, iv) {
 function addKeysToMessageStanza (stanza, dicts, iv) {
     for (const i in dicts) {
     for (const i in dicts) {
@@ -84,7 +227,6 @@ function parseBundle (bundle_el) {
     }
     }
 }
 }
 
 
-
 async function generateFingerprint (device) {
 async function generateFingerprint (device) {
     if (device.get('bundle')?.fingerprint) {
     if (device.get('bundle')?.fingerprint) {
         return;
         return;
@@ -107,11 +249,14 @@ function generateDeviceID () {
     /* Generates a device ID, making sure that it's unique */
     /* Generates a device ID, making sure that it's unique */
     const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id');
     const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id');
     let device_id = libsignal.KeyHelper.generateRegistrationId();
     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;
     let i = 0;
     while (existing_ids.includes(device_id)) {
     while (existing_ids.includes(device_id)) {
         device_id = libsignal.KeyHelper.generateRegistrationId();
         device_id = libsignal.KeyHelper.generateRegistrationId();
         i++;
         i++;
-        if (i == 10) {
+        if (i === 10) {
             throw new Error("Unable to generate a unique device ID");
             throw new Error("Unable to generate a unique device ID");
         }
         }
     }
     }
@@ -119,10 +264,13 @@ function generateDeviceID () {
 }
 }
 
 
 async function buildSession (device) {
 async function buildSession (device) {
-    const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')),
-            sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address),
-            prekey = device.getRandomPreKey(),
-            bundle = await device.getBundle();
+    // TODO: check device-get('jid') versus the 'from' attribute which is used
+    // to build a session when receiving an encrypted message in a MUC.
+    // https://github.com/conversejs/converse.js/issues/1481#issuecomment-509183431
+    const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
+    const sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address);
+    const prekey = device.getRandomPreKey();
+    const bundle = await device.getBundle();
 
 
     return sessionBuilder.processPreKey({
     return sessionBuilder.processPreKey({
         'registrationId': parseInt(device.get('id'), 10),
         'registrationId': parseInt(device.get('id'), 10),
@@ -143,7 +291,7 @@ async function getSession (device) {
     const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
     const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
     const session = await _converse.omemo_store.loadSession(address.toString());
     const session = await _converse.omemo_store.loadSession(address.toString());
     if (session) {
     if (session) {
-        return Promise.resolve(session);
+        return session;
     } else {
     } else {
         try {
         try {
             const session = await buildSession(device);
             const session = await buildSession(device);
@@ -161,11 +309,11 @@ function updateBundleFromStanza (stanza) {
     if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
     if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
         return;
         return;
     }
     }
-    const device_id = items_el.getAttribute('node').split(':')[1],
-            jid = stanza.getAttribute('from'),
-            bundle_el = sizzle(`item > bundle`, items_el).pop(),
-            devicelist = _converse.devicelists.getDeviceList(jid),
-            device = devicelist.devices.get(device_id) || devicelist.devices.create({'id': device_id, 'jid': jid});
+    const device_id = items_el.getAttribute('node').split(':')[1];
+    const jid = stanza.getAttribute('from');
+    const bundle_el = sizzle(`item > bundle`, items_el).pop();
+    const devicelist = _converse.devicelists.getDeviceList(jid);
+    const device = devicelist.devices.get(device_id) || devicelist.devices.create({'id': device_id, 'jid': jid});
     device.save({'bundle': parseBundle(bundle_el)});
     device.save({'bundle': parseBundle(bundle_el)});
 }
 }
 
 
@@ -260,10 +408,9 @@ async function initOMEMO () {
         return;
         return;
     }
     }
     /**
     /**
-        * Triggered once OMEMO support has been initialized
-        * @event _converse#OMEMOInitialized
-        * @example _converse.api.listen.on('OMEMOInitialized', () => { ... });
-        */
+     * Triggered once OMEMO support has been initialized
+     * @event _converse#OMEMOInitialized
+     * @example _converse.api.listen.on('OMEMOInitialized', () => { ... }); */
     api.trigger('OMEMOInitialized');
     api.trigger('OMEMOInitialized');
 }
 }
 
 
@@ -298,7 +445,6 @@ async function checkOMEMOSupported (chatbox) {
     }
     }
 }
 }
 
 
-
 function toggleOMEMO (ev) {
 function toggleOMEMO (ev) {
     ev.stopPropagation();
     ev.stopPropagation();
     ev.preventDefault();
     ev.preventDefault();
@@ -491,120 +637,8 @@ converse.plugins.add('converse-omemo', {
          */
          */
         const OMEMOEnabledChatBox = {
         const OMEMOEnabledChatBox = {
 
 
-            async 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)),
-                      key = await crypto.subtle.generateKey(KEY_ALGO, true, ["encrypt", "decrypt"]),
-                      algo = {
-                          'name': 'AES-GCM',
-                          'iv': iv,
-                          'tagLength': TAG_LENGTH
-                      },
-                      encrypted = await crypto.subtle.encrypt(algo, key, u.stringToArrayBuffer(plaintext)),
-                      length = encrypted.byteLength - ((128 + 7) >> 3),
-                      ciphertext = encrypted.slice(0, length),
-                      tag = encrypted.slice(length),
-                      exported_key = await crypto.subtle.exportKey("raw", key);
-
-                return Promise.resolve({
-                    'key': exported_key,
-                    'tag': tag,
-                    'key_and_tag': u.appendArrayBuffer(exported_key, tag),
-                    'payload': u.arrayBufferToBase64(ciphertext),
-                    'iv': u.arrayBufferToBase64(iv)
-                });
-            },
-
-            async 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 = {
-                    'name': "AES-GCM",
-                    'iv': u.base64ToArrayBuffer(obj.iv),
-                    'tagLength': TAG_LENGTH
-                };
-                return u.arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
-            },
-
-            reportDecryptionError (e) {
-                if (api.settings.get("loglevel") === 'debug') {
-                    this.createMessage({
-                        'message': __("Sorry, could not decrypt a received OMEMO message due to an error.") + ` ${e.name} ${e.message}`,
-                        'type': 'error',
-                    });
-                }
-                log.error(`${e.name} ${e.message}`);
-            },
-
-            async handleDecryptedWhisperMessage (attrs, key_and_tag) {
-                const encrypted = attrs.encrypted;
-                const devicelist = _converse.devicelists.getDeviceList(this.get('jid'));
-                await devicelist._devices_promise;
-
-                this.save('omemo_supported', true);
-                let device = devicelist.get(encrypted.device_id);
-                if (!device) {
-                    device = await devicelist.devices.create({'id': encrypted.device_id, 'jid': attrs.from}, {'promise': true});
-                }
-                if (encrypted.payload) {
-                    const key = key_and_tag.slice(0, 16);
-                    const tag = key_and_tag.slice(16);
-                    const result = await this.decryptMessage(Object.assign(encrypted, {'key': key, 'tag': tag}));
-                    device.save('active', true);
-                    return result;
-                }
-            },
-
-            async decrypt (attrs) {
-                const session_cipher = this.getSessionCipher(attrs.from, parseInt(attrs.encrypted.device_id, 10));
-
-                // https://xmpp.org/extensions/xep-0384.html#usecases-receiving
-                const key = u.base64ToArrayBuffer(attrs.encrypted.key);
-                if (attrs.encrypted.prekey === true) {
-                    try {
-                        const key_and_tag = await session_cipher.decryptPreKeyWhisperMessage(key, 'binary');
-                        const plaintext = await this.handleDecryptedWhisperMessage(attrs, key_and_tag);
-                        await _converse.omemo_store.generateMissingPreKeys();
-                        await _converse.omemo_store.publishBundle();
-                        if (plaintext) {
-                            return Object.assign(attrs, {'plaintext': plaintext});
-                        } else {
-                            return Object.assign(attrs, {'is_only_key': true});
-                        }
-                    } catch (e) {
-                        this.reportDecryptionError(e);
-                        return attrs;
-                    }
-                } else {
-                    try {
-                        const key_and_tag = await session_cipher.decryptWhisperMessage(key, 'binary')
-                        const plaintext = await this.handleDecryptedWhisperMessage(attrs, key_and_tag);
-                        return Object.assign(attrs, {'plaintext': plaintext});
-                    } catch (e) {
-                        this.reportDecryptionError(e);
-                        return attrs;
-                    }
-                }
-            },
-
-            getSessionCipher (jid, id) {
-                const address = new libsignal.SignalProtocolAddress(jid, id);
-                this.session_cipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
-                return this.session_cipher;
-            },
-
             encryptKey (plaintext, device) {
             encryptKey (plaintext, device) {
-                return this.getSessionCipher(device.get('jid'), device.get('id'))
+                return getSessionCipher(device.get('jid'), device.get('id'))
                     .encrypt(plaintext)
                     .encrypt(plaintext)
                     .then(payload => ({'payload': payload, 'device': device}));
                     .then(payload => ({'payload': payload, 'device': device}));
             },
             },
@@ -713,7 +747,7 @@ converse.plugins.add('converse-omemo', {
             stanza.c('encrypted', {'xmlns': Strophe.NS.OMEMO})
             stanza.c('encrypted', {'xmlns': Strophe.NS.OMEMO})
                   .c('header', {'sid':  _converse.omemo_store.get('device_id')});
                   .c('header', {'sid':  _converse.omemo_store.get('device_id')});
 
 
-            return chatbox.encryptMessage(message.get('message')).then(obj => {
+            return omemo.encryptMessage(message.get('message')).then(obj => {
                 // The 16 bytes key and the GCM authentication tag (The tag
                 // The 16 bytes key and the GCM authentication tag (The tag
                 // SHOULD have at least 128 bit) are concatenated and for each
                 // SHOULD have at least 128 bit) are concatenated and for each
                 // intended recipient device, i.e. both own devices as well as
                 // intended recipient device, i.e. both own devices as well as
@@ -916,17 +950,20 @@ converse.plugins.add('converse-omemo', {
                 device.save('bundle', Object.assign(bundle, {'prekeys': marshalled_keys}));
                 device.save('bundle', Object.assign(bundle, {'prekeys': marshalled_keys}));
             },
             },
 
 
+            /**
+             * Generate a the data used by the X3DH key agreement protocol
+             * that can be used to build a session with a device.
+             */
             async generateBundle () {
             async generateBundle () {
-                /* The first thing that needs to happen if a client wants to
-                 * start using OMEMO is they need to generate an IdentityKey
-                 * and a Device ID. The IdentityKey is a Curve25519 [6]
-                 * public/private Key pair. The Device ID is a randomly
-                 * generated integer between 1 and 2^31 - 1.
-                 */
+                // The first thing that needs to happen if a client wants to
+                // start using OMEMO is they need to generate an IdentityKey
+                // and a Device ID. The IdentityKey is a Curve25519 [6]
+                // public/private Key pair. The Device ID is a randomly
+                // generated integer between 1 and 2^31 - 1.
                 const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
                 const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
-                const bundle = {},
-                      identity_key = u.arrayBufferToBase64(identity_keypair.pubKey),
-                      device_id = generateDeviceID();
+                const bundle = {};
+                const identity_key = u.arrayBufferToBase64(identity_keypair.pubKey);
+                const device_id = generateDeviceID();
 
 
                 bundle['identity_key'] = identity_key;
                 bundle['identity_key'] = identity_key;
                 bundle['device_id'] = device_id;
                 bundle['device_id'] = device_id;
@@ -1078,6 +1115,16 @@ converse.plugins.add('converse-omemo', {
                 return this._devices_promise;
                 return this._devices_promise;
             },
             },
 
 
+            async getOwnDeviceId () {
+                let device_id = _converse.omemo_store.get('device_id');
+                if (!this.devices.findWhere({'id': device_id})) {
+                    // Generate a new bundle if we cannot find our device
+                    await _converse.omemo_store.generateBundle();
+                    device_id = _converse.omemo_store.get('device_id');
+                }
+                return device_id;
+            },
+
             async publishCurrentDevice (device_ids) {
             async publishCurrentDevice (device_ids) {
                 if (this.get('jid') !== _converse.bare_jid) {
                 if (this.get('jid') !== _converse.bare_jid) {
                     return // We only publish for ourselves.
                     return // We only publish for ourselves.
@@ -1090,14 +1137,7 @@ converse.plugins.add('converse-omemo', {
                     log.warn('publishCurrentDevice: omemo_store is not defined, likely a timing issue');
                     log.warn('publishCurrentDevice: omemo_store is not defined, likely a timing issue');
                     return;
                     return;
                 }
                 }
-
-                let device_id = _converse.omemo_store.get('device_id');
-                if (!this.devices.findWhere({'id': device_id})) {
-                    // Generate a new bundle if we cannot find our device
-                    await _converse.omemo_store.generateBundle();
-                    device_id = _converse.omemo_store.get('device_id');
-                }
-                if (!device_ids.includes(device_id)) {
+                if (!device_ids.includes(await this.getOwnDeviceId())) {
                     return this.publishDevices();
                     return this.publishDevices();
                 }
                 }
             },
             },
@@ -1125,6 +1165,11 @@ converse.plugins.add('converse-omemo', {
                 return device_ids;
                 return device_ids;
             },
             },
 
 
+            /**
+             * Send an IQ stanza to the current user's "devices" PEP node to
+             * ensure that all devices are published for potential chat partners to see.
+             * See: https://xmpp.org/extensions/xep-0384.html#usecases-announcing
+             */
             publishDevices () {
             publishDevices () {
                 const item = $build('item').c('list', {'xmlns': Strophe.NS.OMEMO})
                 const item = $build('item').c('list', {'xmlns': Strophe.NS.OMEMO})
                 this.devices.filter(d => d.get('active')).forEach(d => item.c('device', {'id': d.get('id')}).up());
                 this.devices.filter(d => d.get('active')).forEach(d => item.c('device', {'id': d.get('id')}).up());
@@ -1160,8 +1205,22 @@ converse.plugins.add('converse-omemo', {
             }
             }
         });
         });
 
 
+        function parseEncryptedMessage (attrs) {
+            if (attrs.is_encrypted) {
+                // https://xmpp.org/extensions/xep-0384.html#usecases-receiving
+                if (attrs.encrypted.prekey === true) {
+                    return decryptPrekeyWhisperMessage(attrs);
+                } else {
+                    return decryptWhisperMessage(attrs);
+                }
+            } else {
+                return attrs;
+            }
+        }
 
 
         /******************** Event Handlers ********************/
         /******************** Event Handlers ********************/
+        api.listen.on('parseMessage', parseEncryptedMessage);
+        api.listen.on('parseMUCMessage', parseEncryptedMessage);
 
 
         api.waitUntil('chatBoxesInitialized').then(() =>
         api.waitUntil('chatBoxesInitialized').then(() =>
             _converse.chatboxes.on('add', chatbox => {
             _converse.chatboxes.on('add', chatbox => {
@@ -1174,6 +1233,11 @@ converse.plugins.add('converse-omemo', {
         );
         );
 
 
         const onChatInitialized = view => {
         const onChatInitialized = view => {
+            view.listenTo(view.model.messages, 'add', (message) => {
+                if (message.get('is_encrypted') && !message.get('is_error')) {
+                    view.model.save('omemo_supported', true);
+                }
+            });
             view.listenTo(view.model, 'change:omemo_supported', () => {
             view.listenTo(view.model, 'change:omemo_supported', () => {
                 if (!view.model.get('omemo_supported') && view.model.get('omemo_active')) {
                 if (!view.model.get('omemo_supported') && view.model.get('omemo_active')) {
                     view.model.set('omemo_active', false);
                     view.model.set('omemo_active', false);
@@ -1244,8 +1308,8 @@ converse.plugins.add('converse-omemo', {
                      */
                      */
                     'generate': async () => {
                     'generate': async () => {
                         // Remove current device
                         // Remove current device
-                        const devicelist = _converse.devicelists.get(_converse.bare_jid),
-                              device_id = _converse.omemo_store.get('device_id');
+                        const devicelist = _converse.devicelists.get(_converse.bare_jid);
+                        const device_id = _converse.omemo_store.get('device_id');
                         if (device_id) {
                         if (device_id) {
                             const device = devicelist.devices.get(device_id);
                             const device = devicelist.devices.get(device_id);
                             _converse.omemo_store.unset(device_id);
                             _converse.omemo_store.unset(device_id);

+ 1 - 3
src/headless/converse-chat.js

@@ -457,8 +457,6 @@ converse.plugins.add('converse-chat', {
                     attrs.stanza && log.error(attrs.stanza);
                     attrs.stanza && log.error(attrs.stanza);
                     return log.error(attrs.message);
                     return log.error(attrs.message);
                 }
                 }
-                // TODO: move to OMEMO
-                attrs = attrs.encrypted ? await this.decrypt(attrs) : attrs;
                 const message = this.getDuplicateMessage(attrs);
                 const message = this.getDuplicateMessage(attrs);
                 if (message) {
                 if (message) {
                     this.updateMessage(message, attrs);
                     this.updateMessage(message, attrs);
@@ -1215,7 +1213,7 @@ converse.plugins.add('converse-chat', {
             }
             }
             const has_body = !!sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length;
             const has_body = !!sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length;
             const chatbox = await api.chats.get(attrs.contact_jid, {'nickname': attrs.nick }, has_body);
             const chatbox = await api.chats.get(attrs.contact_jid, {'nickname': attrs.nick }, has_body);
-            chatbox && await chatbox.queueMessage(attrs);
+            await chatbox?.queueMessage(attrs);
             /**
             /**
              * Triggered when a message stanza is been received and processed.
              * Triggered when a message stanza is been received and processed.
              * @event _converse#message
              * @event _converse#message

+ 0 - 2
src/headless/converse-muc.js

@@ -1993,8 +1993,6 @@ converse.plugins.add('converse-muc', {
                     attrs.stanza && log.error(attrs.stanza);
                     attrs.stanza && log.error(attrs.stanza);
                     return log.error(attrs.message);
                     return log.error(attrs.message);
                 }
                 }
-                // TODO: move to OMEMO
-                attrs = attrs.encrypted ? await this.decrypt(attrs) : attrs;
                 const message = this.getDuplicateMessage(attrs);
                 const message = this.getDuplicateMessage(attrs);
                 if (message) {
                 if (message) {
                     return this.updateMessage(message, attrs);
                     return this.updateMessage(message, attrs);

+ 11 - 4
src/headless/utils/stanza.js

@@ -362,7 +362,6 @@ const st = {
         }, {});
         }, {});
     },
     },
 
 
-
     /**
     /**
      * Parses a passed in message stanza and returns an object of attributes.
      * Parses a passed in message stanza and returns an object of attributes.
      * @method st#parseMessage
      * @method st#parseMessage
@@ -418,7 +417,6 @@ const st = {
             );
             );
         }
         }
 
 
-
         const is_headline = st.isHeadline(stanza);
         const is_headline = st.isHeadline(stanza);
         const is_server_message = st.isServerMessage(stanza);
         const is_server_message = st.isServerMessage(stanza);
         let contact, contact_jid;
         let contact, contact_jid;
@@ -534,7 +532,12 @@ const st = {
         // We prefer to use one of the XEP-0359 unique and stable stanza IDs
         // We prefer to use one of the XEP-0359 unique and stable stanza IDs
         // as the Model id, to avoid duplicates.
         // as the Model id, to avoid duplicates.
         attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from)}`] || u.getUniqueId();
         attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from)}`] || u.getUniqueId();
-        return attrs;
+
+        /**
+         * *Hook* which allows plugins to add additional parsing
+         * @event _converse#parseMessage
+         */
+        return api.hook('parseMessage', attrs);
     },
     },
 
 
     /**
     /**
@@ -678,7 +681,11 @@ const st = {
         }
         }
         // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates.
         // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates.
         attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from_muc || attrs.from)}`] || u.getUniqueId();
         attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from_muc || attrs.from)}`] || u.getUniqueId();
-        return attrs;
+        /**
+         * *Hook* which allows plugins to add additional parsing
+         * @event _converse#parseMUCMessage
+         */
+        return api.hook('parseMUCMessage', attrs);
     },
     },
 
 
     /**
     /**