From 01efb02f9e55aaaf1d27a3b77a8bc7f96e1cde88 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 2 Jul 2021 20:29:54 +0200 Subject: [PATCH] Make sure XEP-0363 urls are also OMEMO encrypted by re-using `ChatBox.prototype.sendMessage`. updates #1182 --- karma.conf.js | 1 + spec/mock.js | 65 +++++++++ src/headless/plugins/chat/model.js | 32 +++-- src/headless/plugins/muc/muc.js | 12 +- src/headless/plugins/muc/tests/pruning.js | 12 +- src/plugins/chatview/message-form.js | 2 +- src/plugins/chatview/tests/messages.js | 8 +- src/plugins/muc-views/tests/component.js | 4 +- src/plugins/muc-views/tests/muc-messages.js | 4 +- src/plugins/muc-views/tests/retractions.js | 8 +- src/plugins/omemo/consts.js | 7 + src/plugins/omemo/mixins/converse.js | 99 +------------ src/plugins/omemo/overrides/chatbox.js | 14 +- src/plugins/omemo/tests/media-sharing.js | 152 ++++++++++++++++++++ src/plugins/omemo/tests/omemo.js | 147 ++++++------------- src/plugins/omemo/utils.js | 106 ++++++++++++-- 16 files changed, 413 insertions(+), 260 deletions(-) create mode 100644 src/plugins/omemo/tests/media-sharing.js diff --git a/karma.conf.js b/karma.conf.js index a98eab169..974a8cc40 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -85,6 +85,7 @@ module.exports = function(config) { { pattern: "src/plugins/muc-views/tests/unfurls.js", type: 'module' }, { pattern: "src/plugins/muc-views/tests/xss.js", type: 'module' }, { pattern: "src/plugins/notifications/tests/notification.js", type: 'module' }, + { pattern: "src/plugins/omemo/tests/media-sharing.js", type: 'module' }, { pattern: "src/plugins/omemo/tests/omemo.js", type: 'module' }, { pattern: "src/plugins/register/tests/register.js", type: 'module' }, { pattern: "src/plugins/rootview/tests/root.js", type: 'module' }, diff --git a/spec/mock.js b/spec/mock.js index 9d2d60862..7c083e9ad 100644 --- a/spec/mock.js +++ b/spec/mock.js @@ -672,3 +672,68 @@ const initConverse = async (settings) => { window.converse_disable_effects = true; return _converse; } + + +mock.deviceListFetched = async function deviceListFetched (_converse, jid) { + const selector = `iq[to="${jid}"] items[node="eu.siacs.conversations.axolotl.devicelist"]`; + const stanza = await u.waitUntil( + () => Array.from(_converse.connection.IQ_stanzas).filter(iq => iq.querySelector(selector)).pop() + ); + await u.waitUntil(() => _converse.devicelists.get(jid)); + return stanza; +} + +mock.ownDeviceHasBeenPublished = function ownDeviceHasBeenPublished (_converse) { + return Array.from(_converse.connection.IQ_stanzas).filter( + iq => iq.querySelector('iq[from="'+_converse.bare_jid+'"] publish[node="eu.siacs.conversations.axolotl.devicelist"]') + ).pop(); +} + +mock.bundleHasBeenPublished = function bundleHasBeenPublished (_converse) { + const selector = 'publish[node="eu.siacs.conversations.axolotl.bundles:123456789"]'; + return Array.from(_converse.connection.IQ_stanzas).filter(iq => iq.querySelector(selector)).pop(); +} + +mock.bundleFetched = function bundleFetched (_converse, jid, device_id) { + return Array.from(_converse.connection.IQ_stanzas).filter( + iq => iq.querySelector(`iq[to="${jid}"] items[node="eu.siacs.conversations.axolotl.bundles:${device_id}"]`) + ).pop(); +} + +mock.initializedOMEMO = async function initializedOMEMO (_converse) { + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid)); + let 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(mock.createRequest(stanza)); + iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse)) + + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse)) + + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await _converse.api.waitUntil('OMEMOInitialized'); +} diff --git a/src/headless/plugins/chat/model.js b/src/headless/plugins/chat/model.js index 21df447e5..57e74e858 100644 --- a/src/headless/plugins/chat/model.js +++ b/src/headless/plugins/chat/model.js @@ -241,9 +241,16 @@ const ChatBox = ModelWithContact.extend({ } }, - onMessageUploadChanged (message) { + async onMessageUploadChanged (message) { if (message.get('upload') === _converse.SUCCESS) { - api.send(this.createMessageStanza(message)); + const attrs = { + 'body': message.get('message'), + 'spoiler_hint': message.get('spoiler_hint'), + 'oob_url': message.get('oob_url') + + } + await this.sendMessage(attrs); + message.destroy(); } }, @@ -842,11 +849,12 @@ const ChatBox = ModelWithContact.extend({ return stanza; }, - getOutgoingMessageAttributes (text, spoiler_hint) { - const is_spoiler = this.get('composing_spoiler'); + getOutgoingMessageAttributes (attrs) { + const is_spoiler = !!this.get('composing_spoiler'); const origin_id = u.getUniqueId(); + const text = attrs?.body; const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined; - return { + return Object.assign({}, attrs, { 'from': _converse.bare_jid, 'fullname': _converse.xmppstatus.get('fullname'), 'id': origin_id, @@ -856,13 +864,12 @@ const ChatBox = ModelWithContact.extend({ 'msgid': origin_id, 'nickname': this.get('nickname'), 'sender': 'me', - 'spoiler_hint': is_spoiler ? spoiler_hint : undefined, 'time': (new Date()).toISOString(), 'type': this.get('message_type'), body, is_spoiler, origin_id - } + }); }, /** @@ -911,15 +918,14 @@ const ChatBox = ModelWithContact.extend({ * @private * @method _converse.ChatBox#sendMessage * @memberOf _converse.ChatBox - * @param { String } text - The chat message text - * @param { String } spoiler_hint - An optional hint, if the message being sent is a spoiler + * @param { Object } [attrs] - A map of attributes to be saved on the message * @returns { _converse.Message } * @example - * const chat = api.chats.get('buddy1@example.com'); - * chat.sendMessage('hello world'); + * const chat = api.chats.get('buddy1@example.org'); + * chat.sendMessage({'body': 'hello world'}); */ - async sendMessage (text, spoiler_hint) { - const attrs = this.getOutgoingMessageAttributes(text, spoiler_hint); + async sendMessage (attrs) { + attrs = this.getOutgoingMessageAttributes(attrs); let message = this.messages.findWhere('correcting') if (message) { const older_versions = message.get('older_versions') || {}; diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index b6a103a21..1c58774e1 100644 --- a/src/headless/plugins/muc/muc.js +++ b/src/headless/plugins/muc/muc.js @@ -959,12 +959,15 @@ const ChatRoomMixin = { return [updated_message, updated_references]; }, - getOutgoingMessageAttributes (original_message, spoiler_hint) { + getOutgoingMessageAttributes (attrs) { const is_spoiler = this.get('composing_spoiler'); - const [text, references] = this.parseTextForReferences(original_message); + let text = '', references; + if (attrs?.body) { + [text, references] = this.parseTextForReferences(attrs.body); + } const origin_id = u.getUniqueId(); const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined; - return { + return Object.assign({}, attrs, { body, is_spoiler, origin_id, @@ -977,9 +980,8 @@ const ChatRoomMixin = { 'message': body, 'nick': this.get('nick'), 'sender': 'me', - 'spoiler_hint': is_spoiler ? spoiler_hint : undefined, 'type': 'groupchat' - }; + }); }, /** diff --git a/src/headless/plugins/muc/tests/pruning.js b/src/headless/plugins/muc/tests/pruning.js index 84918b28f..069c8e59d 100644 --- a/src/headless/plugins/muc/tests/pruning.js +++ b/src/headless/plugins/muc/tests/pruning.js @@ -14,20 +14,20 @@ describe("A Groupchat Message", function () { const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); expect(model.ui.get('scrolled')).toBeFalsy(); - model.sendMessage('1st message'); - model.sendMessage('2nd message'); - model.sendMessage('3rd message'); + model.sendMessage({'body': '1st message'}); + model.sendMessage({'body': '2nd message'}); + model.sendMessage({'body': '3rd message'}); await u.waitUntil(() => model.messages.length === 3); // Make sure pruneHistory fires await new Promise(resolve => setTimeout(resolve, 550)); - model.sendMessage('4th message'); + model.sendMessage({'body': '4th message'}); await u.waitUntil(() => model.messages.length === 4); await u.waitUntil(() => model.messages.length === 3, 550); model.ui.set('scrolled', true); - model.sendMessage('5th message'); - model.sendMessage('6th message'); + model.sendMessage({'body': '5th message'}); + model.sendMessage({'body': '6th message'}); await u.waitUntil(() => model.messages.length === 5); // Wait long enough to be sure the debounced pruneHistory method didn't fire. diff --git a/src/plugins/chatview/message-form.js b/src/plugins/chatview/message-form.js index 4ed47c376..afe544ae3 100644 --- a/src/plugins/chatview/message-form.js +++ b/src/plugins/chatview/message-form.js @@ -195,7 +195,7 @@ export default class MessageForm extends ElementView { this.querySelector('converse-emoji-dropdown')?.hideMenu(); const is_command = this.parseMessageForCommands(message_text); - const message = is_command ? null : await this.model.sendMessage(message_text, spoiler_hint); + const message = is_command ? null : await this.model.sendMessage({'body': message_text, spoiler_hint}); if (is_command || message) { hint_el.value = ''; textarea.value = ''; diff --git a/src/plugins/chatview/tests/messages.js b/src/plugins/chatview/tests/messages.js index 310a921f8..084224bdc 100644 --- a/src/plugins/chatview/tests/messages.js +++ b/src/plugins/chatview/tests/messages.js @@ -1030,7 +1030,7 @@ describe("A Chat Message", function () { await _converse.api.chats.open(sender_jid) let msg_text = 'This message will not be sent, due to an error'; const view = _converse.chatboxviews.get(sender_jid); - const message = await view.model.sendMessage(msg_text); + const message = await view.model.sendMessage({'body': msg_text}); await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); let msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent; expect(msg_txt).toEqual(msg_text); @@ -1039,7 +1039,7 @@ describe("A Chat Message", function () { // not be received, to test that errors appear // after the relevant message. msg_text = 'This message will be sent, and also receive an error'; - const second_message = await view.model.sendMessage(msg_text); + const second_message = await view.model.sendMessage({'body': msg_text}); await u.waitUntil(() => sizzle('.chat-msg .chat-msg__text', view).length === 2, 1000); msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent; expect(msg_txt).toEqual(msg_text); @@ -1098,7 +1098,7 @@ describe("A Chat Message", function () { expect(view.querySelectorAll('.chat-msg__error').length).toEqual(2); msg_text = 'This message will be sent, and also receive an error'; - const third_message = await view.model.sendMessage(msg_text); + const third_message = await view.model.sendMessage({'body': msg_text}); await u.waitUntil(() => sizzle('converse-chat-message:last-child .chat-msg__text', view).pop()?.textContent === msg_text); // A different error message will however render @@ -1157,7 +1157,7 @@ describe("A Chat Message", function () { _converse.connection._dataRecv(mock.createRequest(stanza)); const view = _converse.chatboxviews.get(contact_jid); const msg_text = 'This message will show!'; - await view.model.sendMessage(msg_text); + await view.model.sendMessage({'body': msg_text}); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); expect(view.querySelectorAll('.chat-error').length).toEqual(0); })); diff --git a/src/plugins/muc-views/tests/component.js b/src/plugins/muc-views/tests/component.js index 47c181ef9..835d7682c 100644 --- a/src/plugins/muc-views/tests/component.js +++ b/src/plugins/muc-views/tests/component.js @@ -53,7 +53,7 @@ describe("The component", function () { await mock.returnMemberLists(_converse, muc_jid, [], all_affiliations); await model.messages.fetched; - model.sendMessage('hello from the lounge!'); + model.sendMessage({'body': 'hello from the lounge!'}); const span_el = document.createElement('span'); span_el.classList.add('conversejs'); @@ -83,7 +83,7 @@ describe("The component", function () { await mock.returnMemberLists(_converse, muc2_jid, [], all_affiliations); await model.messages.fetched; - model2.sendMessage('hello from the bar!'); + model2.sendMessage({'body': 'hello from the bar!'}); muc_el.setAttribute('jid', muc2_jid); await u.waitUntil(() => muc_el.querySelector('converse-chat-message-body').textContent.trim() === 'hello from the bar!'); diff --git a/src/plugins/muc-views/tests/muc-messages.js b/src/plugins/muc-views/tests/muc-messages.js index af2ce923a..1e8a11daa 100644 --- a/src/plugins/muc-views/tests/muc-messages.js +++ b/src/plugins/muc-views/tests/muc-messages.js @@ -411,7 +411,7 @@ describe("A Groupchat Message", function () { .c('status').attrs({code:'210'}).nodeTree; _converse.connection._dataRecv(mock.createRequest(presence)); - view.model.sendMessage('hello world'); + view.model.sendMessage({'body': 'hello world'}); await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 3); const occupant = await u.waitUntil(() => view.model.messages.filter(m => m.get('type') === 'groupchat')[2].occupant); @@ -542,7 +542,7 @@ describe("A Groupchat Message", function () { await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); const view = _converse.chatboxviews.get(muc_jid); - view.model.sendMessage('hello world'); + view.model.sendMessage({'body': 'hello world'}); await u.waitUntil(() => view.model.messages.length === 1); const msg = view.model.messages.at(0); expect(msg.get('stanza_id')).toBeUndefined(); diff --git a/src/plugins/muc-views/tests/retractions.js b/src/plugins/muc-views/tests/retractions.js index 8c340b523..25071d819 100644 --- a/src/plugins/muc-views/tests/retractions.js +++ b/src/plugins/muc-views/tests/retractions.js @@ -5,7 +5,7 @@ const u = converse.env.utils; async function sendAndThenRetractMessage (_converse, view) { - view.model.sendMessage('hello world'); + view.model.sendMessage({'body': 'hello world'}); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1); const msg_obj = view.model.messages.last(); const reflection_stanza = u.toStanza(` @@ -313,7 +313,7 @@ describe("Message Retractions", function () { const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; const view = await mock.openChatBoxFor(_converse, contact_jid); - view.model.sendMessage('hello world'); + view.model.sendMessage({'body': 'hello world'}); await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); const message = view.model.messages.at(0); @@ -709,7 +709,7 @@ describe("Message Retractions", function () { const occupant = view.model.getOwnOccupant(); expect(occupant.get('role')).toBe('moderator'); - view.model.sendMessage('Visit this site to get free bitcoin'); + view.model.sendMessage({'body': 'Visit this site to get free bitcoin'}); await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); const stanza_id = 'retraction-id-1'; const msg_obj = view.model.messages.at(0); @@ -758,7 +758,7 @@ describe("Message Retractions", function () { const occupant = view.model.getOwnOccupant(); expect(occupant.get('role')).toBe('moderator'); - view.model.sendMessage('Visit this site to get free bitcoin'); + view.model.sendMessage({'body': 'Visit this site to get free bitcoin'}); await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); const stanza_id = 'retraction-id-1'; const msg_obj = view.model.messages.at(0); diff --git a/src/plugins/omemo/consts.js b/src/plugins/omemo/consts.js index c61d70aa5..a5d0ef699 100644 --- a/src/plugins/omemo/consts.js +++ b/src/plugins/omemo/consts.js @@ -1,3 +1,10 @@ export const UNDECIDED = 0; export const TRUSTED = 1; export const UNTRUSTED = -1; + +export const TAG_LENGTH = 128; + +export const KEY_ALGO = { + 'name': 'AES-GCM', + 'length': 128 +}; diff --git a/src/plugins/omemo/mixins/converse.js b/src/plugins/omemo/mixins/converse.js index 1a71f01f4..4badf874c 100644 --- a/src/plugins/omemo/mixins/converse.js +++ b/src/plugins/omemo/mixins/converse.js @@ -1,16 +1,5 @@ -import concat from 'lodash-es/concat'; -import { UNTRUSTED } from '../consts.js'; -import { __ } from 'i18n'; -import { _converse, converse } from '@converse/headless/core'; -import { - addKeysToMessageStanza, - generateFingerprint, - getDevicesForContact, - getSession, - omemo, -} from '../utils.js'; +import { generateFingerprint, getDevicesForContact, } from '../utils.js'; -const { Strophe, $msg } = converse.env; const ConverseMixins = { @@ -27,92 +16,6 @@ const ConverseMixins = { /* Checks whether the contact advertises any OMEMO-compatible devices. */ const devices = await getDevicesForContact(jid); return devices.length > 0; - }, - - getBundlesAndBuildSessions: async function (chatbox) { - const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.'); - let devices; - 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')); - if (their_devices.length === 0) { - const err = new Error(no_devices_err); - err.user_facing = true; - throw err; - } - const own_devices = _converse.devicelists.get(_converse.bare_jid).devices; - devices = [...own_devices.models, ...their_devices.models]; - } - // Filter out our own device - const id = _converse.omemo_store.get('device_id'); - devices = devices.filter(d => d.get('id') !== id); - // Fetch bundles if necessary - await Promise.all(devices.map(d => d.getBundle())); - - const sessions = devices.filter(d => d).map(d => getSession(d)); - await Promise.all(sessions); - if (sessions.includes(null)) { - // We couldn't build a session for certain devices. - devices = devices.filter(d => sessions[devices.indexOf(d)]); - if (devices.length === 0) { - const err = new Error(no_devices_err); - err.user_facing = true; - throw err; - } - } - return devices; - }, - - createOMEMOMessageStanza: function (chatbox, message, devices) { - const body = __( - 'This is an OMEMO encrypted message which your client doesn’t 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 omemo.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 && device.get('active')) - .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; - }); - }); } } diff --git a/src/plugins/omemo/overrides/chatbox.js b/src/plugins/omemo/overrides/chatbox.js index 865367903..58f2f8443 100644 --- a/src/plugins/omemo/overrides/chatbox.js +++ b/src/plugins/omemo/overrides/chatbox.js @@ -1,16 +1,18 @@ import { _converse } from '@converse/headless/core'; +import { createOMEMOMessageStanza, getBundlesAndBuildSessions } from '../utils.js'; const ChatBox = { - async sendMessage (text, spoiler_hint) { - if (this.get('omemo_active') && text) { - const attrs = this.getOutgoingMessageAttributes(text, spoiler_hint); + async sendMessage (attrs) { + if (this.get('omemo_active') && attrs?.body) { + const plaintext = attrs?.body; + attrs = this.getOutgoingMessageAttributes(attrs); attrs['is_encrypted'] = true; - attrs['plaintext'] = attrs.message; + attrs['plaintext'] = plaintext; let message, stanza; try { - const devices = await _converse.getBundlesAndBuildSessions(this); + const devices = await getBundlesAndBuildSessions(this); message = await this.createMessage(attrs); - stanza = await _converse.createOMEMOMessageStanza(this, message, devices); + stanza = await createOMEMOMessageStanza(this, message, devices); } catch (e) { this.handleMessageSendError(e); return null; diff --git a/src/plugins/omemo/tests/media-sharing.js b/src/plugins/omemo/tests/media-sharing.js new file mode 100644 index 000000000..bda46f70c --- /dev/null +++ b/src/plugins/omemo/tests/media-sharing.js @@ -0,0 +1,152 @@ +/*global mock, converse */ + +const { $iq, Strophe, u } = converse.env; + + +describe("The OMEMO module", function() { + + it("implements XEP-0454 to encrypt uploaded files", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const base_url = 'https://example.org/'; + await mock.waitUntilDiscoConfirmed( + _converse, _converse.domain, + [{'category': 'server', 'type':'IM'}], + ['http://jabber.org/protocol/disco#items'], [], 'info'); + + const send_backup = XMLHttpRequest.prototype.send; + const IQ_stanzas = _converse.connection.IQ_stanzas; + + await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items'); + await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []); + await mock.waitForRoster(_converse, 'current', 3); + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + await u.waitUntil(() => mock.initializedOMEMO(_converse)); + + await mock.openChatBoxFor(_converse, contact_jid); + + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + let 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'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + const devicelist = _converse.devicelists.get({'jid': contact_jid}); + await u.waitUntil(() => devicelist.devices.length === 1); + + const view = _converse.chatboxviews.get(contact_jid); + const file = new File(['secret'], 'secret.txt', { type: 'text/plain' }) + view.model.set('omemo_active', true); + view.model.sendFiles([file]); + + await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length); + const iq = IQ_stanzas.pop(); + const url = base_url+"/secret.txt"; + stanza = u.toStanza(` + + + +
Basic Base64String==
+
foo=bar; user=romeo
+
+ +
+
`); + + spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () { + const message = view.model.messages.at(0); + message.set('progress', 1); + await u.waitUntil(() => view.querySelector('.chat-content progress')?.getAttribute('value') === '1') + message.save({ + 'upload': _converse.SUCCESS, + 'oob_url': message.get('get'), + 'message': message.get('get') + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + }); + let sent_stanza; + _converse.connection._dataRecv(mock.createRequest(stanza)); + + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555')); + 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')); + _converse.connection._dataRecv(mock.createRequest(stanza)); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe')); + 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.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').and.callFake(stanza => (sent_stanza = stanza)); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => sent_stanza); + + const fallback = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo'; + expect(Strophe.serialize(sent_stanza)).toBe( + ``+ + `${fallback}`+ + ``+ + ``+ + `
`+ + `YzFwaDNSNzNYNw==`+ + `YzFwaDNSNzNYNw==`+ + `${sent_stanza.querySelector('header iv').textContent}`+ + `
`+ + `${sent_stanza.querySelector('payload').textContent}`+ + `
`+ + ``+ + `
`); + + const link_el = await u.waitUntil(() => view.querySelector('.chat-msg__media')); + expect(link_el.textContent.trim()).toBe('Download file "secret.txt"', 1000); + + const message = view.model.messages.at(0); + expect(message.get('is_encrypted')).toBe(true); + + XMLHttpRequest.prototype.send = send_backup; + })); +}); diff --git a/src/plugins/omemo/tests/omemo.js b/src/plugins/omemo/tests/omemo.js index befd6d912..80d978cf7 100644 --- a/src/plugins/omemo/tests/omemo.js +++ b/src/plugins/omemo/tests/omemo.js @@ -3,71 +3,6 @@ const { $iq, $pres, $msg, omemo, Strophe } = converse.env; const u = converse.env.utils; -async function deviceListFetched (_converse, jid) { - const selector = `iq[to="${jid}"] items[node="eu.siacs.conversations.axolotl.devicelist"]`; - const stanza = await u.waitUntil( - () => Array.from(_converse.connection.IQ_stanzas).filter(iq => iq.querySelector(selector)).pop() - ); - await u.waitUntil(() => _converse.devicelists.get(jid)); - return stanza; -} - -function ownDeviceHasBeenPublished (_converse) { - return Array.from(_converse.connection.IQ_stanzas).filter( - iq => iq.querySelector('iq[from="'+_converse.bare_jid+'"] publish[node="eu.siacs.conversations.axolotl.devicelist"]') - ).pop(); -} - -function bundleHasBeenPublished (_converse) { - const selector = 'publish[node="eu.siacs.conversations.axolotl.bundles:123456789"]'; - return Array.from(_converse.connection.IQ_stanzas).filter(iq => iq.querySelector(selector)).pop(); -} - -function bundleFetched (_converse, jid, device_id) { - return Array.from(_converse.connection.IQ_stanzas).filter( - iq => iq.querySelector(`iq[to="${jid}"] items[node="eu.siacs.conversations.axolotl.bundles:${device_id}"]`) - ).pop(); -} - -async function initializedOMEMO (_converse) { - await mock.waitUntilDiscoConfirmed( - _converse, _converse.bare_jid, - [{'category': 'pubsub', 'type': 'pep'}], - ['http://jabber.org/protocol/pubsub#publish-options'] - ); - let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, _converse.bare_jid)); - let 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(mock.createRequest(stanza)); - iq_stanza = await u.waitUntil(() => ownDeviceHasBeenPublished(_converse)) - - stanza = $iq({ - 'from': _converse.bare_jid, - 'id': iq_stanza.getAttribute('id'), - 'to': _converse.bare_jid, - 'type': 'result'}); - _converse.connection._dataRecv(mock.createRequest(stanza)); - - iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse)) - - stanza = $iq({ - 'from': _converse.bare_jid, - 'id': iq_stanza.getAttribute('id'), - 'to': _converse.bare_jid, - 'type': 'result'}); - _converse.connection._dataRecv(mock.createRequest(stanza)); - await _converse.api.waitUntil('OMEMOInitialized'); -} - - describe("The OMEMO module", function() { it("adds methods for encrypting and decrypting messages via AES GCM", @@ -86,9 +21,9 @@ describe("The OMEMO module", function() { let sent_stanza; await mock.waitForRoster(_converse, 'current', 1); const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; - await u.waitUntil(() => initializedOMEMO(_converse)); + await u.waitUntil(() => mock.initializedOMEMO(_converse)); await mock.openChatBoxFor(_converse, contact_jid); - let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid)); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); let stanza = $iq({ 'from': contact_jid, 'id': iq_stanza.getAttribute('id'), @@ -115,7 +50,7 @@ describe("The OMEMO module", function() { preventDefault: function preventDefault () {}, keyCode: 13 // Enter }); - iq_stanza = await u.waitUntil(() => bundleFetched(_converse, contact_jid, '555')); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555')); stanza = $iq({ 'from': contact_jid, 'id': iq_stanza.getAttribute('id'), @@ -134,7 +69,7 @@ describe("The OMEMO module", function() { .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up() .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003')); _converse.connection._dataRecv(mock.createRequest(stanza)); - iq_stanza = await u.waitUntil(() => bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe')); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe')); stanza = $iq({ 'from': _converse.bare_jid, 'id': iq_stanza.getAttribute('id'), @@ -233,7 +168,7 @@ describe("The OMEMO module", function() { ]; await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features); const view = _converse.chatboxviews.get('lounge@montague.lit'); - await u.waitUntil(() => initializedOMEMO(_converse)); + await u.waitUntil(() => mock.initializedOMEMO(_converse)); const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); const el = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')); @@ -255,7 +190,7 @@ describe("The OMEMO module", function() { _converse.connection._dataRecv(mock.createRequest(stanza)); // Wait for Converse to fetch newguy's device list - let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid)); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -278,7 +213,7 @@ describe("The OMEMO module", function() { await u.waitUntil(() => _converse.omemo_store); expect(_converse.devicelists.length).toBe(2); - await u.waitUntil(() => deviceListFetched(_converse, contact_jid)); + await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); const devicelist = _converse.devicelists.get(contact_jid); expect(devicelist.devices.length).toBe(1); expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228'); @@ -296,7 +231,7 @@ describe("The OMEMO module", function() { preventDefault: function preventDefault () {}, keyCode: 13 // Enter }); - iq_stanza = await u.waitUntil(() => bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'), 1000); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'), 1000); console.log("Bundle fetched 4e30f35051b7b8b42abe083742187228"); stanza = $iq({ 'from': contact_jid, @@ -317,7 +252,7 @@ describe("The OMEMO module", function() { .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003')); _converse.connection._dataRecv(mock.createRequest(stanza)); - iq_stanza = await u.waitUntil(() => bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'), 1000); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'), 1000); console.log("Bundle fetched 482886413b977930064a5888b92134fe"); stanza = $iq({ 'from': _converse.bare_jid, @@ -367,9 +302,9 @@ describe("The OMEMO module", function() { await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]); await mock.waitForRoster(_converse, 'current', 1); const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; - await u.waitUntil(() => initializedOMEMO(_converse)); + await u.waitUntil(() => mock.initializedOMEMO(_converse)); await mock.openChatBoxFor(_converse, contact_jid); - let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid)); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); const my_devicelist = _converse.devicelists.get({'jid': _converse.bare_jid}); expect(my_devicelist.devices.length).toBe(2); @@ -429,7 +364,7 @@ describe("The OMEMO module", function() { // The message received is a prekey message, so missing prekeys are // generated and a new bundle published. - iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse)); + iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse)); const result_iq = $iq({ 'from': _converse.bare_jid, 'id': iq_stanza.getAttribute('id'), @@ -460,7 +395,7 @@ describe("The OMEMO module", function() { preventDefault: function preventDefault () {}, keyCode: 13 // Enter }); - iq_stanza = await u.waitUntil(() => bundleFetched(_converse, _converse.bare_jid, '988349631')); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '988349631')); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -486,7 +421,7 @@ describe("The OMEMO module", function() { ]; await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features); const view = _converse.chatboxviews.get('lounge@montague.lit'); - await u.waitUntil(() => initializedOMEMO(_converse)); + await u.waitUntil(() => mock.initializedOMEMO(_converse)); const contact_jid = 'newguy@montague.lit'; let stanza = $pres({ @@ -515,7 +450,7 @@ describe("The OMEMO module", function() { preventDefault: function preventDefault () {}, keyCode: 13 // Enter }); - let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid)); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -539,11 +474,11 @@ describe("The OMEMO module", function() { expect(_converse.devicelists.length).toBe(2); const devicelist = _converse.devicelists.get(contact_jid); - await u.waitUntil(() => deviceListFetched(_converse, contact_jid)); + await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); expect(devicelist.devices.length).toBe(1); expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228'); - iq_stanza = await u.waitUntil(() => bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe')); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe')); stanza = $iq({ 'from': _converse.bare_jid, 'id': iq_stanza.getAttribute('id'), @@ -561,7 +496,7 @@ describe("The OMEMO module", function() { .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up() .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up() .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993')); - iq_stanza = await u.waitUntil(() => bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228')); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228')); /* * @@ -603,7 +538,7 @@ describe("The OMEMO module", function() { await mock.waitForRoster(_converse, 'current', 1); const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; - await u.waitUntil(() => initializedOMEMO(_converse)); + await u.waitUntil(() => mock.initializedOMEMO(_converse)); const obj = await omemo.encryptMessage('This is an encrypted message from the contact'); // XXX: Normally the key will be encrypted via libsignal. // However, we're mocking libsignal in the tests, so we include @@ -635,7 +570,7 @@ describe("The OMEMO module", function() { }); _converse.connection._dataRecv(mock.createRequest(stanza)); - let iq_stanza = await deviceListFetched(_converse, contact_jid); + let iq_stanza = await mock.deviceListFetched(_converse, contact_jid); stanza = $iq({ 'from': contact_jid, 'id': iq_stanza.getAttribute('id'), @@ -653,7 +588,7 @@ describe("The OMEMO module", function() { _converse.connection.IQ_stanzas = []; _converse.connection._dataRecv(mock.createRequest(stanza)); await u.waitUntil(() => _converse.omemo_store); - iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse), 1000); + iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse), 1000); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -703,7 +638,7 @@ describe("The OMEMO module", function() { const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; // Wait until own devices are fetched - let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, _converse.bare_jid)); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid)); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -729,14 +664,14 @@ describe("The OMEMO module", function() { expect(devicelist.devices.length).toBe(2); expect(devicelist.devices.at(0).get('id')).toBe('555'); expect(devicelist.devices.at(1).get('id')).toBe('123456789'); - iq_stanza = await u.waitUntil(() => ownDeviceHasBeenPublished(_converse)); + iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse)); stanza = $iq({ 'from': _converse.bare_jid, 'id': iq_stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'}); _converse.connection._dataRecv(mock.createRequest(stanza)); - iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse)); + iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse)); stanza = $iq({ 'from': _converse.bare_jid, @@ -822,7 +757,7 @@ describe("The OMEMO module", function() { .c('device', {'id': '444'}) _converse.connection._dataRecv(mock.createRequest(stanza)); - iq_stanza = await u.waitUntil(() => ownDeviceHasBeenPublished(_converse)); + iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse)); // Check that our own device is added again, but that removed // devices are not added. expect(Strophe.serialize(iq_stanza)).toBe( @@ -872,7 +807,7 @@ describe("The OMEMO module", function() { await mock.waitForRoster(_converse, 'current'); const contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit'; - let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, _converse.bare_jid)); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid)); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -897,14 +832,14 @@ describe("The OMEMO module", function() { expect(devicelist.devices.length).toBe(2); expect(devicelist.devices.at(0).get('id')).toBe('555'); expect(devicelist.devices.at(1).get('id')).toBe('123456789'); - iq_stanza = await u.waitUntil(() => ownDeviceHasBeenPublished(_converse)); + iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse)); stanza = $iq({ 'from': _converse.bare_jid, 'id': iq_stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'}); _converse.connection._dataRecv(mock.createRequest(stanza)); - iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse)); + iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse)); stanza = $iq({ 'from': _converse.bare_jid, 'id': iq_stanza.getAttribute('id'), @@ -1021,7 +956,7 @@ describe("The OMEMO module", function() { await mock.waitForRoster(_converse, 'current', 1); const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; - let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, _converse.bare_jid)); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid)); let stanza = $iq({ 'from': contact_jid, 'id': iq_stanza.getAttribute('id'), @@ -1035,7 +970,7 @@ describe("The OMEMO module", function() { _converse.connection._dataRecv(mock.createRequest(stanza)); expect(_converse.devicelists.length).toBe(1); await mock.openChatBoxFor(_converse, contact_jid); - iq_stanza = await ownDeviceHasBeenPublished(_converse); + iq_stanza = await mock.ownDeviceHasBeenPublished(_converse); stanza = $iq({ 'from': _converse.bare_jid, 'id': iq_stanza.getAttribute('id'), @@ -1043,7 +978,7 @@ describe("The OMEMO module", function() { 'type': 'result'}); _converse.connection._dataRecv(mock.createRequest(stanza)); - iq_stanza = await u.waitUntil(() => bundleHasBeenPublished(_converse)); + iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse)); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -1095,7 +1030,7 @@ describe("The OMEMO module", function() { await mock.waitForRoster(_converse, 'current', 1); const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; - let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, _converse.bare_jid)); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid)); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -1121,7 +1056,7 @@ describe("The OMEMO module", function() { expect(devicelist.devices.at(0).get('id')).toBe('482886413b977930064a5888b92134fe'); expect(devicelist.devices.at(1).get('id')).toBe('123456789'); // Check that own device was published - iq_stanza = await u.waitUntil(() => ownDeviceHasBeenPublished(_converse)); + iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse)); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -1153,7 +1088,7 @@ describe("The OMEMO module", function() { 'type': 'result'}); _converse.connection._dataRecv(mock.createRequest(stanza)); - const iq_el = await u.waitUntil(() => bundleHasBeenPublished(_converse)); + const iq_el = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse)); expect(iq_el.getAttributeNames().sort().join()).toBe(["from", "type", "xmlns", "id"].sort().join()); expect(iq_el.querySelector('prekeys').childNodes.length).toBe(100); @@ -1172,7 +1107,7 @@ describe("The OMEMO module", function() { _converse.connection._dataRecv(mock.createRequest(stanza)); await _converse.api.waitUntil('OMEMOInitialized', 1000); await mock.openChatBoxFor(_converse, contact_jid); - iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid)); + iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -1264,7 +1199,7 @@ describe("The OMEMO module", function() { ]; await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features); const view = _converse.chatboxviews.get('lounge@montague.lit'); - await u.waitUntil(() => initializedOMEMO(_converse)); + await u.waitUntil(() => mock.initializedOMEMO(_converse)); const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); let toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')); @@ -1298,7 +1233,7 @@ describe("The OMEMO module", function() { }).tree(); _converse.connection._dataRecv(mock.createRequest(stanza)); - let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid)); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -1321,7 +1256,7 @@ describe("The OMEMO module", function() { await u.waitUntil(() => _converse.omemo_store); expect(_converse.devicelists.length).toBe(2); - await u.waitUntil(() => deviceListFetched(_converse, contact_jid)); + await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); const devicelist = _converse.devicelists.get(contact_jid); expect(devicelist.devices.length).toBe(2); expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228'); @@ -1381,7 +1316,7 @@ describe("The OMEMO module", function() { 'role': 'participant' }).tree(); _converse.connection._dataRecv(mock.createRequest(stanza)); - iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid)); + iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -1432,7 +1367,7 @@ describe("The OMEMO module", function() { show_modal_button.click(); const modal = _converse.api.modal.get('user-details-modal'); await u.waitUntil(() => u.isVisible(modal.el), 1000); - let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid)); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ @@ -1449,7 +1384,7 @@ describe("The OMEMO module", function() { .c('device', {'id': '555'}); _converse.connection._dataRecv(mock.createRequest(stanza)); await u.waitUntil(() => u.isVisible(modal.el), 1000); - iq_stanza = await u.waitUntil(() => bundleFetched(_converse, contact_jid, '555')); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555')); expect(Strophe.serialize(iq_stanza)).toBe( ``+ ``+ diff --git a/src/plugins/omemo/utils.js b/src/plugins/omemo/utils.js index acc2dc88f..2d71104ba 100644 --- a/src/plugins/omemo/utils.js +++ b/src/plugins/omemo/utils.js @@ -6,13 +6,15 @@ import tpl_audio from 'templates/audio.js'; import tpl_file from 'templates/file.js'; import tpl_image from 'templates/image.js'; import tpl_video from 'templates/video.js'; +import { MIMETYPES_MAP } from 'utils/file.js'; +import { KEY_ALGO, UNTRUSTED, TAG_LENGTH } from './consts.js'; import { __ } from 'i18n'; import { _converse, converse, api } from '@converse/headless/core'; import { html } from 'lit'; import { initStorage } from '@converse/headless/shared/utils.js'; import { isAudioURL, isImageURL, isVideoURL, getURI } from 'utils/html.js'; +import concat from 'lodash-es/concat'; import { until } from 'lit/directives/until.js'; -import { MIMETYPES_MAP } from 'utils/file.js'; import { appendArrayBuffer, arrayBufferToBase64, @@ -23,13 +25,7 @@ import { stringToArrayBuffer } from '@converse/headless/utils/arraybuffer.js'; -const { Strophe, sizzle, u } = converse.env; - -const TAG_LENGTH = 128; -const KEY_ALGO = { - 'name': 'AES-GCM', - 'length': 128 -}; +const { $msg, Strophe, sizzle, u } = converse.env; async function encryptMessage (plaintext) { @@ -368,11 +364,7 @@ export function addKeysToMessageStanza (stanza, dicts, iv) { } stanza.up(); if (i == dicts.length - 1) { - stanza - .c('iv') - .t(iv) - .up() - .up(); + stanza.c('iv').t(iv).up().up(); } } } @@ -687,3 +679,91 @@ export function getOMEMOToolbarButton (toolbar_el, buttons) { `); return buttons; } + + +export async function getBundlesAndBuildSessions (chatbox) { + const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.'); + let devices; + 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')); + if (their_devices.length === 0) { + const err = new Error(no_devices_err); + err.user_facing = true; + throw err; + } + const own_devices = _converse.devicelists.get(_converse.bare_jid).devices; + devices = [...own_devices.models, ...their_devices.models]; + } + // Filter out our own device + const id = _converse.omemo_store.get('device_id'); + devices = devices.filter(d => d.get('id') !== id); + // Fetch bundles if necessary + await Promise.all(devices.map(d => d.getBundle())); + + const sessions = devices.filter(d => d).map(d => getSession(d)); + await Promise.all(sessions); + if (sessions.includes(null)) { + // We couldn't build a session for certain devices. + devices = devices.filter(d => sessions[devices.indexOf(d)]); + if (devices.length === 0) { + const err = new Error(no_devices_err); + err.user_facing = true; + throw err; + } + } + return devices; +} + + +export function createOMEMOMessageStanza (chatbox, message, devices) { + const body = __( + 'This is an OMEMO encrypted message which your client doesn’t 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 omemo.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 && device.get('active')) + .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; + }); + }); +}