2021-04-11 19:51:58 +02:00
|
|
|
|
/* global libsignal */
|
2022-05-08 22:50:51 +02:00
|
|
|
|
import concat from 'lodash-es/concat';
|
2021-04-11 19:51:58 +02:00
|
|
|
|
import difference from 'lodash-es/difference';
|
|
|
|
|
import log from '@converse/headless/log';
|
2021-06-24 20:20:02 +02:00
|
|
|
|
import tpl_audio from 'templates/audio.js';
|
|
|
|
|
import tpl_file from 'templates/file.js';
|
|
|
|
|
import tpl_image from 'templates/image.js';
|
|
|
|
|
import tpl_video from 'templates/video.js';
|
2021-07-02 20:29:54 +02:00
|
|
|
|
import { KEY_ALGO, UNTRUSTED, TAG_LENGTH } from './consts.js';
|
2022-05-08 22:50:51 +02:00
|
|
|
|
import { MIMETYPES_MAP } from 'utils/file.js';
|
2021-04-12 04:29:00 +02:00
|
|
|
|
import { __ } from 'i18n';
|
2021-04-11 19:51:58 +02:00
|
|
|
|
import { _converse, converse, api } from '@converse/headless/core';
|
2021-04-14 22:56:59 +02:00
|
|
|
|
import { html } from 'lit';
|
2021-07-15 18:45:16 +02:00
|
|
|
|
import { initStorage } from '@converse/headless/utils/storage.js';
|
2022-10-23 11:56:59 +02:00
|
|
|
|
import { isError } from '@converse/headless/utils/core.js';
|
2021-07-05 17:15:48 +02:00
|
|
|
|
import { isAudioURL, isImageURL, isVideoURL, getURI } from '@converse/headless/utils/url.js';
|
2021-06-24 20:20:02 +02:00
|
|
|
|
import { until } from 'lit/directives/until.js';
|
2021-06-24 18:56:25 +02:00
|
|
|
|
import {
|
|
|
|
|
appendArrayBuffer,
|
|
|
|
|
arrayBufferToBase64,
|
|
|
|
|
arrayBufferToHex,
|
|
|
|
|
arrayBufferToString,
|
|
|
|
|
base64ToArrayBuffer,
|
2021-06-24 20:20:02 +02:00
|
|
|
|
hexToArrayBuffer,
|
2021-06-24 18:56:25 +02:00
|
|
|
|
stringToArrayBuffer
|
|
|
|
|
} from '@converse/headless/utils/arraybuffer.js';
|
2021-04-11 19:51:58 +02:00
|
|
|
|
|
2022-02-19 11:45:04 +01:00
|
|
|
|
const { Strophe, URI, sizzle, u } = converse.env;
|
2021-04-11 19:51:58 +02:00
|
|
|
|
|
2021-11-24 00:25:07 +01:00
|
|
|
|
export function formatFingerprint (fp) {
|
|
|
|
|
fp = fp.replace(/^05/, '');
|
|
|
|
|
for (let i=1; i<8; i++) {
|
|
|
|
|
const idx = i*8+i-1;
|
|
|
|
|
fp = fp.slice(0, idx) + ' ' + fp.slice(idx);
|
|
|
|
|
}
|
|
|
|
|
return fp;
|
|
|
|
|
}
|
2021-04-11 19:51:58 +02:00
|
|
|
|
|
2022-02-19 11:45:04 +01:00
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2021-07-01 12:57:28 +02:00
|
|
|
|
async function encryptMessage (plaintext) {
|
|
|
|
|
// The client MUST use fresh, randomly generated key/IV pairs
|
|
|
|
|
// with AES-128 in Galois/Counter Mode (GCM).
|
|
|
|
|
|
|
|
|
|
// For GCM a 12 byte IV is strongly suggested as other IV lengths
|
|
|
|
|
// will require additional calculations. In principle any IV size
|
|
|
|
|
// can be used as long as the IV doesn't ever repeat. NIST however
|
|
|
|
|
// suggests that only an IV size of 12 bytes needs to be supported
|
|
|
|
|
// by implementations.
|
|
|
|
|
//
|
|
|
|
|
// https://crypto.stackexchange.com/questions/26783/ciphertext-and-tag-size-and-iv-transmission-with-aes-in-gcm-mode
|
|
|
|
|
const iv = crypto.getRandomValues(new window.Uint8Array(12));
|
|
|
|
|
const key = await crypto.subtle.generateKey(KEY_ALGO, true, ['encrypt', 'decrypt']);
|
|
|
|
|
const algo = {
|
2021-04-11 19:51:58 +02:00
|
|
|
|
'name': 'AES-GCM',
|
2021-07-01 12:57:28 +02:00
|
|
|
|
'iv': iv,
|
2021-04-11 19:51:58 +02:00
|
|
|
|
'tagLength': TAG_LENGTH
|
|
|
|
|
};
|
2021-07-01 12:57:28 +02:00
|
|
|
|
const encrypted = await crypto.subtle.encrypt(algo, key, stringToArrayBuffer(plaintext));
|
|
|
|
|
const length = encrypted.byteLength - ((128 + 7) >> 3);
|
|
|
|
|
const ciphertext = encrypted.slice(0, length);
|
|
|
|
|
const tag = encrypted.slice(length);
|
|
|
|
|
const exported_key = await crypto.subtle.exportKey('raw', key);
|
|
|
|
|
return {
|
|
|
|
|
'key': exported_key,
|
|
|
|
|
'tag': tag,
|
|
|
|
|
'key_and_tag': appendArrayBuffer(exported_key, tag),
|
|
|
|
|
'payload': arrayBufferToBase64(ciphertext),
|
|
|
|
|
'iv': arrayBufferToBase64(iv)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function decryptMessage (obj) {
|
|
|
|
|
const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt', 'decrypt']);
|
|
|
|
|
const cipher = appendArrayBuffer(base64ToArrayBuffer(obj.payload), obj.tag);
|
|
|
|
|
const algo = {
|
|
|
|
|
'name': 'AES-GCM',
|
|
|
|
|
'iv': base64ToArrayBuffer(obj.iv),
|
|
|
|
|
'tagLength': TAG_LENGTH
|
|
|
|
|
};
|
|
|
|
|
return arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function encryptFile (file) {
|
|
|
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
|
|
|
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256, }, true, ['encrypt', 'decrypt']);
|
|
|
|
|
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, }, key, await file.arrayBuffer());
|
|
|
|
|
const exported_key = await window.crypto.subtle.exportKey('raw', key);
|
|
|
|
|
const encrypted_file = new File([encrypted], file.name, { type: file.type, lastModified: file.lastModified });
|
|
|
|
|
encrypted_file.xep454_ivkey = arrayBufferToHex(iv) + arrayBufferToHex(exported_key);
|
|
|
|
|
return encrypted_file;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setEncryptedFileURL (message, attrs) {
|
|
|
|
|
const url = attrs.oob_url.replace(/^https?:/, 'aesgcm:') + '#' + message.file.xep454_ivkey;
|
|
|
|
|
return Object.assign(attrs, {
|
|
|
|
|
'oob_url': null, // Since only the body gets encrypted, we don't set the oob_url
|
|
|
|
|
'message': url,
|
|
|
|
|
'body': url
|
|
|
|
|
});
|
2021-04-12 04:29:00 +02:00
|
|
|
|
}
|
2021-04-11 19:51:58 +02:00
|
|
|
|
|
2021-06-24 20:20:02 +02:00
|
|
|
|
async function decryptFile (iv, key, cipher) {
|
|
|
|
|
const key_obj = await crypto.subtle.importKey('raw', hexToArrayBuffer(key), 'AES-GCM', false, ['decrypt']);
|
|
|
|
|
const algo = {
|
|
|
|
|
'name': 'AES-GCM',
|
|
|
|
|
'iv': hexToArrayBuffer(iv),
|
|
|
|
|
};
|
|
|
|
|
return crypto.subtle.decrypt(algo, key_obj, cipher);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function downloadFile(url) {
|
|
|
|
|
let response;
|
|
|
|
|
try {
|
|
|
|
|
response = await fetch(url)
|
|
|
|
|
} catch(e) {
|
2021-07-26 19:27:59 +02:00
|
|
|
|
log.error(`${e.name}: Failed to download encrypted media: ${url}`);
|
2021-06-24 20:20:02 +02:00
|
|
|
|
log.error(e);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response.status >= 200 && response.status < 400) {
|
|
|
|
|
return response.arrayBuffer();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getAndDecryptFile (uri) {
|
2022-10-25 14:33:49 +02:00
|
|
|
|
const protocol = (window.location.hostname === 'localhost' && uri.domain() === 'localhost') ? 'http' : 'https';
|
2021-06-24 20:20:02 +02:00
|
|
|
|
const http_url = uri.toString().replace(/^aesgcm/, protocol);
|
|
|
|
|
const cipher = await downloadFile(http_url);
|
2021-07-26 19:27:59 +02:00
|
|
|
|
if (cipher === null) {
|
2022-10-23 11:56:59 +02:00
|
|
|
|
log.error(`Could not decrypt a received encrypted file ${uri.toString()} since it could not be downloaded`);
|
|
|
|
|
return new Error(__('Error: could not decrypt a received encrypted file, because it could not be downloaded'));
|
2021-07-26 19:27:59 +02:00
|
|
|
|
}
|
2022-10-25 15:49:20 +02:00
|
|
|
|
|
|
|
|
|
const hash = uri.hash().slice(1);
|
|
|
|
|
const key = hash.substring(hash.length-64);
|
|
|
|
|
const iv = hash.replace(key, '');
|
2021-06-24 20:20:02 +02:00
|
|
|
|
let content;
|
|
|
|
|
try {
|
|
|
|
|
content = await decryptFile(iv, key, cipher);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log.error(`Could not decrypt file ${uri.toString()}`);
|
|
|
|
|
log.error(e);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2022-10-23 11:56:59 +02:00
|
|
|
|
const [filename, extension] = uri.filename().split('.');
|
2021-06-24 20:20:02 +02:00
|
|
|
|
const mimetype = MIMETYPES_MAP[extension];
|
|
|
|
|
try {
|
|
|
|
|
const file = new File([content], filename, { 'type': mimetype });
|
|
|
|
|
return URL.createObjectURL(file);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log.error(`Could not decrypt file ${uri.toString()}`);
|
|
|
|
|
log.error(e);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTemplateForObjectURL (uri, obj_url, richtext) {
|
2022-10-23 11:56:59 +02:00
|
|
|
|
if (isError(obj_url)) {
|
|
|
|
|
return html`<p class="error">${obj_url.message}</p>`;
|
2021-06-24 20:20:02 +02:00
|
|
|
|
}
|
2022-10-23 11:56:59 +02:00
|
|
|
|
|
|
|
|
|
const file_url = uri.toString();
|
2021-06-24 20:20:02 +02:00
|
|
|
|
if (isImageURL(file_url)) {
|
|
|
|
|
return tpl_image({
|
2022-04-04 16:31:32 +02:00
|
|
|
|
'src': obj_url,
|
2021-06-24 20:20:02 +02:00
|
|
|
|
'onClick': richtext.onImgClick,
|
|
|
|
|
'onLoad': richtext.onImgLoad
|
|
|
|
|
});
|
|
|
|
|
} else if (isAudioURL(file_url)) {
|
|
|
|
|
return tpl_audio(obj_url);
|
|
|
|
|
} else if (isVideoURL(file_url)) {
|
|
|
|
|
return tpl_video(obj_url);
|
|
|
|
|
} else {
|
|
|
|
|
return tpl_file(obj_url, uri.filename());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addEncryptedFiles(text, offset, richtext) {
|
|
|
|
|
const objs = [];
|
|
|
|
|
try {
|
|
|
|
|
const parse_options = { 'start': /\b(aesgcm:\/\/)/gi };
|
|
|
|
|
URI.withinString(
|
|
|
|
|
text,
|
|
|
|
|
(url, start, end) => {
|
|
|
|
|
objs.push({ url, start, end });
|
|
|
|
|
return url;
|
|
|
|
|
},
|
|
|
|
|
parse_options
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
log.debug(error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
objs.forEach(o => {
|
|
|
|
|
const uri = getURI(text.slice(o.start, o.end));
|
|
|
|
|
const promise = getAndDecryptFile(uri)
|
|
|
|
|
.then(obj_url => getTemplateForObjectURL(uri, obj_url, richtext));
|
2022-10-23 11:56:59 +02:00
|
|
|
|
|
2021-06-24 20:20:02 +02:00
|
|
|
|
const template = html`${until(promise, '')}`;
|
|
|
|
|
richtext.addTemplateResult(o.start + offset, o.end + offset, template);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function handleEncryptedFiles (richtext) {
|
|
|
|
|
if (!_converse.config.get('trusted')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
richtext.addAnnotations((text, offset) => addEncryptedFiles(text, offset, richtext));
|
|
|
|
|
}
|
|
|
|
|
|
2021-11-23 22:56:27 +01:00
|
|
|
|
/**
|
|
|
|
|
* Hook handler for { @link parseMessage } and { @link parseMUCMessage }, which
|
|
|
|
|
* parses the passed in `message` stanza for OMEMO attributes and then sets
|
|
|
|
|
* them on the attrs object.
|
|
|
|
|
* @param { XMLElement } stanza - The message stanza
|
|
|
|
|
* @param { (MUCMessageAttributes|MessageAttributes) } attrs
|
|
|
|
|
* @returns (MUCMessageAttributes|MessageAttributes)
|
|
|
|
|
*/
|
|
|
|
|
export async function parseEncryptedMessage (stanza, attrs) {
|
|
|
|
|
if (api.settings.get('clear_cache_on_logout') ||
|
|
|
|
|
!attrs.is_encrypted ||
|
|
|
|
|
attrs.encryption_namespace !== Strophe.NS.OMEMO) {
|
2021-04-11 19:51:58 +02:00
|
|
|
|
return attrs;
|
|
|
|
|
}
|
2021-11-23 22:56:27 +01:00
|
|
|
|
const encrypted_el = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop();
|
|
|
|
|
const header = encrypted_el.querySelector('header');
|
|
|
|
|
attrs.encrypted = { 'device_id': header.getAttribute('sid') };
|
|
|
|
|
|
|
|
|
|
const device_id = await api.omemo?.getDeviceID();
|
|
|
|
|
const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted_el).pop();
|
|
|
|
|
if (key) {
|
|
|
|
|
Object.assign(attrs.encrypted, {
|
|
|
|
|
'iv': header.querySelector('iv').textContent,
|
|
|
|
|
'key': key.textContent,
|
|
|
|
|
'payload': encrypted_el.querySelector('payload')?.textContent || null,
|
|
|
|
|
'prekey': ['true', '1'].includes(key.getAttribute('prekey'))
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
return Object.assign(attrs, {
|
|
|
|
|
'error_condition': 'not-encrypted-for-this-device',
|
|
|
|
|
'error_type': 'Decryption',
|
|
|
|
|
'is_ephemeral': true,
|
|
|
|
|
'is_error': true,
|
|
|
|
|
'type': 'error'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// https://xmpp.org/extensions/xep-0384.html#usecases-receiving
|
|
|
|
|
if (attrs.encrypted.prekey === true) {
|
|
|
|
|
return decryptPrekeyWhisperMessage(attrs);
|
|
|
|
|
} else {
|
|
|
|
|
return decryptWhisperMessage(attrs);
|
|
|
|
|
}
|
2021-04-11 19:51:58 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function onChatBoxesInitialized () {
|
|
|
|
|
_converse.chatboxes.on('add', chatbox => {
|
|
|
|
|
checkOMEMOSupported(chatbox);
|
|
|
|
|
if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
|
|
|
|
|
chatbox.occupants.on('add', o => onOccupantAdded(chatbox, o));
|
|
|
|
|
chatbox.features.on('change', () => checkOMEMOSupported(chatbox));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function onChatInitialized (el) {
|
|
|
|
|
el.listenTo(el.model.messages, 'add', message => {
|
|
|
|
|
if (message.get('is_encrypted') && !message.get('is_error')) {
|
|
|
|
|
el.model.save('omemo_supported', true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
el.listenTo(el.model, 'change:omemo_supported', () => {
|
|
|
|
|
if (!el.model.get('omemo_supported') && el.model.get('omemo_active')) {
|
|
|
|
|
el.model.set('omemo_active', false);
|
|
|
|
|
} else {
|
|
|
|
|
// Manually trigger an update, setting omemo_active to
|
|
|
|
|
// false above will automatically trigger one.
|
|
|
|
|
el.querySelector('converse-chat-toolbar')?.requestUpdate();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
el.listenTo(el.model, 'change:omemo_active', () => {
|
|
|
|
|
el.querySelector('converse-chat-toolbar').requestUpdate();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getSessionCipher (jid, id) {
|
|
|
|
|
const address = new libsignal.SignalProtocolAddress(jid, id);
|
|
|
|
|
return new window.libsignal.SessionCipher(_converse.omemo_store, address);
|
|
|
|
|
}
|
|
|
|
|
|
2021-10-30 20:53:26 +02:00
|
|
|
|
function getJIDForDecryption (attrs) {
|
|
|
|
|
const from_jid = attrs.from_muc ? attrs.from_real_jid : attrs.from;
|
|
|
|
|
if (!from_jid) {
|
|
|
|
|
Object.assign(attrs, {
|
2021-11-03 21:33:23 +01:00
|
|
|
|
'error_text': __("Sorry, could not decrypt a received OMEMO "+
|
|
|
|
|
"message because we don't have the XMPP address for that user."),
|
2021-10-30 20:53:26 +02:00
|
|
|
|
'error_type': 'Decryption',
|
2021-11-03 21:33:23 +01:00
|
|
|
|
'is_ephemeral': true,
|
2021-10-30 20:53:26 +02:00
|
|
|
|
'is_error': true,
|
|
|
|
|
'type': 'error'
|
|
|
|
|
});
|
|
|
|
|
throw new Error("Could not find JID to decrypt OMEMO message for");
|
|
|
|
|
}
|
|
|
|
|
return from_jid;
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-11 19:51:58 +02:00
|
|
|
|
async function handleDecryptedWhisperMessage (attrs, key_and_tag) {
|
2021-10-30 20:53:26 +02:00
|
|
|
|
const from_jid = getJIDForDecryption(attrs);
|
2022-02-19 12:09:30 +01:00
|
|
|
|
const devicelist = await api.omemo.devicelists.get(from_jid, true);
|
2021-10-30 20:53:26 +02:00
|
|
|
|
const encrypted = attrs.encrypted;
|
2022-02-05 22:02:24 +01:00
|
|
|
|
let device = devicelist.devices.get(encrypted.device_id);
|
2021-04-11 19:51:58 +02:00
|
|
|
|
if (!device) {
|
2021-10-30 20:53:26 +02:00
|
|
|
|
device = await devicelist.devices.create({ 'id': encrypted.device_id, 'jid': from_jid }, { 'promise': true });
|
2021-04-11 19:51:58 +02:00
|
|
|
|
}
|
|
|
|
|
if (encrypted.payload) {
|
|
|
|
|
const key = key_and_tag.slice(0, 16);
|
|
|
|
|
const tag = key_and_tag.slice(16);
|
|
|
|
|
const result = await omemo.decryptMessage(Object.assign(encrypted, { 'key': key, 'tag': tag }));
|
|
|
|
|
device.save('active', true);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDecryptionErrorAttributes (e) {
|
2021-11-20 12:54:33 +01:00
|
|
|
|
return {
|
|
|
|
|
'error_text':
|
|
|
|
|
__('Sorry, could not decrypt a received OMEMO message due to an error.') + ` ${e.name} ${e.message}`,
|
|
|
|
|
'error_condition': e.name,
|
|
|
|
|
'error_message': e.message,
|
|
|
|
|
'error_type': 'Decryption',
|
|
|
|
|
'is_ephemeral': true,
|
|
|
|
|
'is_error': true,
|
|
|
|
|
'type': 'error'
|
|
|
|
|
};
|
2021-04-11 19:51:58 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function decryptPrekeyWhisperMessage (attrs) {
|
2021-10-30 20:53:26 +02:00
|
|
|
|
const from_jid = getJIDForDecryption(attrs);
|
|
|
|
|
const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
|
2021-06-24 18:56:25 +02:00
|
|
|
|
const key = base64ToArrayBuffer(attrs.encrypted.key);
|
2021-04-11 19:51:58 +02:00
|
|
|
|
let key_and_tag;
|
|
|
|
|
try {
|
|
|
|
|
key_and_tag = await session_cipher.decryptPreKeyWhisperMessage(key, 'binary');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// TODO from the XEP:
|
|
|
|
|
// There are various reasons why decryption of an
|
|
|
|
|
// OMEMOKeyExchange or an OMEMOAuthenticatedMessage
|
|
|
|
|
// could fail. One reason is if the message was
|
|
|
|
|
// received twice and already decrypted once, in this
|
|
|
|
|
// case the client MUST ignore the decryption failure
|
|
|
|
|
// and not show any warnings/errors. In all other cases
|
|
|
|
|
// of decryption failure, clients SHOULD respond by
|
|
|
|
|
// forcibly doing a new key exchange and sending a new
|
|
|
|
|
// OMEMOKeyExchange with a potentially empty SCE
|
|
|
|
|
// payload. By building a new session with the original
|
|
|
|
|
// sender this way, the invalid session of the original
|
|
|
|
|
// sender will get overwritten with this newly created,
|
|
|
|
|
// valid session.
|
|
|
|
|
log.error(`${e.name} ${e.message}`);
|
|
|
|
|
return Object.assign(attrs, getDecryptionErrorAttributes(e));
|
|
|
|
|
}
|
|
|
|
|
// TODO from the XEP:
|
|
|
|
|
// When a client receives the first message for a given
|
|
|
|
|
// ratchet key with a counter of 53 or higher, it MUST send
|
|
|
|
|
// a heartbeat message. Heartbeat messages are normal OMEMO
|
|
|
|
|
// encrypted messages where the SCE payload does not include
|
|
|
|
|
// any elements. These heartbeat messages cause the ratchet
|
|
|
|
|
// to forward, thus consequent messages will have the
|
|
|
|
|
// counter restarted from 0.
|
|
|
|
|
try {
|
|
|
|
|
const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
|
|
|
|
|
await _converse.omemo_store.generateMissingPreKeys();
|
|
|
|
|
await _converse.omemo_store.publishBundle();
|
|
|
|
|
if (plaintext) {
|
|
|
|
|
return Object.assign(attrs, { 'plaintext': plaintext });
|
|
|
|
|
} else {
|
|
|
|
|
return Object.assign(attrs, { 'is_only_key': true });
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log.error(`${e.name} ${e.message}`);
|
|
|
|
|
return Object.assign(attrs, getDecryptionErrorAttributes(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function decryptWhisperMessage (attrs) {
|
2021-10-30 20:53:26 +02:00
|
|
|
|
const from_jid = getJIDForDecryption(attrs);
|
2021-04-11 19:51:58 +02:00
|
|
|
|
const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
|
2021-06-24 18:56:25 +02:00
|
|
|
|
const key = base64ToArrayBuffer(attrs.encrypted.key);
|
2021-04-11 19:51:58 +02:00
|
|
|
|
try {
|
|
|
|
|
const key_and_tag = await session_cipher.decryptWhisperMessage(key, 'binary');
|
|
|
|
|
const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
|
|
|
|
|
return Object.assign(attrs, { 'plaintext': plaintext });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log.error(`${e.name} ${e.message}`);
|
|
|
|
|
return Object.assign(attrs, getDecryptionErrorAttributes(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function addKeysToMessageStanza (stanza, dicts, iv) {
|
|
|
|
|
for (const i in dicts) {
|
|
|
|
|
if (Object.prototype.hasOwnProperty.call(dicts, i)) {
|
|
|
|
|
const payload = dicts[i].payload;
|
|
|
|
|
const device = dicts[i].device;
|
|
|
|
|
const prekey = 3 == parseInt(payload.type, 10);
|
|
|
|
|
|
|
|
|
|
stanza.c('key', { 'rid': device.get('id') }).t(btoa(payload.body));
|
|
|
|
|
if (prekey) {
|
|
|
|
|
stanza.attrs({ 'prekey': prekey });
|
|
|
|
|
}
|
|
|
|
|
stanza.up();
|
|
|
|
|
if (i == dicts.length - 1) {
|
2021-07-02 20:29:54 +02:00
|
|
|
|
stanza.c('iv').t(iv).up().up();
|
2021-04-11 19:51:58 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve(stanza);
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-12 04:29:00 +02:00
|
|
|
|
/**
|
|
|
|
|
* Given an XML element representing a user's OMEMO bundle, parse it
|
|
|
|
|
* and return a map.
|
|
|
|
|
*/
|
|
|
|
|
export function parseBundle (bundle_el) {
|
2021-04-11 19:51:58 +02:00
|
|
|
|
const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic');
|
|
|
|
|
const signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature');
|
|
|
|
|
const prekeys = sizzle(`prekeys > preKeyPublic`, bundle_el).map(el => ({
|
|
|
|
|
'id': parseInt(el.getAttribute('preKeyId'), 10),
|
|
|
|
|
'key': el.textContent
|
|
|
|
|
}));
|
|
|
|
|
return {
|
|
|
|
|
'identity_key': bundle_el.querySelector('identityKey').textContent.trim(),
|
|
|
|
|
'signed_prekey': {
|
|
|
|
|
'id': parseInt(signed_prekey_public_el.getAttribute('signedPreKeyId'), 10),
|
|
|
|
|
'public_key': signed_prekey_public_el.textContent,
|
|
|
|
|
'signature': signed_prekey_signature_el.textContent
|
|
|
|
|
},
|
|
|
|
|
'prekeys': prekeys
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function generateFingerprint (device) {
|
|
|
|
|
if (device.get('bundle')?.fingerprint) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const bundle = await device.getBundle();
|
2021-06-24 18:56:25 +02:00
|
|
|
|
bundle['fingerprint'] = arrayBufferToHex(base64ToArrayBuffer(bundle['identity_key']));
|
2021-04-11 19:51:58 +02:00
|
|
|
|
device.save('bundle', bundle);
|
|
|
|
|
device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getDevicesForContact (jid) {
|
|
|
|
|
await api.waitUntil('OMEMOInitialized');
|
2022-02-19 12:09:30 +01:00
|
|
|
|
const devicelist = await api.omemo.devicelists.get(jid, true);
|
2021-04-11 19:51:58 +02:00
|
|
|
|
await devicelist.fetchDevices();
|
|
|
|
|
return devicelist.devices;
|
|
|
|
|
}
|
|
|
|
|
|
2022-02-19 12:09:30 +01:00
|
|
|
|
export async function generateDeviceID () {
|
2021-04-11 19:51:58 +02:00
|
|
|
|
/* Generates a device ID, making sure that it's unique */
|
2022-03-12 19:54:42 +01:00
|
|
|
|
const devicelist = await api.omemo.devicelists.get(_converse.bare_jid, true);
|
2022-02-19 12:09:30 +01:00
|
|
|
|
const existing_ids = devicelist.devices.pluck('id');
|
2021-04-11 19:51:58 +02:00
|
|
|
|
let device_id = libsignal.KeyHelper.generateRegistrationId();
|
|
|
|
|
|
|
|
|
|
// Before publishing a freshly generated device id for the first time,
|
|
|
|
|
// a device MUST check whether that device id already exists, and if so, generate a new one.
|
|
|
|
|
let i = 0;
|
|
|
|
|
while (existing_ids.includes(device_id)) {
|
|
|
|
|
device_id = libsignal.KeyHelper.generateRegistrationId();
|
|
|
|
|
i++;
|
|
|
|
|
if (i === 10) {
|
|
|
|
|
throw new Error('Unable to generate a unique device ID');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return device_id.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function buildSession (device) {
|
|
|
|
|
const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
|
|
|
|
|
const sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address);
|
|
|
|
|
const prekey = device.getRandomPreKey();
|
|
|
|
|
const bundle = await device.getBundle();
|
|
|
|
|
|
|
|
|
|
return sessionBuilder.processPreKey({
|
|
|
|
|
'registrationId': parseInt(device.get('id'), 10),
|
2021-06-24 18:56:25 +02:00
|
|
|
|
'identityKey': base64ToArrayBuffer(bundle.identity_key),
|
2021-04-11 19:51:58 +02:00
|
|
|
|
'signedPreKey': {
|
|
|
|
|
'keyId': bundle.signed_prekey.id, // <Number>
|
2021-06-24 18:56:25 +02:00
|
|
|
|
'publicKey': base64ToArrayBuffer(bundle.signed_prekey.public_key),
|
|
|
|
|
'signature': base64ToArrayBuffer(bundle.signed_prekey.signature)
|
2021-04-11 19:51:58 +02:00
|
|
|
|
},
|
|
|
|
|
'preKey': {
|
|
|
|
|
'keyId': prekey.id, // <Number>
|
2021-06-24 18:56:25 +02:00
|
|
|
|
'publicKey': base64ToArrayBuffer(prekey.key)
|
2021-04-11 19:51:58 +02:00
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getSession (device) {
|
|
|
|
|
if (!device.get('bundle')) {
|
|
|
|
|
log.error(`Could not build an OMEMO session for device ${device.get('id')} because we don't have its bundle`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
|
|
|
|
|
const session = await _converse.omemo_store.loadSession(address.toString());
|
|
|
|
|
if (session) {
|
|
|
|
|
return session;
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
const session = await buildSession(device);
|
|
|
|
|
return session;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log.error(`Could not build an OMEMO session for device ${device.get('id')}`);
|
|
|
|
|
log.error(e);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-11-24 00:25:07 +01:00
|
|
|
|
async function updateBundleFromStanza (stanza) {
|
2021-04-11 19:51:58 +02:00
|
|
|
|
const items_el = sizzle(`items`, stanza).pop();
|
|
|
|
|
if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const device_id = items_el.getAttribute('node').split(':')[1];
|
|
|
|
|
const jid = stanza.getAttribute('from');
|
|
|
|
|
const bundle_el = sizzle(`item > bundle`, items_el).pop();
|
2022-02-19 12:09:30 +01:00
|
|
|
|
const devicelist = await api.omemo.devicelists.get(jid, true);
|
2021-11-23 22:56:27 +01:00
|
|
|
|
const device = devicelist.devices.get(device_id) || devicelist.devices.create({ 'id': device_id, jid });
|
2021-04-11 19:51:58 +02:00
|
|
|
|
device.save({ 'bundle': parseBundle(bundle_el) });
|
|
|
|
|
}
|
|
|
|
|
|
2021-11-24 00:25:07 +01:00
|
|
|
|
async function updateDevicesFromStanza (stanza) {
|
2021-04-11 19:51:58 +02:00
|
|
|
|
const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop();
|
|
|
|
|
if (!items_el) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const device_selector = `item list[xmlns="${Strophe.NS.OMEMO}"] device`;
|
|
|
|
|
const device_ids = sizzle(device_selector, items_el).map(d => d.getAttribute('id'));
|
|
|
|
|
const jid = stanza.getAttribute('from');
|
2022-02-19 12:09:30 +01:00
|
|
|
|
const devicelist = await api.omemo.devicelists.get(jid, true);
|
2021-04-11 19:51:58 +02:00
|
|
|
|
const devices = devicelist.devices;
|
|
|
|
|
const removed_ids = difference(devices.pluck('id'), device_ids);
|
|
|
|
|
|
|
|
|
|
removed_ids.forEach(id => {
|
|
|
|
|
if (jid === _converse.bare_jid && id === _converse.omemo_store.get('device_id')) {
|
|
|
|
|
return; // We don't set the current device as inactive
|
|
|
|
|
}
|
|
|
|
|
devices.get(id).save('active', false);
|
|
|
|
|
});
|
|
|
|
|
device_ids.forEach(device_id => {
|
|
|
|
|
const device = devices.get(device_id);
|
|
|
|
|
if (device) {
|
|
|
|
|
device.save('active', true);
|
|
|
|
|
} else {
|
|
|
|
|
devices.create({ 'id': device_id, 'jid': jid });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (u.isSameBareJID(jid, _converse.bare_jid)) {
|
|
|
|
|
// Make sure our own device is on the list
|
|
|
|
|
// (i.e. if it was removed, add it again).
|
|
|
|
|
devicelist.publishCurrentDevice(device_ids);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function registerPEPPushHandler () {
|
|
|
|
|
// Add a handler for devices pushed from other connected clients
|
|
|
|
|
_converse.connection.addHandler(
|
2021-11-24 00:25:07 +01:00
|
|
|
|
async message => {
|
2021-04-11 19:51:58 +02:00
|
|
|
|
try {
|
|
|
|
|
if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
|
2021-11-24 00:25:07 +01:00
|
|
|
|
await api.waitUntil('OMEMOInitialized');
|
|
|
|
|
await updateDevicesFromStanza(message);
|
|
|
|
|
await updateBundleFromStanza(message);
|
2021-04-11 19:51:58 +02:00
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log.error(e.message);
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
null,
|
|
|
|
|
'message',
|
|
|
|
|
'headline'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2022-02-19 12:09:30 +01:00
|
|
|
|
export async function restoreOMEMOSession () {
|
2021-04-11 19:51:58 +02:00
|
|
|
|
if (_converse.omemo_store === undefined) {
|
|
|
|
|
const id = `converse.omemosession-${_converse.bare_jid}`;
|
2021-03-23 14:52:08 +01:00
|
|
|
|
_converse.omemo_store = new _converse.OMEMOStore({ id });
|
|
|
|
|
initStorage(_converse.omemo_store, id);
|
2021-04-11 19:51:58 +02:00
|
|
|
|
}
|
2022-02-19 12:09:30 +01:00
|
|
|
|
await _converse.omemo_store.fetchSession();
|
2021-04-11 19:51:58 +02:00
|
|
|
|
}
|
|
|
|
|
|
2021-11-24 00:25:07 +01:00
|
|
|
|
async function fetchDeviceLists () {
|
2022-04-20 08:51:53 +02:00
|
|
|
|
_converse.devicelists = new _converse.DeviceLists();
|
2021-11-23 22:56:27 +01:00
|
|
|
|
const id = `converse.devicelists-${_converse.bare_jid}`;
|
|
|
|
|
initStorage(_converse.devicelists, id);
|
2021-11-24 00:25:07 +01:00
|
|
|
|
await new Promise(resolve => {
|
2021-11-23 22:56:27 +01:00
|
|
|
|
_converse.devicelists.fetch({
|
|
|
|
|
'success': resolve,
|
2022-04-20 08:51:53 +02:00
|
|
|
|
'error': (_m, e) => { log.error(e); resolve(); }
|
2021-11-23 22:56:27 +01:00
|
|
|
|
})
|
|
|
|
|
});
|
2022-02-19 12:09:30 +01:00
|
|
|
|
// Call API method to wait for our own device list to be fetched from the
|
|
|
|
|
// server or to be created. If we have no pre-existing OMEMO session, this
|
|
|
|
|
// will cause a new device and bundle to be generated and published.
|
|
|
|
|
await api.omemo.devicelists.get(_converse.bare_jid, true);
|
2021-04-11 19:51:58 +02:00
|
|
|
|
}
|
|
|
|
|
|
2022-02-02 14:10:19 +01:00
|
|
|
|
export async function initOMEMO (reconnecting) {
|
|
|
|
|
if (reconnecting) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2021-04-11 19:51:58 +02:00
|
|
|
|
if (!_converse.config.get('trusted') || api.settings.get('clear_cache_on_logout')) {
|
|
|
|
|
log.warn('Not initializing OMEMO, since this browser is not trusted or clear_cache_on_logout is set to true');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
2021-11-23 22:56:27 +01:00
|
|
|
|
await fetchDeviceLists();
|
2021-04-11 19:51:58 +02:00
|
|
|
|
await restoreOMEMOSession();
|
|
|
|
|
await _converse.omemo_store.publishBundle();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log.error('Could not initialize OMEMO support');
|
|
|
|
|
log.error(e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Triggered once OMEMO support has been initialized
|
|
|
|
|
* @event _converse#OMEMOInitialized
|
2021-11-24 00:25:07 +01:00
|
|
|
|
* @example _converse.api.listen.on('OMEMOInitialized', () => { ... });
|
|
|
|
|
*/
|
2021-04-11 19:51:58 +02:00
|
|
|
|
api.trigger('OMEMOInitialized');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onOccupantAdded (chatroom, occupant) {
|
|
|
|
|
if (occupant.isSelf() || !chatroom.features.get('nonanonymous') || !chatroom.features.get('membersonly')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (chatroom.get('omemo_active')) {
|
|
|
|
|
const supported = await _converse.contactHasOMEMOSupport(occupant.get('jid'));
|
|
|
|
|
if (!supported) {
|
|
|
|
|
chatroom.createMessage({
|
|
|
|
|
'message': __(
|
|
|
|
|
"%1$s doesn't appear to have a client that supports OMEMO. " +
|
|
|
|
|
'Encrypted chat will no longer be possible in this grouchat.',
|
|
|
|
|
occupant.get('nick')
|
|
|
|
|
),
|
|
|
|
|
'type': 'error'
|
|
|
|
|
});
|
|
|
|
|
chatroom.save({ 'omemo_active': false, 'omemo_supported': false });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function checkOMEMOSupported (chatbox) {
|
|
|
|
|
let supported;
|
|
|
|
|
if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
|
|
|
|
|
await api.waitUntil('OMEMOInitialized');
|
|
|
|
|
supported = chatbox.features.get('nonanonymous') && chatbox.features.get('membersonly');
|
|
|
|
|
} else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
|
|
|
|
|
supported = await _converse.contactHasOMEMOSupport(chatbox.get('jid'));
|
|
|
|
|
}
|
|
|
|
|
chatbox.set('omemo_supported', supported);
|
|
|
|
|
if (supported && api.settings.get('omemo_default')) {
|
|
|
|
|
chatbox.set('omemo_active', true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleOMEMO (ev) {
|
|
|
|
|
ev.stopPropagation();
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
const toolbar_el = u.ancestor(ev.target, 'converse-chat-toolbar');
|
|
|
|
|
if (!toolbar_el.model.get('omemo_supported')) {
|
|
|
|
|
let messages;
|
|
|
|
|
if (toolbar_el.model.get('type') === _converse.CHATROOMS_TYPE) {
|
|
|
|
|
messages = [
|
|
|
|
|
__(
|
|
|
|
|
'Cannot use end-to-end encryption in this groupchat, ' +
|
|
|
|
|
'either the groupchat has some anonymity or not all participants support OMEMO.'
|
|
|
|
|
)
|
|
|
|
|
];
|
|
|
|
|
} else {
|
|
|
|
|
messages = [
|
|
|
|
|
__(
|
|
|
|
|
"Cannot use end-to-end encryption because %1$s uses a client that doesn't support OMEMO.",
|
|
|
|
|
toolbar_el.model.contact.getDisplayName()
|
|
|
|
|
)
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
return api.alert('error', __('Error'), messages);
|
|
|
|
|
}
|
|
|
|
|
toolbar_el.model.save({ 'omemo_active': !toolbar_el.model.get('omemo_active') });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getOMEMOToolbarButton (toolbar_el, buttons) {
|
|
|
|
|
const model = toolbar_el.model;
|
|
|
|
|
const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
|
|
|
|
|
let title;
|
2021-09-06 20:59:26 +02:00
|
|
|
|
if (model.get('omemo_supported')) {
|
2021-04-11 19:51:58 +02:00
|
|
|
|
const i18n_plaintext = __('Messages are being sent in plaintext');
|
|
|
|
|
const i18n_encrypted = __('Messages are sent encrypted');
|
|
|
|
|
title = model.get('omemo_active') ? i18n_encrypted : i18n_plaintext;
|
2021-09-06 20:59:26 +02:00
|
|
|
|
} else if (is_muc) {
|
2021-04-11 19:51:58 +02:00
|
|
|
|
title = __(
|
|
|
|
|
'This groupchat needs to be members-only and non-anonymous in ' +
|
|
|
|
|
'order to support OMEMO encrypted messages'
|
|
|
|
|
);
|
2021-09-06 20:59:26 +02:00
|
|
|
|
} else {
|
|
|
|
|
title = __('OMEMO encryption is not supported');
|
2021-04-11 19:51:58 +02:00
|
|
|
|
}
|
2021-09-06 20:59:26 +02:00
|
|
|
|
|
2021-07-01 12:57:28 +02:00
|
|
|
|
let color;
|
|
|
|
|
if (model.get('omemo_supported')) {
|
2022-01-16 10:17:36 +01:00
|
|
|
|
if (model.get('omemo_active')) {
|
|
|
|
|
color = is_muc ? `var(--muc-color)` : `var(--chat-toolbar-btn-color)`;
|
|
|
|
|
} else {
|
|
|
|
|
color = `var(--error-color)`;
|
|
|
|
|
}
|
2021-07-01 12:57:28 +02:00
|
|
|
|
} else {
|
|
|
|
|
color = `var(--muc-toolbar-btn-disabled-color)`;
|
|
|
|
|
}
|
2021-04-11 19:51:58 +02:00
|
|
|
|
buttons.push(html`
|
2021-07-01 12:57:28 +02:00
|
|
|
|
<button class="toggle-omemo" title="${title}" data-disabled=${!model.get('omemo_supported')} @click=${toggleOMEMO}>
|
2021-04-11 19:51:58 +02:00
|
|
|
|
<converse-icon
|
|
|
|
|
class="fa ${model.get('omemo_active') ? `fa-lock` : `fa-unlock`}"
|
|
|
|
|
path-prefix="${api.settings.get('assets_path')}"
|
|
|
|
|
size="1em"
|
2021-07-01 12:57:28 +02:00
|
|
|
|
color="${color}"
|
2021-04-11 19:51:58 +02:00
|
|
|
|
></converse-icon>
|
|
|
|
|
</button>
|
|
|
|
|
`);
|
|
|
|
|
return buttons;
|
|
|
|
|
}
|
2021-07-02 20:29:54 +02:00
|
|
|
|
|
|
|
|
|
|
2022-02-19 11:45:04 +01:00
|
|
|
|
async function getBundlesAndBuildSessions (chatbox) {
|
2021-07-02 20:29:54 +02:00
|
|
|
|
const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.');
|
|
|
|
|
let devices;
|
|
|
|
|
if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
|
|
|
|
|
const collections = await Promise.all(chatbox.occupants.map(o => getDevicesForContact(o.get('jid'))));
|
|
|
|
|
devices = collections.reduce((a, b) => concat(a, b.models), []);
|
|
|
|
|
} else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
|
|
|
|
|
const their_devices = await getDevicesForContact(chatbox.get('jid'));
|
|
|
|
|
if (their_devices.length === 0) {
|
|
|
|
|
const err = new Error(no_devices_err);
|
|
|
|
|
err.user_facing = true;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
2022-02-19 12:09:30 +01:00
|
|
|
|
const own_list = await api.omemo.devicelists.get(_converse.bare_jid)
|
|
|
|
|
const own_devices = own_list.devices;
|
2021-07-02 20:29:54 +02:00
|
|
|
|
devices = [...own_devices.models, ...their_devices.models];
|
|
|
|
|
}
|
|
|
|
|
// Filter out our own device
|
|
|
|
|
const id = _converse.omemo_store.get('device_id');
|
|
|
|
|
devices = devices.filter(d => d.get('id') !== id);
|
|
|
|
|
// Fetch bundles if necessary
|
|
|
|
|
await Promise.all(devices.map(d => d.getBundle()));
|
|
|
|
|
|
|
|
|
|
const sessions = devices.filter(d => d).map(d => getSession(d));
|
|
|
|
|
await Promise.all(sessions);
|
|
|
|
|
if (sessions.includes(null)) {
|
|
|
|
|
// We couldn't build a session for certain devices.
|
|
|
|
|
devices = devices.filter(d => sessions[devices.indexOf(d)]);
|
|
|
|
|
if (devices.length === 0) {
|
|
|
|
|
const err = new Error(no_devices_err);
|
|
|
|
|
err.user_facing = true;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return devices;
|
|
|
|
|
}
|
|
|
|
|
|
2022-02-19 11:45:04 +01:00
|
|
|
|
function encryptKey (key_and_tag, device) {
|
|
|
|
|
return getSessionCipher(device.get('jid'), device.get('id'))
|
|
|
|
|
.encrypt(key_and_tag)
|
|
|
|
|
.then(payload => ({ 'payload': payload, 'device': device }));
|
|
|
|
|
}
|
2021-07-02 20:29:54 +02:00
|
|
|
|
|
2022-02-19 11:45:04 +01:00
|
|
|
|
export async function createOMEMOMessageStanza (chat, data) {
|
|
|
|
|
let { stanza } = data;
|
|
|
|
|
const { message } = data;
|
|
|
|
|
if (!message.get('is_encrypted')) {
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
if (!message.get('body')) {
|
2021-07-02 20:29:54 +02:00
|
|
|
|
throw new Error('No message body to encrypt!');
|
|
|
|
|
}
|
2022-02-19 11:45:04 +01:00
|
|
|
|
const devices = await getBundlesAndBuildSessions(chat);
|
2021-07-02 20:29:54 +02:00
|
|
|
|
|
|
|
|
|
// 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.
|
2022-02-19 11:45:04 +01:00
|
|
|
|
stanza.c('encrypted', { 'xmlns': Strophe.NS.OMEMO })
|
2021-07-02 20:29:54 +02:00
|
|
|
|
.c('header', { 'sid': _converse.omemo_store.get('device_id') });
|
|
|
|
|
|
2022-02-19 11:45:04 +01:00
|
|
|
|
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 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 };
|
2021-07-02 20:29:54 +02:00
|
|
|
|
}
|
2021-11-24 00:25:07 +01:00
|
|
|
|
|
|
|
|
|
export const omemo = {
|
|
|
|
|
decryptMessage,
|
|
|
|
|
encryptMessage,
|
|
|
|
|
formatFingerprint
|
|
|
|
|
}
|