Browse Source

Add method to generate missing prekeys

When receiving a PreKeySignalMessage, then a prekey has been chosen and
should now be removed from the list of available prekeys in the bundle,
so that a different device doesn't choose it as well.

AFAICT, libsignal removes the prekey, so it's then up to us to
regenerate it and republish our bundle.

updates #497
JC Brand 6 years ago
parent
commit
15a4bcd11e
3 changed files with 195 additions and 61 deletions
  1. 139 39
      spec/omemo.js
  2. 51 22
      src/converse-omemo.js
  3. 5 0
      tests/mock.js

+ 139 - 39
spec/omemo.js

@@ -37,6 +37,40 @@
         ).pop(), 'nodeTree');
     }
 
+    function initializedOMEMO (_converse) {
+        return test_utils.waitUntil(() => deviceListFetched(_converse, _converse.bare_jid))
+        .then(iq_stanza => {
+            const stanza = $iq({
+                'from': _converse.bare_jid,
+                'id': iq_stanza.getAttribute('id'),
+                'to': _converse.bare_jid,
+                'type': 'result',
+            }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+                .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+                    .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+                        .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+                            .c('device', {'id': '482886413b977930064a5888b92134fe'});
+            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+            return test_utils.waitUntil(() => ownDeviceHasBeenPublished(_converse))
+        }).then(iq_stanza => {
+            const stanza = $iq({
+                'from': _converse.bare_jid,
+                'id': iq_stanza.getAttribute('id'),
+                'to': _converse.bare_jid,
+                'type': 'result'});
+            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+            return test_utils.waitUntil(() => bundleHasBeenPublished(_converse))
+        }).then(iq_stanza => {
+            const stanza = $iq({
+                'from': _converse.bare_jid,
+                'id': iq_stanza.getAttribute('id'),
+                'to': _converse.bare_jid,
+                'type': 'result'});
+            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+            return _converse.api.waitUntil('OMEMOInitialized');
+        });
+    }
+
 
     describe("The OMEMO module", function() {
 
@@ -73,43 +107,10 @@
             _converse.emit('rosterContactsFetched');
             const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
 
-            test_utils.waitUntil(() => deviceListFetched(_converse, _converse.bare_jid))
+            return test_utils.waitUntil(() => initializedOMEMO(_converse))
+            .then(() => test_utils.openChatBoxFor(_converse, contact_jid))
+            .then(() => test_utils.waitUntil(() => deviceListFetched(_converse, contact_jid)))
             .then(iq_stanza => {
-                const stanza = $iq({
-                    'from': _converse.bare_jid,
-                    'id': iq_stanza.getAttribute('id'),
-                    'to': _converse.bare_jid,
-                    'type': 'result',
-                }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
-                    .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
-                        .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
-                            .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
-                                .c('device', {'id': '482886413b977930064a5888b92134fe'});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                return test_utils.waitUntil(() => ownDeviceHasBeenPublished(_converse))
-            }).then(iq_stanza => {
-                const stanza = $iq({
-                    'from': _converse.bare_jid,
-                    'id': iq_stanza.getAttribute('id'),
-                    'to': _converse.bare_jid,
-                    'type': 'result'});
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                return test_utils.waitUntil(() => bundleHasBeenPublished(_converse))
-            }).then(iq_stanza => {
-                const stanza = $iq({
-                    'from': _converse.bare_jid,
-                    'id': iq_stanza.getAttribute('id'),
-                    'to': _converse.bare_jid,
-                    'type': 'result'
-                });
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                return _converse.api.waitUntil('OMEMOInitialized');
-            }).then(() => test_utils.openChatBoxFor(_converse, contact_jid))
-              .then(() => test_utils.waitUntil(() => deviceListFetched(_converse, contact_jid)))
-              .then(iq_stanza => {
                 const stanza = $iq({
                     'from': contact_jid,
                     'id': iq_stanza.getAttribute('id'),
@@ -158,7 +159,7 @@
                 _converse.connection._dataRecv(test_utils.createRequest(stanza));
 
                 return test_utils.waitUntil(() => bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
-            }).then((iq_stanza) => {
+            }).then(iq_stanza => {
                 const stanza = $iq({
                     'from': _converse.bare_jid,
                     'id': iq_stanza.getAttribute('id'),
@@ -204,13 +205,13 @@
                 const key = btoa(JSON.stringify({
                     'type': 1,
                     'body': obj.key_and_tag,
-                    'registrationId': '1337' 
+                    'registrationId': '1337'
                 }));
                 const stanza = $msg({
                         'from': contact_jid,
                         'to': _converse.connection.jid,
                         'type': 'chat',
-                        'id': 'qwerty' 
+                        'id': 'qwerty'
                     }).c('body').t('This is a fallback message').up()
                         .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
                             .c('header', {'sid':  '555'})
@@ -229,6 +230,103 @@
             }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL))
         }));
 
+
+        it("can receive a PreKeySignalMessage",
+                mock.initConverseWithPromises(
+                    null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                    function (done, _converse) {
+
+            _converse.NUM_PREKEYS = 5; // Restrict to 5, otherwise the resulting stanza is too large to easily test
+            let view, sent_stanza;
+            test_utils.createContacts(_converse, 'current', 1);
+            _converse.emit('rosterContactsFetched');
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
+
+            return test_utils.waitUntil(() => initializedOMEMO(_converse))
+            .then(() => _converse.ChatBox.prototype.encryptMessage('This is an encrypted message from the contact'))
+            .then(obj => {
+                // 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.
+                const key = btoa(JSON.stringify({
+                    'type': 1,
+                    'body': obj.key_and_tag,
+                    'registrationId': '1337'
+                }));
+                const stanza = $msg({
+                        'from': contact_jid,
+                        'to': _converse.connection.jid,
+                        'type': 'chat',
+                        'id': 'qwerty'
+                    }).c('body').t('This is a fallback message').up()
+                        .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+                            .c('header', {'sid':  '555'})
+                                .c('key', {'prekey': 'true', 'rid':  _converse.omemo_store.get('device_id')}).t(key).up()
+                                .c('iv').t(obj.iv)
+                                .up().up()
+                            .c('payload').t(obj.payload);
+
+                const generateMissingPreKeys = _converse.omemo_store.generateMissingPreKeys;
+                spyOn(_converse.omemo_store, 'generateMissingPreKeys').and.callFake(() => {
+                    // Since it's difficult to override
+                    // decryptPreKeyWhisperMessage, where a prekey will be
+                    // removed from the store, we do it here, before the
+                    // missing prekeys are generated.
+                    _converse.omemo_store.removePreKey(1);
+                    return generateMissingPreKeys.apply(_converse.omemo_store, arguments);
+                });
+                _converse.connection._dataRecv(test_utils.createRequest(stanza));
+                return test_utils.waitUntil(() => _converse.chatboxviews.get(contact_jid))
+            }).then(iq_stanza => deviceListFetched(_converse, contact_jid))
+            .then(iq_stanza => {
+                const stanza = $iq({
+                    'from': contact_jid,
+                    'id': iq_stanza.getAttribute('id'),
+                    'to': _converse.connection.jid,
+                    'type': 'result',
+                }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+                    .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+                        .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+                            .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+                                .c('device', {'id': '555'});
+
+                // XXX: the bundle gets published twice, we want to make sure
+                // that we wait for the 2nd, so we clear all the already sent
+                // stanzas.
+                _converse.connection.IQ_stanzas = [];
+                _converse.connection._dataRecv(test_utils.createRequest(stanza));
+                return test_utils.waitUntil(() => _converse.omemo_store);
+            }).then(() => test_utils.waitUntil(() => bundleHasBeenPublished(_converse)))
+            .then(iq_stanza => {
+                expect(iq_stanza.outerHTML).toBe(
+                    `<iq from="dummy@localhost" type="set" xmlns="jabber:client" id="${iq_stanza.getAttribute('id')}">`+
+                        `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+                            `<publish node="eu.siacs.conversations.axolotl.bundles:123456789">`+
+                                `<item>`+
+                                    `<bundle xmlns="eu.siacs.conversations.axolotl">`+
+                                        `<signedPreKeyPublic signedPreKeyId="0">${btoa('1234')}</signedPreKeyPublic>`+
+                                            `<signedPreKeySignature>${btoa('11112222333344445555')}</signedPreKeySignature>`+
+                                            `<identityKey>${btoa('1234')}</identityKey>`+
+                                        `<prekeys>`+
+                                            `<preKeyPublic preKeyId="0">${btoa('1234')}</preKeyPublic>`+
+                                            `<preKeyPublic preKeyId="1">${btoa('1234')}</preKeyPublic>`+
+                                            `<preKeyPublic preKeyId="2">${btoa('1234')}</preKeyPublic>`+
+                                            `<preKeyPublic preKeyId="3">${btoa('1234')}</preKeyPublic>`+
+                                            `<preKeyPublic preKeyId="4">${btoa('1234')}</preKeyPublic>`+
+                                        `</prekeys>`+
+                                    `</bundle>`+
+                                `</item>`+
+                            `</publish>`+
+                        `</pubsub>`+
+                    `</iq>`)
+                const own_device = _converse.devicelists.get(_converse.bare_jid).devices.get(_converse.omemo_store.get('device_id'));
+                expect(own_device.get('bundle').prekeys.length).toBe(5);
+                expect(_converse.omemo_store.generateMissingPreKeys).toHaveBeenCalled();
+                done();
+            }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL))
+        }));
+
+
         it("will add processing hints to sent out encrypted <message> stanzas",
             mock.initConverseWithPromises(
                 null, ['rosterGroupsFetched'], {},
@@ -237,6 +335,7 @@
             done();
         }));
 
+
         it("updates device lists based on PEP messages",
             mock.initConverseWithPromises(
                 null, ['rosterGroupsFetched'], {},
@@ -388,6 +487,7 @@
             }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL))
         }));
 
+
         it("updates device bundles based on PEP messages",
             mock.initConverseWithPromises(
                 null, ['rosterGroupsFetched'], {},

+ 51 - 22
src/converse-omemo.js

@@ -157,8 +157,8 @@
                     return _converse.getDevicesForContact(this.get('jid'))
                         .then((their_devices) => {
                             const device_id = _converse.omemo_store.get('device_id'),
-                                devicelist = _converse.devicelists.get(_converse.bare_jid),
-                                own_devices = devicelist.devices.filter(device => device.get('id') !== device_id);
+                                  devicelist = _converse.devicelists.get(_converse.bare_jid),
+                                  own_devices = devicelist.devices.filter(device => device.get('id') !== device_id);
                             devices = _.concat(own_devices, their_devices.models);
                             return Promise.all(devices.map(device => device.getBundle()));
                         }).then(() => this.buildSessions(devices))
@@ -240,32 +240,30 @@
 
                 decrypt (attrs) {
                     const { _converse } = this.__super__,
-                          devicelist = _converse.devicelists.get(attrs.from),
-                          device = devicelist.devices.get(attrs.encrypted.device_id),
-                          address  = new libsignal.SignalProtocolAddress(
-                              attrs.from,
-                              parseInt(attrs.encrypted.device_id, 10)
-                          ),
+                          address  = new libsignal.SignalProtocolAddress(attrs.from, parseInt(attrs.encrypted.device_id, 10)),
                           session_cipher = new window.libsignal.SessionCipher(_converse.omemo_store, address),
                           libsignal_payload = JSON.parse(atob(attrs.encrypted.key));
 
+                    // https://xmpp.org/extensions/xep-0384.html#usecases-receiving
                     if (attrs.encrypted.prekey === 'true') {
-                        // If this is the case, a new session is built from this received element. The client
-                        // SHOULD then republish their bundle information, replacing the used PreKey, such
-                        // that it won't be used again by a different client. If the client already has a session
-                        // with the sender's device, it MUST replace this session with the newly built session.
-                        // The client MUST delete the private key belonging to the PreKey after use.
+                        let plaintext;
                         return session_cipher.decryptPreKeyWhisperMessage(libsignal_payload.body, 'binary')
                             .then(key_and_tag => {
-                                const aes_data = this.getKeyAndTag(u.arrayBufferToString(key_and_tag));
-                                return this.decryptMessage(_.extend(attrs.encrypted, {'key': aes_data.key, 'tag': aes_data.tag}));
-                            }).then(plaintext => {
-                                // TODO the prekey should now have been removed.
-                                // Double-check that this is the case and then
-                                // generate a new key to replace it, before
-                                // republishing.
-                                _converse.omemo_store.publishBundle()
-                                return _.extend(attrs, {'plaintext': plaintext});
+                                if (attrs.encrypted.payload) {
+                                    const aes_data = this.getKeyAndTag(u.arrayBufferToString(key_and_tag));
+                                    return this.decryptMessage(_.extend(attrs.encrypted, {'key': aes_data.key, 'tag': aes_data.tag}));
+                                }
+                                return Promise.resolve();
+                            }).then(pt => {
+                                plaintext = pt;
+                                return _converse.omemo_store.generateMissingPreKeys();
+                            }).then(() => _converse.omemo_store.publishBundle())
+                              .then(() => {
+                                if (plaintext) {
+                                    return _.extend(attrs, {'plaintext': plaintext});
+                                } else {
+                                    return _.extend(attrs, {'is_only_key': true});
+                                }
                             }).catch((e) => {
                                 this.reportDecryptionError(e);
                                 return attrs;
@@ -446,6 +444,13 @@
                     'click .toggle-omemo': 'toggleOMEMO'
                 },
 
+                showMessage (message) {
+                    // We don't show a message if it's only keying material
+                    if (!message.get('is_only_key')) {
+                        return this.__super__.showMessage.apply(this, arguments);
+                    }
+                },
+
                 renderOMEMOToolbarButton () {
                     const { _converse } = this.__super__,
                           { __ } = _converse;
@@ -497,6 +502,10 @@
                     .then(devices => Promise.all(devices.map(d => generateFingerprint(d))))
             }
 
+            _converse.getDeviceForContact = function (jid, device_id) {
+                return _converse.getDevicesForContact(jid).then(devices => devices.get(device_id));
+            }
+
             _converse.getDevicesForContact = function (jid) {
                 let devicelist;
                 return _converse.api.waitUntil('OMEMOInitialized')
@@ -705,6 +714,26 @@
                     return _converse.api.sendIQ(stanza);
                 },
 
+                generateMissingPreKeys () {
+                    const current_keys = this.getPreKeys(),
+                          missing_keys = _.difference(_.invokeMap(_.range(0, _converse.NUM_PREKEYS), Number.prototype.toString), _.keys(current_keys));
+
+                    if (missing_keys.length < 1) {
+                        _converse.log("No missing prekeys to generate for our own device", Strophe.LogLevel.WARN);
+                        return Promise.resolve();
+                    }
+                    return Promise.all(_.map(missing_keys, id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10))))
+                        .then(keys => {
+                            _.forEach(keys, k => this.storePreKey(k.keyId, k.keyPair));
+                            const marshalled_keys = _.map(this.getPreKeys(), k => ({'id': k.keyId, 'key': u.arrayBufferToBase64(k.pubKey)})),
+                                  devicelist = _converse.devicelists.get(_converse.bare_jid),
+                                  device = devicelist.devices.get(this.get('device_id'));
+
+                            return device.getBundle()
+                                .then(bundle => device.save('bundle', _.extend(bundle, {'prekeys': marshalled_keys})));
+                        });
+                },
+
                 generateBundle () {
                     /* The first thing that needs to happen if a client wants to
                      * start using OMEMO is they need to generate an IdentityKey

+ 5 - 0
tests/mock.js

@@ -21,6 +21,11 @@
                 'body': 'c1ph3R73X7',
                 'registrationId': '1337' 
             });
+            this.decryptPreKeyWhisperMessage = (key_and_tag) => {
+                // TODO: remove the prekey
+                return Promise.resolve(u.stringToArrayBuffer(key_and_tag));
+            };
+
             this.decryptWhisperMessage = (key_and_tag) => {
                 return Promise.resolve(u.stringToArrayBuffer(key_and_tag));
             }