diff --git a/karma.conf.js b/karma.conf.js index 3dde5a667..d23da0f99 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -33,7 +33,6 @@ module.exports = function(config) { { pattern: "spec/http-file-upload.js", type: 'module' }, { pattern: "spec/mam.js", type: 'module' }, { pattern: "spec/markers.js", type: 'module' }, - { pattern: "spec/omemo.js", type: 'module' }, { pattern: "spec/ping.js", type: 'module' }, { pattern: "spec/presence.js", type: 'module' }, { pattern: "spec/protocol.js", type: 'module' }, @@ -69,6 +68,7 @@ module.exports = function(config) { { pattern: "src/plugins/muc-views/tests/rai.js", type: 'module' }, { pattern: "src/plugins/muc-views/tests/xss.js", type: 'module' }, { pattern: "src/plugins/notifications/tests/notification.js", type: 'module' }, + { pattern: "src/plugins/omemo/tests/omemo.js", type: 'module' }, { pattern: "src/plugins/register/tests/register.js", type: 'module' }, { pattern: "src/plugins/rosterview/tests/roster.js", type: 'module' } ], diff --git a/src/converse.js b/src/converse.js index 4b89f548e..83e18bf91 100644 --- a/src/converse.js +++ b/src/converse.js @@ -25,7 +25,7 @@ import "./plugins/mam-views.js"; import "./plugins/minimize/index.js"; // Allows chat boxes to be minimized import "./plugins/muc-views/index.js"; // Views related to MUC import "./plugins/notifications/index.js"; -import "./plugins/omemo.js"; +import "./plugins/omemo/index.js"; import "./plugins/profile/index.js"; import "./plugins/push.js"; // XEP-0357 Push Notifications import "./plugins/register/index.js"; // XEP-0077 In-band registration diff --git a/src/plugins/omemo.js b/src/plugins/omemo/index.js similarity index 52% rename from src/plugins/omemo.js rename to src/plugins/omemo/index.js index 58b2a9c7f..33f992d5e 100644 --- a/src/plugins/omemo.js +++ b/src/plugins/omemo/index.js @@ -5,33 +5,45 @@ */ /* global libsignal */ -import "./profile/index.js"; import '../modals/user-details.js'; -import log from "@converse/headless/log"; -import { Collection } from "@converse/skeletor/src/collection"; +import './profile/index.js'; +import concat from 'lodash-es/concat'; +import debounce from 'lodash-es/debounce'; +import difference from 'lodash-es/difference'; +import invokeMap from 'lodash-es/invokeMap'; +import log from '@converse/headless/log'; +import omit from 'lodash-es/omit'; +import range from 'lodash-es/range'; +import { Collection } from '@converse/skeletor/src/collection'; import { Model } from '@converse/skeletor/src/model.js'; import { __ } from '../i18n'; -import { _converse, api, converse } from "@converse/headless/core"; -import { concat, debounce, difference, invokeMap, range, omit } from "lodash-es"; -import { html } from 'lit-html'; +import { _converse, api, converse } from '@converse/headless/core'; +import { + addKeysToMessageStanza, + generateDeviceID, + generateFingerprint, + getDevicesForContact, + getOMEMOToolbarButton, + getSession, + getSessionCipher, + initOMEMO, + onChatBoxesInitialized, + onChatInitialized, + parseEncryptedMessage, + registerPEPPushHandler, + restoreOMEMOSession, +} from './utils.js'; -const { Strophe, sizzle, $build, $iq, $msg } = converse.env; -const u = converse.env.utils; +const { Strophe, sizzle, $build, $iq, $msg, u } = converse.env; -Strophe.addNamespace('OMEMO_DEVICELIST', Strophe.NS.OMEMO+".devicelist"); -Strophe.addNamespace('OMEMO_VERIFICATION', Strophe.NS.OMEMO+".verification"); -Strophe.addNamespace('OMEMO_WHITELISTED', Strophe.NS.OMEMO+".whitelisted"); -Strophe.addNamespace('OMEMO_BUNDLES', Strophe.NS.OMEMO+".bundles"); +Strophe.addNamespace('OMEMO_DEVICELIST', Strophe.NS.OMEMO + '.devicelist'); +Strophe.addNamespace('OMEMO_VERIFICATION', Strophe.NS.OMEMO + '.verification'); +Strophe.addNamespace('OMEMO_WHITELISTED', Strophe.NS.OMEMO + '.whitelisted'); +Strophe.addNamespace('OMEMO_BUNDLES', Strophe.NS.OMEMO + '.bundles'); const UNDECIDED = 0; const TRUSTED = 1; // eslint-disable-line no-unused-vars const UNTRUSTED = -1; -const TAG_LENGTH = 128; -const KEY_ALGO = { - 'name': "AES-GCM", - 'length': 128 -}; - class IQError extends Error { constructor (message, iq) { @@ -41,524 +53,6 @@ class IQError extends Error { } } - -function parseEncryptedMessage (stanza, attrs) { - if (attrs.is_encrypted && attrs.encrypted.key) { - // https://xmpp.org/extensions/xep-0384.html#usecases-receiving - if (attrs.encrypted.prekey === true) { - return decryptPrekeyWhisperMessage(attrs); - } else { - return decryptWhisperMessage(attrs); - } - } else { - return attrs; - } -} - - -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)); - } - }); -} - - -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(); - }); -} - - -const omemo = converse.env.omemo = { - - async 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)), - key = await crypto.subtle.generateKey(KEY_ALGO, true, ["encrypt", "decrypt"]), - algo = { - 'name': 'AES-GCM', - 'iv': iv, - 'tagLength': TAG_LENGTH - }, - encrypted = await crypto.subtle.encrypt(algo, key, u.stringToArrayBuffer(plaintext)), - length = encrypted.byteLength - ((128 + 7) >> 3), - ciphertext = encrypted.slice(0, length), - tag = encrypted.slice(length), - exported_key = await crypto.subtle.exportKey("raw", key); - - return { - 'key': exported_key, - 'tag': tag, - 'key_and_tag': u.appendArrayBuffer(exported_key, tag), - 'payload': u.arrayBufferToBase64(ciphertext), - 'iv': u.arrayBufferToBase64(iv) - }; - }, - - async decryptMessage (obj) { - const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt','decrypt']); - const cipher = u.appendArrayBuffer(u.base64ToArrayBuffer(obj.payload), obj.tag); - const algo = { - 'name': "AES-GCM", - 'iv': u.base64ToArrayBuffer(obj.iv), - 'tagLength': TAG_LENGTH - }; - return u.arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher)); - } -}; - -function getSessionCipher (jid, id) { - const address = new libsignal.SignalProtocolAddress(jid, id); - return new window.libsignal.SessionCipher(_converse.omemo_store, address); -} - -async function handleDecryptedWhisperMessage (attrs, key_and_tag) { - const encrypted = attrs.encrypted; - const devicelist = _converse.devicelists.getDeviceList(attrs.from); - await devicelist._devices_promise; - - let device = devicelist.get(encrypted.device_id); - if (!device) { - device = await devicelist.devices.create({'id': encrypted.device_id, 'jid': attrs.from}, {'promise': true}); - } - 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) { - if (api.settings.get("loglevel") === 'debug') { - return { - 'error_text': __("Sorry, could not decrypt a received OMEMO message due to an error.") + ` ${e.name} ${e.message}`, - 'error_type': 'Decryption', - 'is_ephemeral': true, - 'is_error': true, - 'type': 'error', - } - } else { - return {}; - } -} - -async function decryptPrekeyWhisperMessage (attrs) { - const session_cipher = getSessionCipher(attrs.from, parseInt(attrs.encrypted.device_id, 10)); - const key = u.base64ToArrayBuffer(attrs.encrypted.key); - 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) { - const from_jid = attrs.from_muc ? attrs.from_real_jid : attrs.from; - if (!from_jid) { - Object.assign(attrs, { - 'error_text': __("Sorry, could not decrypt a received OMEMO because we don't have the JID for that user."), - 'error_type': 'Decryption', - 'is_ephemeral': false, - 'is_error': true, - 'type': 'error', - }); - } - const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10)); - const key = u.base64ToArrayBuffer(attrs.encrypted.key); - 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)); - } -} - -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) { - stanza.c('iv').t(iv).up().up() - } - } - } - return Promise.resolve(stanza); -} - - -function parseBundle (bundle_el) { - /* Given an XML element representing a user's OMEMO bundle, parse it - * and return a map. - */ - 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 - } -} - -async function generateFingerprint (device) { - if (device.get('bundle')?.fingerprint) { - return; - } - const bundle = await device.getBundle(); - bundle['fingerprint'] = u.arrayBufferToHex(u.base64ToArrayBuffer(bundle['identity_key'])); - device.save('bundle', bundle); - device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference -} - - -async function getDevicesForContact (jid) { - await api.waitUntil('OMEMOInitialized'); - const devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid}); - await devicelist.fetchDevices(); - return devicelist.devices; -} - -function generateDeviceID () { - /* Generates a device ID, making sure that it's unique */ - const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id'); - 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) { - // TODO: check device-get('jid') versus the 'from' attribute which is used - // to build a session when receiving an encrypted message in a MUC. - // https://github.com/conversejs/converse.js/issues/1481#issuecomment-509183431 - 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), - 'identityKey': u.base64ToArrayBuffer(bundle.identity_key), - 'signedPreKey': { - 'keyId': bundle.signed_prekey.id, // - 'publicKey': u.base64ToArrayBuffer(bundle.signed_prekey.public_key), - 'signature': u.base64ToArrayBuffer(bundle.signed_prekey.signature) - }, - 'preKey': { - 'keyId': prekey.id, // - 'publicKey': u.base64ToArrayBuffer(prekey.key), - } - }); -} - -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; - } - } -} - -function updateBundleFromStanza (stanza) { - 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(); - const devicelist = _converse.devicelists.getDeviceList(jid); - const device = devicelist.devices.get(device_id) || devicelist.devices.create({'id': device_id, 'jid': jid}); - device.save({'bundle': parseBundle(bundle_el)}); -} - -function updateDevicesFromStanza (stanza) { - 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'); - const devicelist = _converse.devicelists.getDeviceList(jid); - 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); - } -} - -function registerPEPPushHandler () { - // Add a handler for devices pushed from other connected clients - _converse.connection.addHandler((message) => { - try { - if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) { - updateDevicesFromStanza(message); - updateBundleFromStanza(message); - } - } catch (e) { - log.error(e.message); - } - return true; - }, null, 'message', 'headline'); -} - -function restoreOMEMOSession () { - if (_converse.omemo_store === undefined) { - const id = `converse.omemosession-${_converse.bare_jid}`; - _converse.omemo_store = new _converse.OMEMOStore({'id': id}); - _converse.omemo_store.browserStorage = _converse.createStore(id); - } - return _converse.omemo_store.fetchSession(); -} - - -function fetchDeviceLists () { - return new Promise((success, error) => _converse.devicelists.fetch({success, 'error': (m, e) => error(e)})); -} - -async function fetchOwnDevices () { - await fetchDeviceLists(); - let own_devicelist = _converse.devicelists.get(_converse.bare_jid); - if (own_devicelist) { - own_devicelist.fetchDevices(); - } else { - own_devicelist = await _converse.devicelists.create({'jid': _converse.bare_jid}, {'promise': true}); - } - return own_devicelist._devices_promise; -} - -async function initOMEMO () { - 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; - } - _converse.devicelists = new _converse.DeviceLists(); - const id = `converse.devicelists-${_converse.bare_jid}`; - _converse.devicelists.browserStorage = _converse.createStore(id); - - try { - await fetchOwnDevices(); - 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 - * @example _converse.api.listen.on('OMEMOInitialized', () => { ... }); */ - 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')}); -} - - -function getOMEMOToolbarButton (toolbar_el, buttons) { - const model = toolbar_el.model; - const is_muc = model.get('type') === _converse.CHATROOMS_TYPE; - let title; - if (is_muc && model.get('omemo_supported')) { - 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; - } else { - title = __('This groupchat needs to be members-only and non-anonymous in '+ - 'order to support OMEMO encrypted messages'); - } - - buttons.push(html` - ` - ); - return buttons; -} - - /** * Mixin object that contains OMEMO-related methods for * {@link _converse.ChatBox} or {@link _converse.ChatRoom} objects. @@ -566,11 +60,10 @@ function getOMEMOToolbarButton (toolbar_el, buttons) { * @typedef {Object} OMEMOEnabledChatBox */ const OMEMOEnabledChatBox = { - encryptKey (plaintext, device) { return getSessionCipher(device.get('jid'), device.get('id')) .encrypt(plaintext) - .then(payload => ({'payload': payload, 'device': device})); + .then(payload => ({ 'payload': payload, 'device': device })); }, handleMessageSendError (e) { @@ -580,17 +73,21 @@ const OMEMOEnabledChatBox = { 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')) + __( + "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')) + __( + "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(__('Unable to send an encrypted message due to an unexpected error.')); err_msgs.push(e.iq.outerHTML); } api.alert('error', __('Error'), err_msgs); @@ -602,22 +99,21 @@ const OMEMOEnabledChatBox = { throw e; } } -} - +}; converse.plugins.add('converse-omemo', { - enabled (_converse) { - return window.libsignal && + return ( + window.libsignal && _converse.config.get('trusted') && !api.settings.get('clear_cache_on_logout') && - !_converse.api.settings.get("blacklisted_plugins").includes('converse-omemo'); + !_converse.api.settings.get('blacklisted_plugins').includes('converse-omemo') + ); }, - dependencies: ["converse-chatview", "converse-pubsub", "converse-profile"], + dependencies: ['converse-chatview', 'converse-pubsub', 'converse-profile'], overrides: { - ProfileModal: { events: { 'change input.select-all': 'selectAll', @@ -642,7 +138,7 @@ converse.plugins.add('converse-omemo', { if (device_id) { this.current_device = this.devicelist.devices.get(device_id); } - this.other_devices = this.devicelist.devices.filter(d => (d.get('id') !== device_id)); + this.other_devices = this.devicelist.devices.filter(d => d.get('id') !== device_id); if (this.__super__.beforeRender) { return this.__super__.beforeRender.apply(this, arguments); } @@ -659,25 +155,31 @@ converse.plugins.add('converse-omemo', { removeSelectedFingerprints (ev) { ev.preventDefault(); ev.stopPropagation(); - ev.target.querySelector('.select-all').checked = false - const device_ids = sizzle('.fingerprint-removal-item input[type="checkbox"]:checked', ev.target).map(c => c.value); - this.devicelist.removeOwnDevices(device_ids) + ev.target.querySelector('.select-all').checked = false; + const device_ids = sizzle('.fingerprint-removal-item input[type="checkbox"]:checked', ev.target).map( + c => c.value + ); + this.devicelist + .removeOwnDevices(device_ids) .then(this.modal.hide) .catch(err => { log.error(err); - _converse.api.alert( - Strophe.LogLevel.ERROR, - __('Error'), [__('Sorry, an error occurred while trying to remove the devices.')] - ) + _converse.api.alert(Strophe.LogLevel.ERROR, __('Error'), [ + __('Sorry, an error occurred while trying to remove the devices.') + ]); }); }, generateOMEMODeviceBundle (ev) { ev.preventDefault(); - if (confirm(__( - "Are you sure you want to generate new OMEMO keys? " + - "This will remove your old keys and all previously encrypted messages will no longer be decryptable on this device.") - )) { + if ( + confirm( + __( + 'Are you sure you want to generate new OMEMO keys? ' + + 'This will remove your old keys and all previously encrypted messages will no longer be decryptable on this device.' + ) + ) + ) { api.omemo.bundle.generate(); } } @@ -735,31 +237,30 @@ converse.plugins.add('converse-omemo', { * loaded by Converse.js's plugin machinery. */ - api.settings.extend({'omemo_default': false}); + api.settings.extend({ 'omemo_default': false }); api.promises.add(['OMEMOInitialized']); _converse.NUM_PREKEYS = 100; // Set here so that tests can override Object.assign(_converse.ChatBox.prototype, OMEMOEnabledChatBox); - _converse.generateFingerprints = async function (jid) { - const devices = await getDevicesForContact(jid) + const devices = await getDevicesForContact(jid); return Promise.all(devices.map(d => generateFingerprint(d))); - } + }; _converse.getDeviceForContact = function (jid, device_id) { return getDevicesForContact(jid).then(devices => devices.get(device_id)); - } + }; _converse.contactHasOMEMOSupport = async function (jid) { /* Checks whether the contact advertises any OMEMO-compatible devices. */ const devices = await getDevicesForContact(jid); return devices.length > 0; - } + }; _converse.getBundlesAndBuildSessions = async function (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; if (chatbox.get('type') === _converse.CHATROOMS_TYPE) { const collections = await Promise.all(chatbox.occupants.map(o => getDevicesForContact(o.get('jid')))); @@ -792,24 +293,29 @@ converse.plugins.add('converse-omemo', { } } return devices; - } + }; _converse.createOMEMOMessageStanza = function (chatbox, message, devices) { - const body = __("This is an OMEMO encrypted message which your client doesn’t seem to support. "+ - "Find more information on https://conversations.im/omemo"); + const body = __( + 'This is an OMEMO encrypted message which your client doesn’t seem to support. ' + + 'Find more information on https://conversations.im/omemo' + ); if (!message.get('message')) { - throw new Error("No message body to encrypt!"); + throw new Error('No message body to encrypt!'); } const stanza = $msg({ - 'from': _converse.connection.jid, - 'to': chatbox.get('jid'), - 'type': chatbox.get('message_type'), - 'id': message.get('msgid') - }).c('body').t(body).up() + '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(); + stanza.c('request', { 'xmlns': Strophe.NS.RECEIPTS }).up(); } // An encrypted header is added to the message for // each device that is supposed to receive it. @@ -817,8 +323,9 @@ converse.plugins.add('converse-omemo', { // payload message is encrypted with, // and they are separately encrypted using the // session corresponding to the counterpart device. - stanza.c('encrypted', {'xmlns': Strophe.NS.OMEMO}) - .c('header', {'sid': _converse.omemo_store.get('device_id')}); + stanza + .c('encrypted', { 'xmlns': Strophe.NS.OMEMO }) + .c('header', { 'sid': _converse.omemo_store.get('device_id') }); return omemo.encryptMessage(message.get('message')).then(obj => { // The 16 bytes key and the GCM authentication tag (The tag @@ -828,25 +335,27 @@ converse.plugins.add('converse-omemo', { // concatenation is encrypted using the corresponding // long-standing SignalProtocol session. const promises = devices - .filter(device => (device.get('trusted') != UNTRUSTED && device.get('active'))) + .filter(device => device.get('trusted') != UNTRUSTED && device.get('active')) .map(device => chatbox.encryptKey(obj.key_and_tag, device)); return Promise.all(promises) .then(dicts => addKeysToMessageStanza(stanza, dicts, obj.iv)) .then(stanza => { - stanza.c('payload').t(obj.payload).up().up(); - stanza.c('store', {'xmlns': Strophe.NS.HINTS}); + stanza + .c('payload') + .t(obj.payload) + .up() + .up(); + stanza.c('store', { 'xmlns': Strophe.NS.HINTS }); return stanza; }); }); - } - + }; _converse.OMEMOStore = Model.extend({ - Direction: { SENDING: 1, - RECEIVING: 2, + RECEIVING: 2 }, getIdentityKeyPair () { @@ -861,14 +370,15 @@ converse.plugins.add('converse-omemo', { return Promise.resolve(parseInt(this.get('device_id'), 10)); }, - isTrustedIdentity (identifier, identity_key, direction) { // eslint-disable-line no-unused-vars + isTrustedIdentity (identifier, identity_key, direction) { + // eslint-disable-line no-unused-vars if (identifier === null || identifier === undefined) { throw new Error("Can't check identity key for invalid key"); } if (!(identity_key instanceof ArrayBuffer)) { - throw new Error("Expected identity_key to be an ArrayBuffer"); + throw new Error('Expected identity_key to be an ArrayBuffer'); } - const trusted = this.get('identity_key'+identifier); + const trusted = this.get('identity_key' + identifier); if (trusted === undefined) { return Promise.resolve(true); } @@ -879,7 +389,7 @@ converse.plugins.add('converse-omemo', { if (identifier === null || identifier === undefined) { throw new Error("Can't load identity_key for invalid identifier"); } - return Promise.resolve(u.base64ToArrayBuffer(this.get('identity_key'+identifier))); + return Promise.resolve(u.base64ToArrayBuffer(this.get('identity_key' + identifier))); }, saveIdentity (identifier, identity_key) { @@ -887,9 +397,9 @@ converse.plugins.add('converse-omemo', { throw new Error("Can't save identity_key for invalid identifier"); } const address = new libsignal.SignalProtocolAddress.fromString(identifier); - const existing = this.get('identity_key'+address.getName()); + const existing = this.get('identity_key' + address.getName()); const b64_idkey = u.arrayBufferToBase64(identity_key); - this.save('identity_key'+address.getName(), b64_idkey) + this.save('identity_key' + address.getName(), b64_idkey); if (existing && b64_idkey !== existing) { return Promise.resolve(true); @@ -918,7 +428,7 @@ converse.plugins.add('converse-omemo', { prekey[key_id] = { 'pubKey': u.arrayBufferToBase64(key_pair.pubKey), 'privKey': u.arrayBufferToBase64(key_pair.privKey) - } + }; this.save('prekeys', Object.assign(this.getPreKeys(), prekey)); return Promise.resolve(); }, @@ -928,7 +438,8 @@ converse.plugins.add('converse-omemo', { return Promise.resolve(); }, - loadSignedPreKey (keyId) { // eslint-disable-line no-unused-vars + loadSignedPreKey (keyId) { + // eslint-disable-line no-unused-vars const res = this.get('signed_prekey'); if (res) { return Promise.resolve({ @@ -940,12 +451,12 @@ converse.plugins.add('converse-omemo', { }, storeSignedPreKey (spk) { - if (typeof spk !== "object") { + if (typeof spk !== 'object') { // XXX: We've changed the signature of this method from the // example given in InMemorySignalProtocolStore. // Should be fine because the libsignal code doesn't // actually call this method. - throw new Error("storeSignedPreKey: expected an object"); + throw new Error('storeSignedPreKey: expected an object'); } this.save('signed_prekey', { 'id': spk.keyId, @@ -970,21 +481,25 @@ converse.plugins.add('converse-omemo', { }, loadSession (identifier) { - return Promise.resolve(this.get('session'+identifier)); + return Promise.resolve(this.get('session' + identifier)); }, storeSession (identifier, record) { - return Promise.resolve(this.save('session'+identifier, record)); + return Promise.resolve(this.save('session' + identifier, record)); }, removeSession (identifier) { - return Promise.resolve(this.unset('session'+identifier)); + return Promise.resolve(this.unset('session' + identifier)); }, removeAllSessions (identifier) { - const keys = Object.keys(this.attributes).filter(key => key.startsWith('session'+identifier) ? key : false); + const keys = Object.keys(this.attributes).filter(key => + key.startsWith('session' + identifier) ? key : false + ); const attrs = {}; - keys.forEach(key => {attrs[key] = undefined}); + keys.forEach(key => { + attrs[key] = undefined; + }); this.save(attrs); return Promise.resolve(); }, @@ -993,15 +508,25 @@ converse.plugins.add('converse-omemo', { const signed_prekey = this.get('signed_prekey'); const node = `${Strophe.NS.OMEMO_BUNDLES}:${this.get('device_id')}`; const item = $build('item') - .c('bundle', {'xmlns': Strophe.NS.OMEMO}) - .c('signedPreKeyPublic', {'signedPreKeyId': signed_prekey.id}) - .t(signed_prekey.pubKey).up() - .c('signedPreKeySignature').t(signed_prekey.signature).up() - .c('identityKey').t(this.get('identity_keypair').pubKey).up() - .c('prekeys'); + .c('bundle', { 'xmlns': Strophe.NS.OMEMO }) + .c('signedPreKeyPublic', { 'signedPreKeyId': signed_prekey.id }) + .t(signed_prekey.pubKey) + .up() + .c('signedPreKeySignature') + .t(signed_prekey.signature) + .up() + .c('identityKey') + .t(this.get('identity_keypair').pubKey) + .up() + .c('prekeys'); - Object.values(this.get('prekeys')).forEach((prekey, id) => item.c('preKeyPublic', {'preKeyId': id}).t(prekey.pubKey).up()); - const options = {'pubsub#access_model': 'open'}; + Object.values(this.get('prekeys')).forEach((prekey, id) => + item + .c('preKeyPublic', { 'preKeyId': id }) + .t(prekey.pubKey) + .up() + ); + const options = { 'pubsub#access_model': 'open' }; return api.pubsub.publish(null, node, item, options, false); }, @@ -1011,16 +536,21 @@ converse.plugins.add('converse-omemo', { Object.keys(this.getPreKeys()) ); if (missing_keys.length < 1) { - log.warn("No missing prekeys to generate for our own device"); + log.warn('No missing prekeys to generate for our own device'); return Promise.resolve(); } - const keys = await Promise.all(missing_keys.map(id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10)))); + const keys = await Promise.all( + missing_keys.map(id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10))) + ); keys.forEach(k => this.storePreKey(k.keyId, k.keyPair)); - const marshalled_keys = Object.keys(this.getPreKeys()).map(k => ({'id': k.keyId, 'key': u.arrayBufferToBase64(k.pubKey)})); + const marshalled_keys = Object.keys(this.getPreKeys()).map(k => ({ + 'id': k.keyId, + 'key': u.arrayBufferToBase64(k.pubKey) + })); const devicelist = _converse.devicelists.get(_converse.bare_jid); const device = devicelist.devices.get(this.get('device_id')); const bundle = await device.getBundle(); - device.save('bundle', Object.assign(bundle, {'prekeys': marshalled_keys})); + device.save('bundle', Object.assign(bundle, { 'prekeys': marshalled_keys })); }, /** @@ -1055,12 +585,20 @@ converse.plugins.add('converse-omemo', { 'id': signed_prekey.keyId, 'public_key': u.arrayBufferToBase64(signed_prekey.keyPair.privKey), 'signature': u.arrayBufferToBase64(signed_prekey.signature) - } - const keys = await Promise.all(range(0, _converse.NUM_PREKEYS).map(id => libsignal.KeyHelper.generatePreKey(id))); + }; + const keys = await Promise.all( + range(0, _converse.NUM_PREKEYS).map(id => libsignal.KeyHelper.generatePreKey(id)) + ); keys.forEach(k => _converse.omemo_store.storePreKey(k.keyId, k.keyPair)); const devicelist = _converse.devicelists.get(_converse.bare_jid); - const device = await devicelist.devices.create({'id': bundle.device_id, 'jid': _converse.bare_jid}, {'promise': true}); - const marshalled_keys = keys.map(k => ({'id': k.keyId, 'key': u.arrayBufferToBase64(k.keyPair.pubKey)})); + const device = await devicelist.devices.create( + { 'id': bundle.device_id, 'jid': _converse.bare_jid }, + { 'promise': true } + ); + const marshalled_keys = keys.map(k => ({ + 'id': k.keyId, + 'key': u.arrayBufferToBase64(k.keyPair.pubKey) + })); bundle['prekeys'] = marshalled_keys; device.save('bundle', bundle); }, @@ -1071,7 +609,9 @@ converse.plugins.add('converse-omemo', { this.fetch({ 'success': () => { if (!_converse.omemo_store.get('device_id')) { - this.generateBundle().then(resolve).catch(reject); + this.generateBundle() + .then(resolve) + .catch(reject); } else { resolve(); } @@ -1079,7 +619,9 @@ converse.plugins.add('converse-omemo', { 'error': (model, resp) => { log.warn("Could not fetch OMEMO session from cache, we'll generate a new one."); log.warn(resp); - this.generateBundle().then(resolve).catch(reject); + this.generateBundle() + .then(resolve) + .catch(reject); } }); }); @@ -1110,19 +652,20 @@ converse.plugins.add('converse-omemo', { 'type': 'get', 'from': _converse.bare_jid, 'to': this.get('jid') - }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB}) - .c('items', {'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}`}); + }) + .c('pubsub', { 'xmlns': Strophe.NS.PUBSUB }) + .c('items', { 'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}` }); let iq; try { - iq = await api.sendIQ(stanza) + iq = await api.sendIQ(stanza); } catch (iq) { log.error(`Could not fetch bundle for device ${this.get('id')} from ${this.get('jid')}`); log.error(iq); return null; } if (iq.querySelector('error')) { - throw new IQError("Could not fetch bundle", iq); + throw new IQError('Could not fetch bundle', iq); } const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop(); const bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop(); @@ -1146,7 +689,7 @@ converse.plugins.add('converse-omemo', { }); _converse.Devices = Collection.extend({ - model: _converse.Device, + model: _converse.Device }); /** @@ -1162,14 +705,13 @@ converse.plugins.add('converse-omemo', { const id = `converse.devicelist-${_converse.bare_jid}-${this.get('jid')}`; this.devices.browserStorage = _converse.createStore(id); this.fetchDevices(); - }, async onDevicesFound (collection) { if (collection.length === 0) { let ids; try { - ids = await this.fetchDevicesFromServer() + ids = await this.fetchDevicesFromServer(); } catch (e) { if (e === null) { log.error(`Timeout error while fetching devices for ${this.get('jid')}`); @@ -1190,7 +732,10 @@ converse.plugins.add('converse-omemo', { this._devices_promise = new Promise(resolve => { this.devices.fetch({ 'success': c => resolve(this.onDevicesFound(c)), - 'error': (m, e) => { log.error(e); resolve(); } + 'error': (m, e) => { + log.error(e); + resolve(); + } }); }); } @@ -1199,7 +744,7 @@ converse.plugins.add('converse-omemo', { async getOwnDeviceId () { let device_id = _converse.omemo_store.get('device_id'); - if (!this.devices.findWhere({'id': device_id})) { + if (!this.devices.findWhere({ 'id': device_id })) { // Generate a new bundle if we cannot find our device await _converse.omemo_store.generateBundle(); device_id = _converse.omemo_store.get('device_id'); @@ -1209,7 +754,7 @@ converse.plugins.add('converse-omemo', { async publishCurrentDevice (device_ids) { if (this.get('jid') !== _converse.bare_jid) { - return // We only publish for ourselves. + return; // We only publish for ourselves. } await restoreOMEMOSession(); @@ -1229,8 +774,9 @@ converse.plugins.add('converse-omemo', { 'type': 'get', 'from': _converse.bare_jid, 'to': this.get('jid') - }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB}) - .c('items', {'node': Strophe.NS.OMEMO_DEVICELIST}); + }) + .c('pubsub', { 'xmlns': Strophe.NS.PUBSUB }) + .c('items', { 'node': Strophe.NS.OMEMO_DEVICELIST }); let iq; try { @@ -1242,7 +788,7 @@ converse.plugins.add('converse-omemo', { const selector = `list[xmlns="${Strophe.NS.OMEMO}"] device`; const device_ids = sizzle(selector, iq).map(d => d.getAttribute('id')); await Promise.all( - device_ids.map(id => this.devices.create({id, 'jid': this.get('jid')}, {'promise': true})) + device_ids.map(id => this.devices.create({ id, 'jid': this.get('jid') }, { 'promise': true })) ); return device_ids; }, @@ -1253,9 +799,9 @@ converse.plugins.add('converse-omemo', { * See: https://xmpp.org/extensions/xep-0384.html#usecases-announcing */ publishDevices () { - const item = $build('item', {'id': 'current'}).c('list', {'xmlns': Strophe.NS.OMEMO}) - this.devices.filter(d => d.get('active')).forEach(d => item.c('device', {'id': d.get('id')}).up()); - const options = {'pubsub#access_model': 'open'}; + const item = $build('item', { 'id': 'current' }).c('list', { 'xmlns': Strophe.NS.OMEMO }); + this.devices.filter(d => d.get('active')).forEach(d => item.c('device', { 'id': d.get('id') }).up()); + const options = { 'pubsub#access_model': 'open' }; return api.pubsub.publish(null, Strophe.NS.OMEMO_DEVICELIST, item, options, false); }, @@ -1283,7 +829,7 @@ converse.plugins.add('converse-omemo', { * @param { String } jid - The Jabber ID for which the device list will be returned. */ getDeviceList (jid) { - return this.get(jid) || this.create({'jid': jid}); + return this.get(jid) || this.create({ 'jid': jid }); } }); @@ -1300,8 +846,7 @@ converse.plugins.add('converse-omemo', { api.listen.on('getToolbarButtons', getOMEMOToolbarButton); api.listen.on('statusInitialized', initOMEMO); - api.listen.on('addClientFeatures', - () => api.disco.own.features.add(`${Strophe.NS.OMEMO_DEVICELIST}+notify`)); + api.listen.on('addClientFeatures', () => api.disco.own.features.add(`${Strophe.NS.OMEMO_DEVICELIST}+notify`)); api.listen.on('userDetailsModalInitialized', contact => { const jid = contact.get('jid'); @@ -1312,7 +857,7 @@ converse.plugins.add('converse-omemo', { _converse.generateFingerprints(_converse.bare_jid).catch(e => log.error(e)); }); - api.listen.on('afterTearDown', () => (delete _converse.omemo_store)); + api.listen.on('afterTearDown', () => delete _converse.omemo_store); api.listen.on('clearSession', () => { if (_converse.shouldClearCache() && _converse.devicelists) { @@ -1321,7 +866,6 @@ converse.plugins.add('converse-omemo', { } }); - /************************ API ************************/ Object.assign(_converse.api, { /** @@ -1354,7 +898,7 @@ converse.plugins.add('converse-omemo', { const device = devicelist.devices.get(device_id); _converse.omemo_store.unset(device_id); if (device) { - await new Promise(done => device.destroy({'success': done, 'error': done})); + await new Promise(done => device.destroy({ 'success': done, 'error': done })); } devicelist.devices.trigger('remove'); } diff --git a/spec/omemo.js b/src/plugins/omemo/tests/omemo.js similarity index 100% rename from spec/omemo.js rename to src/plugins/omemo/tests/omemo.js diff --git a/src/plugins/omemo/utils.js b/src/plugins/omemo/utils.js new file mode 100644 index 000000000..c42d3f136 --- /dev/null +++ b/src/plugins/omemo/utils.js @@ -0,0 +1,539 @@ +/* global libsignal */ +import difference from 'lodash-es/difference'; +import log from '@converse/headless/log'; +import { __ } from '../i18n'; +import { _converse, converse, api } from '@converse/headless/core'; +import { html } from 'lit-html'; + +const { Strophe, sizzle, u } = converse.env; + +const TAG_LENGTH = 128; +const KEY_ALGO = { + 'name': 'AES-GCM', + 'length': 128 +}; + +const omemo = (converse.env.omemo = { + async 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)), + key = await crypto.subtle.generateKey(KEY_ALGO, true, ['encrypt', 'decrypt']), + algo = { + 'name': 'AES-GCM', + 'iv': iv, + 'tagLength': TAG_LENGTH + }, + encrypted = await crypto.subtle.encrypt(algo, key, u.stringToArrayBuffer(plaintext)), + length = encrypted.byteLength - ((128 + 7) >> 3), + ciphertext = encrypted.slice(0, length), + tag = encrypted.slice(length), + exported_key = await crypto.subtle.exportKey('raw', key); + + return { + 'key': exported_key, + 'tag': tag, + 'key_and_tag': u.appendArrayBuffer(exported_key, tag), + 'payload': u.arrayBufferToBase64(ciphertext), + 'iv': u.arrayBufferToBase64(iv) + }; + }, + + async decryptMessage (obj) { + const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt', 'decrypt']); + const cipher = u.appendArrayBuffer(u.base64ToArrayBuffer(obj.payload), obj.tag); + const algo = { + 'name': 'AES-GCM', + 'iv': u.base64ToArrayBuffer(obj.iv), + 'tagLength': TAG_LENGTH + }; + return u.arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher)); + } +}); + +export function parseEncryptedMessage (stanza, attrs) { + if (attrs.is_encrypted && attrs.encrypted.key) { + // https://xmpp.org/extensions/xep-0384.html#usecases-receiving + if (attrs.encrypted.prekey === true) { + return decryptPrekeyWhisperMessage(attrs); + } else { + return decryptWhisperMessage(attrs); + } + } else { + return attrs; + } +} + +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); +} + +async function handleDecryptedWhisperMessage (attrs, key_and_tag) { + const encrypted = attrs.encrypted; + const devicelist = _converse.devicelists.getDeviceList(attrs.from); + await devicelist._devices_promise; + + let device = devicelist.get(encrypted.device_id); + if (!device) { + device = await devicelist.devices.create({ 'id': encrypted.device_id, 'jid': attrs.from }, { 'promise': true }); + } + 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) { + if (api.settings.get('loglevel') === 'debug') { + return { + 'error_text': + __('Sorry, could not decrypt a received OMEMO message due to an error.') + ` ${e.name} ${e.message}`, + 'error_type': 'Decryption', + 'is_ephemeral': true, + 'is_error': true, + 'type': 'error' + }; + } else { + return {}; + } +} + +async function decryptPrekeyWhisperMessage (attrs) { + const session_cipher = getSessionCipher(attrs.from, parseInt(attrs.encrypted.device_id, 10)); + const key = u.base64ToArrayBuffer(attrs.encrypted.key); + 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) { + const from_jid = attrs.from_muc ? attrs.from_real_jid : attrs.from; + if (!from_jid) { + Object.assign(attrs, { + 'error_text': __("Sorry, could not decrypt a received OMEMO because we don't have the JID for that user."), + 'error_type': 'Decryption', + 'is_ephemeral': false, + 'is_error': true, + 'type': 'error' + }); + } + const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10)); + const key = u.base64ToArrayBuffer(attrs.encrypted.key); + 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) { + stanza + .c('iv') + .t(iv) + .up() + .up(); + } + } + } + return Promise.resolve(stanza); +} + +function parseBundle (bundle_el) { + /* Given an XML element representing a user's OMEMO bundle, parse it + * and return a map. + */ + 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(); + bundle['fingerprint'] = u.arrayBufferToHex(u.base64ToArrayBuffer(bundle['identity_key'])); + 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'); + const devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({ 'jid': jid }); + await devicelist.fetchDevices(); + return devicelist.devices; +} + +export function generateDeviceID () { + /* Generates a device ID, making sure that it's unique */ + const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id'); + 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) { + // TODO: check device-get('jid') versus the 'from' attribute which is used + // to build a session when receiving an encrypted message in a MUC. + // https://github.com/conversejs/converse.js/issues/1481#issuecomment-509183431 + 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), + 'identityKey': u.base64ToArrayBuffer(bundle.identity_key), + 'signedPreKey': { + 'keyId': bundle.signed_prekey.id, // + 'publicKey': u.base64ToArrayBuffer(bundle.signed_prekey.public_key), + 'signature': u.base64ToArrayBuffer(bundle.signed_prekey.signature) + }, + 'preKey': { + 'keyId': prekey.id, // + 'publicKey': u.base64ToArrayBuffer(prekey.key) + } + }); +} + +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; + } + } +} + +function updateBundleFromStanza (stanza) { + 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(); + const devicelist = _converse.devicelists.getDeviceList(jid); + const device = devicelist.devices.get(device_id) || devicelist.devices.create({ 'id': device_id, 'jid': jid }); + device.save({ 'bundle': parseBundle(bundle_el) }); +} + +function updateDevicesFromStanza (stanza) { + 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'); + const devicelist = _converse.devicelists.getDeviceList(jid); + 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( + message => { + try { + if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) { + updateDevicesFromStanza(message); + updateBundleFromStanza(message); + } + } catch (e) { + log.error(e.message); + } + return true; + }, + null, + 'message', + 'headline' + ); +} + +export function restoreOMEMOSession () { + if (_converse.omemo_store === undefined) { + const id = `converse.omemosession-${_converse.bare_jid}`; + _converse.omemo_store = new _converse.OMEMOStore({ 'id': id }); + _converse.omemo_store.browserStorage = _converse.createStore(id); + } + return _converse.omemo_store.fetchSession(); +} + +function fetchDeviceLists () { + return new Promise((success, error) => _converse.devicelists.fetch({ success, 'error': (m, e) => error(e) })); +} + +async function fetchOwnDevices () { + await fetchDeviceLists(); + let own_devicelist = _converse.devicelists.get(_converse.bare_jid); + if (own_devicelist) { + own_devicelist.fetchDevices(); + } else { + own_devicelist = await _converse.devicelists.create({ 'jid': _converse.bare_jid }, { 'promise': true }); + } + return own_devicelist._devices_promise; +} + +export async function initOMEMO () { + 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; + } + _converse.devicelists = new _converse.DeviceLists(); + const id = `converse.devicelists-${_converse.bare_jid}`; + _converse.devicelists.browserStorage = _converse.createStore(id); + + try { + await fetchOwnDevices(); + 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 + * @example _converse.api.listen.on('OMEMOInitialized', () => { ... }); */ + 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; + if (is_muc && model.get('omemo_supported')) { + 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; + } else { + title = __( + 'This groupchat needs to be members-only and non-anonymous in ' + + 'order to support OMEMO encrypted messages' + ); + } + + buttons.push(html` + + `); + return buttons; +}