UNPKG

@converse/headless

Version:

Converse.js Headless build

1,211 lines (1,115 loc) 50.6 kB
import sizzle from 'sizzle'; import log from '@converse/log'; import { Model } from '@converse/skeletor'; import converse from '../../shared/api/public.js'; import _converse from '../../shared/_converse.js'; import { constants, errors } from '../../shared/index.js'; import { initStorage } from '../../utils/storage.js'; import api from '../../shared/api/index.js'; import MUC from '../../plugins/muc/muc.js'; import { getCrypto } from './crypto.js'; import { KEY_ALGO, TAG_LENGTH, UNTRUSTED } from './constants.js'; import DeviceLists from './devicelists.js'; import { VersionedOMEMOStore } from './versioned-store.js'; import { encryptSCE, decryptSCE } from './sce.js'; const { u, Strophe, stx, Stanza } = converse.env; const { arrayBufferToHex, base64ToArrayBuffer } = u; // Affiliations whose members are valid recipients of OMEMO-encrypted MUC // messages (XEP-0384 §MUC). Excludes `outcast` (banned) and `none`. const OMEMO_MUC_AFFILIATIONS = ['member', 'admin', 'owner']; /** * Returns a VersionedOMEMOStore proxy for the given OMEMO version. * * The proxy implements the subset of libomemo's `OMEMOStore` interface that * `SessionCipher` and `SessionBuilder` actually exercise at runtime (the * crypto/session methods); the interface's raw key-value members * (`store`/`put`/`get`/`remove`) are part of the reference `InMemoryStore` and * are never called on a consumer store, so we present the proxy as an * `OMEMOStore` here. * @param {import('./types').OMEMOVersion} version * @returns {import('libomemo.js').OMEMOStore} */ export function getVersionedStore(version) { return /** @type {import('libomemo.js').OMEMOStore} */ ( /** @type {unknown} */ (new VersionedOMEMOStore(_converse.state.omemo_store, version)) ); } /** * @param {Element} stanza */ async function updateDevicesFromStanza(stanza) { // Detect which version's devicelist was pushed let items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop(); let version = Strophe.NS.OMEMO; if (!items_el) { items_el = sizzle(`items[node="${Strophe.NS.OMEMO2_DEVICELIST}"]`, stanza).pop(); if (!items_el) return; version = Strophe.NS.OMEMO2; } let device_ids; if (version === Strophe.NS.OMEMO2) { const sel = `item devices[xmlns="${Strophe.NS.OMEMO2}"] device`; device_ids = sizzle(sel, items_el).map((d) => d.getAttribute('id')); } else { const sel = `item list[xmlns="${Strophe.NS.OMEMO}"] device`; device_ids = sizzle(sel, items_el).map((d) => d.getAttribute('id')); } const jid = stanza.getAttribute('from'); const devicelist = await api.omemo.devicelists.get(jid, true, version); const devices = devicelist.devices; const removed_ids = devices.pluck('id').filter(/** @param {string} id */ (id) => !device_ids.includes(id)); const bare_jid = _converse.session.get('bare_jid'); removed_ids.forEach( /** @param {string} id */ (id) => { if (jid === bare_jid && id === _converse.state.omemo_store.get('device_id')) { return; // We don't set the current device as inactive } devices.get(id).save('active', false); }, ); device_ids.forEach( /** @param {string} device_id */ (device_id) => { const device = devices.get(device_id); if (device) { device.save('active', true); } else { devices.create({ id: device_id, jid }); } }, ); if (u.isSameBareJID(bare_jid, jid)) { // Make sure our own device is on the list // (i.e. if it was removed, add it again). devicelist.publishCurrentDevice(device_ids); } } /** * @param {Element} stanza */ async function updateBundleFromStanza(stanza) { const items_el = sizzle(`items`, stanza).pop(); if (!items_el) return; const node = items_el.getAttribute('node'); const item_el = sizzle(`item`, items_el).pop(); let version, device_id; if (node === Strophe.NS.OMEMO2_BUNDLES) { // omemo:2: a single bundles node; the device id is the item id. version = Strophe.NS.OMEMO2; device_id = item_el?.getAttribute('id'); } else if (node.startsWith(Strophe.NS.OMEMO_BUNDLES + ':')) { // legacy 0.3.0: a per-device node, device id is the node suffix. version = Strophe.NS.OMEMO; device_id = node.slice(Strophe.NS.OMEMO_BUNDLES.length + 1); } else { return; } if (!device_id) return; const jid = stanza.getAttribute('from'); const bundle_el = sizzle(`bundle`, item_el).pop(); const devicelist = await api.omemo.devicelists.get(jid, true, version); const device = devicelist.devices.get(device_id) || devicelist.devices.create({ 'id': device_id, jid }); const bundle = version === Strophe.NS.OMEMO2 ? u.omemo.parseBundleV2(bundle_el) : u.omemo.parseBundle(bundle_el); // A push without a parseable <bundle> (malformed or emptied item) yields // null; don't overwrite a previously cached good bundle with it. if (bundle) device.save({ bundle }); } /** * @param {Element} message */ async function handlePEPPush(message) { try { if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) { await api.waitUntil('OMEMOInitialized'); await updateDevicesFromStanza(message); await updateBundleFromStanza(message); } } catch (e) { log.error(e); } } /** * Register a pubsub handler for devices pushed from other connected clients */ export function registerPEPPushHandler() { api.connection.get().addHandler( /** @param {Element} message */ (message) => { handlePEPPush(message); return true; }, null, 'message', ); } async function fetchDeviceLists() { const bare_jid = _converse.session.get('bare_jid'); _converse.state.devicelists = new DeviceLists(); const id = `converse.devicelists-${bare_jid}`; initStorage(_converse.state.devicelists, id); await new Promise((resolve) => { _converse.state.devicelists.fetch({ success: resolve, error: (_m, e) => { log.error(e); resolve(); }, }); }); _converse.state.devicelists_v2 = new DeviceLists(); const id_v2 = `converse.devicelists-v2-${bare_jid}`; initStorage(_converse.state.devicelists_v2, id_v2); await new Promise((resolve) => { _converse.state.devicelists_v2.fetch({ success: resolve, error: (_m, e) => { log.error(e); resolve(); }, }); }); // Ensure our own legacy device list exists (creates + publishes if needed). // This is awaited (unlike the v2 fetch below) because OMEMO initialization // depends on it: by the time `session.restore` runs and `OMEMOInitialized` // fires, our own device must already be present in (and published to) the // legacy device list, otherwise consumers race against `publishCurrentDevice`. await api.omemo.devicelists.get(bare_jid, true); // Start v2 device list initialization without blocking legacy OMEMO setup. // The v2 PEP fetch can take time and failing it (e.g. on servers that don't // support omemo:2) must not prevent the legacy path from working. api.omemo.devicelists.get(bare_jid, true, Strophe.NS.OMEMO2).catch((e) => log.error(e)); } /** * Loads (and caches on `_converse.state`) the persistent store that remembers * whether OMEMO encryption was last enabled or disabled for a given chat. * * Unlike the chatbox itself (which is removed from storage when the chat is * closed), this store persists across chat-close and re-login. * @returns {Promise<Model<{id: string}>>} */ async function fetchOMEMOActiveStates() { if (_converse.state.omemo_active_states) { return _converse.state.omemo_active_states; } const bare_jid = _converse.session.get('bare_jid'); const id = `converse.omemo-active-states-${bare_jid}`; const model = new Model({ id }); initStorage(model, id); await new Promise((resolve) => model.fetch({ success: resolve, error: resolve })); _converse.state.omemo_active_states = model; return model; } /** * Returns the remembered OMEMO active state for a chat, or `undefined` if the * user has never made an explicit choice for it. * @param {string} jid * @returns {boolean|undefined} */ export function getOMEMOActiveState(jid) { return /** @type {boolean|undefined} */ (_converse.state.omemo_active_states?.get(jid)); } /** * Persists the user's explicit choice to enable/disable OMEMO for a chat, so * that it's remembered the next time the chat is opened. * @param {string} jid * @param {boolean} active */ export function setOMEMOActiveState(jid, active) { _converse.state.omemo_active_states?.save({ [jid]: active }); } /** * @param {boolean} reconnecting */ export async function initOMEMO(reconnecting) { if (reconnecting) { return; } if (!_converse.state.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 { await fetchDeviceLists(); await fetchOMEMOActiveStates(); await api.omemo.session.restore(); await _converse.state.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'); } /** * @param {String} jid - The Jabber ID for which the device list will be returned. * @param {boolean} [create=false] - Set to `true` if the device list should be * created if it cannot be found. * @param {import('./types').OMEMOVersion} [version] - Defaults to legacy version. */ export async function getDeviceList(jid, create = false, version = Strophe.NS.OMEMO) { const collection = version === Strophe.NS.OMEMO2 ? _converse.state.devicelists_v2 : _converse.state.devicelists; const list = collection.get(jid) || (create ? collection.create({ jid, version }) : null); await list?.initialized; return list; } /** * @param {import('./device.js').default} device */ export async function generateFingerprint(device) { if (device.get('bundle')?.fingerprint) { return; } const bundle = await device.getBundle(); const raw = base64ToArrayBuffer(bundle['identity_key']); // For legacy (33-byte Curve25519), strip the leading 0x05 encoding byte // so the display fingerprint is always the 64-char (32-byte) key hex. // For v2 (32-byte Ed25519), the key has no leading byte. const fp_buf = device.isV2 && device.isV2() ? raw : raw.slice(1); bundle['fingerprint'] = arrayBufferToHex(fp_buf); device.save('bundle', bundle); device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference } /** * Surface a user-facing alert for a failed encrypted send and re-throw, so the * send pipeline (the `createMessageStanza` handler) aborts. Always throws. * @param {Error|errors.IQError|errors.UserFacingError} e * @param {import('../../shared/chatbox.js').default} chat * @returns {never} */ export function handleMessageSendError(e, chat) { const { __ } = _converse; if (e instanceof errors.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 instanceof errors.UserFacingError) { api.alert('error', __('Error'), [e.message]); } throw e; } /** * Returns the device collection for a contact and OMEMO version. * Doesn't throw on any failure, instead logs and returns an empty collection. * @param {string} jid * @param {import('./types').OMEMOVersion} [version] * @returns {Promise<import('./devices.js').default>} */ export async function getDevicesForContact(jid, version = Strophe.NS.OMEMO) { await api.waitUntil('OMEMOInitialized'); try { const devicelist = await api.omemo.devicelists.get(jid, true, version); await devicelist.fetchDevices(); // Only force a re-fetch when the previous attempt actually failed (timeout or error). if (devicelist.devices.length === 0 && devicelist.lastFetchFailed) { await devicelist.fetchDevices(true); } return devicelist.devices; } catch (e) { log.error(e); return new _converse.exports.Devices(null, { version }); } } /** * @param {string} jid * @param {number} id * @param {import('./types').OMEMOVersion} [version] * @returns {Promise<import('libomemo.js').SessionCipher>} */ export async function getSessionCipher(jid, id, version = Strophe.NS.OMEMO) { const { OMEMOAddress, SessionCipher } = await getCrypto(); const address = new OMEMOAddress(jid, id); const store = getVersionedStore(version); return new SessionCipher(store, address, version); } /** * @param {ArrayBuffer} key_and_tag * @param {import('./device').default} device * @param {import('./types').OMEMOVersion} [version] */ async function encryptKey(key_and_tag, device, version = Strophe.NS.OMEMO) { const session_cipher = await getSessionCipher(device.get('jid'), Number(device.get('id')), version); const payload = await session_cipher.encrypt(key_and_tag); return { payload, device }; } /** * @param {import('./device').default} device * @param {import('./types').OMEMOVersion} [version] */ async function buildSession(device, version = Strophe.NS.OMEMO) { const { OMEMOAddress, SessionBuilder } = await getCrypto(); const address = new OMEMOAddress(device.get('jid'), device.get('id')); const store = getVersionedStore(version); const sessionBuilder = new SessionBuilder(store, address, version); const prekey = device.getRandomPreKey(); const bundle = await device.getBundle(); const device_id = device.get('id'); return sessionBuilder.processPreKey({ registrationId: typeof device_id === 'string' ? parseInt(device_id, 10) : device_id, identityKey: base64ToArrayBuffer(bundle.identity_key), signedPreKey: { keyId: bundle.signed_prekey.id, // <Number> publicKey: base64ToArrayBuffer(bundle.signed_prekey.public_key), signature: base64ToArrayBuffer(bundle.signed_prekey.signature), }, preKey: { keyId: prekey.id, // <Number> publicKey: base64ToArrayBuffer(prekey.key), }, }); } /** * @param {import('./device').default} device * @param {import('./types').OMEMOVersion} [version] */ export async function getSession(device, version = Strophe.NS.OMEMO) { 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 { OMEMOAddress } = await getCrypto(); const address = new OMEMOAddress(device.get('jid'), device.get('id')); const store = getVersionedStore(version); const session = await store.loadSession(address.toString()); if (session) { return session; } else { try { return await buildSession(device, version); } catch (e) { log.error(`Could not build an OMEMO session for device ${device.get('id')}`); log.error(e); return null; } } } /** * OMEMO in a MUC requires that real JIDs are visible (non-anonymous) and that * the membership is restricted (members-only). This is the single source of * truth for that rule. * @param {MUC} chatroom */ function isOMEMOMUCEligible(chatroom) { return !!(chatroom.features.get('nonanonymous') && chatroom.features.get('membersonly')); } /** * A bundle-fetch error is "actionable" when it tells the user something they * can fix and applies to the whole contact/server rather than a single stale * device: presence-subscription-required or remote-server-not-found. These must * abort the send and be surfaced; any other (benign) per-device failure is * dropped silently so it only loses that one device. * @param {unknown} e * @returns {boolean} */ function isActionableBundleError(e) { return ( e instanceof errors.IQError && sizzle( `presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"], ` + `remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq, ).length > 0 ); } /** * Collects the set of recipient devices for a given chatbox, split by OMEMO * version. Deduplicates across versions (by device id) so each physical * device is addressed exactly once. V2 is preferred when both versions are present. * @param {import('../../shared/chatbox.js').default} chatbox * @returns {Promise<{legacy: import('./device.js').default[], v2: import('./device.js').default[]}>} */ async function getBundlesAndBuildSessions(chatbox) { const { __ } = _converse; const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.'); let legacy_devices = []; let v2_devices = []; if (chatbox instanceof MUC) { // Defense-in-depth: OMEMO activation is already gated on this upstream // (checkOMEMOSupported), but a room can flip to anonymous mid-session. if (!isOMEMOMUCEligible(chatbox)) { throw new errors.UserFacingError( __('Cannot use OMEMO in this groupchat — it must be non-anonymous and members-only.'), ); } // Restrict recipients to the member/admin/owner affiliation lists, per // XEP-0384 §MUC: the real JIDs of those three lists are the union of // OMEMO recipients. This excludes `outcast` (banned) and `none` // occupants, so encrypted key material is never addressed to a user who // has been removed from the room. // // Our own devices are included here via the self-occupant (it carries // our real JID and an eligible affiliation in a non-anonymous, // members-only room); the our_id filter below then drops the sending // device, exactly as the 1:1 branch does. const occupants = chatbox.occupants.filter( /** @param {import('../../plugins/muc/occupant').default} o */ (o) => o.get('jid') && OMEMO_MUC_AFFILIATIONS.includes(o.get('affiliation')), ); const [legacy_cols, v2_cols] = await Promise.all([ Promise.all(occupants.map((o) => getDevicesForContact(o.get('jid'), Strophe.NS.OMEMO))), Promise.all(occupants.map((o) => getDevicesForContact(o.get('jid'), Strophe.NS.OMEMO2))), ]); legacy_devices = legacy_cols.flatMap((c) => c.models); v2_devices = v2_cols.flatMap((c) => c.models); } else if (chatbox.get('type') === constants.PRIVATE_CHAT_TYPE) { const contact_jid = chatbox.get('jid'); const bare_jid = _converse.session.get('bare_jid'); // Fetch the contact's devices for both versions in parallel (same // server, different PEP node), so v2 activates deterministically rather // than only when the contact's v2 devicelist happens to already be // cached (e.g. from a PEP push). An empty v2 list just means the contact // doesn't support omemo:2, in which case we fall back to legacy. const [their_legacy, their_v2] = await Promise.all([ getDevicesForContact(contact_jid, Strophe.NS.OMEMO), getDevicesForContact(contact_jid, Strophe.NS.OMEMO2), ]); // Our own device lists are populated at init, so a cached lookup suffices. const [own_legacy_list, own_v2_list] = await Promise.all([ api.omemo.devicelists.get(bare_jid, false, Strophe.NS.OMEMO), api.omemo.devicelists.get(bare_jid, false, Strophe.NS.OMEMO2), ]); if (their_legacy.length === 0 && their_v2.length === 0) { throw new errors.UserFacingError(no_devices_err); } legacy_devices = [...(own_legacy_list?.devices.models ?? []), ...their_legacy.models]; v2_devices = [...(own_v2_list?.devices.models ?? []), ...their_v2.models]; } // A device's identity for routing is the (bare JID, id) pair: device ids are // unique only per user, so the JID is needed to disambiguate. /** @param {string} jid @param {string} id */ const deviceKey = (jid, id) => `${Strophe.getBareJidFromJid(jid).toLowerCase()}/${id}`; // Exclude our own sending device (we encrypt to our other devices, never the // one composing the message). const our_id = _converse.state.omemo_store.get('device_id'); const our_key = deviceKey(_converse.session.get('bare_jid'), our_id); legacy_devices = legacy_devices.filter((d) => deviceKey(d.get('jid'), d.get('id')) !== our_key); v2_devices = v2_devices.filter((d) => deviceKey(d.get('jid'), d.get('id')) !== our_key); // Deduplicate across versions: a device that supports both is addressed via // omemo:2 only (preferred), so we drop it from the legacy list here and never // fetch its legacy bundle. const v2_keys = new Set(v2_devices.map((d) => deviceKey(d.get('jid'), d.get('id')))); legacy_devices = legacy_devices.filter((d) => !v2_keys.has(deviceKey(d.get('jid'), d.get('id')))); // Fetch bundles for the remaining devices. A benign single-device failure // (e.g. a stale/orphaned device with no published bundle) must only drop // that one device, not abort the whole send — so fetchBundle swallows it and // returns null. A genuinely actionable, contact-wide error // (presence-subscription-required, remote-server-not-found) is rethrown // instead: it aborts the send and propagates to the user, even when our own // other devices are still reachable (encrypting only to ourselves would // silently leave the intended recipient unable to read the message). const fetchBundle = async (d) => { try { await d.getBundle(); return d; } catch (e) { if (isActionableBundleError(e)) throw e; log.error(`Skipping device ${d.get('id')} of ${d.get('jid')}: could not fetch its OMEMO bundle`); log.error(e); return null; } }; const [legacy_fetched, v2_fetched] = await Promise.all([ Promise.all(legacy_devices.map(fetchBundle)), Promise.all(v2_devices.map(fetchBundle)), ]); legacy_devices = legacy_devices.filter((_d, i) => legacy_fetched[i] !== null); v2_devices = v2_devices.filter((_d, i) => v2_fetched[i] !== null); // Build sessions, dropping devices where session establishment fails. const [legacy_sessions, v2_sessions] = await Promise.all([ Promise.all(legacy_devices.map((d) => getSession(d, Strophe.NS.OMEMO))), Promise.all(v2_devices.map((d) => getSession(d, Strophe.NS.OMEMO2))), ]); legacy_devices = legacy_devices.filter((_d, i) => legacy_sessions[i] !== null); v2_devices = v2_devices.filter((_d, i) => v2_sessions[i] !== null); if (legacy_devices.length === 0 && v2_devices.length === 0) { // Nothing reachable and no actionable server error to explain why. throw new errors.UserFacingError(no_devices_err); } return { legacy: legacy_devices, v2: v2_devices }; } /** * @param {string} plaintext * @returns {Promise<import('./types').EncryptedMessage>} */ 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 = /** @type {AesGcmParams} */ { iv, name: 'AES-GCM', tagLength: TAG_LENGTH, }; const encrypted = await crypto.subtle.encrypt(algo, key, u.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 { tag, key: exported_key, key_and_tag: u.appendArrayBuffer(exported_key, tag), payload: u.arrayBufferToBase64(ciphertext), iv: u.arrayBufferToBase64(iv), }; } /** * @param {import('./types').EncryptedMessage} obj * @returns {Promise<string>} */ export async function 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 = /** @type {AesGcmParams} */ { name: 'AES-GCM', iv: u.base64ToArrayBuffer(obj.iv), tagLength: TAG_LENGTH, }; return u.arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher)); } /** * Groups an array of {payload, device} dicts by the device's bare JID. * Used to produce <keys jid="..."> groupings in the v2 <encrypted> element. * @param {Array<{payload: import('libomemo.js').EncryptResult, device: import('./device.js').default}>} dicts * @returns {Map<string, Array<{payload: import('libomemo.js').EncryptResult, device: import('./device.js').default}>>} */ function groupByJID(dicts) { const map = new Map(); for (const entry of dicts) { const jid = entry.device.get('jid'); if (!map.has(jid)) map.set(jid, []); map.get(jid).push(entry); } return map; } /** * Encrypt the given key material with the long-standing OMEMO session of each * eligible (trusted + active) recipient device. * @param {ArrayBuffer} key_and_tag * @param {import('./device').default[]} devices * @param {import('./types').OMEMOVersion} version */ function encryptKeyForDevices(key_and_tag, devices, version) { return Promise.all( devices .filter((d) => d.get('trusted') != UNTRUSTED && d.get('active')) .map((d) => encryptKey(key_and_tag, d, version)), ); } /** * Build a legacy (eu.siacs.conversations.axolotl) `<encrypted>` element from * the per-device encrypted keys. When `payload` is null the result is a * KeyTransportElement (the `<payload>` is omitted), as used for heartbeats. * @param {Array<{payload: import('libomemo.js').EncryptResult, device: import('./device.js').default}>} legacy_dicts * @param {string} iv * @param {string|null} payload */ function buildLegacyEncryptedElement(legacy_dicts, iv, payload) { const sid = _converse.state.omemo_store.get('device_id'); // 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. return stx`<encrypted xmlns="${Strophe.NS.OMEMO}"> <header sid="${sid}"> ${legacy_dicts.map(({ payload: p, device }) => { const prekey = 3 == p.type; if (prekey) { return stx`<key rid="${device.get('id')}" prekey="true">${btoa(p.body)}</key>`; } return stx`<key rid="${device.get('id')}">${btoa(p.body)}</key>`; })} <iv>${iv}</iv> </header> ${payload ? stx`<payload>${payload}</payload>` : ''} </encrypted>`; } /** * Build an OMEMO:2 (urn:xmpp:omemo:2) `<encrypted>` element from the per-device * encrypted keys. When `payload` is null the `<payload>` is omitted, yielding * an empty OMEMO message (used for heartbeats). * @param {Array<{payload: import('libomemo.js').EncryptResult, device: import('./device.js').default}>} v2_dicts * @param {string|null} payload */ function buildOMEMO2EncryptedElement(v2_dicts, payload) { // Group keys by recipient JID for the v2 <keys jid='...'> structure const by_jid = groupByJID(v2_dicts); const keys_elements = []; for (const [jid, entries] of by_jid) { const key_els = entries.map(({ payload: p, device }) => { if (p.kex) { return stx`<key rid="${device.get('id')}" kex="true">${btoa(p.body)}</key>`; } return stx`<key rid="${device.get('id')}">${btoa(p.body)}</key>`; }); keys_elements.push(stx`<keys jid="${jid}">${key_els}</keys>`); } const sid = _converse.state.omemo_store.get('device_id'); return stx`<encrypted xmlns="${Strophe.NS.OMEMO2}"> <header sid="${sid}">${keys_elements}</header> ${payload ? stx`<payload>${payload}</payload>` : ''} </encrypted>`; } /** * @param {string} plaintext - the message body to encrypt (the legacy payload) * @param {import('./device').default[]} devices */ async function getLegacyEncryptedElement(plaintext, devices) { const { key_and_tag, iv, payload } = await encryptMessage(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 OMEMO session. const legacy_dicts = await encryptKeyForDevices(key_and_tag, devices, Strophe.NS.OMEMO); return buildLegacyEncryptedElement(legacy_dicts, iv, payload); } /** * Build the metadata elements that go inside the OMEMO 2 SCE `<content>` envelope. * * These mirror the cleartext builders in `createMessageStanza` * ({@link module:headless-shared-model-with-messages}), but for encrypted * messages they live encrypted inside `<content>` instead of in cleartext. * Legacy OMEMO 1 recipients don't get them (its payload is a string, not an element) * and degrade gracefully. * @param {import('../../shared/message').default} message * @returns {import('strophe.js').Builder[]} */ function getSCEExtensions(message) { const { oob_url, is_spoiler, spoiler_hint, references, reply_to_id, reply_to } = message.attributes; // The `<active/>` chat state that `createMessageStanza` would otherwise add // in cleartext is carried here instead (it's gated out of the cleartext // stanza for encrypted messages), so it still clears the recipient's typing // indicator without leaking activity metadata to the server. const extensions = [stx`<active xmlns="${Strophe.NS.CHATSTATES}"/>`]; if (oob_url) { extensions.push(stx`<x xmlns="${Strophe.NS.OUTOFBAND}"><url>${oob_url}</url></x>`); } if (is_spoiler) { extensions.push(stx`<spoiler xmlns="${Strophe.NS.SPOILER}">${spoiler_hint ?? ''}</spoiler>`); } references?.forEach((ref) => { extensions.push(stx`<reference xmlns="${Strophe.NS.REFERENCE}" begin="${ref.begin}" end="${ref.end}" type="${ref.type}" uri="${ref.uri}"></reference>`); }); if (reply_to_id) { extensions.push(stx`<reply xmlns="${Strophe.NS.REPLY}" id="${reply_to_id}" to="${reply_to || ''}"></reply>`); const reply_fallback = message.get('fallback')?.[Strophe.NS.REPLY]; if (reply_fallback) { extensions.push( stx`<fallback xmlns="${Strophe.NS.FALLBACK}" for="${Strophe.NS.REPLY}"> <body start="${reply_fallback.start}" end="${reply_fallback.end}"/> </fallback>`, ); } } return extensions; } /** * @param {import('../../shared/chatbox').default} chat * @param {string|null} plaintext - the SCE `<body>` (omitted when falsy, e.g. a metadata-only message) * @param {import('strophe.js').Builder[]} extensions - extra elements for the SCE `<content>` * @param {import('./device').default[]} devices */ async function getOMEMO2EncryptedElement(chat, plaintext, extensions, devices) { const is_muc = chat instanceof MUC; const muc_jid = is_muc ? chat.get('jid') : null; const bare_jid = _converse.session.get('bare_jid'); // Build SCE envelope and encrypt it. Body-coupled metadata // (references/reply/oob/spoiler/reactions) is encrypted inside <content>. const { key_and_tag, payload } = await encryptSCE(plaintext, { from_jid: bare_jid, to_jid: muc_jid }, extensions); const v2_dicts = await encryptKeyForDevices(key_and_tag, devices, Strophe.NS.OMEMO2); return buildOMEMO2EncryptedElement(v2_dicts, payload); } /** * Build a legacy heartbeat (KeyTransportElement): a fresh key/IV pair with no * `<payload>` (XEP-0384 0.3.0 §Sending a key). Encrypting an empty plaintext * with AES-GCM yields a valid 16-byte authentication tag and empty ciphertext, * so we reuse {@link encryptMessage} and simply drop the payload. * @param {import('./device').default[]} devices */ async function getLegacyHeartbeatElement(devices) { const { key_and_tag, iv } = await encryptMessage(''); const legacy_dicts = await encryptKeyForDevices(key_and_tag, devices, Strophe.NS.OMEMO); return buildLegacyEncryptedElement(legacy_dicts, iv, null); } /** * Build an OMEMO:2 heartbeat (empty OMEMO message): per XEP-0384, 32 zero-bytes * are encrypted directly with the Double Ratchet session for each device and the * `<payload>` is omitted altogether. * @param {import('./device').default[]} devices */ async function getOMEMO2HeartbeatElement(devices) { const zero_bytes = new ArrayBuffer(32); const v2_dicts = await encryptKeyForDevices(zero_bytes, devices, Strophe.NS.OMEMO2); return buildOMEMO2EncryptedElement(v2_dicts, null); } /** * Send an OMEMO heartbeat (an empty/payload-less OMEMO message) to `chat` for * the given protocol version. Heartbeats forward the Double Ratchet so a peer's * message counter restarts at 0; see the XEP-0384 "counter of 53 or higher" * rule. The message carries no `<body>`, so it produces no visible/stored chat * message. We reuse the normal send-path session setup so every (trusted, * active) device gets the heartbeat and any missing sessions are (re)built. * @param {import('../../shared/chatbox.js').default} chat * @param {import('./types').OMEMOVersion} version */ export async function sendOMEMOHeartbeat(chat, version) { const is_v2 = version === Strophe.NS.OMEMO2; const { legacy, v2 } = await getBundlesAndBuildSessions(chat); const devices = is_v2 ? v2 : legacy; if (!devices.length) return; const encrypted_el = is_v2 ? await getOMEMO2HeartbeatElement(devices) : await getLegacyHeartbeatElement(devices); const is_muc = chat instanceof MUC; const stanza = stx`<message xmlns="jabber:client" from="${_converse.session.get('jid')}" to="${chat.get('jid')}" type="${is_muc ? 'groupchat' : 'chat'}" id="${u.getUniqueId()}"> ${encrypted_el} <encryption xmlns="${Strophe.NS.EME}" namespace="${is_v2 ? Strophe.NS.OMEMO2 : Strophe.NS.OMEMO}"/> <store xmlns="${Strophe.NS.HINTS}"/> </message>`; api.send(stanza); } /** * Send a XEP-0085 chat state notification for an OMEMO-active chat. * * Called from the `change:chat_state` listener added by {@link onChatInitialized}. * The cleartext path in `sendChatState` / `MUC#sendChatState` is suppressed * when `omemo_active`, so this function is solely responsible for the outgoing * notification in encrypted sessions. * * - OMEMO:2 devices present → encrypted SCE stanza with `<no-store>` hints. * - Legacy-only session (no v2 devices) → cleartext stanza, matching what * other clients do for legacy OMEMO. * - Mixed session (both v2 and legacy devices) → encrypted only; legacy * devices in a mixed session do not receive a CSN. * * @param {import('../../shared/chatbox.js').default} chatbox */ async function sendOMEMO2ChatState(chatbox) { if (!chatbox.get('omemo_active')) return; const chat_state = chatbox.get('chat_state'); if (!chat_state) return; const allowed = api.settings.get('send_chat_state_notifications'); if (!allowed || (Array.isArray(allowed) && !allowed.includes(chat_state))) { return; } const is_muc = chatbox instanceof MUC; if (is_muc) { // Mirror the guards in MUC#sendChatState if (!(/** @type {MUC} */ (chatbox).isEntered())) return; if ( /** @type {MUC} */ (chatbox).features?.get('moderated') && /** @type {MUC} */ (chatbox).getOwnRole() === 'visitor' ) return; if (chat_state === 'gone') return; // <gone/> not applicable in MUC } const jid = chatbox.get('jid'); const type = is_muc ? 'groupchat' : 'chat'; const { v2, legacy } = await getBundlesAndBuildSessions(chatbox); if (v2.length) { const el = await getOMEMO2EncryptedElement( chatbox, null, [stx`<${Stanza.unsafeXML(chat_state)} xmlns="${Strophe.NS.CHATSTATES}"/>`], v2, ); api.send( stx`<message xmlns="jabber:client" from="${_converse.session.get('jid')}" to="${jid}" type="${type}" id="${u.getUniqueId()}"> ${el} <encryption xmlns="${Strophe.NS.EME}" namespace="${Strophe.NS.OMEMO2}"/> <no-store xmlns="${Strophe.NS.HINTS}"/> <no-permanent-store xmlns="${Strophe.NS.HINTS}"/> </message>`, ); } else if (legacy.length) { // Legacy-only session: send cleartext, same as non-OMEMO clients do. api.send( stx`<message to="${jid}" type="${type}" xmlns="jabber:client"> <${Stanza.unsafeXML(chat_state)} xmlns="${Strophe.NS.CHATSTATES}"/> <no-store xmlns="${Strophe.NS.HINTS}"/> <no-permanent-store xmlns="${Strophe.NS.HINTS}"/> </message>`, ); } } /** * Send a XEP-0333 chat marker for an OMEMO-active chat. * * Called from {@link sendMarker} in `shared/actions.js` when `omemo_active` is * set. The cleartext path is suppressed in that case, so this function is solely * responsible for the outgoing marker in encrypted sessions. * * - OMEMO:2 devices present → encrypted SCE stanza. * - Legacy-only session (no v2 devices) → cleartext marker, matching what * other clients do for legacy OMEMO. * - Mixed session (both v2 and legacy devices) → encrypted only; legacy * devices in a mixed session do not receive the marker. * * @param {import('../../shared/chatbox.js').default} chatbox * @param {string} to_jid * @param {string} msg_id - the `id` of the message being marked * @param {string} type - marker type ('displayed', 'received', 'acknowledged') * @param {string} [msg_type] - 'chat' or 'groupchat'; defaults to 'chat' */ export async function sendOMEMO2Marker(chatbox, to_jid, msg_id, type, msg_type) { const stanza_type = msg_type || 'chat'; const { v2, legacy } = await getBundlesAndBuildSessions(chatbox); if (v2.length) { const el = await getOMEMO2EncryptedElement( chatbox, null, [stx`<${Stanza.unsafeXML(type)} xmlns="${Strophe.NS.MARKERS}" id="${msg_id}"/>`], v2, ); api.send( stx`<message xmlns="jabber:client" from="${_converse.session.get('jid')}" to="${to_jid}" type="${stanza_type}" id="${u.getUniqueId()}"> ${el} <encryption xmlns="${Strophe.NS.EME}" namespace="${Strophe.NS.OMEMO2}"/> </message>`, ); } else if (legacy.length) { // Legacy-only session: send cleartext marker, same as non-OMEMO clients do. api.send( stx`<message from="${_converse.session.get('jid')}" id="${u.getUniqueId()}" to="${to_jid}" type="${stanza_type}" xmlns="jabber:client"> <${Stanza.unsafeXML(type)} xmlns="${Strophe.NS.MARKERS}" id="${msg_id}"/> </message>`, ); } } /** * Encrypt `plaintext` (and, for omemo:2, `extensions` inside the SCE * `<content>`) for every reachable recipient device of `chat`, returning the * OMEMO `<encrypted>` element(s) to attach to a message stanza plus the EME * (XEP-0380) namespace to advertise. * * @param {import('../../shared/chatbox').default} chat * @param {string|null} plaintext - the body (legacy payload / omemo:2 SCE `<body>`) * @param {import('strophe.js').Builder[]} [extensions] - encrypted SCE `<content>` children * @returns {Promise<{elements: import('strophe.js').Builder[], eme_ns: string}>} */ export async function getEncryptedElements(chat, plaintext, extensions = []) { const { legacy, v2 } = await getBundlesAndBuildSessions(chat); const elements = []; if (legacy.length > 0) { elements.push(await getLegacyEncryptedElement(plaintext, legacy)); } if (v2.length > 0) { elements.push(await getOMEMO2EncryptedElement(chat, plaintext, extensions, v2)); } // Advertise the newest method present (v2 if any device uses it). If both // device sets are empty getBundlesAndBuildSessions has already thrown. const eme_ns = v2.length > 0 ? Strophe.NS.OMEMO2 : Strophe.NS.OMEMO; return { elements, eme_ns }; } /** * Handler for the `createMessageStanza` hook: for an encrypted outgoing chat * message, attach the OMEMO `<encrypted>` element(s) (plus store + EME hints) to * the stanza that was built from the Message model. Non-encrypted messages pass * through untouched. A send failure is surfaced to the user via * {@link handleMessageSendError} (which re-throws to abort the send). * * @param {import('../../shared/chatbox').default} chat * @param {import('../../shared/types').MessageAndStanza} data * @return {Promise<import('../../shared/types').MessageAndStanza>} */ export async function createMessageStanzaHandler(chat, data) { const { stanza, message } = data; if (!message.get('is_encrypted')) return data; try { if (!message.get('body')) throw new Error('No message body to encrypt!'); const { elements, eme_ns } = await getEncryptedElements( chat, message.get('plaintext'), getSCEExtensions(message), ); for (const el of elements) { stanza.cnode(el).root(); } stanza.cnode(stx`<store xmlns="${Strophe.NS.HINTS}"/>`).root(); stanza.cnode(stx`<encryption xmlns="${Strophe.NS.EME}" namespace="${eme_ns}"/>`).root(); return { message, stanza }; } catch (e) { handleMessageSendError(e, chat); // surfaces a user-facing alert and re-throws } } /** * @param {import('../../shared/chatbox.js').default} chat * @param {import('../../shared/types').MessageAttributes} attrs * @return {import('../../shared/types').MessageAttributes} */ export function getOutgoingMessageAttributes(chat, attrs) { const { __ } = _converse; if (chat.get('omemo_active') && attrs.body) { return { ...attrs, is_encrypted: true, plaintext: attrs.body, 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; } /** * Checks whether the contact advertises any OMEMO-compatible devices. * Legacy is checked first and short-circuits. * @param {string} jid */ export async function contactHasOMEMOSupport(jid) { const legacy = await u.omemo.getDevicesForContact(jid, Strophe.NS.OMEMO); if (legacy.length > 0) { return true; } const v2 = await u.omemo.getDevicesForContact(jid, Strophe.NS.OMEMO2); return v2.length > 0; } /** * @param {import('../../shared/chatbox.js').default} chatbox * @param {boolean} [restore=false] Whether to restore remembered OMEMO active state. */ async function checkOMEMOSupported(chatbox, restore = false) { // OMEMO is never initialized on an untrusted device (see `initOMEMO`), so // `OMEMOInitialized` never resolves and we'd wait forever below. Mark it // unsupported up front so the UI doesn't offer encryption. See #2336. if (!_converse.state.config.get('trusted')) { chatbox.set('omemo_supported', false); return; } let supported; if (chatbox.get('type') === constants.CHATROOMS_TYPE) { await api.waitUntil('OMEMOInitialized'); supported = isOMEMOMUCEligible(/** @type {MUC} */ (chatbox)); } else if (chatbox.get('type') === constants.PRIVATE_CHAT_TYPE) { supported = await contactHasOMEMOSupport(chatbox.get('jid')); } chatbox.set('omemo_supported', !!supported); if (supported && restore) { // Restore the user's last explicit choice for this chat, falling back // to `omemo_default` when no choice has been remembered yet const remembered = getOMEMOActiveState(chatbox.get('jid')); if (remembered !== undefined) { chatbox.set('omemo_active', remembered); } else if (api.settings.get('omemo_default')) { chatbox.set('omemo_active', true); } } } /** * @param {MUC} chatroom * @param {import('../../plugins/muc/occupant').default} occupant */ async function onOccupantAdded(chatroom, occupant) { if (occupant.isSelf() || !isOMEMOMUCEligible(chatroom)) { return; } const { __ } = _converse; if (chatroom.get('omemo_active')) { const supported = await 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 }); } } } /** * @param {import('../../shared/chatbox.js').default} chatbox */ export function onChatInitialized(chatbox) { checkOMEMOSupported(chatbox, true); chatbox.on('change:chat_state', () => sendOMEMO2ChatState(chatbox)); if (chatbox.get('type') === constants.CHATROOMS_TYPE) { /** @type {MUC} */ (chatbox).occupants.on( 'add', /** @param {import('../../plugins/muc/occupant').default} o */ (o) => onOccupantAdded(/** @type {MUC} */ (chatbox), o), ); /** @type {MUC} */ (chatbox).features.on('change', () => checkOMEMOSupported(chatbox)); } } /** * @param {import('../../shared/message').default} message * @param {import('../../shared/types').FileUploadMessageAttributes} attrs */ export function setEncryptedFileURL(message, attrs) { if (message.file.xep454_ivkey) { const url = attrs.oob_url.replace(/^https?:/, 'aesgcm:') + '#' + message.file.xep454_ivkey; return { ...attrs, ...{ oob_url: null, // Since only the body gets encrypted, we don't set the oob_url message: url, body: url, }, }; } return attrs; } /** * @param {File} file * @returns {Promise<File>} */ 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); c