From d2622f6fed819e875a9528e4ada6526f0b787ec7 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sat, 19 Feb 2022 11:45:04 +0100 Subject: [PATCH] Fixes #2557 Add the ability to send OMEMO corrections. Refactor how OMEMO messages are sent to avoid having to override `sendMessage` and thereby also allowing corrections of OMEMO messages to be sent out. Add two new hooks. - getOutgoingMessageAttributes - createMessageStanza --- CHANGES.md | 1 + karma.conf.js | 3 +- src/headless/core.js | 7 +- src/headless/plugins/chat/message.js | 7 +- src/headless/plugins/chat/model.js | 119 +++++--- src/headless/plugins/muc/muc.js | 12 +- src/headless/utils/core.js | 14 +- src/plugins/chatview/message-form.js | 7 +- src/plugins/omemo/device.js | 7 +- src/plugins/omemo/index.js | 21 +- src/plugins/omemo/mixins/chatbox.js | 55 ---- src/plugins/omemo/overrides/chatbox.js | 28 -- src/plugins/omemo/tests/corrections.js | 344 ++++++++++++++++++++++ src/plugins/omemo/tests/media-sharing.js | 2 + src/plugins/omemo/tests/muc.js | 2 + src/plugins/omemo/tests/omemo.js | 2 + src/plugins/omemo/utils.js | 114 ++++--- src/shared/chat/templates/message-text.js | 1 + src/shared/components/message-versions.js | 6 +- 19 files changed, 562 insertions(+), 190 deletions(-) delete mode 100644 src/plugins/omemo/mixins/chatbox.js delete mode 100644 src/plugins/omemo/overrides/chatbox.js create mode 100644 src/plugins/omemo/tests/corrections.js diff --git a/CHANGES.md b/CHANGES.md index 356754d2d..d5300a32c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,7 @@ - Fix bug where MUC config wasn't persisted across page loads - Add support for calling the IndexedDB `getAll` method to speed up fetching models from storage. - #1761: Add a new dark theme based on the [Dracula](https://draculatheme.com/) theme +- #2557: Allow OMEMO encrypted messages to be edited - #2627: Spoiler toggles only after switching to another tab and back - #2733: Fix OMEMO race condition related to automatic reconnection and SMACKS - #2733: Wait for decrypted/parsed message before queuing to UI diff --git a/karma.conf.js b/karma.conf.js index 6cd99e71d..c9051b6d7 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -95,9 +95,10 @@ 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/corrections.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/omemo/tests/muc.js", type: 'module' }, + { pattern: "src/plugins/omemo/tests/omemo.js", type: 'module' }, { pattern: "src/plugins/push/tests/push.js", type: 'module' }, { pattern: "src/plugins/register/tests/register.js", type: 'module' }, { pattern: "src/plugins/rootview/tests/root.js", type: 'module' }, diff --git a/src/headless/core.js b/src/headless/core.js index cd5842b5e..a744a16c9 100644 --- a/src/headless/core.js +++ b/src/headless/core.js @@ -210,12 +210,7 @@ export const api = _converse.api = { // Create a chain of promises, with each one feeding its output to // the next. The first input is a promise with the original data // sent to this hook. - const o = events.reduce((o, e) => o.then(d => e.callback(context, d)), Promise.resolve(data)); - o.catch(e => { - log.error(e) - throw e; - }); - return o; + return events.reduce((o, e) => o.then(d => e.callback(context, d)), Promise.resolve(data)); } else { return data; } diff --git a/src/headless/plugins/chat/message.js b/src/headless/plugins/chat/message.js index 411676109..c75f18fcc 100644 --- a/src/headless/plugins/chat/message.js +++ b/src/headless/plugins/chat/message.js @@ -162,11 +162,14 @@ const MessageMixin = { }, getMessageText () { - const { __ } = _converse; if (this.get('is_encrypted')) { + const { __ } = _converse; return this.get('plaintext') || this.get('body') || __('Undecryptable OMEMO message'); + } else if (['groupchat', 'chat'].includes(this.get('type'))) { + return this.get('body'); + } else { + return this.get('message'); } - return this.get('message'); }, /** diff --git a/src/headless/plugins/chat/model.js b/src/headless/plugins/chat/model.js index bb967e4e5..32940094e 100644 --- a/src/headless/plugins/chat/model.js +++ b/src/headless/plugins/chat/model.js @@ -816,59 +816,79 @@ const ChatBox = ModelWithContact.extend({ * @method _converse.ChatBox#createMessageStanza * @param { _converse.Message } message - The message object */ - createMessageStanza (message) { + async createMessageStanza (message) { const stanza = $msg({ 'from': _converse.connection.jid, 'to': this.get('jid'), 'type': this.get('message_type'), 'id': message.get('edited') && u.getUniqueId() || message.get('msgid'), - }).c('body').t(message.get('message')).up() + }).c('body').t(message.get('body')).up() .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).root(); if (message.get('type') === 'chat') { stanza.c('request', {'xmlns': Strophe.NS.RECEIPTS}).root(); } - if (message.get('is_spoiler')) { - if (message.get('spoiler_hint')) { - stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}, message.get('spoiler_hint')).root(); - } else { - stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}).root(); - } - } - (message.get('references') || []).forEach(reference => { - const attrs = { - 'xmlns': Strophe.NS.REFERENCE, - 'begin': reference.begin, - 'end': reference.end, - 'type': reference.type, - } - if (reference.uri) { - attrs.uri = reference.uri; - } - stanza.c('reference', attrs).root(); - }); - if (message.get('oob_url')) { - stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('oob_url')).root(); + if (!message.get('is_encrypted')) { + if (message.get('is_spoiler')) { + if (message.get('spoiler_hint')) { + stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}, message.get('spoiler_hint')).root(); + } else { + stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}).root(); + } + } + (message.get('references') || []).forEach(reference => { + const attrs = { + 'xmlns': Strophe.NS.REFERENCE, + 'begin': reference.begin, + 'end': reference.end, + 'type': reference.type, + } + if (reference.uri) { + attrs.uri = reference.uri; + } + stanza.c('reference', attrs).root(); + }); + + if (message.get('oob_url')) { + stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('oob_url')).root(); + } } + if (message.get('edited')) { stanza.c('replace', { 'xmlns': Strophe.NS.MESSAGE_CORRECT, 'id': message.get('msgid') }).root(); } + if (message.get('origin_id')) { stanza.c('origin-id', {'xmlns': Strophe.NS.SID, 'id': message.get('origin_id')}).root(); } - return stanza; + stanza.root(); + /** + * *Hook* which allows plugins to update an outgoing message stanza + * @event _converse#createMessageStanza + * @param { _converse.ChatBox | _converse.ChatRoom } - The chat from + * which this message stanza is being sent. + * @param { Object } data - Message data + * @param { _converse.Message | _converse.ChatRoomMessage } data.message + * The message object from which the stanza is created and which gets persisted to storage. + * @param { Strophe.Builder } data.stanza + * The stanza that will be sent out, as a Strophe.Builder object. + * You can use the Strophe.Builder functions to extend the stanza. + * See http://strophe.im/strophejs/doc/1.4.3/files/strophe-umd-js.html#Strophe.Builder.Functions + */ + const data = await api.hook('createMessageStanza', this, { message, stanza }); + return data.stanza; }, - getOutgoingMessageAttributes (attrs) { + async 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 Object.assign({}, attrs, { + attrs = Object.assign({}, attrs, { 'from': _converse.bare_jid, 'fullname': _converse.xmppstatus.get('fullname'), 'id': origin_id, @@ -884,6 +904,19 @@ const ChatBox = ModelWithContact.extend({ is_spoiler, origin_id }, getMediaURLsMetadata(text)); + + /** + * *Hook* which allows plugins to update the attributes of an outgoing message. + * These attributes get set on the { @link _converse.Message } or + * { @link _converse.ChatRoomMessage } and persisted to storage. + * @event _converse#getOutgoingMessageAttributes + * @param { _converse.ChatBox | _converse.ChatRoom } chat + * The chat from which this message will be sent. + * @param { MessageAttributes } attrs + * The message attributes, from which the stanza will be created. + */ + attrs = await api.hook('getOutgoingMessageAttributes', this, attrs); + return attrs; }, /** @@ -939,26 +972,38 @@ const ChatBox = ModelWithContact.extend({ * chat.sendMessage({'body': 'hello world'}); */ async sendMessage (attrs) { - attrs = this.getOutgoingMessageAttributes(attrs); + attrs = await this.getOutgoingMessageAttributes(attrs); let message = this.messages.findWhere('correcting') if (message) { const older_versions = message.get('older_versions') || {}; - older_versions[message.get('time')] = message.get('message'); + older_versions[message.get('time')] = message.getMessageText(); + const plaintext = attrs.is_encrypted ? attrs.message : undefined; + message.save({ + 'body': attrs.body, + 'message': attrs.body, 'correcting': false, 'edited': (new Date()).toISOString(), - 'message': attrs.message, - 'older_versions': older_versions, - 'references': attrs.references, 'is_only_emojis': attrs.is_only_emojis, 'origin_id': u.getUniqueId(), - 'received': undefined + 'received': undefined, + 'references': attrs.references, + older_versions, + plaintext, }); } else { this.setEditable(attrs, (new Date()).toISOString()); message = await this.createMessage(attrs); } - api.send(this.createMessageStanza(message)); + + try { + const stanza = await this.createMessageStanza(message); + api.send(stanza); + } catch (e) { + message.destroy(); + log.error(e); + return; + } /** * Triggered when a message is being sent out @@ -1026,6 +1071,10 @@ const ChatBox = ModelWithContact.extend({ * *Hook* which allows plugins to transform files before they'll be * uploaded. The main use-case is to encrypt the files. * @event _converse#beforeFileUpload + * @param { _converse.ChatBox | _converse.ChatRoom } chat + * The chat from which this file will be uploaded. + * @param { File } file + * The file that will be uploaded */ file = await api.hook('beforeFileUpload', this, file); @@ -1037,8 +1086,8 @@ const ChatBox = ModelWithContact.extend({ 'is_ephemeral': true }); } else { - const attrs = Object.assign( - this.getOutgoingMessageAttributes(), { + const initial_attrs = await this.getOutgoingMessageAttributes(); + const attrs = Object.assign(initial_attrs, { 'file': true, 'progress': 0, 'slot_request_url': slot_request_url diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index b67e0fe9a..e4ffad292 100644 --- a/src/headless/plugins/muc/muc.js +++ b/src/headless/plugins/muc/muc.js @@ -989,7 +989,7 @@ const ChatRoomMixin = { return [updated_message, updated_references]; }, - getOutgoingMessageAttributes (attrs) { + async getOutgoingMessageAttributes (attrs) { const is_spoiler = this.get('composing_spoiler'); let text = '', references; if (attrs?.body) { @@ -997,7 +997,7 @@ const ChatRoomMixin = { } const origin_id = getUniqueId(); const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined; - return Object.assign({}, attrs, { + attrs = Object.assign({}, attrs, { body, is_spoiler, origin_id, @@ -1012,6 +1012,14 @@ const ChatRoomMixin = { 'sender': 'me', 'type': 'groupchat' }, getMediaURLsMetadata(text)); + + /** + * *Hook* which allows plugins to update the attributes of an outgoing + * message. + * @event _converse#getOutgoingMessageAttributes + */ + attrs = await api.hook('getOutgoingMessageAttributes', this, attrs); + return attrs; }, /** diff --git a/src/headless/utils/core.js b/src/headless/utils/core.js index f684bdfaa..925e519d3 100644 --- a/src/headless/utils/core.js +++ b/src/headless/utils/core.js @@ -103,18 +103,19 @@ u.getLongestSubstring = function (string, candidates) { return candidates.reduce(reducer, ''); } -u.prefixMentions = function (message) { - /* Given a message object, return its text with @ chars - * inserted before the mentioned nicknames. - */ - let text = message.get('message'); +/** + * Given a message object, return its text with @ chars + * inserted before the mentioned nicknames. + */ +export function prefixMentions (message) { + let text = message.getMessageText(); (message.get('references') || []) .sort((a, b) => b.begin - a.begin) .forEach(ref => { text = `${text.slice(0, ref.begin)}@${text.slice(ref.begin)}` }); return text; -}; +} u.isValidJID = function (jid) { if (typeof jid === 'string') { @@ -587,6 +588,7 @@ export function decodeHTMLEntities (str) { } export default Object.assign({ + prefixMentions, isEmptyMessage, getUniqueId }, u); diff --git a/src/plugins/chatview/message-form.js b/src/plugins/chatview/message-form.js index e2757634e..4576f3955 100644 --- a/src/plugins/chatview/message-form.js +++ b/src/plugins/chatview/message-form.js @@ -1,8 +1,9 @@ import tpl_message_form from './templates/message-form.js'; import { ElementView } from '@converse/skeletor/src/element.js'; import { __ } from 'i18n'; -import { _converse, api, converse } from "@converse/headless/core"; +import { _converse, api, converse } from "@converse/headless/core.js"; import { parseMessageForCommands } from './utils.js'; +import { prefixMentions } from '@converse/headless/utils/core.js'; const { u } = converse.env; @@ -86,11 +87,11 @@ export default class MessageForm extends ElementView { onMessageCorrecting (message) { if (message.get('correcting')) { - this.insertIntoTextArea(u.prefixMentions(message), true, true); + this.insertIntoTextArea(prefixMentions(message), true, true); } else { const currently_correcting = this.model.messages.findWhere('correcting'); if (currently_correcting && currently_correcting !== message) { - this.insertIntoTextArea(u.prefixMentions(message), true, true); + this.insertIntoTextArea(prefixMentions(message), true, true); } else { this.insertIntoTextArea('', true, false); } diff --git a/src/plugins/omemo/device.js b/src/plugins/omemo/device.js index 2b06551c6..4cb75ad2f 100644 --- a/src/plugins/omemo/device.js +++ b/src/plugins/omemo/device.js @@ -3,7 +3,7 @@ import { IQError } from './errors.js'; import { Model } from '@converse/skeletor/src/model.js'; import { UNDECIDED } from './consts.js'; import { _converse, api, converse } from '@converse/headless/core'; -import { parseBundle } from './utils.js'; +import { parseBundle, handleMessageSendError } from './utils.js'; const { Strophe, sizzle, u, $iq } = converse.env; @@ -30,9 +30,8 @@ const Device = Model.extend({ 'type': 'get', 'from': _converse.bare_jid, 'to': this.get('jid') - }) - .c('pubsub', { 'xmlns': Strophe.NS.PUBSUB }) - .c('items', { 'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}` }); + }).c('pubsub', { 'xmlns': Strophe.NS.PUBSUB }) + .c('items', { 'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}` }); let iq; try { diff --git a/src/plugins/omemo/index.js b/src/plugins/omemo/index.js index 8579a385a..20f16642a 100644 --- a/src/plugins/omemo/index.js +++ b/src/plugins/omemo/index.js @@ -6,7 +6,6 @@ import './fingerprints.js'; import './profile.js'; import 'modals/user-details.js'; import 'plugins/profile/index.js'; -import ChatBox from './overrides/chatbox.js'; import ConverseMixins from './mixins/converse.js'; import Device from './device.js'; import DeviceList from './devicelist.js'; @@ -15,19 +14,21 @@ import Devices from './devices.js'; import OMEMOStore from './store.js'; import log from '@converse/headless/log'; import omemo_api from './api.js'; -import { OMEMOEnabledChatBox } from './mixins/chatbox.js'; import { _converse, api, converse } from '@converse/headless/core'; import { + createOMEMOMessageStanza, encryptFile, getOMEMOToolbarButton, + getOutgoingMessageAttributes, handleEncryptedFiles, + handleMessageSendError, initOMEMO, omemo, onChatBoxesInitialized, onChatInitialized, parseEncryptedMessage, - setEncryptedFileURL, registerPEPPushHandler, + setEncryptedFileURL, } from './utils.js'; const { Strophe } = converse.env; @@ -52,15 +53,12 @@ converse.plugins.add('converse-omemo', { dependencies: ['converse-chatview', 'converse-pubsub'], - overrides: { ChatBox }, - initialize () { api.settings.extend({ 'omemo_default': false }); api.promises.add(['OMEMOInitialized']); _converse.NUM_PREKEYS = 100; // Set here so that tests can override - Object.assign(_converse.ChatBox.prototype, OMEMOEnabledChatBox); Object.assign(_converse, ConverseMixins); Object.assign(_converse.api, omemo_api); @@ -73,6 +71,17 @@ converse.plugins.add('converse-omemo', { /******************** Event Handlers ********************/ api.waitUntil('chatBoxesInitialized').then(onChatBoxesInitialized); + api.listen.on('getOutgoingMessageAttributes', getOutgoingMessageAttributes); + + api.listen.on('createMessageStanza', async (chat, data) => { + try { + data = await createOMEMOMessageStanza(chat, data); + } catch (e) { + handleMessageSendError(e, chat); + } + return data; + }); + api.listen.on('afterFileUploaded', (msg, attrs) => msg.file.xep454_ivkey ? setEncryptedFileURL(msg, attrs) : attrs); api.listen.on('beforeFileUpload', (chat, file) => chat.get('omemo_active') ? encryptFile(file) : file); diff --git a/src/plugins/omemo/mixins/chatbox.js b/src/plugins/omemo/mixins/chatbox.js deleted file mode 100644 index 148d5be0a..000000000 --- a/src/plugins/omemo/mixins/chatbox.js +++ /dev/null @@ -1,55 +0,0 @@ -import log from '@converse/headless/log'; -import { __ } from 'i18n'; -import { api, converse } from '@converse/headless/core'; -import { getSessionCipher } from '../utils.js'; - -const { Strophe, sizzle } = converse.env; - -/** - * Mixin object that contains OMEMO-related methods for - * {@link _converse.ChatBox} or {@link _converse.ChatRoom} objects. - * - * @typedef {Object} OMEMOEnabledChatBox - */ -export const OMEMOEnabledChatBox = { - encryptKey (plaintext, device) { - return getSessionCipher(device.get('jid'), device.get('id')) - .encrypt(plaintext) - .then(payload => ({ 'payload': payload, 'device': device })); - }, - - handleMessageSendError (e) { - if (e.name === 'IQError') { - this.save('omemo_supported', false); - - const err_msgs = []; - if (sizzle(`presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, e.iq).length) { - err_msgs.push( - __( - "Sorry, we're unable to send an encrypted message because %1$s " + - 'requires you to be subscribed to their presence in order to see their OMEMO information', - e.iq.getAttribute('from') - ) - ); - } else if (sizzle(`remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq).length) { - err_msgs.push( - __( - "Sorry, we're unable to send an encrypted message because the remote server for %1$s could not be found", - e.iq.getAttribute('from') - ) - ); - } else { - err_msgs.push(__('Unable to send an encrypted message due to an unexpected error.')); - err_msgs.push(e.iq.outerHTML); - } - api.alert('error', __('Error'), err_msgs); - log.error(e); - } else if (e.user_facing) { - api.alert('error', __('Error'), [e.message]); - log.error(e); - } else { - throw e; - } - } -}; - diff --git a/src/plugins/omemo/overrides/chatbox.js b/src/plugins/omemo/overrides/chatbox.js deleted file mode 100644 index 58f2f8443..000000000 --- a/src/plugins/omemo/overrides/chatbox.js +++ /dev/null @@ -1,28 +0,0 @@ -import { _converse } from '@converse/headless/core'; -import { createOMEMOMessageStanza, getBundlesAndBuildSessions } from '../utils.js'; - -const ChatBox = { - async sendMessage (attrs) { - if (this.get('omemo_active') && attrs?.body) { - const plaintext = attrs?.body; - attrs = this.getOutgoingMessageAttributes(attrs); - attrs['is_encrypted'] = true; - attrs['plaintext'] = plaintext; - let message, stanza; - try { - const devices = await getBundlesAndBuildSessions(this); - message = await this.createMessage(attrs); - stanza = await createOMEMOMessageStanza(this, message, devices); - } catch (e) { - this.handleMessageSendError(e); - return null; - } - _converse.api.send(stanza); - return message; - } else { - return this.__super__.sendMessage.apply(this, arguments); - } - } -} - -export default ChatBox; diff --git a/src/plugins/omemo/tests/corrections.js b/src/plugins/omemo/tests/corrections.js new file mode 100644 index 000000000..fad45fe45 --- /dev/null +++ b/src/plugins/omemo/tests/corrections.js @@ -0,0 +1,344 @@ +/*global mock, converse */ + +const { Strophe, $iq, $pres, u } = converse.env; + +describe("An OMEMO encrypted message", function() { + + it("can be edited", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await 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); + view.model.set('omemo_active', true); + + const textarea = view.querySelector('.chat-textarea'); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + 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')); + + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelector('.chat-msg__text').textContent) + .toBe('But soft, what light through yonder airlock breaks?'); + + await u.waitUntil(() => textarea.value === ''); + + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?'); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + + const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'}); + + const new_text = 'But soft, what light through yonder window breaks?'; + textarea.value = new_text; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(//g, '') === new_text); + + await u.waitUntil(() => _converse.connection.sent_stanzas.filter(s => s.nodeName === 'message').length === 3); + const msg = _converse.connection.sent_stanzas.pop(); + const fallback_text = '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(msg)) + .toBe(``+ + `${fallback_text}`+ + ``+ + ``+ + ``+ + ``+ + ``+ + `
`+ + `YzFwaDNSNzNYNw==`+ + `YzFwaDNSNzNYNw==`+ + `${msg.querySelector('header iv').textContent}`+ + `
`+ + `${msg.querySelector('payload').textContent}`+ + `
`+ + ``+ + ``+ + `
`); + + const older_versions = first_msg.get('older_versions'); + const keys = Object.keys(older_versions); + expect(keys.length).toBe(1); + expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?'); + expect(first_msg.get('plaintext')).toBe(new_text); + expect(first_msg.get('is_encrypted')).toBe(true); + expect(first_msg.get('body')).toBe(fallback_text); + expect(first_msg.get('message')).toBe(fallback_text); + + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe('But soft, what light through yonder window breaks?'); + })); +}); + +describe("An OMEMO encrypted MUC message", function() { + + it("can be edited", mock.initConverse(['chatBoxesFetched'], {}, async function (_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' + ]; + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features); + await u.waitUntil(() => mock.initializedOMEMO(_converse)); + + const view = _converse.chatboxviews.get(muc_jid); + const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); + const omemo_toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')); + omemo_toggle.click(); + expect(view.model.get('omemo_active')).toBe(true); + + // newguy enters the room + const contact_jid = 'newguy@montague.lit'; + let stanza = $pres({ + 'to': 'romeo@montague.lit/orchard', + 'from': 'lounge@montague.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + // Wait for Converse to fetch newguy's device list + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + expect(Strophe.serialize(iq_stanza)).toBe( + ``+ + ``+ + ``+ + ``+ + ``); + + // The server returns his device list + 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.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(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + expect(_converse.devicelists.length).toBe(2); + + 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'); + expect(view.model.get('omemo_active')).toBe(true); + + const original_text = 'This message will be encrypted'; + const textarea = view.querySelector('.chat-textarea'); + textarea.value = original_text; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'), 1000); + 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: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(mock.createRequest(stanza)); + + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'), 1000); + 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.callThrough(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.connection.send.calls.count(), 1000); + const sent_stanza = _converse.connection.send.calls.all()[0].args[0]; + + expect(Strophe.serialize(sent_stanza)).toBe( + ``+ + `This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo`+ + ``+ + ``+ + ``+ + `
`+ + `YzFwaDNSNzNYNw==`+ + `YzFwaDNSNzNYNw==`+ + `${sent_stanza.querySelector("iv").textContent}`+ + `
`+ + `${sent_stanza.querySelector("payload").textContent}`+ + `
`+ + ``+ + ``+ + `
`); + + await u.waitUntil(() => textarea.value === ''); + + const first_msg = view.model.messages.findWhere({'message': original_text}); + + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe(original_text); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + + const new_text = 'This is an edit of the encrypted message'; + textarea.value = new_text; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(//g, '') === new_text); + + const fallback_text = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo'; + const older_versions = first_msg.get('older_versions'); + const keys = Object.keys(older_versions); + expect(keys.length).toBe(1); + expect(older_versions[keys[0]]).toBe(original_text); + expect(first_msg.get('plaintext')).toBe(new_text); + expect(first_msg.get('is_encrypted')).toBe(true); + expect(first_msg.get('body')).toBe(fallback_text); + expect(first_msg.get('message')).toBe(fallback_text); + + await u.waitUntil(() => _converse.connection.sent_stanzas.filter(s => s.nodeName === 'message').length === 2); + const msg = _converse.connection.sent_stanzas.pop(); + + expect(Strophe.serialize(msg)) + .toBe(``+ + `${fallback_text}`+ + ``+ + ``+ + ``+ + ``+ + `
`+ + `YzFwaDNSNzNYNw==`+ + `YzFwaDNSNzNYNw==`+ + `${msg.querySelector("iv").textContent}`+ + `
`+ + `${msg.querySelector("payload").textContent}`+ + `
`+ + ``+ + ``+ + `
`); + })); + +}); diff --git a/src/plugins/omemo/tests/media-sharing.js b/src/plugins/omemo/tests/media-sharing.js index 48ab74fd9..adeb2a8de 100644 --- a/src/plugins/omemo/tests/media-sharing.js +++ b/src/plugins/omemo/tests/media-sharing.js @@ -129,7 +129,9 @@ describe("The OMEMO module", function() { `type="chat" `+ `xmlns="jabber:client">`+ `${fallback}`+ + ``+ ``+ + ``+ ``+ `
`+ `YzFwaDNSNzNYNw==`+ diff --git a/src/plugins/omemo/tests/muc.js b/src/plugins/omemo/tests/muc.js index 4b258fd66..8fb24487d 100644 --- a/src/plugins/omemo/tests/muc.js +++ b/src/plugins/omemo/tests/muc.js @@ -139,6 +139,8 @@ describe("The OMEMO module", function() { `type="groupchat" `+ `xmlns="jabber:client">`+ `This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo`+ + ``+ + ``+ ``+ `
`+ `YzFwaDNSNzNYNw==`+ diff --git a/src/plugins/omemo/tests/omemo.js b/src/plugins/omemo/tests/omemo.js index 6e87317fd..beb289fd1 100644 --- a/src/plugins/omemo/tests/omemo.js +++ b/src/plugins/omemo/tests/omemo.js @@ -96,7 +96,9 @@ describe("The OMEMO module", function() { `to="mercutio@montague.lit" `+ `type="chat" xmlns="jabber:client">`+ `This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo`+ + ``+ ``+ + ``+ ``+ `
`+ `YzFwaDNSNzNYNw==`+ diff --git a/src/plugins/omemo/utils.js b/src/plugins/omemo/utils.js index 3bec0ac42..5fb764c4d 100644 --- a/src/plugins/omemo/utils.js +++ b/src/plugins/omemo/utils.js @@ -24,7 +24,7 @@ import { stringToArrayBuffer } from '@converse/headless/utils/arraybuffer.js'; -const { $msg, Strophe, URI, sizzle, u } = converse.env; +const { Strophe, URI, sizzle, u } = converse.env; export function formatFingerprint (fp) { fp = fp.replace(/^05/, ''); @@ -35,6 +35,49 @@ export function formatFingerprint (fp) { return fp; } +export function handleMessageSendError (e, chat) { + if (e.name === 'IQError') { + chat.save('omemo_supported', false); + + const err_msgs = []; + if (sizzle(`presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, e.iq).length) { + err_msgs.push( + __( + "Sorry, we're unable to send an encrypted message because %1$s " + + 'requires you to be subscribed to their presence in order to see their OMEMO information', + e.iq.getAttribute('from') + ) + ); + } else if (sizzle(`remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq).length) { + err_msgs.push( + __( + "Sorry, we're unable to send an encrypted message because the remote server for %1$s could not be found", + e.iq.getAttribute('from') + ) + ); + } else { + err_msgs.push(__('Unable to send an encrypted message due to an unexpected error.')); + err_msgs.push(e.iq.outerHTML); + } + api.alert('error', __('Error'), err_msgs); + } else if (e.user_facing) { + api.alert('error', __('Error'), [e.message]); + } + throw e; +} + +export function getOutgoingMessageAttributes (chat, attrs) { + if (chat.get('omemo_active') && attrs.body) { + attrs['is_encrypted'] = true; + attrs['plaintext'] = attrs.body; + attrs['body'] = __( + 'This is an OMEMO encrypted message which your client doesn’t seem to support. ' + + 'Find more information on https://conversations.im/omemo' + ); + } + return attrs; +} + async function encryptMessage (plaintext) { // The client MUST use fresh, randomly generated key/IV pairs // with AES-128 in Galois/Counter Mode (GCM). @@ -730,7 +773,7 @@ export function getOMEMOToolbarButton (toolbar_el, buttons) { } -export async function getBundlesAndBuildSessions (chatbox) { +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) { @@ -767,56 +810,49 @@ export async function getBundlesAndBuildSessions (chatbox) { return devices; } +function encryptKey (key_and_tag, device) { + return getSessionCipher(device.get('jid'), device.get('id')) + .encrypt(key_and_tag) + .then(payload => ({ 'payload': payload, 'device': device })); +} -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')) { +export async function createOMEMOMessageStanza (chat, data) { + let { stanza } = data; + const { message } = data; + if (!message.get('is_encrypted')) { + return data; + } + if (!message.get('body')) { 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(); + const devices = await getBundlesAndBuildSessions(chat); - 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 }) + 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)); + const { key_and_tag, iv, payload } = await omemo.encryptMessage(message.get('plaintext')); - 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 }).up(); - stanza.c('encryption', { 'xmlns': Strophe.NS.EME, namespace: Strophe.NS.OMEMO }); - return stanza; - }); - }); + // 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 dicts = await Promise.all(devices + .filter(device => device.get('trusted') != UNTRUSTED && device.get('active')) + .map(device => encryptKey(key_and_tag, device))); + + stanza = await addKeysToMessageStanza(stanza, dicts, iv); + stanza.c('payload').t(payload).up().up(); + stanza.c('store', { 'xmlns': Strophe.NS.HINTS }).up(); + stanza.c('encryption', { 'xmlns': Strophe.NS.EME, namespace: Strophe.NS.OMEMO }); + return { message, stanza }; } export const omemo = { diff --git a/src/shared/chat/templates/message-text.js b/src/shared/chat/templates/message-text.js index e314d0a4f..bb66e9571 100644 --- a/src/shared/chat/templates/message-text.js +++ b/src/shared/chat/templates/message-text.js @@ -22,6 +22,7 @@ export default (el) => { `; + const spoiler_classes = el.model.get('is_spoiler') ? `spoiler ${el.model.get('is_spoiler_visible') ? '' : 'hidden'}` : ''; const text = el.model.getMessageText(); const show_oob = el.model.get('oob_url') && text !== el.model.get('oob_url'); diff --git a/src/shared/components/message-versions.js b/src/shared/components/message-versions.js index d6486e8cd..2af7634b2 100644 --- a/src/shared/components/message-versions.js +++ b/src/shared/components/message-versions.js @@ -15,13 +15,13 @@ export class MessageVersions extends CustomElement { render () { const older_versions = this.model.get('older_versions'); - const message = this.model.get('message'); return html`

Older versions

- ${Object.keys(older_versions).map(k => html`

: ${older_versions[k]}

`) } + ${ Object.keys(older_versions).map( + k => html`

: ${older_versions[k]}

`) }

Current version

-

${message}

`; +

${this.model.getMessageText()}

`; } }