Add test for sending/receiving MUC OMEMO messages

While adding support for MUCs, I refactored converse-omemo somewhat to move functions
out of `overrides` and to use async/await

Updates #1180
This commit is contained in:
JC Brand 2018-12-18 11:21:04 +01:00
parent f64fdb8088
commit 9c05ca9a09
3 changed files with 450 additions and 288 deletions

305
dist/converse.js vendored
View File

@ -56122,54 +56122,6 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins
},
ChatBox: {
getBundlesAndBuildSessions() {
const _converse = this.__super__._converse;
let devices;
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);
devices = _.concat(own_devices, their_devices.models);
return Promise.all(devices.map(device => device.getBundle()));
}).then(() => this.buildSessions(devices));
},
async buildSession(device) {
const _converse = this.__super__._converse,
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();
return sessionBuilder.processPreKey({
'registrationId': parseInt(device.get('id'), 10),
'identityKey': u.base64ToArrayBuffer(bundle.identity_key),
'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)
}
});
},
getSession(device) {
const _converse = this.__super__._converse,
address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
return _converse.omemo_store.loadSession(address.toString()).then(session => {
if (session) {
return Promise.resolve();
} else {
return this.buildSession(device);
}
});
},
async encryptMessage(plaintext) {
// The client MUST use fresh, randomly generated key/IV pairs
// with AES-128 in Galois/Counter Mode (GCM).
@ -56310,10 +56262,6 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins
}
},
buildSessions(devices) {
return Promise.all(devices.map(device => this.getSession(device))).then(() => devices);
},
getSessionCipher(jid, id) {
const _converse = this.__super__._converse,
address = new libsignal.SignalProtocolAddress(jid, id);
@ -56328,95 +56276,26 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins
}));
},
addKeysToMessageStanza(stanza, dicts, iv) {
for (var i in dicts) {
if (Object.prototype.hasOwnProperty.call(dicts, i)) {
const payload = dicts[i].payload,
device = dicts[i].device,
prekey = 3 == parseInt(payload.type, 10);
stanza.c('key', {
'rid': device.get('id')
}).t(btoa(payload.body));
if (prekey) {
stanza.attrs({
'prekey': prekey
});
}
stanza.up();
if (i == dicts.length - 1) {
stanza.c('iv').t(iv).up().up();
}
}
}
return Promise.resolve(stanza);
},
createOMEMOMessageStanza(message, devices) {
const _converse = this.__super__._converse,
__ = _converse.__;
const body = __("This is an OMEMO encrypted message which your client doesnt seem to support. " + "Find more information on https://conversations.im/omemo");
if (!message.get('message')) {
throw new Error("No message body to encrypt!");
}
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('request', {
'xmlns': Strophe.NS.RECEIPTS
}).up() // 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.
.c('encrypted', {
'xmlns': Strophe.NS.OMEMO
}).c('header', {
'sid': _converse.omemo_store.get('device_id')
});
return this.encryptMessage(message.get('message')).then(obj => {
// The 16 bytes key and the GCM authentication tag (The tag
// SHOULD have at least 128 bit) are concatenated and for each
// intended recipient device, i.e. both own devices as well as
// devices associated with the contact, the result of this
// concatenation is encrypted using the corresponding
// long-standing SignalProtocol session.
const promises = devices.filter(device => device.get('trusted') != UNTRUSTED).map(device => this.encryptKey(obj.key_and_tag, device));
return Promise.all(promises).then(dicts => this.addKeysToMessageStanza(stanza, dicts, obj.iv)).then(stanza => {
stanza.c('payload').t(obj.payload).up().up();
stanza.c('store', {
'xmlns': Strophe.NS.HINTS
});
return stanza;
});
});
},
sendMessage(attrs) {
async sendMessage(attrs) {
const _converse = this.__super__._converse,
__ = _converse.__;
if (this.get('omemo_active') && attrs.message) {
attrs['is_encrypted'] = true;
attrs['plaintext'] = attrs.message;
const message = this.messages.create(attrs);
this.getBundlesAndBuildSessions().then(devices => this.createOMEMOMessageStanza(message, devices)).then(stanza => this.sendMessageStanza(stanza)).catch(e => {
const devices = await _converse.getBundlesAndBuildSessions(this);
const stanza = await _converse.createOMEMOMessageStanza(this, this.messages.create(attrs), devices);
try {
this.sendMessageStanza(stanza);
} catch (e) {
this.messages.create({
'message': __("Sorry, could not send the message due to an error.") + ` ${e.message}`,
'type': 'error'
});
_converse.log(e, Strophe.LogLevel.ERROR);
});
}
} else {
return this.__super__.sendMessage.apply(this, arguments);
}
@ -56532,15 +56411,15 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins
}
_converse.generateFingerprints = async function (jid) {
const devices = await _converse.getDevicesForContact(jid);
const devices = await getDevicesForContact(jid);
return Promise.all(devices.map(d => generateFingerprint(d)));
};
_converse.getDeviceForContact = function (jid, device_id) {
return _converse.getDevicesForContact(jid).then(devices => devices.get(device_id));
return getDevicesForContact(jid).then(devices => devices.get(device_id));
};
_converse.getDevicesForContact = async function (jid) {
async function getDevicesForContact(jid) {
await _converse.api.waitUntil('OMEMOInitialized');
const devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({
@ -56549,11 +56428,11 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins
await devicelist.fetchDevices();
return devicelist.devices;
};
}
_converse.contactHasOMEMOSupport = async function (jid) {
/* Checks whether the contact advertises any OMEMO-compatible devices. */
const devices = await _converse.getDevicesForContact(jid);
const devices = await getDevicesForContact(jid);
return devices.length > 0;
};
@ -56576,6 +56455,140 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins
return device_id.toString();
}
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();
return sessionBuilder.processPreKey({
'registrationId': parseInt(device.get('id'), 10),
'identityKey': u.base64ToArrayBuffer(bundle.identity_key),
'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)
}
});
}
function getSession(device) {
const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
return _converse.omemo_store.loadSession(address.toString()).then(session => {
if (session) {
return Promise.resolve();
} else {
return buildSession(device);
}
});
}
_converse.getBundlesAndBuildSessions = async function (chatbox) {
let devices;
const id = _converse.omemo_store.get('device_id');
if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
const collections = await Promise.all(chatbox.occupants.map(o => getDevicesForContact(o.get('jid'))));
devices = collections.reduce((a, b) => _.concat(a, b.models), []);
} else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
const their_devices = await getDevicesForContact(chatbox.get('jid')),
devicelist = _converse.devicelists.get(_converse.bare_jid),
own_devices = devicelist.devices.filter(d => d.get('id') !== id);
devices = _.concat(own_devices, their_devices.models);
} // Filter out our own device
devices = devices.filter(d => d.get('id') !== id);
await Promise.all(devices.map(d => d.getBundle()));
await Promise.all(devices.map(d => getSession(d)));
return devices;
};
function addKeysToMessageStanza(stanza, dicts, iv) {
for (var i in dicts) {
if (Object.prototype.hasOwnProperty.call(dicts, i)) {
const payload = dicts[i].payload,
device = dicts[i].device,
prekey = 3 == parseInt(payload.type, 10);
stanza.c('key', {
'rid': device.get('id')
}).t(btoa(payload.body));
if (prekey) {
stanza.attrs({
'prekey': prekey
});
}
stanza.up();
if (i == dicts.length - 1) {
stanza.c('iv').t(iv).up().up();
}
}
}
return Promise.resolve(stanza);
}
_converse.createOMEMOMessageStanza = function (chatbox, message, devices) {
const __ = _converse.__;
const body = __("This is an OMEMO encrypted message which your client doesnt seem to support. " + "Find more information on https://conversations.im/omemo");
if (!message.get('message')) {
throw new Error("No message body to encrypt!");
}
const stanza = $msg({
'from': _converse.connection.jid,
'to': chatbox.get('jid'),
'type': chatbox.get('message_type'),
'id': message.get('msgid')
}).c('body').t(body).up();
if (message.get('type') === 'chat') {
stanza.c('request', {
'xmlns': Strophe.NS.RECEIPTS
}).up();
} // An encrypted header is added to the message for
// each device that is supposed to receive it.
// These headers simply contain the key that the
// payload message is encrypted with,
// and they are separately encrypted using the
// session corresponding to the counterpart device.
stanza.c('encrypted', {
'xmlns': Strophe.NS.OMEMO
}).c('header', {
'sid': _converse.omemo_store.get('device_id')
});
return chatbox.encryptMessage(message.get('message')).then(obj => {
// The 16 bytes key and the GCM authentication tag (The tag
// SHOULD have at least 128 bit) are concatenated and for each
// intended recipient device, i.e. both own devices as well as
// devices associated with the contact, the result of this
// concatenation is encrypted using the corresponding
// long-standing SignalProtocol session.
const promises = devices.filter(device => device.get('trusted') != UNTRUSTED).map(device => chatbox.encryptKey(obj.key_and_tag, device));
return Promise.all(promises).then(dicts => addKeysToMessageStanza(stanza, dicts, obj.iv)).then(stanza => {
stanza.c('payload').t(obj.payload).up().up();
stanza.c('store', {
'xmlns': Strophe.NS.HINTS
});
return stanza;
});
});
};
_converse.OMEMOStore = Backbone.Model.extend({
Direction: {
SENDING: 1,
@ -56872,7 +56885,7 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins
return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
},
fetchBundleFromServer() {
async fetchBundleFromServer() {
const stanza = $iq({
'type': 'get',
'from': _converse.bare_jid,
@ -56882,15 +56895,19 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins
}).c('items', {
'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}`
});
return _converse.api.sendIQ(stanza).then(iq => {
const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop(),
bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop(),
bundle = parseBundle(bundle_el);
this.save('bundle', bundle);
return bundle;
}).catch(iq => {
_converse.log(iq.outerHTML, Strophe.LogLevel.ERROR);
});
let iq;
try {
iq = await _converse.api.sendIQ(stanza);
} catch (iq) {
return _converse.log(iq.outerHTML, Strophe.LogLevel.ERROR);
}
const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop(),
bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop(),
bundle = parseBundle(bundle_el);
this.save('bundle', bundle);
return bundle;
},
getBundle() {

View File

@ -225,6 +225,147 @@
done();
}));
it("enables encrypted groupchat messages to be sent and received",
mock.initConverseWithPromises(
null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
// MEMO encryption works only in members only conferences
// that are non-anonymous.
const features = [
'http://jabber.org/protocol/muc',
'jabber:iq:register',
'muc_passwordprotected',
'muc_hidden',
'muc_temporary',
'muc_membersonly',
'muc_unmoderated',
'muc_nonanonymous'
];
await test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy', features);
const view = _converse.chatboxviews.get('lounge@localhost');
await test_utils.waitUntil(() => initializedOMEMO(_converse));
const toolbar = view.el.querySelector('.chat-toolbar');
let toggle = toolbar.querySelector('.toggle-omemo');
toggle.click();
expect(view.model.get('omemo_active')).toBe(true);
const contact_jid = 'newguy@localhost';
let stanza = $pres({
'to': 'dummy@localhost/resource',
'from': 'lounge@localhost/newguy'
})
.c('x', {xmlns: Strophe.NS.MUC_USER})
.c('item', {
'affiliation': 'none',
'jid': 'newguy@localhost/_converse.js-290929789',
'role': 'participant'
}).tree();
_converse.connection._dataRecv(test_utils.createRequest(stanza));
let iq_stanza = await test_utils.waitUntil(() => deviceListFetched(_converse, contact_jid));
expect(iq_stanza.toLocaleString()).toBe(
`<iq from="dummy@localhost" id="${iq_stanza.nodeTree.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+
`<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
`<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
`</pubsub>`+
`</iq>`);
stanza = $iq({
'from': contact_jid,
'id': iq_stanza.nodeTree.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': '4e30f35051b7b8b42abe083742187228'}).up()
_converse.connection._dataRecv(test_utils.createRequest(stanza));
await test_utils.waitUntil(() => _converse.omemo_store);
expect(_converse.devicelists.length).toBe(2);
const devicelist = _converse.devicelists.get(contact_jid);
expect(devicelist.devices.length).toBe(1);
expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228');
toggle = toolbar.querySelector('.toggle-omemo');
expect(view.model.get('omemo_active')).toBe(true);
expect(u.hasClass('fa-unlock', toggle)).toBe(false);
expect(u.hasClass('fa-lock', toggle)).toBe(true);
const textarea = view.el.querySelector('.chat-textarea');
textarea.value = 'This message will be encrypted';
view.keyPressed({
target: textarea,
preventDefault: _.noop,
keyCode: 13 // Enter
});
iq_stanza = await test_utils.waitUntil(() => bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'));
stanza = $iq({
'from': contact_jid,
'id': iq_stanza.nodeTree.getAttribute('id'),
'to': _converse.bare_jid,
'type': 'result',
}).c('pubsub', {
'xmlns': 'http://jabber.org/protocol/pubsub'
}).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:4e30f35051b7b8b42abe083742187228"})
.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'));
_converse.connection._dataRecv(test_utils.createRequest(stanza));
iq_stanza = await test_utils.waitUntil(() => bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
stanza = $iq({
'from': _converse.bare_jid,
'id': iq_stanza.nodeTree.getAttribute('id'),
'to': _converse.bare_jid,
'type': 'result',
}).c('pubsub', {
'xmlns': 'http://jabber.org/protocol/pubsub'
}).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"})
.c('item')
.c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
.c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up()
.c('signedPreKeySignature').t(btoa('200000')).up()
.c('identityKey').t(btoa('300000')).up()
.c('prekeys')
.c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up()
.c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up()
.c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993'));
spyOn(_converse.connection, 'send');
_converse.connection._dataRecv(test_utils.createRequest(stanza));
await test_utils.waitUntil(() => _converse.connection.send.calls.count());
const sent_stanza = _converse.connection.send.calls.all()[0].args[0];
expect(Strophe.serialize(sent_stanza)).toBe(
`<message from="dummy@localhost/resource" `+
`id="${sent_stanza.nodeTree.getAttribute("id")}" `+
`to="lounge@localhost" `+
`type="groupchat" `+
`xmlns="jabber:client">`+
`<body>This is an OMEMO encrypted message which your client doesnt seem to support. Find more information on https://conversations.im/omemo</body>`+
`<encrypted xmlns="eu.siacs.conversations.axolotl">`+
`<header sid="123456789">`+
`<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
`<key rid="4e30f35051b7b8b42abe083742187228">YzFwaDNSNzNYNw==</key>`+
`<iv>${sent_stanza.nodeTree.querySelector("iv").textContent}</iv>`+
`</header>`+
`<payload>${sent_stanza.nodeTree.querySelector("payload").textContent}</payload>`+
`</encrypted>`+
`<store xmlns="urn:xmpp:hints"/>`+
`</message>`);
done();
}));
it("can receive a PreKeySignalMessage",
mock.initConverseWithPromises(
null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},

View File

@ -163,54 +163,6 @@ converse.plugins.add('converse-omemo', {
ChatBox: {
getBundlesAndBuildSessions () {
const { _converse } = this.__super__;
let devices;
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);
devices = _.concat(own_devices, their_devices.models);
return Promise.all(devices.map(device => device.getBundle()));
}).then(() => this.buildSessions(devices))
},
async buildSession (device) {
const { _converse } = this.__super__,
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();
return sessionBuilder.processPreKey({
'registrationId': parseInt(device.get('id'), 10),
'identityKey': u.base64ToArrayBuffer(bundle.identity_key),
'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),
}
});
},
getSession (device) {
const { _converse } = this.__super__,
address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
return _converse.omemo_store.loadSession(address.toString()).then(session => {
if (session) {
return Promise.resolve();
} else {
return this.buildSession(device);
}
});
},
async encryptMessage (plaintext) {
// The client MUST use fresh, randomly generated key/IV pairs
// with AES-128 in Galois/Counter Mode (GCM).
@ -343,9 +295,6 @@ converse.plugins.add('converse-omemo', {
}
},
buildSessions (devices) {
return Promise.all(devices.map(device => this.getSession(device))).then(() => devices);
},
getSessionCipher (jid, id) {
const { _converse } = this.__super__,
@ -360,89 +309,24 @@ converse.plugins.add('converse-omemo', {
.then(payload => ({'payload': payload, 'device': device}));
},
addKeysToMessageStanza (stanza, dicts, iv) {
for (var i in dicts) {
if (Object.prototype.hasOwnProperty.call(dicts, i)) {
const payload = dicts[i].payload,
device = dicts[i].device,
prekey = 3 == parseInt(payload.type, 10);
stanza.c('key', {'rid': device.get('id') }).t(btoa(payload.body));
if (prekey) {
stanza.attrs({'prekey': prekey});
}
stanza.up();
if (i == dicts.length-1) {
stanza.c('iv').t(iv).up().up()
}
}
}
return Promise.resolve(stanza);
},
createOMEMOMessageStanza (message, devices) {
const { _converse } = this.__super__, { __ } = _converse;
const body = __("This is an OMEMO encrypted message which your client doesnt seem to support. "+
"Find more information on https://conversations.im/omemo");
if (!message.get('message')) {
throw new Error("No message body to encrypt!");
}
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('request', {'xmlns': Strophe.NS.RECEIPTS}).up()
// 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.
.c('encrypted', {'xmlns': Strophe.NS.OMEMO})
.c('header', {'sid': _converse.omemo_store.get('device_id')});
return this.encryptMessage(message.get('message')).then(obj => {
// The 16 bytes key and the GCM authentication tag (The tag
// SHOULD have at least 128 bit) are concatenated and for each
// intended recipient device, i.e. both own devices as well as
// devices associated with the contact, the result of this
// concatenation is encrypted using the corresponding
// long-standing SignalProtocol session.
const promises = devices
.filter(device => device.get('trusted') != UNTRUSTED)
.map(device => this.encryptKey(obj.key_and_tag, device));
return Promise.all(promises)
.then(dicts => this.addKeysToMessageStanza(stanza, dicts, obj.iv))
.then(stanza => {
stanza.c('payload').t(obj.payload).up().up();
stanza.c('store', {'xmlns': Strophe.NS.HINTS});
return stanza;
});
});
},
sendMessage (attrs) {
async sendMessage (attrs) {
const { _converse } = this.__super__,
{ __ } = _converse;
if (this.get('omemo_active') && attrs.message) {
attrs['is_encrypted'] = true;
attrs['plaintext'] = attrs.message;
const message = this.messages.create(attrs);
this.getBundlesAndBuildSessions()
.then(devices => this.createOMEMOMessageStanza(message, devices))
.then(stanza => this.sendMessageStanza(stanza))
.catch(e => {
this.messages.create({
'message': __("Sorry, could not send the message due to an error.") + ` ${e.message}`,
'type': 'error',
});
_converse.log(e, Strophe.LogLevel.ERROR);
const devices = await _converse.getBundlesAndBuildSessions(this);
const stanza = await _converse.createOMEMOMessageStanza(this, this.messages.create(attrs), devices);
try {
this.sendMessageStanza(stanza);
} catch (e) {
this.messages.create({
'message': __("Sorry, could not send the message due to an error.") + ` ${e.message}`,
'type': 'error',
});
_converse.log(e, Strophe.LogLevel.ERROR);
}
} else {
return this.__super__.sendMessage.apply(this, arguments);
}
@ -554,15 +438,15 @@ converse.plugins.add('converse-omemo', {
}
_converse.generateFingerprints = async function (jid) {
const devices = await _converse.getDevicesForContact(jid)
const devices = await getDevicesForContact(jid)
return Promise.all(devices.map(d => generateFingerprint(d)));
}
_converse.getDeviceForContact = function (jid, device_id) {
return _converse.getDevicesForContact(jid).then(devices => devices.get(device_id));
return getDevicesForContact(jid).then(devices => devices.get(device_id));
}
_converse.getDevicesForContact = async function (jid) {
async function getDevicesForContact (jid) {
await _converse.api.waitUntil('OMEMOInitialized');
const devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid});
await devicelist.fetchDevices();
@ -571,11 +455,10 @@ converse.plugins.add('converse-omemo', {
_converse.contactHasOMEMOSupport = async function (jid) {
/* Checks whether the contact advertises any OMEMO-compatible devices. */
const devices = await _converse.getDevicesForContact(jid);
const devices = await getDevicesForContact(jid);
return devices.length > 0;
}
function generateDeviceID () {
/* Generates a device ID, making sure that it's unique */
const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id');
@ -591,6 +474,127 @@ converse.plugins.add('converse-omemo', {
return device_id.toString();
}
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();
return sessionBuilder.processPreKey({
'registrationId': parseInt(device.get('id'), 10),
'identityKey': u.base64ToArrayBuffer(bundle.identity_key),
'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),
}
});
}
function getSession (device) {
const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
return _converse.omemo_store.loadSession(address.toString()).then(session => {
if (session) {
return Promise.resolve();
} else {
return buildSession(device);
}
});
}
_converse.getBundlesAndBuildSessions = async function (chatbox) {
let devices;
const id = _converse.omemo_store.get('device_id');
if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
const collections = await Promise.all(chatbox.occupants.map(o => getDevicesForContact(o.get('jid'))));
devices = collections.reduce((a, b) => _.concat(a, b.models), []);
} else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
const their_devices = await getDevicesForContact(chatbox.get('jid')),
devicelist = _converse.devicelists.get(_converse.bare_jid),
own_devices = devicelist.devices.filter(d => d.get('id') !== id);
devices = _.concat(own_devices, their_devices.models);
}
// Filter out our own device
devices = devices.filter(d => d.get('id') !== id);
await Promise.all(devices.map(d => d.getBundle()));
await Promise.all(devices.map(d => getSession(d)));
return devices;
}
function addKeysToMessageStanza (stanza, dicts, iv) {
for (var i in dicts) {
if (Object.prototype.hasOwnProperty.call(dicts, i)) {
const payload = dicts[i].payload,
device = dicts[i].device,
prekey = 3 == parseInt(payload.type, 10);
stanza.c('key', {'rid': device.get('id') }).t(btoa(payload.body));
if (prekey) {
stanza.attrs({'prekey': prekey});
}
stanza.up();
if (i == dicts.length-1) {
stanza.c('iv').t(iv).up().up()
}
}
}
return Promise.resolve(stanza);
}
_converse.createOMEMOMessageStanza = function (chatbox, message, devices) {
const { __ } = _converse;
const body = __("This is an OMEMO encrypted message which your client doesnt seem to support. "+
"Find more information on https://conversations.im/omemo");
if (!message.get('message')) {
throw new Error("No message body to encrypt!");
}
const stanza = $msg({
'from': _converse.connection.jid,
'to': chatbox.get('jid'),
'type': chatbox.get('message_type'),
'id': message.get('msgid')
}).c('body').t(body).up()
if (message.get('type') === 'chat') {
stanza.c('request', {'xmlns': Strophe.NS.RECEIPTS}).up();
}
// An encrypted header is added to the message for
// each device that is supposed to receive it.
// These headers simply contain the key that the
// payload message is encrypted with,
// and they are separately encrypted using the
// session corresponding to the counterpart device.
stanza.c('encrypted', {'xmlns': Strophe.NS.OMEMO})
.c('header', {'sid': _converse.omemo_store.get('device_id')});
return chatbox.encryptMessage(message.get('message')).then(obj => {
// The 16 bytes key and the GCM authentication tag (The tag
// SHOULD have at least 128 bit) are concatenated and for each
// intended recipient device, i.e. both own devices as well as
// devices associated with the contact, the result of this
// concatenation is encrypted using the corresponding
// long-standing SignalProtocol session.
const promises = devices
.filter(device => device.get('trusted') != UNTRUSTED)
.map(device => chatbox.encryptKey(obj.key_and_tag, device));
return Promise.all(promises)
.then(dicts => addKeysToMessageStanza(stanza, dicts, obj.iv))
.then(stanza => {
stanza.c('payload').t(obj.payload).up().up();
stanza.c('store', {'xmlns': Strophe.NS.HINTS});
return stanza;
});
});
}
_converse.OMEMOStore = Backbone.Model.extend({
@ -857,7 +861,7 @@ converse.plugins.add('converse-omemo', {
return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
},
fetchBundleFromServer () {
async fetchBundleFromServer () {
const stanza = $iq({
'type': 'get',
'from': _converse.bare_jid,
@ -865,16 +869,17 @@ converse.plugins.add('converse-omemo', {
}).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
.c('items', {'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}`});
return _converse.api.sendIQ(stanza)
.then(iq => {
const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop(),
bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop(),
bundle = parseBundle(bundle_el);
this.save('bundle', bundle);
return bundle;
}).catch(iq => {
_converse.log(iq.outerHTML, Strophe.LogLevel.ERROR);
});
let iq;
try {
iq = await _converse.api.sendIQ(stanza)
} catch(iq) {
return _converse.log(iq.outerHTML, Strophe.LogLevel.ERROR);
}
const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop(),
bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop(),
bundle = parseBundle(bundle_el);
this.save('bundle', bundle);
return bundle;
},
getBundle () {
@ -1129,7 +1134,6 @@ converse.plugins.add('converse-omemo', {
})
);
_converse.api.listen.on('afterTearDown', () => {
if (_converse.devicelists) {
_converse.devicelists.reset();