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
This commit is contained in:
parent
731e98b07c
commit
d2622f6fed
@ -8,6 +8,7 @@
|
|||||||
- Fix bug where MUC config wasn't persisted across page loads
|
- 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.
|
- 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
|
- #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
|
- #2627: Spoiler toggles only after switching to another tab and back
|
||||||
- #2733: Fix OMEMO race condition related to automatic reconnection and SMACKS
|
- #2733: Fix OMEMO race condition related to automatic reconnection and SMACKS
|
||||||
- #2733: Wait for decrypted/parsed message before queuing to UI
|
- #2733: Wait for decrypted/parsed message before queuing to UI
|
||||||
|
@ -95,9 +95,10 @@ module.exports = function(config) {
|
|||||||
{ pattern: "src/plugins/muc-views/tests/unfurls.js", type: 'module' },
|
{ pattern: "src/plugins/muc-views/tests/unfurls.js", type: 'module' },
|
||||||
{ pattern: "src/plugins/muc-views/tests/xss.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/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/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/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/push/tests/push.js", type: 'module' },
|
||||||
{ pattern: "src/plugins/register/tests/register.js", type: 'module' },
|
{ pattern: "src/plugins/register/tests/register.js", type: 'module' },
|
||||||
{ pattern: "src/plugins/rootview/tests/root.js", type: 'module' },
|
{ pattern: "src/plugins/rootview/tests/root.js", type: 'module' },
|
||||||
|
@ -210,12 +210,7 @@ export const api = _converse.api = {
|
|||||||
// Create a chain of promises, with each one feeding its output to
|
// Create a chain of promises, with each one feeding its output to
|
||||||
// the next. The first input is a promise with the original data
|
// the next. The first input is a promise with the original data
|
||||||
// sent to this hook.
|
// sent to this hook.
|
||||||
const o = events.reduce((o, e) => o.then(d => e.callback(context, d)), Promise.resolve(data));
|
return events.reduce((o, e) => o.then(d => e.callback(context, d)), Promise.resolve(data));
|
||||||
o.catch(e => {
|
|
||||||
log.error(e)
|
|
||||||
throw e;
|
|
||||||
});
|
|
||||||
return o;
|
|
||||||
} else {
|
} else {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
@ -162,11 +162,14 @@ const MessageMixin = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getMessageText () {
|
getMessageText () {
|
||||||
const { __ } = _converse;
|
|
||||||
if (this.get('is_encrypted')) {
|
if (this.get('is_encrypted')) {
|
||||||
|
const { __ } = _converse;
|
||||||
return this.get('plaintext') || this.get('body') || __('Undecryptable OMEMO message');
|
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');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -816,59 +816,79 @@ const ChatBox = ModelWithContact.extend({
|
|||||||
* @method _converse.ChatBox#createMessageStanza
|
* @method _converse.ChatBox#createMessageStanza
|
||||||
* @param { _converse.Message } message - The message object
|
* @param { _converse.Message } message - The message object
|
||||||
*/
|
*/
|
||||||
createMessageStanza (message) {
|
async createMessageStanza (message) {
|
||||||
const stanza = $msg({
|
const stanza = $msg({
|
||||||
'from': _converse.connection.jid,
|
'from': _converse.connection.jid,
|
||||||
'to': this.get('jid'),
|
'to': this.get('jid'),
|
||||||
'type': this.get('message_type'),
|
'type': this.get('message_type'),
|
||||||
'id': message.get('edited') && u.getUniqueId() || message.get('msgid'),
|
'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();
|
.c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).root();
|
||||||
|
|
||||||
if (message.get('type') === 'chat') {
|
if (message.get('type') === 'chat') {
|
||||||
stanza.c('request', {'xmlns': Strophe.NS.RECEIPTS}).root();
|
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')) {
|
if (!message.get('is_encrypted')) {
|
||||||
stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('oob_url')).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('edited')) {
|
if (message.get('edited')) {
|
||||||
stanza.c('replace', {
|
stanza.c('replace', {
|
||||||
'xmlns': Strophe.NS.MESSAGE_CORRECT,
|
'xmlns': Strophe.NS.MESSAGE_CORRECT,
|
||||||
'id': message.get('msgid')
|
'id': message.get('msgid')
|
||||||
}).root();
|
}).root();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.get('origin_id')) {
|
if (message.get('origin_id')) {
|
||||||
stanza.c('origin-id', {'xmlns': Strophe.NS.SID, 'id': message.get('origin_id')}).root();
|
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 is_spoiler = !!this.get('composing_spoiler');
|
||||||
const origin_id = u.getUniqueId();
|
const origin_id = u.getUniqueId();
|
||||||
const text = attrs?.body;
|
const text = attrs?.body;
|
||||||
const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
|
const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
|
||||||
return Object.assign({}, attrs, {
|
attrs = Object.assign({}, attrs, {
|
||||||
'from': _converse.bare_jid,
|
'from': _converse.bare_jid,
|
||||||
'fullname': _converse.xmppstatus.get('fullname'),
|
'fullname': _converse.xmppstatus.get('fullname'),
|
||||||
'id': origin_id,
|
'id': origin_id,
|
||||||
@ -884,6 +904,19 @@ const ChatBox = ModelWithContact.extend({
|
|||||||
is_spoiler,
|
is_spoiler,
|
||||||
origin_id
|
origin_id
|
||||||
}, getMediaURLsMetadata(text));
|
}, 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'});
|
* chat.sendMessage({'body': 'hello world'});
|
||||||
*/
|
*/
|
||||||
async sendMessage (attrs) {
|
async sendMessage (attrs) {
|
||||||
attrs = this.getOutgoingMessageAttributes(attrs);
|
attrs = await this.getOutgoingMessageAttributes(attrs);
|
||||||
let message = this.messages.findWhere('correcting')
|
let message = this.messages.findWhere('correcting')
|
||||||
if (message) {
|
if (message) {
|
||||||
const older_versions = message.get('older_versions') || {};
|
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({
|
message.save({
|
||||||
|
'body': attrs.body,
|
||||||
|
'message': attrs.body,
|
||||||
'correcting': false,
|
'correcting': false,
|
||||||
'edited': (new Date()).toISOString(),
|
'edited': (new Date()).toISOString(),
|
||||||
'message': attrs.message,
|
|
||||||
'older_versions': older_versions,
|
|
||||||
'references': attrs.references,
|
|
||||||
'is_only_emojis': attrs.is_only_emojis,
|
'is_only_emojis': attrs.is_only_emojis,
|
||||||
'origin_id': u.getUniqueId(),
|
'origin_id': u.getUniqueId(),
|
||||||
'received': undefined
|
'received': undefined,
|
||||||
|
'references': attrs.references,
|
||||||
|
older_versions,
|
||||||
|
plaintext,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.setEditable(attrs, (new Date()).toISOString());
|
this.setEditable(attrs, (new Date()).toISOString());
|
||||||
message = await this.createMessage(attrs);
|
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
|
* 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
|
* *Hook* which allows plugins to transform files before they'll be
|
||||||
* uploaded. The main use-case is to encrypt the files.
|
* uploaded. The main use-case is to encrypt the files.
|
||||||
* @event _converse#beforeFileUpload
|
* @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);
|
file = await api.hook('beforeFileUpload', this, file);
|
||||||
|
|
||||||
@ -1037,8 +1086,8 @@ const ChatBox = ModelWithContact.extend({
|
|||||||
'is_ephemeral': true
|
'is_ephemeral': true
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const attrs = Object.assign(
|
const initial_attrs = await this.getOutgoingMessageAttributes();
|
||||||
this.getOutgoingMessageAttributes(), {
|
const attrs = Object.assign(initial_attrs, {
|
||||||
'file': true,
|
'file': true,
|
||||||
'progress': 0,
|
'progress': 0,
|
||||||
'slot_request_url': slot_request_url
|
'slot_request_url': slot_request_url
|
||||||
|
@ -989,7 +989,7 @@ const ChatRoomMixin = {
|
|||||||
return [updated_message, updated_references];
|
return [updated_message, updated_references];
|
||||||
},
|
},
|
||||||
|
|
||||||
getOutgoingMessageAttributes (attrs) {
|
async getOutgoingMessageAttributes (attrs) {
|
||||||
const is_spoiler = this.get('composing_spoiler');
|
const is_spoiler = this.get('composing_spoiler');
|
||||||
let text = '', references;
|
let text = '', references;
|
||||||
if (attrs?.body) {
|
if (attrs?.body) {
|
||||||
@ -997,7 +997,7 @@ const ChatRoomMixin = {
|
|||||||
}
|
}
|
||||||
const origin_id = getUniqueId();
|
const origin_id = getUniqueId();
|
||||||
const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
|
const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
|
||||||
return Object.assign({}, attrs, {
|
attrs = Object.assign({}, attrs, {
|
||||||
body,
|
body,
|
||||||
is_spoiler,
|
is_spoiler,
|
||||||
origin_id,
|
origin_id,
|
||||||
@ -1012,6 +1012,14 @@ const ChatRoomMixin = {
|
|||||||
'sender': 'me',
|
'sender': 'me',
|
||||||
'type': 'groupchat'
|
'type': 'groupchat'
|
||||||
}, getMediaURLsMetadata(text));
|
}, 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;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -103,18 +103,19 @@ u.getLongestSubstring = function (string, candidates) {
|
|||||||
return candidates.reduce(reducer, '');
|
return candidates.reduce(reducer, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
u.prefixMentions = function (message) {
|
/**
|
||||||
/* Given a message object, return its text with @ chars
|
* Given a message object, return its text with @ chars
|
||||||
* inserted before the mentioned nicknames.
|
* inserted before the mentioned nicknames.
|
||||||
*/
|
*/
|
||||||
let text = message.get('message');
|
export function prefixMentions (message) {
|
||||||
|
let text = message.getMessageText();
|
||||||
(message.get('references') || [])
|
(message.get('references') || [])
|
||||||
.sort((a, b) => b.begin - a.begin)
|
.sort((a, b) => b.begin - a.begin)
|
||||||
.forEach(ref => {
|
.forEach(ref => {
|
||||||
text = `${text.slice(0, ref.begin)}@${text.slice(ref.begin)}`
|
text = `${text.slice(0, ref.begin)}@${text.slice(ref.begin)}`
|
||||||
});
|
});
|
||||||
return text;
|
return text;
|
||||||
};
|
}
|
||||||
|
|
||||||
u.isValidJID = function (jid) {
|
u.isValidJID = function (jid) {
|
||||||
if (typeof jid === 'string') {
|
if (typeof jid === 'string') {
|
||||||
@ -587,6 +588,7 @@ export function decodeHTMLEntities (str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign({
|
export default Object.assign({
|
||||||
|
prefixMentions,
|
||||||
isEmptyMessage,
|
isEmptyMessage,
|
||||||
getUniqueId
|
getUniqueId
|
||||||
}, u);
|
}, u);
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import tpl_message_form from './templates/message-form.js';
|
import tpl_message_form from './templates/message-form.js';
|
||||||
import { ElementView } from '@converse/skeletor/src/element.js';
|
import { ElementView } from '@converse/skeletor/src/element.js';
|
||||||
import { __ } from 'i18n';
|
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 { parseMessageForCommands } from './utils.js';
|
||||||
|
import { prefixMentions } from '@converse/headless/utils/core.js';
|
||||||
|
|
||||||
const { u } = converse.env;
|
const { u } = converse.env;
|
||||||
|
|
||||||
@ -86,11 +87,11 @@ export default class MessageForm extends ElementView {
|
|||||||
|
|
||||||
onMessageCorrecting (message) {
|
onMessageCorrecting (message) {
|
||||||
if (message.get('correcting')) {
|
if (message.get('correcting')) {
|
||||||
this.insertIntoTextArea(u.prefixMentions(message), true, true);
|
this.insertIntoTextArea(prefixMentions(message), true, true);
|
||||||
} else {
|
} else {
|
||||||
const currently_correcting = this.model.messages.findWhere('correcting');
|
const currently_correcting = this.model.messages.findWhere('correcting');
|
||||||
if (currently_correcting && currently_correcting !== message) {
|
if (currently_correcting && currently_correcting !== message) {
|
||||||
this.insertIntoTextArea(u.prefixMentions(message), true, true);
|
this.insertIntoTextArea(prefixMentions(message), true, true);
|
||||||
} else {
|
} else {
|
||||||
this.insertIntoTextArea('', true, false);
|
this.insertIntoTextArea('', true, false);
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { IQError } from './errors.js';
|
|||||||
import { Model } from '@converse/skeletor/src/model.js';
|
import { Model } from '@converse/skeletor/src/model.js';
|
||||||
import { UNDECIDED } from './consts.js';
|
import { UNDECIDED } from './consts.js';
|
||||||
import { _converse, api, converse } from '@converse/headless/core';
|
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;
|
const { Strophe, sizzle, u, $iq } = converse.env;
|
||||||
|
|
||||||
@ -30,9 +30,8 @@ const Device = Model.extend({
|
|||||||
'type': 'get',
|
'type': 'get',
|
||||||
'from': _converse.bare_jid,
|
'from': _converse.bare_jid,
|
||||||
'to': this.get('jid')
|
'to': this.get('jid')
|
||||||
})
|
}).c('pubsub', { 'xmlns': Strophe.NS.PUBSUB })
|
||||||
.c('pubsub', { 'xmlns': Strophe.NS.PUBSUB })
|
.c('items', { 'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}` });
|
||||||
.c('items', { 'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}` });
|
|
||||||
|
|
||||||
let iq;
|
let iq;
|
||||||
try {
|
try {
|
||||||
|
@ -6,7 +6,6 @@ import './fingerprints.js';
|
|||||||
import './profile.js';
|
import './profile.js';
|
||||||
import 'modals/user-details.js';
|
import 'modals/user-details.js';
|
||||||
import 'plugins/profile/index.js';
|
import 'plugins/profile/index.js';
|
||||||
import ChatBox from './overrides/chatbox.js';
|
|
||||||
import ConverseMixins from './mixins/converse.js';
|
import ConverseMixins from './mixins/converse.js';
|
||||||
import Device from './device.js';
|
import Device from './device.js';
|
||||||
import DeviceList from './devicelist.js';
|
import DeviceList from './devicelist.js';
|
||||||
@ -15,19 +14,21 @@ import Devices from './devices.js';
|
|||||||
import OMEMOStore from './store.js';
|
import OMEMOStore from './store.js';
|
||||||
import log from '@converse/headless/log';
|
import log from '@converse/headless/log';
|
||||||
import omemo_api from './api.js';
|
import omemo_api from './api.js';
|
||||||
import { OMEMOEnabledChatBox } from './mixins/chatbox.js';
|
|
||||||
import { _converse, api, converse } from '@converse/headless/core';
|
import { _converse, api, converse } from '@converse/headless/core';
|
||||||
import {
|
import {
|
||||||
|
createOMEMOMessageStanza,
|
||||||
encryptFile,
|
encryptFile,
|
||||||
getOMEMOToolbarButton,
|
getOMEMOToolbarButton,
|
||||||
|
getOutgoingMessageAttributes,
|
||||||
handleEncryptedFiles,
|
handleEncryptedFiles,
|
||||||
|
handleMessageSendError,
|
||||||
initOMEMO,
|
initOMEMO,
|
||||||
omemo,
|
omemo,
|
||||||
onChatBoxesInitialized,
|
onChatBoxesInitialized,
|
||||||
onChatInitialized,
|
onChatInitialized,
|
||||||
parseEncryptedMessage,
|
parseEncryptedMessage,
|
||||||
setEncryptedFileURL,
|
|
||||||
registerPEPPushHandler,
|
registerPEPPushHandler,
|
||||||
|
setEncryptedFileURL,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
|
||||||
const { Strophe } = converse.env;
|
const { Strophe } = converse.env;
|
||||||
@ -52,15 +53,12 @@ converse.plugins.add('converse-omemo', {
|
|||||||
|
|
||||||
dependencies: ['converse-chatview', 'converse-pubsub'],
|
dependencies: ['converse-chatview', 'converse-pubsub'],
|
||||||
|
|
||||||
overrides: { ChatBox },
|
|
||||||
|
|
||||||
initialize () {
|
initialize () {
|
||||||
api.settings.extend({ 'omemo_default': false });
|
api.settings.extend({ 'omemo_default': false });
|
||||||
api.promises.add(['OMEMOInitialized']);
|
api.promises.add(['OMEMOInitialized']);
|
||||||
|
|
||||||
_converse.NUM_PREKEYS = 100; // Set here so that tests can override
|
_converse.NUM_PREKEYS = 100; // Set here so that tests can override
|
||||||
|
|
||||||
Object.assign(_converse.ChatBox.prototype, OMEMOEnabledChatBox);
|
|
||||||
Object.assign(_converse, ConverseMixins);
|
Object.assign(_converse, ConverseMixins);
|
||||||
Object.assign(_converse.api, omemo_api);
|
Object.assign(_converse.api, omemo_api);
|
||||||
|
|
||||||
@ -73,6 +71,17 @@ converse.plugins.add('converse-omemo', {
|
|||||||
/******************** Event Handlers ********************/
|
/******************** Event Handlers ********************/
|
||||||
api.waitUntil('chatBoxesInitialized').then(onChatBoxesInitialized);
|
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('afterFileUploaded', (msg, attrs) => msg.file.xep454_ivkey ? setEncryptedFileURL(msg, attrs) : attrs);
|
||||||
api.listen.on('beforeFileUpload', (chat, file) => chat.get('omemo_active') ? encryptFile(file) : file);
|
api.listen.on('beforeFileUpload', (chat, file) => chat.get('omemo_active') ? encryptFile(file) : file);
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
344
src/plugins/omemo/tests/corrections.js
Normal file
344
src/plugins/omemo/tests/corrections.js
Normal file
@ -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(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
|
||||||
|
`to="mercutio@montague.lit" type="chat" `+
|
||||||
|
`xmlns="jabber:client">`+
|
||||||
|
`<body>${fallback_text}</body>`+
|
||||||
|
`<active xmlns="http://jabber.org/protocol/chatstates"/>`+
|
||||||
|
`<request xmlns="urn:xmpp:receipts"/>`+
|
||||||
|
`<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
|
||||||
|
`<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
|
||||||
|
`<encrypted xmlns="eu.siacs.conversations.axolotl">`+
|
||||||
|
`<header sid="123456789">`+
|
||||||
|
`<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
|
||||||
|
`<key rid="555">YzFwaDNSNzNYNw==</key>`+
|
||||||
|
`<iv>${msg.querySelector('header iv').textContent}</iv>`+
|
||||||
|
`</header>`+
|
||||||
|
`<payload>${msg.querySelector('payload').textContent}</payload>`+
|
||||||
|
`</encrypted>`+
|
||||||
|
`<store xmlns="urn:xmpp:hints"/>`+
|
||||||
|
`<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
|
||||||
|
`</message>`);
|
||||||
|
|
||||||
|
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(
|
||||||
|
`<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+
|
||||||
|
`<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
|
||||||
|
`<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
|
||||||
|
`</pubsub>`+
|
||||||
|
`</iq>`);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
`<message from="romeo@montague.lit/orchard" `+
|
||||||
|
`id="${sent_stanza.getAttribute("id")}" `+
|
||||||
|
`to="lounge@montague.lit" `+
|
||||||
|
`type="groupchat" `+
|
||||||
|
`xmlns="jabber:client">`+
|
||||||
|
`<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
|
||||||
|
`<active xmlns="http://jabber.org/protocol/chatstates"/>`+
|
||||||
|
`<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
|
||||||
|
`<encrypted xmlns="eu.siacs.conversations.axolotl">`+
|
||||||
|
`<header sid="123456789">`+
|
||||||
|
`<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
|
||||||
|
`<key rid="4e30f35051b7b8b42abe083742187228">YzFwaDNSNzNYNw==</key>`+
|
||||||
|
`<iv>${sent_stanza.querySelector("iv").textContent}</iv>`+
|
||||||
|
`</header>`+
|
||||||
|
`<payload>${sent_stanza.querySelector("payload").textContent}</payload>`+
|
||||||
|
`</encrypted>`+
|
||||||
|
`<store xmlns="urn:xmpp:hints"/>`+
|
||||||
|
`<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
|
||||||
|
`</message>`);
|
||||||
|
|
||||||
|
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(`<message from="${_converse.jid}" id="${msg.getAttribute("id")}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
|
||||||
|
`<body>${fallback_text}</body>`+
|
||||||
|
`<active xmlns="http://jabber.org/protocol/chatstates"/>`+
|
||||||
|
`<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
|
||||||
|
`<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
|
||||||
|
`<encrypted xmlns="eu.siacs.conversations.axolotl">`+
|
||||||
|
`<header sid="123456789">`+
|
||||||
|
`<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
|
||||||
|
`<key rid="4e30f35051b7b8b42abe083742187228">YzFwaDNSNzNYNw==</key>`+
|
||||||
|
`<iv>${msg.querySelector("iv").textContent}</iv>`+
|
||||||
|
`</header>`+
|
||||||
|
`<payload>${msg.querySelector("payload").textContent}</payload>`+
|
||||||
|
`</encrypted>`+
|
||||||
|
`<store xmlns="urn:xmpp:hints"/>`+
|
||||||
|
`<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
|
||||||
|
`</message>`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
});
|
@ -129,7 +129,9 @@ describe("The OMEMO module", function() {
|
|||||||
`type="chat" `+
|
`type="chat" `+
|
||||||
`xmlns="jabber:client">`+
|
`xmlns="jabber:client">`+
|
||||||
`<body>${fallback}</body>`+
|
`<body>${fallback}</body>`+
|
||||||
|
`<active xmlns="http://jabber.org/protocol/chatstates"/>`+
|
||||||
`<request xmlns="urn:xmpp:receipts"/>`+
|
`<request xmlns="urn:xmpp:receipts"/>`+
|
||||||
|
`<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
|
||||||
`<encrypted xmlns="eu.siacs.conversations.axolotl">`+
|
`<encrypted xmlns="eu.siacs.conversations.axolotl">`+
|
||||||
`<header sid="123456789">`+
|
`<header sid="123456789">`+
|
||||||
`<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
|
`<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
|
||||||
|
@ -139,6 +139,8 @@ describe("The OMEMO module", function() {
|
|||||||
`type="groupchat" `+
|
`type="groupchat" `+
|
||||||
`xmlns="jabber:client">`+
|
`xmlns="jabber:client">`+
|
||||||
`<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
|
`<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
|
||||||
|
`<active xmlns="http://jabber.org/protocol/chatstates"/>`+
|
||||||
|
`<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
|
||||||
`<encrypted xmlns="eu.siacs.conversations.axolotl">`+
|
`<encrypted xmlns="eu.siacs.conversations.axolotl">`+
|
||||||
`<header sid="123456789">`+
|
`<header sid="123456789">`+
|
||||||
`<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
|
`<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
|
||||||
|
@ -96,7 +96,9 @@ describe("The OMEMO module", function() {
|
|||||||
`to="mercutio@montague.lit" `+
|
`to="mercutio@montague.lit" `+
|
||||||
`type="chat" xmlns="jabber:client">`+
|
`type="chat" xmlns="jabber:client">`+
|
||||||
`<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
|
`<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
|
||||||
|
`<active xmlns="http://jabber.org/protocol/chatstates"/>`+
|
||||||
`<request xmlns="urn:xmpp:receipts"/>`+
|
`<request xmlns="urn:xmpp:receipts"/>`+
|
||||||
|
`<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
|
||||||
`<encrypted xmlns="eu.siacs.conversations.axolotl">`+
|
`<encrypted xmlns="eu.siacs.conversations.axolotl">`+
|
||||||
`<header sid="123456789">`+
|
`<header sid="123456789">`+
|
||||||
`<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
|
`<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
|
||||||
|
@ -24,7 +24,7 @@ import {
|
|||||||
stringToArrayBuffer
|
stringToArrayBuffer
|
||||||
} from '@converse/headless/utils/arraybuffer.js';
|
} 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) {
|
export function formatFingerprint (fp) {
|
||||||
fp = fp.replace(/^05/, '');
|
fp = fp.replace(/^05/, '');
|
||||||
@ -35,6 +35,49 @@ export function formatFingerprint (fp) {
|
|||||||
return 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) {
|
async function encryptMessage (plaintext) {
|
||||||
// The client MUST use fresh, randomly generated key/IV pairs
|
// The client MUST use fresh, randomly generated key/IV pairs
|
||||||
// with AES-128 in Galois/Counter Mode (GCM).
|
// 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.');
|
const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.');
|
||||||
let devices;
|
let devices;
|
||||||
if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
|
if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
|
||||||
@ -767,56 +810,49 @@ export async function getBundlesAndBuildSessions (chatbox) {
|
|||||||
return devices;
|
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) {
|
export async function createOMEMOMessageStanza (chat, data) {
|
||||||
const body = __(
|
let { stanza } = data;
|
||||||
'This is an OMEMO encrypted message which your client doesn’t seem to support. ' +
|
const { message } = data;
|
||||||
'Find more information on https://conversations.im/omemo'
|
if (!message.get('is_encrypted')) {
|
||||||
);
|
return data;
|
||||||
|
}
|
||||||
if (!message.get('message')) {
|
if (!message.get('body')) {
|
||||||
throw new Error('No message body to encrypt!');
|
throw new Error('No message body to encrypt!');
|
||||||
}
|
}
|
||||||
const stanza = $msg({
|
const devices = await getBundlesAndBuildSessions(chat);
|
||||||
'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
|
// An encrypted header is added to the message for
|
||||||
// each device that is supposed to receive it.
|
// each device that is supposed to receive it.
|
||||||
// These headers simply contain the key that the
|
// These headers simply contain the key that the
|
||||||
// payload message is encrypted with,
|
// payload message is encrypted with,
|
||||||
// and they are separately encrypted using the
|
// and they are separately encrypted using the
|
||||||
// session corresponding to the counterpart device.
|
// session corresponding to the counterpart device.
|
||||||
stanza
|
stanza.c('encrypted', { 'xmlns': Strophe.NS.OMEMO })
|
||||||
.c('encrypted', { 'xmlns': Strophe.NS.OMEMO })
|
|
||||||
.c('header', { 'sid': _converse.omemo_store.get('device_id') });
|
.c('header', { 'sid': _converse.omemo_store.get('device_id') });
|
||||||
|
|
||||||
return omemo.encryptMessage(message.get('message')).then(obj => {
|
const { key_and_tag, iv, payload } = await omemo.encryptMessage(message.get('plaintext'));
|
||||||
// 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)
|
// The 16 bytes key and the GCM authentication tag (The tag
|
||||||
.then(dicts => addKeysToMessageStanza(stanza, dicts, obj.iv))
|
// SHOULD have at least 128 bit) are concatenated and for each
|
||||||
.then(stanza => {
|
// intended recipient device, i.e. both own devices as well as
|
||||||
stanza.c('payload').t(obj.payload).up().up();
|
// devices associated with the contact, the result of this
|
||||||
stanza.c('store', { 'xmlns': Strophe.NS.HINTS }).up();
|
// concatenation is encrypted using the corresponding
|
||||||
stanza.c('encryption', { 'xmlns': Strophe.NS.EME, namespace: Strophe.NS.OMEMO });
|
// long-standing SignalProtocol session.
|
||||||
return stanza;
|
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 = {
|
export const omemo = {
|
||||||
|
@ -22,6 +22,7 @@ export default (el) => {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const spoiler_classes = el.model.get('is_spoiler') ? `spoiler ${el.model.get('is_spoiler_visible') ? '' : 'hidden'}` : '';
|
const spoiler_classes = el.model.get('is_spoiler') ? `spoiler ${el.model.get('is_spoiler_visible') ? '' : 'hidden'}` : '';
|
||||||
const text = el.model.getMessageText();
|
const text = el.model.getMessageText();
|
||||||
const show_oob = el.model.get('oob_url') && text !== el.model.get('oob_url');
|
const show_oob = el.model.get('oob_url') && text !== el.model.get('oob_url');
|
||||||
|
@ -15,13 +15,13 @@ export class MessageVersions extends CustomElement {
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
const older_versions = this.model.get('older_versions');
|
const older_versions = this.model.get('older_versions');
|
||||||
const message = this.model.get('message');
|
|
||||||
return html`
|
return html`
|
||||||
<h4>Older versions</h4>
|
<h4>Older versions</h4>
|
||||||
${Object.keys(older_versions).map(k => html`<p class="older-msg"><time>${dayjs(k).format('MMM D, YYYY, HH:mm:ss')}</time>: ${older_versions[k]}</p>`) }
|
${ Object.keys(older_versions).map(
|
||||||
|
k => html`<p class="older-msg"><time>${dayjs(k).format('MMM D, YYYY, HH:mm:ss')}</time>: ${older_versions[k]}</p>`) }
|
||||||
<hr/>
|
<hr/>
|
||||||
<h4>Current version</h4>
|
<h4>Current version</h4>
|
||||||
<p>${message}</p>`;
|
<p>${this.model.getMessageText()}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user