浏览代码

Implement and test sending of encrypted messages

updates #497
JC Brand 7 年之前
父节点
当前提交
a3593dbc7d
共有 6 个文件被更改,包括 389 次插入122 次删除
  1. 128 60
      dist/converse.js
  2. 118 2
      spec/omemo.js
  3. 122 59
      src/converse-omemo.js
  4. 4 0
      src/utils/core.js
  5. 1 1
      src/utils/form.js
  6. 16 0
      tests/mock.js

+ 128 - 60
dist/converse.js

@@ -73263,85 +73263,143 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
           const _converse = this.__super__._converse;
           const _converse = this.__super__._converse;
           return new Promise((resolve, reject) => {
           return new Promise((resolve, reject) => {
             _converse.getDevicesForContact(this.get('jid')).then(devices => {
             _converse.getDevicesForContact(this.get('jid')).then(devices => {
-              const promises = devices.map(device => device.getBundle());
-              Promise.all(promises).then(() => {
-                this.buildSessions(devices).then(() => resolve(devices)).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
-              }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+              Promise.all(devices.map(device => device.getBundle())).then(() => this.buildSessions(devices)).then(() => resolve(devices)).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
             }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
             }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
-          }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+          });
+        },
+
+        buildSession(device) {
+          const _converse = this.__super__._converse;
+          const bundle = device.get('bundle'),
+                address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')),
+                sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address),
+                prekey = device.getRandomPreKey();
+          return sessionBuilder.processPreKey({
+            'registrationId': _converse.omemo_store.get('registration_id'),
+            'identityKey': _converse.omemo_store.get('identity_keypair'),
+            'signedPreKey': {
+              'keyId': bundle.signed_prekey.id,
+              // <Number>
+              'publicKey': u.base64ToArrayBuffer(bundle.signed_prekey.public_key),
+              'signature': u.base64ToArrayBuffer(bundle.signed_prekey.signature)
+            },
+            'preKey': {
+              'keyId': prekey.id,
+              // <Number>
+              'publicKey': u.base64ToArrayBuffer(prekey.key)
+            }
+          });
         },
         },
 
 
         buildSessions(devices) {
         buildSessions(devices) {
+          return Promise.all(devices.map(device => this.buildSession(device)));
+        },
+
+        encryptMessage(plaintext) {
+          // The client MUST use fresh, randomly generated key/IV pairs
+          // with AES-128 in Galois/Counter Mode (GCM).
+          const TAG_LENGTH = 128,
+                iv = window.crypto.getRandomValues(new window.Uint8Array(16));
+          let key;
+          return window.crypto.subtle.generateKey({
+            'name': "AES-GCM",
+            'length': 256
+          }, true, // extractable
+          ["encrypt", "decrypt"] // key usages
+          ).then(result => {
+            key = result;
+            const algo = {
+              'name': 'AES-GCM',
+              'iv': iv,
+              'tagLength': TAG_LENGTH
+            };
+            return window.crypto.subtle.encrypt(algo, key, new TextEncoder().encode(plaintext));
+          }).then(ciphertext => {
+            return window.crypto.subtle.exportKey("jwk", key).then(key_str => {
+              return Promise.resolve({
+                'key_str': key_str,
+                'tag': ciphertext.slice(ciphertext.byteLength - (TAG_LENGTH + 7 >> 3)),
+                'iv': iv
+              });
+            });
+          });
+        },
+
+        encryptKey(plaintext, device) {
           const _converse = this.__super__._converse,
           const _converse = this.__super__._converse,
-                device_id = _converse.omemo_store.get('device_id');
-
-          return Promise.all(_.map(devices, device => {
-            const recipient_id = device['id'];
-            const address = new libsignal.SignalProtocolAddress(parseInt(recipient_id, 10), device_id);
-            const sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address);
-            return sessionBuilder.processPreKey({
-              'registrationId': _converse.omemo_store.get('registration_id'),
-              'identityKey': _converse.omemo_store.get('identity_keypair'),
-              'signedPreKey': {
-                'keyId': '',
-                // <Number>,
-                'publicKey': '',
-                // <ArrayBuffer>,
-                'signature': '' // <ArrayBuffer>
+                address = new libsignal.SignalProtocolAddress(this.get('jid'), device.get('id')),
+                sessionCipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
+          return sessionCipher.encrypt(plaintext);
+        },
 
 
-              },
-              'preKey': {
-                'keyId': '',
-                // <Number>,
-                'publicKey': '' // <ArrayBuffer>
+        addKeysToMessageStanza(stanza, devices, payloads) {
+          for (var i in payloads) {
+            if (Object.prototype.hasOwnProperty.call(payloads, i)) {
+              const payload = btoa(JSON.stringify(payloads[i]));
+              const prekey = 3 == parseInt(payloads[i].type, 10);
 
 
+              if (i == payloads.length - 1) {
+                stanza.c('key', {
+                  'rid': devices.get('id')
+                }).t(payload);
+
+                if (prekey) {
+                  stanza.attrs({
+                    'prekey': prekey
+                  });
+                }
+
+                stanza.up().c('iv').t(payloads[0].iv).up().up();
+              } else {
+                stanza.c('key', {
+                  prekey: prekey,
+                  rid: devices.get('id')
+                }).t(payload).up();
               }
               }
-            });
-          }));
-        },
+            }
+          }
 
 
-        encryptMessage(message) {// TODO:
-          // const { _converse } = this.__super__;
-          // const plaintext = message.get('message');
-          // const address = new libsignal.SignalProtocolAddress(recipientId, deviceId);
-          // return new Promise((resolve, reject) => {
-          //     var sessionCipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
-          //     sessionCipher.encrypt(plaintext).then((ciphertext) => {});
-          // });
+          return Promise.resolve(stanza);
         },
         },
 
 
