From 15a4bcd11e12fa0de732c44cec3e152c46c59af5 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Mon, 27 Aug 2018 14:25:34 +0200 Subject: [PATCH] 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 --- spec/omemo.js | 178 +++++++++++++++++++++++++++++++++--------- src/converse-omemo.js | 73 +++++++++++------ tests/mock.js | 5 ++ 3 files changed, 195 insertions(+), 61 deletions(-) diff --git a/spec/omemo.js b/spec/omemo.js index 04f951e02..a01a99e78 100644 --- a/spec/omemo.js +++ b/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( + ``+ + ``+ + ``+ + ``+ + ``+ + `${btoa('1234')}`+ + `${btoa('11112222333344445555')}`+ + `${btoa('1234')}`+ + ``+ + `${btoa('1234')}`+ + `${btoa('1234')}`+ + `${btoa('1234')}`+ + `${btoa('1234')}`+ + `${btoa('1234')}`+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``) + 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 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'], {}, diff --git a/src/converse-omemo.js b/src/converse-omemo.js index d6067d513..f81a19960 100644 --- a/src/converse-omemo.js +++ b/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 diff --git a/tests/mock.js b/tests/mock.js index 7e52e1eff..7b710c25b 100644 --- a/tests/mock.js +++ b/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)); }