-        createOMEMOMessageStanza(message, bundles) {
+        createOMEMOMessageStanza(message, devices) {
           const _converse = this.__super__._converse,
           const _converse = this.__super__._converse,
                 __ = _converse.__;
                 __ = _converse.__;
 
 
-          const body = __("This is an OMEMO encrypted message which your client doesn’t seem to support. " + "Find more information on https://conversations.im/omemo");
+          const body = __("This is an OMEMO encrypted message which your client doesn’t seem to support. " + "Find more information on https://conversations.im/omemo"); // 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.
 
 
-          return new Promise((resolve, reject) => {
-            this.encryptMessage(message).then(payload => {
-              const stanza = $msg({
-                'from': _converse.connection.jid,
-                'to': this.get('jid'),
-                'type': this.get('message_type'),
-                'id': message.get('msgid')
-              }).c('body').t(body).up().c('encrypted').t(payload).c('header').t(payload).up();
-
-              _.forEach(bundles, bundle => {
-                const prekey = bundle.prekeys[Math.random(bundle.prekeys.length)].textContent;
-                stanza('key', {
-                  'rid': bundle.identity_key
-                }).t(prekey).up();
-              }); // TODO: set storage hint urn:xmpp:hints
-
-
-              resolve(stanza);
-            }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+
+          const stanza = $msg({
+            'from': _converse.connection.jid,
+            'to': this.get('jid'),
+            'type': this.get('message_type'),
+            'id': message.get('msgid')
+          }).c('body').t(body).up().c('encrypted', {
+            'xmlns': Strophe.NS.OMEMO
+          }).c('header', {
+            'sid': _converse.omemo_store.get('device_id')
+          });
+          return this.encryptMessage(message).then(payload => {
+            // 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.
+            // TODO: need to include own devices here as well (and filter out distrusted devices)
+            const promises = devices.map(device => this.encryptKey(payload.key_str + payload.tag, device));
+            return Promise.all(promises).then(payloads => this.addKeysToMessageStanza(stanza, devices, payloads));
           });
           });
         },
         },
 
 
         sendMessage(attrs) {
         sendMessage(attrs) {
+          const _converse = this.__super__._converse;
+
           if (this.get('omemo_active')) {
           if (this.get('omemo_active')) {
             const message = this.messages.create(attrs);
             const message = this.messages.create(attrs);
-            this.getBundlesAndBuildSessions().then(bundles => this.createOMEMOMessageStanza(message, bundles)).then(stanza => this.sendMessageStanza(stanza));
+            this.getBundlesAndBuildSessions().then(devices => this.createOMEMOMessageStanza(message, devices)).then(stanza => this.sendMessageStanza(stanza)).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
           } else {
           } else {
             return this.__super__.sendMessage.apply(this, arguments);
             return this.__super__.sendMessage.apply(this, arguments);
           }
           }
@@ -73643,6 +73701,12 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
           'trusted': UNDECIDED
           'trusted': UNDECIDED
         },
         },
 
 
+        getRandomPreKey() {
+          // XXX: assumes that the bundle has already been fetched
+          const bundle = this.get('bundle');
+          return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
+        },
+
         fetchBundleFromServer() {
         fetchBundleFromServer() {
           return new Promise((resolve, reject) => {
           return new Promise((resolve, reject) => {
             const stanza = $iq({
             const stanza = $iq({
@@ -73670,7 +73734,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
            * this device, if the information is not at hand already.
            * this device, if the information is not at hand already.
            */
            */
           if (this.get('bundle')) {
           if (this.get('bundle')) {
-            return Promise.resolve(this.get('bundle').toJSON());
+            return Promise.resolve(this.get('bundle').toJSON(), this);
           } else {
           } else {
             return this.fetchBundleFromServer();
             return this.fetchBundleFromServer();
           }
           }
@@ -82446,6 +82510,10 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
     return bytes.buffer;
     return bytes.buffer;
   };
   };
 
 
+  u.getRandomInt = function (max) {
+    return Math.floor(Math.random() * Math.floor(max));
+  };
+
   u.getUniqueId = function () {
   u.getUniqueId = function () {
     return 'xxxxxxxx-xxxx'.replace(/[x]/g, function (c) {
     return 'xxxxxxxx-xxxx'.replace(/[x]/g, function (c) {
       var r = Math.random() * 16 | 0,
       var r = Math.random() * 16 | 0,
@@ -82474,7 +82542,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
 //
 //
 // This is the utilities module.
 // This is the utilities module.
 //
 //
-// Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
+// Copyright (c) 2013-2018, Jan-Carel Brand <jc@opkode.com>
 // Licensed under the Mozilla Public License (MPLv2)
 // Licensed under the Mozilla Public License (MPLv2)
 //
 //
 
 

+ 118 - 2
spec/omemo.js

@@ -10,6 +10,122 @@
 
 
     describe("The OMEMO module", function() {
     describe("The OMEMO module", function() {
 
 
+        it("enables encrypted messages to be sent",
+            mock.initConverseWithPromises(
+                null, ['rosterGroupsFetched'], {},
+                function (done, _converse) {
+
+            var sent_stanza;
+            let iq_stanza;
+            test_utils.createContacts(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
+
+            // First, fetch own device list
+            return test_utils.waitUntil(() => {
+                return _.filter(
+                    _converse.connection.IQ_stanzas,
+                    (iq) => {
+                        const node = iq.nodeTree.querySelector('iq[to="'+_converse.bare_jid+'"] query[node="eu.siacs.conversations.axolotl.devicelist"]');
+                        if (node) { iq_stanza = iq.nodeTree;}
+                        return node;
+                    }).length;
+            }).then(() => {
+                const stanza = $iq({
+                    'from': contact_jid,
+                    'id': iq_stanza.getAttribute('id'),
+                    'to': _converse.bare_jid,
+                    'type': 'result',
+                }).c('query', {
+                    'xmlns': 'http://jabber.org/protocol/disco#items',
+                    'node': 'eu.siacs.conversations.axolotl.devicelist'
+                }).c('device', {'id': '482886413b977930064a5888b92134fe'}).up()
+                _converse.connection._dataRecv(test_utils.createRequest(stanza));
+
+                _converse.emit('OMEMOInitialized');
+
+                // Check that device list for contact is fetched when chat is opened.
+                test_utils.openChatBoxFor(_converse, contact_jid);
+                return test_utils.waitUntil(() => {
+                    return _.filter(
+                        _converse.connection.IQ_stanzas,
+                        (iq) => {
+                            const node = iq.nodeTree.querySelector('iq[to="'+contact_jid+'"] query[node="eu.siacs.conversations.axolotl.devicelist"]');
+                            if (node) { iq_stanza = iq.nodeTree; }
+                            return node;
+                        }).length;
+                });
+            }).then(() => {
+                const stanza = $iq({
+                    'from': contact_jid,
+                    'id': iq_stanza.getAttribute('id'),
+                    'to': _converse.bare_jid,
+                    'type': 'result',
+                }).c('query', {
+                    'xmlns': 'http://jabber.org/protocol/disco#items',
+                    'node': 'eu.siacs.conversations.axolotl.devicelist'
+                }).c('device', {'id': '555'}).up()
+                _converse.connection._dataRecv(test_utils.createRequest(stanza));
+
+                const devicelist = _converse.devicelists.create({'jid': contact_jid});
+                expect(devicelist.devices.length).toBe(1);
+
+                const view = _converse.chatboxviews.get(contact_jid);
+                view.model.set('omemo_active', true);
+
+                const textarea = view.el.querySelector('.chat-textarea');
+                textarea.value = 'This message will be encrypted';
+                view.keyPressed({
+                    target: textarea,
+                    preventDefault: _.noop,
+                    keyCode: 13 // Enter
+                });
+                return test_utils.waitUntil(() => {
+                    return _.filter(
+                        _converse.connection.IQ_stanzas,
+                        (iq) => {
+                            const node = iq.nodeTree.querySelector('iq[to="'+contact_jid+'"] items[node="eu.siacs.conversations.axolotl.bundles:555"]');
+                            if (node) { iq_stanza = iq.nodeTree; }
+                            return node;
+                        }).length;
+                });
+            }).then(() => {
+                const stanza = $iq({
+                    'from': contact_jid,
+                    'id': iq_stanza.getAttribute('id'),
+                    'to': _converse.bare_jid,
+                    'type': 'result',
+                }).c('pubsub', {
+                    'xmlns': 'http://jabber.org/protocol/pubsub'
+                    }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"})
+                        .c('item')
+                            .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+                                .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
+                                .c('signedPreKeySignature').t(btoa('2222')).up()
+                                .c('identityKey').t(btoa('3333')).up()
+                                .c('prekeys')
+                                    .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
+                                    .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
+                                    .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
+
+                spyOn(_converse.connection, 'send').and.callFake(stanza => { sent_stanza = stanza });
+                _converse.connection._dataRecv(test_utils.createRequest(stanza));
+                return test_utils.waitUntil(() => sent_stanza);
+            }).then(function () {
+                expect(sent_stanza.toLocaleString()).toBe(
+                    `<message from='dummy@localhost/resource' to='max.frankfurter@localhost' `+
+                             `type='chat' id='${sent_stanza.nodeTree.getAttribute('id')}' xmlns='jabber:client'>`+
+                        `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
+                        `<encrypted xmlns='eu.siacs.conversations.axolotl'>`+
+                            `<header sid='123456789'>`+
+                                `<key>eyJpdiI6IjEyMzQ1In0=</key>`+
+                                `<iv>12345</iv>`+
+                            `</header>`+
+                        `</encrypted>`+
+                    `</message>`);
+                done();
+            });
+        }));
+
         it("will add processing hints to sent out encrypted <message> stanzas",
         it("will add processing hints to sent out encrypted <message> stanzas",
             mock.initConverseWithPromises(
             mock.initConverseWithPromises(
                 null, ['rosterGroupsFetched'], {},
                 null, ['rosterGroupsFetched'], {},
@@ -24,8 +140,8 @@
                 function (done, _converse) {
                 function (done, _converse) {
 
 
             let iq_stanza;
             let iq_stanza;
-            test_utils.createContacts(_converse, 'current');
-            const contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@localhost';
+            test_utils.createContacts(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
 
 
             test_utils.waitUntil(function () {
             test_utils.waitUntil(function () {
                 return _.filter(
                 return _.filter(

+ 122 - 59
src/converse-omemo.js

@@ -93,86 +93,143 @@
                     return new Promise((resolve, reject) => {
                     return new Promise((resolve, reject) => {
                         _converse.getDevicesForContact(this.get('jid'))
                         _converse.getDevicesForContact(this.get('jid'))
                             .then((devices) => {
                             .then((devices) => {
-                                const promises = devices.map((device) => device.getBundle());
-                                Promise.all(promises).then(() => {
-                                    this.buildSessions(devices)
-                                        .then(() => resolve(devices))
-                                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
-                                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+                                Promise.all(devices.map((device) => device.getBundle()))
+                                    .then(() => this.buildSessions(devices))
+                                    .then(() => resolve(devices))
+                                    .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
                             }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
                             }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
-                    }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+                        });
+                },
+
+                buildSession (device) {
+                    const { _converse } = this.__super__;
+                    const bundle = device.get('bundle'),
+                            address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')),
+                            sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address),
+                            prekey = device.getRandomPreKey();
+
+                    return sessionBuilder.processPreKey({
+                        'registrationId': _converse.omemo_store.get('registration_id'),
+                        'identityKey': _converse.omemo_store.get('identity_keypair'),
+                        'signedPreKey': {
+                            'keyId': bundle.signed_prekey.id, // <Number>
+                            'publicKey': u.base64ToArrayBuffer(bundle.signed_prekey.public_key),
+                            'signature': u.base64ToArrayBuffer(bundle.signed_prekey.signature)
+                        },
+                        'preKey': {
+                            'keyId': prekey.id, // <Number>
+                            'publicKey': u.base64ToArrayBuffer(prekey.key),
+                        }
+                    })
                 },
                 },
 
 
                 buildSessions (devices) {
                 buildSessions (devices) {
+                    return Promise.all(devices.map((device) => this.buildSession(device)));
+                },
+
+                encryptMessage (plaintext) {
+                    // The client MUST use fresh, randomly generated key/IV pairs
+                    // with AES-128 in Galois/Counter Mode (GCM).
+                    const TAG_LENGTH = 128,
+                          iv = window.crypto.getRandomValues(new window.Uint8Array(16));
+
+                    let key;
+                    return window.crypto.subtle.generateKey({
+                            'name': "AES-GCM",
+                            'length': 256
+                        },
+                        true, // extractable
+                        ["encrypt", "decrypt"] // key usages
+                    ).then((result) => {
+                        key = result;
+                        const algo = {
+                            'name': 'AES-GCM',
+                            'iv': iv,
+                            'tagLength': TAG_LENGTH
+                        }
+                        return window.crypto.subtle.encrypt(algo, key, new TextEncoder().encode(plaintext));
+                    }).then((ciphertext) => {
+                        return window.crypto.subtle.exportKey("jwk", key)
+                            .then((key_str) => {
+                                return Promise.resolve({
+                                    'key_str': key_str,
+                                    'tag': ciphertext.slice(ciphertext.byteLength - ((TAG_LENGTH + 7) >> 3)),
+                                    'iv': iv
+                                });
+                            });
+                    });
+                },
+
+                encryptKey (plaintext, device) {
                     const { _converse } = this.__super__,
                     const { _converse } = this.__super__,
-                          device_id = _converse.omemo_store.get('device_id');
-
-                    return Promise.all(_.map(devices, (device) => {
-                        const recipient_id = device['id'];
-                        const address = new libsignal.SignalProtocolAddress(parseInt(recipient_id, 10), device_id);
-                        const sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address);
-                        return sessionBuilder.processPreKey({
-                            'registrationId': _converse.omemo_store.get('registration_id'),
-                            'identityKey': _converse.omemo_store.get('identity_keypair'),
-                            'signedPreKey': {
-                                'keyId': '', // <Number>,
-                                'publicKey': '', // <ArrayBuffer>,
-                                'signature': '', // <ArrayBuffer>
-                            },
-                            'preKey': {
-                                'keyId': '', // <Number>,
-                                'publicKey': '', // <ArrayBuffer>
-                            }
-                        });
-                    }));
+                          address = new libsignal.SignalProtocolAddress(this.get('jid'), device.get('id')),
+                          sessionCipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
+
+                    return sessionCipher.encrypt(plaintext);
                 },
                 },
 
 
-                encryptMessage (message) {
-                    // TODO:
-                    // const { _converse } = this.__super__;
-                    // const plaintext = message.get('message');
-                    // const address = new libsignal.SignalProtocolAddress(recipientId, deviceId);
-                    // return new Promise((resolve, reject) => {
-                    //     var sessionCipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
-                    //     sessionCipher.encrypt(plaintext).then((ciphertext) => {});
-                    // });
+                addKeysToMessageStanza (stanza, devices, payloads) {
+                    for (var i in payloads) {
+                        if (Object.prototype.hasOwnProperty.call(payloads, i)) {
+                            const payload = btoa(JSON.stringify(payloads[i]))
+                            const prekey = 3 == parseInt(payloads[i].type, 10)
+                            if (i == payloads.length-1) {
+                                stanza.c('key', {'rid': devices.get('id') }).t(payload)
+                                if (prekey) {
+                                    stanza.attrs({'prekey': prekey});
+                                }
+                                stanza.up().c('iv').t(payloads[0].iv).up().up()
+                            } else {
+                                stanza.c('key', {prekey: prekey, rid: devices.get('id') }).t(payload).up()
+                            }
+                        }
+                    }
+                    return Promise.resolve(stanza);
                 },
                 },
 
 
-                createOMEMOMessageStanza (message, bundles) {
+                createOMEMOMessageStanza (message, devices) {
                     const { _converse } = this.__super__, { __ } = _converse;
                     const { _converse } = this.__super__, { __ } = _converse;
                     const body = __("This is an OMEMO encrypted message which your client doesn’t seem to support. "+
                     const body = __("This is an OMEMO encrypted message which your client doesn’t seem to support. "+
                                     "Find more information on https://conversations.im/omemo");
                                     "Find more information on https://conversations.im/omemo");
-                    return new Promise((resolve, reject) => {
-                        this.encryptMessage(message).then((payload) => {
-                            const stanza = $msg({
-                                    'from': _converse.connection.jid,
-                                    'to': this.get('jid'),
-                                    'type': this.get('message_type'),
-                                    'id': message.get('msgid')
-                                }).c('body').t(body).up()
-                                  .c('encrypted').t(payload)
-                                  .c('header').t(payload).up()
-
-                            _.forEach(bundles, (bundle) => {
-                                const prekey = bundle.prekeys[Math.random(bundle.prekeys.length)].textContent;
-                                stanza('key', {'rid': bundle.identity_key}).t(prekey).up()
-                            });
-                            // TODO: set storage hint urn:xmpp:hints
-                            resolve(stanza);
-                        }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+
+                    // 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.
+                    const stanza = $msg({
+                            'from': _converse.connection.jid,
+                            'to': this.get('jid'),
+                            'type': this.get('message_type'),
+                            'id': message.get('msgid')
+                        }).c('body').t(body).up()
+                            .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+                                .c('header', {'sid':  _converse.omemo_store.get('device_id')});
+
+                    return this.encryptMessage(message).then((payload) => {
+                        // 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.
+
+                        // TODO: need to include own devices here as well (and filter out distrusted devices)
+                        const promises = devices.map(device => this.encryptKey(payload.key_str+payload.tag, device));
+                        return Promise.all(promises).then((payloads) => this.addKeysToMessageStanza(stanza, devices, payloads));
                     });
                     });
                 },
                 },
 
 
                 sendMessage (attrs) {
                 sendMessage (attrs) {
+                    const { _converse } = this.__super__;
                     if (this.get('omemo_active')) {
                     if (this.get('omemo_active')) {
                         const message = this.messages.create(attrs);
                         const message = this.messages.create(attrs);
                         this.getBundlesAndBuildSessions()
                         this.getBundlesAndBuildSessions()
-                            .then((bundles) => this.createOMEMOMessageStanza(message, bundles))
-                            .then((stanza) => this.sendMessageStanza(stanza));
+                            .then((devices) => this.createOMEMOMessageStanza(message, devices))
+                            .then((stanza) => this.sendMessageStanza(stanza))
+                            .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
                     } else {
                     } else {
                         return this.__super__.sendMessage.apply(this, arguments);
                         return this.__super__.sendMessage.apply(this, arguments);
                     }
                     }
-                },
+                }
             },
             },
 
 
             ChatBoxView:  {
             ChatBoxView:  {
@@ -439,6 +496,12 @@
                     'trusted': UNDECIDED
                     'trusted': UNDECIDED
                 },
                 },
 
 
+                getRandomPreKey () {
+                    // XXX: assumes that the bundle has already been fetched
+                    const bundle = this.get('bundle');
+                    return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
+                },
+
                 fetchBundleFromServer () {
                 fetchBundleFromServer () {
                     return new Promise((resolve, reject) => {
                     return new Promise((resolve, reject) => {
                         const stanza = $iq({
                         const stanza = $iq({
@@ -467,7 +530,7 @@
                      * this device, if the information is not at hand already.
                      * this device, if the information is not at hand already.
                      */
                      */
                     if (this.get('bundle')) {
                     if (this.get('bundle')) {
-                        return Promise.resolve(this.get('bundle').toJSON());
+                        return Promise.resolve(this.get('bundle').toJSON(), this);
                     } else {
                     } else {
                         return this.fetchBundleFromServer();
                         return this.fetchBundleFromServer();
                     }
                     }

+ 4 - 0
src/utils/core.js

@@ -883,6 +883,10 @@
         return bytes.buffer
         return bytes.buffer
     };
     };
 
 
+    u.getRandomInt = function (max) {
+        return Math.floor(Math.random() * Math.floor(max));
+    };
+
     u.getUniqueId = function () {
     u.getUniqueId = function () {
         return 'xxxxxxxx-xxxx'.replace(/[x]/g, function(c) {
         return 'xxxxxxxx-xxxx'.replace(/[x]/g, function(c) {
             var r = Math.random() * 16 | 0,
             var r = Math.random() * 16 | 0,

+ 1 - 1
src/utils/form.js

@@ -3,7 +3,7 @@
 //
 //
 // This is the utilities module.
 // This is the utilities module.
 //
 //
-// Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
+// Copyright (c) 2013-2018, Jan-Carel Brand <jc@opkode.com>
 // Licensed under the Mozilla Public License (MPLv2)
 // Licensed under the Mozilla Public License (MPLv2)
 //
 //
 /*global define, escape, Jed */
 /*global define, escape, Jed */

+ 16 - 0
tests/mock.js

@@ -8,6 +8,22 @@
     var $iq = converse.env.$iq;
     var $iq = converse.env.$iq;
 
 
     window.libsignal = {
     window.libsignal = {
+        'SignalProtocolAddress': function (name, device_id) {
+            this.name = name;
+            this.deviceId = device_id;
+        },
+        'SessionCipher': function (storage, remote_address) {
+            this.remoteAddress = remote_address;
+            this.storage = storage;
+            this.encrypt = () => Promise.resolve({
+                'iv': '12345'
+            });
+        },
+        'SessionBuilder': function (storage, remote_address) {
+            this.processPreKey = function () {
+                return Promise.resolve();
+            }
+        },
         'KeyHelper': {
         'KeyHelper': {
             'generateIdentityKeyPair': function () {
             'generateIdentityKeyPair': function () {
                 return Promise.resolve({
                 return Promise.resolve({